In the previous lesson we closed the ER diagram of the extended BiblioRed: twenty-one entities, their relationships with cardinality and participation, a total and disjoint material hierarchy, nine recorded decisions and a validation against the twelve business queries. It is a complete conceptual model and it still cannot be executed.
This lesson covers the bridge. Transforming an ER model into a relational schema is, unlike almost everything else in design, an algorithm: a set of mechanical rules that, applied in order, produce the tables. There are ten rules. Eight are deterministic —given the diagram, there is only one correct answer—, and two (rule 6, 1:1 relationships, and rule 10, hierarchies) present real alternatives you have to choose between with judgment. That is precisely why they are the two that take up the most space.
The deliverable is the complete CREATE TABLE script for the BiblioRed extension, fitting in with the seven existing tables, with its foreign keys and referential actions reasoned out according to what we learned in 02-06. A warning right away, because it is the hook for the next lesson: the data types we will use here are provisional. VARCHAR(200), INTEGER, NUMERIC set by default so that the script works. Lesson 04-04 reviews them one by one and adds the complete catalog of constraints.
Contents
- What the transformation algorithm is and what it does not solve
- Rule 1 — Strong entity → table
- Rule 2 — Composite attribute → simple columns
- Rule 3 — Multivalued attribute → a separate table
- Rule 4 — Derived attribute → not stored
- Rule 5 — 1:N relationship → foreign key on the N side
- Rule 6 — 1:1 relationship → three options
- Rule 7 — N:M relationship → junction table
- Rule 8 — Weak entity → composite primary key
- Rule 9 — Ternary relationship → table with three foreign keys
- Rule 10 — Generalization hierarchy → three strategies
- Deliverable: the complete script for the extended BiblioRed
- Post-review: validating the resulting schema
- Common Mistakes and Tips
- Exercises
- Conclusion
- What the transformation algorithm is and what it does not solve
The algorithm is applied in a specific order, and the order matters because each step depends on the previous one having created the tables to point at:
flowchart TD
A["1. Strong entities -> tables with their PK"]
B["2-4. Attributes: composite, multivalued, derived"]
C["8. Weak entities -> composite PK with the owner's"]
D["5. 1:N relationships -> FK on the N side"]
E["6. 1:1 relationships -> choose between three options"]
F["7. N:M relationships -> junction table"]
G["9. Ternary relationships -> table with three FKs"]
H["10. Hierarchies -> choose a strategy"]
I["Review: queries, tables with no key, orphans"]
A --> B --> C --> D --> E --> F --> G --> H --> I
What the algorithm guarantees: a correct relational schema, with no loss of information and no invented relationships. If the diagram was faithful to the domain, the schema will be too.
What the algorithm does NOT solve —worth being clear about so you do not expect magic—:
| It does not solve | Where it is solved |
|---|---|
| A badly drawn diagram. If the cardinality was wrong, the FK will end up on the wrong side | By reviewing the diagram (04-02) |
| The choice of specific data types | Lesson 04-04 |
| Business rules that are not structural (BR1–BR10) | Lesson 04-04 |
| Redundancy anomalies left over in the design | Module 5, normalization |
| Performance | Lesson 06-03, indexes |
An important nuance: the result of applying the algorithm to a well-made diagram is usually already in third normal form. Not by chance: thinking in terms of entities and relationships is, informally, applying the same principles that normalization formalizes. In module 5 we will verify it with proper instruments.
- Rule 1 — Strong entity → table
Rule 1. Each strong entity becomes a table. Its simple attributes become columns. Its identifier becomes the primary key.
It is the most direct rule and the one that creates the skeleton. Applied to rooms, event_types, events and speakers (no foreign keys yet; they arrive with rule 5):
CREATE TABLE event_types (
event_type_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(30) NOT NULL,
name VARCHAR(80) NOT NULL,
description VARCHAR(500),
standard_duration_min INTEGER,
CONSTRAINT pk_event_types PRIMARY KEY (event_type_id),
CONSTRAINT uq_event_types_code UNIQUE (code)
);Three decisions already taken and applied here:
- Surrogate key
event_type_id, following the rule we set in section 10 of 04-01: a surrogate PK in every strong entity. UNIQUEon the natural keycode. This is what prevents two'workshop'rows from existing. Without thisUNIQUE, the surrogate key would have destroyed a guarantee that the conceptual model did give.- Named constraints (
pk_,uq_), for reasons we will see in 04-04 about error messages.
And speakers, with the same structure:
CREATE TABLE speakers (
speaker_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(120),
biography VARCHAR(1000),
external BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_speakers PRIMARY KEY (speaker_id),
CONSTRAINT uq_speakers_email UNIQUE (email)
);Notice that email is not NOT NULL but it is UNIQUE. It is a deliberate combination: there are external speakers for whom only a phone number is known, but two speakers cannot share an address. The fact that PostgreSQL allows several NULLs in a UNIQUE column is what makes this combination viable, and we will explain it in depth in 04-04.
- Rule 2 — Composite attribute → simple columns
Rule 2. A composite attribute is broken down: each component is a column. The composite attribute itself disappears; no trace of it is left in the schema.
R13 asked for the branch address to be broken down. The existing table has:
The transformation:
ALTER TABLE branches ADD COLUMN addr_street VARCHAR(120);
ALTER TABLE branches ADD COLUMN addr_number VARCHAR(10);
ALTER TABLE branches ADD COLUMN addr_postal_code VARCHAR(5);
ALTER TABLE branches ADD COLUMN addr_city VARCHAR(60);And the migration of the existing data, which is the part nobody talks about and always costs you:
-- The four current values, migrated by hand: they are four rows.
UPDATE branches SET addr_street = 'Plaça Major', addr_number = '3',
addr_postal_code = '08820', addr_city = 'Vallmar'
WHERE branch_id = 1;
UPDATE branches SET addr_street = 'Avinguda del Nord', addr_number = '112',
addr_postal_code = '08821', addr_city = 'Vallmar'
WHERE branch_id = 2;
-- ... branches 3 and 4
ALTER TABLE branches DROP COLUMN address;Practical tip: when the composite attribute already has production data as free text, automatic conversion with regular expressions fails more often than it succeeds. With four rows you do it by hand. With forty thousand, you do it in batches, review whatever does not fit the pattern and keep the original column renamed to
address_originalfor a couple of months.
When NOT to break it down
The rule has an important exception and it is frequently misapplied out of excessive zeal. Do not break it down if nobody is going to search, filter, sort or aggregate by the parts.
| Case | Break it down? | Reason |
|---|---|---|
| Branch address (R13) | Yes | It is searched by postal code, the city is displayed separately |
| Postal address of an external speaker | No | It is only printed on a letter; a VARCHAR is enough |
| First and last name of a member | Yes (already done) | Listings are sorted by last name |
| Notes on an event report | No | Free text by nature |
hh:mm duration of a DVD |
No: it is a single number of minutes | Splitting it into hours and minutes complicates every calculation |
The cost of over-decomposing is real: four columns to fill in, validate and maintain, for a value that is only ever printed whole. The cost of under-decomposing is worse —you have to slice strings in every query—, and that is why when in doubt you decompose; but the doubt must exist.
- Rule 3 — Multivalued attribute → a separate table
Rule 3. A multivalued attribute becomes a new table with two parts: a foreign key to the owner entity and the value itself. The primary key is the combination of both.
It is the rule that makes the phone1, phone2, phone3 and subtitles = 'es,ca,en' anti-patterns we denounced in 04-01 disappear forever.
A member's phone numbers (R12)
CREATE TABLE member_phones (
member_id INTEGER NOT NULL,
number VARCHAR(20) NOT NULL,
type VARCHAR(10) NOT NULL,
CONSTRAINT pk_member_phones PRIMARY KEY (member_id, number),
CONSTRAINT fk_member_phones_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE CASCADE ON UPDATE CASCADE
);Analysis of the three decisions:
- The PK is
(member_id, number). The value is part of the key, and that is what prevents storing the same phone number twice for the same member. It is integrity for free: the rule "do not repeat a phone number" needs no code. ON DELETE CASCADE: a phone number has no existence outside its member. If the member is deleted, their phone numbers must go with them. It is the textbook case forCASCADEaccording to the criteria in 02-06.- R12's limit of three phone numbers is not here. No key or table constraint expresses it. It is a pending business rule, and in 04-04 we will decide where it lives.
Let us test it:
INSERT INTO member_phones (member_id, number, type) VALUES
(14, '600111222', 'mobile'),
(14, '938880011', 'landline'),
(15, '600333444', 'mobile');
INSERT INTO member_phones (member_id, number, type) VALUES (14, '600111222', 'work');INSERT 0 3 ERROR: duplicate key value violates unique constraint "pk_member_phones" DETAIL: Key (member_id, number)=(14, 600111222) already exists.
Exactly the error we wanted: the same number cannot be recorded twice for the same member, not even by changing its type.
The subtitles of a DVD (R1)
CREATE TABLE dvd_subtitles (
material_id INTEGER NOT NULL,
language VARCHAR(5) NOT NULL,
CONSTRAINT pk_dvd_subtitles PRIMARY KEY (material_id, language),
CONSTRAINT fk_dvd_subtitles_material
FOREIGN KEY (material_id) REFERENCES materials_dvd (material_id)
ON DELETE CASCADE ON UPDATE CASCADE
);Note the detail: the foreign key points at materials_dvd, not at materials. It is the sub-entity that has subtitles, not just any material. An audiobook cannot have them, and the schema makes it impossible. That precision is one of the advantages of the hierarchy strategy we will choose in rule 10.
And now query Q9 ("DVDs with Catalan subtitles") is answered with a normal JOIN instead of with a LIKE '%ca%' that returned garbage:
SELECT m.material_id, m.title
FROM materials m
JOIN dvd_subtitles s ON s.material_id = m.material_id
WHERE s.language = 'ca'
ORDER BY m.title; material_id | title
-------------+-------------------------------
1204 | El bosc de les ombres
1187 | La ciutat dels prodigis
(2 rows)
- Rule 4 — Derived attribute → not stored
Rule 4. A derived attribute generates no column. It is computed at the moment you query it.
Of the five derived attributes we noted down in 04-02, let us look at the most used one: the free seats of an event (Q2).
SELECT e.event_id,
e.title,
e.offered_seats,
e.offered_seats - COALESCE(SUM(1 + r.companions), 0) AS free_seats
FROM events e
LEFT JOIN registrations r
ON r.event_id = e.event_id
AND r.status = 'confirmed'
WHERE e.event_id = 47
GROUP BY e.event_id, e.title, e.offered_seats; event_id | title | offered_seats | free_seats
----------+--------------------------+---------------+------------
47 | Book club: crime fiction | 20 | 6
(1 row)Three pieces worth pointing out, all of them familiar from earlier modules: the LEFT JOIN keeps the events with no registrations at all (02-04), the COALESCE turns the NULL from an empty SUM into 0 (02-01, three-valued logic) and 1 + companions implements the decision that a member with two companions takes three seats (an ambiguity resolved in 04-01).
The convenient form: a view
Repeating that query in twenty places is an invitation for it to be written wrongly in one of them. A view encapsulates it, and it is also a pure example of the ANSI/SPARC external level from 01-04:
CREATE VIEW v_events_occupancy AS
SELECT e.event_id,
e.title,
e.start_time,
e.offered_seats,
COALESCE(SUM(1 + r.companions) FILTER (WHERE r.status = 'confirmed'), 0) AS occupied_seats,
e.offered_seats
- COALESCE(SUM(1 + r.companions) FILTER (WHERE r.status = 'confirmed'), 0) AS free_seats
FROM events e
LEFT JOIN registrations r ON r.event_id = e.event_id
GROUP BY e.event_id, e.title, e.start_time, e.offered_seats;The view stores nothing: it is recomputed on every query. It still complies with rule 4.
When it does get stored
There are two situations in which a derived value ends up being stored, and both have a name:
- For performance. If the public agenda (Q1) shows the free seats of forty events and each one means aggregating thousands of registrations, it may pay off to maintain an
occupied_seatscolumn updated by a trigger. This is deliberate denormalization, it has a known cost (the risk of the stored value and the real one diverging) and it is the topic of lesson 05-04. Do not do it without measuring first. - Because the value must be frozen. This case is different and gets confused with the previous one. A fine's amount is computed today at €0.20/day, but if the ordinance raises the rate tomorrow, yesterday's fines must not be recomputed. That amount is not a derived value: it is a historical fact that is stored because its value depends on a context that no longer exists. The same logic that justifies storing the sale price on an invoice.
In BiblioRed, fines.amount is of the second kind and that is why it is a column. free_seats is of the first kind and that is why it is not.
- Rule 5 — 1:N relationship → foreign key on the N side
Rule 5. In a 1:N relationship, the table on the N side receives a foreign key pointing at the primary key of the 1 side. The relationship's own attributes, if any, also go to the N side.
It is the rule applied most often in any schema. In BiblioRed:
CREATE TABLE rooms (
room_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
branch_id INTEGER NOT NULL,
name VARCHAR(80) NOT NULL,
capacity INTEGER NOT NULL,
floor INTEGER NOT NULL DEFAULT 0,
accessible BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_rooms PRIMARY KEY (room_id),
CONSTRAINT uq_rooms_branch_name UNIQUE (branch_id, name),
CONSTRAINT fk_rooms_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);Four things derived directly from the diagram:
branch_idgoes inrooms, the N side. Never the other way round.NOT NULLbecause the participation ofROOMwas total: every room is in a branch (04-02, section 6).UNIQUE (branch_id, name)implements R3's statement: the name is unique only within the branch. This is what remains ofROOMbeing conceptually weak; we will come back to it in rule 8.ON DELETE RESTRICT: a branch that has rooms is not deleted. Consistent with the decision already taken formembers.branch_idandcopies.branch_idin 02-06.
Why never the other way round
The question is legitimate: why not put a room_id column in branches? Because a branch has several rooms, and only one value fits in a column. The only two ways of forcing it are the two anti-patterns from 04-01:
| Attempt | What it is | Why it fails |
|---|---|---|
branches.room1_id, room2_id, ... room6_id |
Numbered columns | R3 says "between 1 and 6" today; the seventh arrives as soon as they refurbish a building. Massive NULL. Querying "which branch is room 12 in?" requires checking six columns |
branches.rooms = '4,7,12' |
Comma-separated list | No foreign key, no types, no JOIN, and a LIKE that returns false positives |
The FK always goes on the side that holds at most one value. That side is the N side.
The case of the event with no room
events.room_id is different because decision D4 in 04-02 made the room optional:
room_id INTEGER NULL, -- partial participation: outdoor events
CONSTRAINT fk_events_room
FOREIGN KEY (room_id) REFERENCES rooms (room_id)
ON DELETE RESTRICT ON UPDATE CASCADEThe participation in the diagram translates literally into NOT NULL or its absence. It is the most direct conversion in the whole algorithm, and that is why it is worth having asked that question properly in 04-02.
- Rule 6 — 1:1 relationship → three options
Rule 6. A 1:1 relationship admits three solutions: merge the two entities into one table, put the foreign key on one of the two sides with
UNIQUE, or create an intermediate table. The choice depends on the participation and on the access pattern.
BiblioRed's case is events — event_reports (R9): an event has at most one report, a report belongs to one event.
Option A — Merge into one table
-- Option A: the report fields inside events
ALTER TABLE events ADD COLUMN actual_attendees INTEGER;
ALTER TABLE events ADD COLUMN average_rating NUMERIC(3,2);
ALTER TABLE events ADD COLUMN notes VARCHAR(2000);
ALTER TABLE events ADD COLUMN report_date DATE;In favor: no JOIN, a single row to read, maximum simplicity.
Against: out of the 1,200 events forecast over three years, maybe 700 will have a report; the other 500 will carry four columns at NULL. And there is a worse problem than the waste: you cannot tell "report not written yet" from "report written with zero attendees". Both cases give actual_attendees IS NULL or = 0 depending on how it is filled in, and query Q12 ("events with no report") becomes ambiguous.
Option B — Foreign key with UNIQUE on one of the sides
-- Option B, chosen for BiblioRed
CREATE TABLE event_reports (
event_id INTEGER NOT NULL,
actual_attendees INTEGER NOT NULL,
average_rating NUMERIC(3,2),
notes VARCHAR(2000),
report_date DATE NOT NULL,
CONSTRAINT pk_event_reports PRIMARY KEY (event_id),
CONSTRAINT fk_event_reports_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE
);Here the foreign key is the primary key. That is what makes the relationship 1:1: if event_id is the PK of event_reports, it cannot be repeated, and therefore an event cannot have two reports. No additional UNIQUE is needed: the primary key already is one.
In favor: zero NULLs, the existence of the row is the answer to "does it have a report?", and Q12 is solved with a clean anti-join:
SELECT e.event_id, e.title, e.start_time
FROM events e
LEFT JOIN event_reports r ON r.event_id = e.event_id
WHERE e.status = 'held'
AND e.start_time < CURRENT_DATE - INTERVAL '15 days'
AND r.event_id IS NULL
ORDER BY e.start_time; event_id | title | start_time
----------+-------------------------------+------------------------
31 | Creative writing workshop | 2026-06-18 18:00:00+02
38 | Book launch: "The Long Days" | 2026-07-02 19:30:00+02
(2 rows)Against: one JOIN when you need both together. In this case it does not matter: the report is consulted on a different screen from the agenda.
Option C — Intermediate table
A third table with two foreign keys, both UNIQUE. It only makes sense when both sides have partial participation and the relationship itself is a fact that can appear and disappear (for example, "which employee has which fleet vehicle assigned"). For the report it would be absurd: a report with no event does not exist.
Decision table
| Criterion | Option A: merge | Option B: FK + UNIQUE | Option C: intermediate table |
|---|---|---|---|
| Participation of both sides | Total on both | Total on one, partial on the other | Partial on both |
| Nulls generated | Many if one side is optional | None | None |
JOINs needed |
None | One | Two |
| Telling "does not exist" from "equals zero" | No | Yes | Yes |
| Large or rarely queried columns | Penalizes every read | Isolates the weight | Isolates the weight |
| Complexity | Minimal | Low | High |
Practical rule: if both sides have total participation and are always queried together, merge (option A) —in fact, if that is your case, reconsider whether they really were two entities—. If one side is optional, FK on the optional side with the shared PK (option B). Option C is rare and has to be justified.
BiblioRed's decision: option B. The optional side is the report, and that is where the table goes.
- Rule 7 — N:M relationship → junction table
Rule 7. An N:M relationship becomes a junction table with two foreign keys, one to each entity. The relationship's own attributes become columns of that table. The primary key is, by default, the combination of the two foreign keys.
It is the rule most often applied wrongly, almost always by forgetting the second sentence.
The main case: registrations (R6)
CREATE TABLE registrations (
event_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
registration_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(15) NOT NULL DEFAULT 'confirmed',
companions INTEGER NOT NULL DEFAULT 0,
CONSTRAINT pk_registrations PRIMARY KEY (event_id, member_id),
CONSTRAINT fk_registrations_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_registrations_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);The three attributes of its own —date, status, companions— live here and nowhere else. It is the application of the rule we stated in 04-02: if an attribute needs both keys in order to have a value, it belongs to the relationship.
The composite primary key implements R6's rule for free:
INSERT INTO registrations (event_id, member_id) VALUES (47, 14);
INSERT INTO registrations (event_id, member_id, companions) VALUES (47, 14, 2);INSERT 0 1 ERROR: duplicate key value violates unique constraint "pk_registrations" DETAIL: Key (event_id, member_id)=(47, 14) already exists.
"A member cannot register twice for the same event", says R6. There it is, without a line of application code.
The two referential actions, reasoned out
This is a good moment to apply the criterion from 02-06, because the two foreign keys of the same table carry different actions and that puzzles a lot of people:
| Foreign key | Action | Reason |
|---|---|---|
event_id |
ON DELETE CASCADE |
A registration means nothing without its event. If a scheduled event is removed from the system, its registrations must disappear. It is the same criterion we applied to reservations.book_id |
member_id |
ON DELETE RESTRICT |
The registration is a historical fact with value (attendance, statistics for Q5). Deleting a member must not delete the record that they attended twelve events. Same criterion as loans.member_id |
Compare it with reservations.member_id, which is CASCADE: a reservation is ephemeral and has no historical value; a registration does. The referential action follows from the value of the data, not from the shape of the relationship.
Composite key or surrogate key?
It is the decision you have to make in every junction table:
Composite PK (event_id, member_id) |
Surrogate PK registration_id + UNIQUE (event_id, member_id) |
|
|---|---|---|
| Prevents duplicates | Yes, directly | Yes, with the UNIQUE (if you remember to add it) |
| Referenceable from another table | With a composite FK, heavier | With a single column, more convenient |
| Semantics | The key is the business rule | The key means nothing |
| Size | 8 bytes | 4 bytes + the UNIQUE index |
| ORM compatible | Some ORMs handle it badly | Universal |
BiblioRed's decision: composite PK in registrations, participations and events_materials. No other table needs to reference them, and the composite key expresses the business rule directly.
The other two N:M relationships
events_materials (R8) is the simplest case in the schema, an N:M with a single attribute of its own:
CREATE TABLE events_materials (
event_id INTEGER NOT NULL,
material_id INTEGER NOT NULL,
role VARCHAR(15) NOT NULL DEFAULT 'recommended',
CONSTRAINT pk_events_materials PRIMARY KEY (event_id, material_id),
CONSTRAINT fk_events_materials_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_events_materials_material
FOREIGN KEY (material_id) REFERENCES materials (material_id)
ON DELETE CASCADE ON UPDATE CASCADE
);And participations (R7) is really rule 9; it arrives in two sections' time.
- Rule 8 — Weak entity → composite primary key
Rule 8. A weak entity becomes a table whose primary key is the combination of the owner entity's primary key and its partial identifier. The foreign key to the owner is part of the primary key and is, necessarily,
NOT NULL.
We have already applied it three times without saying so: member_phones (rule 3), event_reports (rule 6) and registrations (rule 7). The pure case is that of copies with respect to the material.
The orthodox version
-- Literal application of rule 8
CREATE TABLE copies (
material_id INTEGER NOT NULL,
copy_number INTEGER NOT NULL, -- partial identifier
code VARCHAR(15) NOT NULL,
branch_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
acquisition_date DATE,
CONSTRAINT pk_copies PRIMARY KEY (material_id, copy_number),
...
);It is correct and expresses exactly the semantics: "copy 3 of The Map of Time".
Why BiblioRed does not use it
There is a specific and decisive reason: loans references copies. With the composite PK, loans would need two columns and a composite foreign key:
-- What the orthodox version would imply
CREATE TABLE loans (
loan_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
material_id INTEGER NOT NULL, -- two columns
copy_number INTEGER NOT NULL, -- to identify one copy
...
CONSTRAINT fk_loans_copy
FOREIGN KEY (material_id, copy_number)
REFERENCES copies (material_id, copy_number)
);And besides, loans already exists with 4,312 rows and an FK to copy_id. Changing it would be a considerable migration for no gain at all.
BiblioRed's decision: surrogate key + UNIQUE on the weak key. It is the usual pattern and it preserves all the integrity:
CREATE TABLE copies (
copy_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(15) NOT NULL,
material_id INTEGER NOT NULL,
copy_number INTEGER NOT NULL,
branch_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
acquisition_date DATE,
CONSTRAINT pk_copies PRIMARY KEY (copy_id),
CONSTRAINT uq_copies_code UNIQUE (code),
CONSTRAINT uq_copies_material_num UNIQUE (material_id, copy_number),
CONSTRAINT fk_copies_material
FOREIGN KEY (material_id) REFERENCES materials (material_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_copies_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);The combination is: copy_id as the primary key (convenient for referencing), uq_copies_material_num as the natural candidate key that preserves the weak-entity semantics, and uq_copies_code for the code printed on the label. Nothing has been lost.
The general principle: in the logical phase, a weak entity may carry a surrogate key without ceasing to be weak, provided its composite natural key is declared
UNIQUE. What is not negotiable is the uniqueness; the shape of the primary key is.
The case of rooms is identical: PK room_id, UNIQUE (branch_id, name). And so is that of payments: PK payment_id, with the peculiarity that there is not even a natural partial identifier there (two payments on the same fine for the same amount on the same day are two different payments), which is an additional argument for the surrogate.
- Rule 9 — Ternary relationship → table with three foreign keys
Rule 9. A degree-3 relationship becomes a table with three foreign keys, one to each participating entity, plus the relationship's own attributes. The primary key is, by default, the combination of the three.
And a warning that is as important as the rule: before applying it, check that the relationship really is ternary.
The decomposition test
A ternary relationship R(A, B, C) is genuine if the existence of a triple cannot be deduced from three binary relationships. The test consists of asking whether R(A,B,C) is equivalent to R1(A,B) ∧ R2(B,C) ∧ R3(A,C).
A classic and mistaken counterexample: "a member borrows a copy at a branch" looks ternary, but it is not: the branch is deduced from the copy. It is a binary relationship with a derived value. Modeling it as ternary introduces redundancy and the possibility that the branch recorded on the loan contradicts the copy's.
A genuinely ternary case: "a supplier supplies a component for a specific project". That supplier A supplies component X, that X is used in project P and that A works with P does not imply that A supplies X for P. The information would be lost by decomposing it.
BiblioRed's case: participations (R7)
In 04-02 (decision D5) we analyzed EVENT — SPEAKER — ROLE. The conclusion was that ROLE is not an entity: it is a small, stable set with no attributes of its own. What is left is a binary N:M relationship whose identifier includes an attribute:
CREATE TABLE participations (
event_id INTEGER NOT NULL,
speaker_id INTEGER NOT NULL,
role VARCHAR(25) NOT NULL,
fee NUMERIC(8,2) NOT NULL DEFAULT 0,
CONSTRAINT pk_participations PRIMARY KEY (event_id, speaker_id, role),
CONSTRAINT fk_participations_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_participations_speaker
FOREIGN KEY (speaker_id) REFERENCES speakers (speaker_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);The three-column primary key is what allows Elena Roig to be both moderator and workshop leader at the same event —R7 required it— and at the same time prevents her from appearing twice as moderator of the same event.
INSERT INTO participations (event_id, speaker_id, role, fee) VALUES
(47, 8, 'moderator', 0),
(47, 8, 'workshop_leader', 180.00),
(47, 9, 'guest_author', 250.00);ERROR: duplicate key value violates unique constraint "pk_participations" DETAIL: Key (event_id, speaker_id, role)=(47, 8, moderator) already exists.
speaker_id is RESTRICT and not CASCADE for a very specific reason: fees are a record with financial implications. Deleting a speaker cannot delete the trace of what was paid to them.
Tip: when a degree-3 relationship shows up in a design, spend five minutes on the decomposition test. In practical experience, four out of five turn out to be a binary relationship with an attribute, a binary relationship with a derived value, or two independent binary relationships.
- Rule 10 — Generalization hierarchy → three strategies
Rule 10. A generalization hierarchy is taken into tables with one of three strategies: single table, table per subclass or table per concrete class. The choice depends on disjointness, totality, the number of specific attributes and the query pattern.
It is the most important decision in the whole BiblioRed extension, because it affects the entire catalog and the tables that already exist.
Strategy 1 — Single table (single table inheritance)
A single table with all the attributes of all the subclasses, plus a discriminant column.
CREATE TABLE materials (
material_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
material_type VARCHAR(15) NOT NULL,
title VARCHAR(200) NOT NULL,
-- ... common attributes ...
isbn VARCHAR(13), -- books only
page_count INTEGER, -- books only
binding VARCHAR(20), -- books only
duration_min INTEGER, -- DVDs and audiobooks
video_format VARCHAR(15), -- DVDs only
region_code INTEGER, -- DVDs only
issn VARCHAR(9), -- magazines only
number VARCHAR(20), -- magazines only
frequency VARCHAR(20), -- magazines only
narrator VARCHAR(120), -- audiobooks only
audio_format VARCHAR(15) -- audiobooks only
);Fast to query (no JOIN) and disastrous to validate: it is impossible to declare isbn NOT NULL even though R1 requires it for books, because DVDs would have it at NULL. The only way out is conditional CHECKs along the lines of CHECK (material_type <> 'book' OR isbn IS NOT NULL), one per mandatory attribute, which multiply rapidly.
Strategy 2 — Table per subclass (class table inheritance)
One table for the superclass with the common part, and one table per subclass with the specific part, joined by the shared primary key.
CREATE TABLE materials (
material_id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
material_type VARCHAR(15) NOT NULL,
title VARCHAR(200) NOT NULL,
author_id INTEGER,
publisher VARCHAR(120),
publication_year INTEGER,
language VARCHAR(5) NOT NULL,
added_date DATE NOT NULL DEFAULT CURRENT_DATE
);
CREATE TABLE materials_book (
material_id INTEGER NOT NULL PRIMARY KEY REFERENCES materials (material_id) ON DELETE CASCADE,
isbn VARCHAR(13) NOT NULL UNIQUE, -- now it can be required
page_count INTEGER,
binding VARCHAR(20)
);Zero unnecessary nulls, specific constraints that can be enforced, and the foreign key of copies points at materials, which is what Q10, loans and reservations needed. Its cost is a JOIN when you need the subtype's details.
Strategy 3 — Table per concrete class (concrete table inheritance)
A complete, independent table for each subclass, with no superclass table: books, dvds, magazines, audiobooks, each with all the attributes, common and specific.
It is the worst option for BiblioRed and it is worth understanding exactly why: copies would have nothing to point at. It would need four columns (book_id, dvd_id, magazine_id, audiobook_id) with three at NULL in every row, or a (type, id) pair with no possible foreign key —an anti-pattern known as a polymorphic reference, which gives up referential integrity—. And query Q10 would require a UNION of four branches. It is only viable when the subclasses are not referenced from outside, and here they are referenced from three places.
Comparison table
| Criterion | Single table | Table per subclass | Table per concrete class |
|---|---|---|---|
| No. of tables | 1 | 1 + n | n |
| Nulls | Many | None | None |
NOT NULL on subclass attributes |
Impossible (only conditional CHECK) |
Direct | Direct |
| Querying all materials | Trivial | Trivial (the superclass) | UNION of n branches |
| Querying one subtype with its details | Trivial | One JOIN |
Trivial |
Referencing from outside (copies, reservations) |
Trivial | Trivial (to the superclass) | Impossible without giving up the FK |
| Adding a new subtype | ALTER TABLE on the big table |
A new table, nothing existing changes | A new table + review every UNION |
| Discriminant ↔ subtype consistency | Automatic | Has to be guaranteed | Not applicable |
| Requires a disjoint hierarchy | No | No | Yes |
| Requires a total hierarchy | No | No | Yes |
| When to choose it | Few specific attributes (2-3), subtypes that barely differ, absolute priority on reads | Many specific attributes, subtypes referenced from outside, integrity matters | Subtypes with almost nothing in common and that nobody references |
BiblioRed's decision: table per subclass
Four reasons, in order of weight:
copies,loansandreservationsneed a common entity to point at. That rules out strategy 3 on its own.- Each subtype has 3-4 mandatory specific attributes. With a single table that would be eleven mostly null columns and eleven conditional
CHECKs. - R1 requires
isbn NOT NULL UNIQUEfor books andissnfor magazines. Only strategy 2 allows that directly. - Adding "maps" or "sheet music" in the future means creating a table, without touching anything existing. It is exactly goal 5 from 04-01, evolution without traumatic migrations.
The price: keeping the discriminant consistent
Strategy 2 has a known hole and it has to be acknowledged: nothing on its own prevents a material with material_type = 'book' from having a row in materials_dvd, or from having no row at all in materials_book (violating the totality of the hierarchy).
It is closed with an elegant technique: include the discriminant in the foreign key. You declare UNIQUE (material_id, material_type) on materials and the subtable references that pair with its own material_type fixed by a CHECK. It is a direct application of the composite foreign keys from 02-06:
ALTER TABLE materials
ADD CONSTRAINT uq_materials_id_type UNIQUE (material_id, material_type);
CREATE TABLE materials_book (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'book',
isbn VARCHAR(13) NOT NULL,
page_count INTEGER,
binding VARCHAR(20),
CONSTRAINT pk_materials_book PRIMARY KEY (material_id),
CONSTRAINT uq_materials_book_isbn UNIQUE (isbn),
CONSTRAINT chk_materials_book_type CHECK (material_type = 'book'),
CONSTRAINT fk_materials_book_material
FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);Now it is physically impossible for a DVD to have a row in materials_book:
-- material 1204 is a DVD
INSERT INTO materials_book (material_id, isbn) VALUES (1204, '9788401339097');ERROR: insert or update on table "materials_book" violates foreign key constraint "fk_materials_book_material" DETAIL: Key (material_id, material_type)=(1204, book) is not present in table "materials".
The other half of the hierarchy —that every material has a row in some subtable, the totality— cannot be guaranteed with declarative constraints. It is the same limitation we saw in 04-02 with "every branch has at least one room". It is solved with a trigger, with a transaction that inserts both rows together, or by accepting the risk and checking it periodically. In 04-04 we settle the criterion.
Compatibility: books becomes a view
All the existing code and all the existing reports query books. Rewriting them all is unnecessary:
CREATE VIEW books AS
SELECT m.material_id AS book_id,
b.isbn,
m.title,
m.author_id,
m.publisher,
m.publication_year,
m.language
FROM materials m
JOIN materials_book b ON b.material_id = m.material_id;The old queries keep working without a single change. It is the clearest example of logical independence in the whole course: we have changed the conceptual level and the external level has absorbed the change, exactly as ANSI/SPARC described in 01-04.
- Deliverable: the complete script for the extended BiblioRed
Here is the result of applying the ten rules. Remember that the types are provisional: VARCHAR(n) sizes set by eye, INTEGER for everything whole, constraints limited to the structural ones. Lesson 04-04 reviews every type and adds the complete catalog of CHECK, DEFAULT, domains and generated columns.
-- =====================================================================
-- BiblioRed · Migration V004: events, materials and fines extension
-- PROVISIONAL types. Reviewed in 04-04.
-- =====================================================================
-- ---------------------------------------------------------------------
-- BLOCK 1 · Rule 2: composite attribute (R13)
-- ---------------------------------------------------------------------
ALTER TABLE branches ADD COLUMN addr_street VARCHAR(120);
ALTER TABLE branches ADD COLUMN addr_number VARCHAR(10);
ALTER TABLE branches ADD COLUMN addr_postal_code VARCHAR(5);
ALTER TABLE branches ADD COLUMN addr_city VARCHAR(60);
-- (data migration and DROP COLUMN address: see section 3)
-- ---------------------------------------------------------------------
-- BLOCK 2 · Rule 10: material hierarchy (R1)
-- ---------------------------------------------------------------------
CREATE TABLE materials (
material_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
material_type VARCHAR(15) NOT NULL,
title VARCHAR(200) NOT NULL,
author_id INTEGER,
publisher VARCHAR(120),
publication_year INTEGER,
language VARCHAR(5) NOT NULL,
added_date DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_materials PRIMARY KEY (material_id),
CONSTRAINT uq_materials_id_type UNIQUE (material_id, material_type),
CONSTRAINT chk_materials_type
CHECK (material_type IN ('book','dvd','magazine','audiobook')),
CONSTRAINT fk_materials_author
FOREIGN KEY (author_id) REFERENCES authors (author_id)
ON DELETE SET NULL ON UPDATE CASCADE -- same as books.author_id
);
CREATE TABLE materials_book (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'book',
isbn VARCHAR(13) NOT NULL,
page_count INTEGER,
binding VARCHAR(20),
CONSTRAINT pk_materials_book PRIMARY KEY (material_id),
CONSTRAINT uq_materials_book_isbn UNIQUE (isbn),
CONSTRAINT chk_materials_book_type CHECK (material_type = 'book'),
CONSTRAINT fk_materials_book_material
FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_dvd (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'dvd',
duration_min INTEGER NOT NULL,
video_format VARCHAR(15),
region_code INTEGER,
CONSTRAINT pk_materials_dvd PRIMARY KEY (material_id),
CONSTRAINT chk_materials_dvd_type CHECK (material_type = 'dvd'),
CONSTRAINT fk_materials_dvd_material
FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_magazine (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'magazine',
issn VARCHAR(9) NOT NULL,
number VARCHAR(20) NOT NULL,
frequency VARCHAR(20),
CONSTRAINT pk_materials_magazine PRIMARY KEY (material_id),
CONSTRAINT uq_materials_magazine_issn_num UNIQUE (issn, number), -- BR9
CONSTRAINT chk_materials_magazine_type CHECK (material_type = 'magazine'),
CONSTRAINT fk_materials_magazine_material
FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE materials_audiobook (
material_id INTEGER NOT NULL,
material_type VARCHAR(15) NOT NULL DEFAULT 'audiobook',
duration_min INTEGER NOT NULL,
narrator VARCHAR(120),
audio_format VARCHAR(15),
CONSTRAINT pk_materials_audiobook PRIMARY KEY (material_id),
CONSTRAINT chk_materials_audiobook_type CHECK (material_type = 'audiobook'),
CONSTRAINT fk_materials_audiobook_material
FOREIGN KEY (material_id, material_type)
REFERENCES materials (material_id, material_type)
ON DELETE CASCADE ON UPDATE CASCADE
);
-- Rule 3: multivalued attribute (R1)
CREATE TABLE dvd_subtitles (
material_id INTEGER NOT NULL,
language VARCHAR(5) NOT NULL,
CONSTRAINT pk_dvd_subtitles PRIMARY KEY (material_id, language),
CONSTRAINT fk_dvd_subtitles_material
FOREIGN KEY (material_id) REFERENCES materials_dvd (material_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
-- Rule 3: multivalued attribute (R12)
CREATE TABLE member_phones (
member_id INTEGER NOT NULL,
number VARCHAR(20) NOT NULL,
type VARCHAR(10) NOT NULL DEFAULT 'mobile',
CONSTRAINT pk_member_phones PRIMARY KEY (member_id, number),
CONSTRAINT chk_member_phones_type CHECK (type IN ('mobile','landline','work')),
CONSTRAINT fk_member_phones_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- BLOCK 3 · Rooms and events (R3, R4, R5)
-- ---------------------------------------------------------------------
CREATE TABLE rooms (
room_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
branch_id INTEGER NOT NULL,
name VARCHAR(80) NOT NULL,
capacity INTEGER NOT NULL,
floor INTEGER NOT NULL DEFAULT 0,
accessible BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_rooms PRIMARY KEY (room_id),
CONSTRAINT uq_rooms_branch_name UNIQUE (branch_id, name), -- R3, rule 8
CONSTRAINT fk_rooms_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE event_types (
event_type_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(30) NOT NULL,
name VARCHAR(80) NOT NULL,
description VARCHAR(500),
standard_duration_min INTEGER,
CONSTRAINT pk_event_types PRIMARY KEY (event_type_id),
CONSTRAINT uq_event_types_code UNIQUE (code)
);
CREATE TABLE events (
event_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
title VARCHAR(200) NOT NULL,
description VARCHAR(2000),
event_type_id INTEGER NOT NULL,
room_id INTEGER, -- optional: decision D4
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
offered_seats INTEGER NOT NULL,
status VARCHAR(15) NOT NULL DEFAULT 'scheduled',
published BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT pk_events PRIMARY KEY (event_id),
CONSTRAINT fk_events_type
FOREIGN KEY (event_type_id) REFERENCES event_types (event_type_id)
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_events_room
FOREIGN KEY (room_id) REFERENCES rooms (room_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE speakers (
speaker_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(120),
biography VARCHAR(1000),
external BOOLEAN NOT NULL DEFAULT TRUE,
CONSTRAINT pk_speakers PRIMARY KEY (speaker_id),
CONSTRAINT uq_speakers_email UNIQUE (email)
);
-- ---------------------------------------------------------------------
-- BLOCK 4 · Rules 6, 7 and 9: registrations, reports, participations
-- ---------------------------------------------------------------------
CREATE TABLE registrations ( -- Rule 7 (N:M with attributes)
event_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
registration_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(15) NOT NULL DEFAULT 'confirmed',
companions INTEGER NOT NULL DEFAULT 0,
CONSTRAINT pk_registrations PRIMARY KEY (event_id, member_id), -- R6
CONSTRAINT fk_registrations_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_registrations_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE event_reports ( -- Rule 6 (1:1, option B)
event_id INTEGER NOT NULL,
actual_attendees INTEGER NOT NULL,
average_rating NUMERIC(3,2),
notes VARCHAR(2000),
report_date DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_event_reports PRIMARY KEY (event_id),
CONSTRAINT fk_event_reports_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE participations ( -- Rule 9 (ternary resolved)
event_id INTEGER NOT NULL,
speaker_id INTEGER NOT NULL,
role VARCHAR(25) NOT NULL,
fee NUMERIC(8,2) NOT NULL DEFAULT 0,
CONSTRAINT pk_participations PRIMARY KEY (event_id, speaker_id, role),
CONSTRAINT fk_participations_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_participations_speaker
FOREIGN KEY (speaker_id) REFERENCES speakers (speaker_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE events_materials ( -- Rule 7 (simple N:M)
event_id INTEGER NOT NULL,
material_id INTEGER NOT NULL,
role VARCHAR(15) NOT NULL DEFAULT 'recommended',
CONSTRAINT pk_events_materials PRIMARY KEY (event_id, material_id),
CONSTRAINT fk_events_materials_event
FOREIGN KEY (event_id) REFERENCES events (event_id)
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT fk_events_materials_material
FOREIGN KEY (material_id) REFERENCES materials (material_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
-- ---------------------------------------------------------------------
-- BLOCK 5 · Fines and payments (R10, R11)
-- ---------------------------------------------------------------------
CREATE TABLE fines (
fine_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
loan_id INTEGER, -- optional: decision D6
reason VARCHAR(15) NOT NULL,
amount NUMERIC(6,2) NOT NULL,
issue_date DATE NOT NULL DEFAULT CURRENT_DATE,
status VARCHAR(15) NOT NULL DEFAULT 'pending',
CONSTRAINT pk_fines PRIMARY KEY (fine_id),
CONSTRAINT uq_fines_loan_reason UNIQUE (loan_id, reason), -- R10
CONSTRAINT fk_fines_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT fk_fines_loan
FOREIGN KEY (loan_id) REFERENCES loans (loan_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE payments ( -- Rule 8 (weak entity)
payment_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
fine_id INTEGER NOT NULL,
payment_date TIMESTAMPTZ NOT NULL DEFAULT now(),
amount NUMERIC(6,2) NOT NULL,
method VARCHAR(15) NOT NULL,
reference VARCHAR(50),
CONSTRAINT pk_payments PRIMARY KEY (payment_id),
CONSTRAINT fk_payments_fine
FOREIGN KEY (fine_id) REFERENCES fines (fine_id)
ON DELETE RESTRICT ON UPDATE CASCADE -- accounting record: no cascade
);
-- ---------------------------------------------------------------------
-- BLOCK 6 · Adapting the existing tables to the hierarchy
-- ---------------------------------------------------------------------
ALTER TABLE copies ADD COLUMN material_id INTEGER;
ALTER TABLE copies ADD COLUMN copy_number INTEGER;
-- (migration: materials inherits the books; copies.material_id = old book_id)
ALTER TABLE copies DROP CONSTRAINT fk_copies_book;
ALTER TABLE copies DROP COLUMN book_id;
ALTER TABLE copies ALTER COLUMN material_id SET NOT NULL;
ALTER TABLE copies
ADD CONSTRAINT uq_copies_material_num UNIQUE (material_id, copy_number),
ADD CONSTRAINT fk_copies_material
FOREIGN KEY (material_id) REFERENCES materials (material_id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE reservations ADD COLUMN material_id INTEGER;
ALTER TABLE reservations DROP CONSTRAINT fk_reservations_book;
ALTER TABLE reservations DROP COLUMN book_id;
ALTER TABLE reservations ALTER COLUMN material_id SET NOT NULL;
ALTER TABLE reservations
ADD CONSTRAINT fk_reservations_material
FOREIGN KEY (material_id) REFERENCES materials (material_id)
ON DELETE CASCADE ON UPDATE CASCADE;
DROP TABLE books;
CREATE VIEW books AS
SELECT m.material_id AS book_id, b.isbn, m.title, m.author_id,
m.publisher, m.publication_year, m.language
FROM materials m
JOIN materials_book b ON b.material_id = m.material_id;Summary of the referential actions
| Foreign key | ON DELETE |
Reason |
|---|---|---|
rooms.branch_id |
RESTRICT | Consistent with members and copies; a branch in use is not deleted |
materials.author_id |
SET NULL | Same as the old books.author_id: the material outlives the author |
materials_*.material_id |
CASCADE | The subtables do not exist without the superclass |
dvd_subtitles.material_id |
CASCADE | Multivalued attribute of the DVD |
member_phones.member_id |
CASCADE | Multivalued attribute of the member |
copies.material_id |
CASCADE | Same as the old copies.book_id |
events.event_type_id |
RESTRICT | A type with historical events is not deleted |
events.room_id |
RESTRICT | Losing the room of a past event destroys Q5 |
registrations.event_id |
CASCADE | The registration does not exist without its event |
registrations.member_id |
RESTRICT | Historical fact with statistical value (Q5) |
event_reports.event_id |
CASCADE | The report does not exist without its event |
participations.event_id |
CASCADE | Same |
participations.speaker_id |
RESTRICT | Record with financial implications |
events_materials.* |
CASCADE | Auxiliary relationship with no value of its own |
fines.member_id |
RESTRICT | Financial record |
fines.loan_id |
RESTRICT | Financial record |
payments.fine_id |
RESTRICT | A weak entity, but an accounting one: never cascade |
The last row deserves attention because it goes against intuition: payments is a weak entity of fines and even so it is not CASCADE. The shape of the relationship suggests the action; the value of the data decides it.
- Post-review: validating the resulting schema
Applying ten rules and sitting back happily is a mistake. There are three checks that are always done.
Check 1 — Every query in the requirements can be answered
We walk through the twelve queries from 04-01 and write the FROM ... JOIN for each one. An extract of the three least obvious ones:
Q5 — Average occupancy of each room by branch and quarter:
SELECT b.name AS branch,
r.name AS room,
DATE_TRUNC('quarter', e.start_time) AS quarter,
ROUND(AVG(oc.occupied_seats::numeric / NULLIF(r.capacity, 0)) * 100, 1) AS occupancy_pct
FROM events e
JOIN rooms r ON r.room_id = e.room_id
JOIN branches b ON b.branch_id = r.branch_id
JOIN v_events_occupancy oc ON oc.event_id = e.event_id
WHERE e.status = 'held'
GROUP BY b.name, r.name, DATE_TRUNC('quarter', e.start_time)
ORDER BY b.name, r.name, quarter;branch | room | quarter | occupancy_pct ---------+-------------------+------------------------+--------------- Central | Multipurpose Room | 2026-04-01 00:00:00+02 | 72.4 Central | Multipurpose Room | 2026-07-01 00:00:00+02 | 61.0 North | Children's Room | 2026-04-01 00:00:00+02 | 88.7 (3 rows)
Q7 — Members with outstanding debt above €20 (rule 4: the outstanding amount is derived):
SELECT m.member_id,
m.first_name || ' ' || m.last_name AS member,
SUM(f.amount - COALESCE(p.paid_amount, 0)) AS debt
FROM members m
JOIN fines f ON f.member_id = m.member_id AND f.status = 'pending'
LEFT JOIN (SELECT fine_id, SUM(amount) AS paid_amount
FROM payments GROUP BY fine_id) p ON p.fine_id = f.fine_id
GROUP BY m.member_id, m.first_name, m.last_name
HAVING SUM(f.amount - COALESCE(p.paid_amount, 0)) > 20
ORDER BY debt DESC; member_id | member | debt
-----------+--------------+-------
15 | Iván Pereda | 27.40
16 | Nuria Bastos | 22.00
(2 rows)Q10 — The ten most borrowed materials by type (this is the one the hierarchy strategy had to make possible):
SELECT m.material_type, m.title, COUNT(*) AS loan_count
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
JOIN materials m ON m.material_id = c.material_id
GROUP BY m.material_type, m.material_id, m.title
ORDER BY loan_count DESC
LIMIT 10;A single JOIN to materials, with no UNION of four branches. It is exactly the benefit we were after when we discarded strategy 3.
Check 2 — No table without a primary key
SELECT t.table_name
FROM information_schema.tables t
LEFT JOIN information_schema.table_constraints c
ON c.table_name = t.table_name
AND c.constraint_type = 'PRIMARY KEY'
WHERE t.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
AND c.constraint_name IS NULL;Zero rows is the correct answer. A table with no primary key admits exact duplicates, cannot be referenced and cannot be updated row by row safely.
Check 3 — No orphan foreign key and no relationship without an FK
Two queries. The first, from the catalog, lists every declared foreign key so you can contrast them with the diagram:
SELECT conrelid::regclass AS table_name,
conname AS constraint_name,
confrelid::regclass AS references_table
FROM pg_constraint
WHERE contype = 'f'
ORDER BY 1, 2;The second looks for columns that look like a foreign key by their name but do not have one declared, which is the most common oversight:
SELECT c.table_name, c.column_name
FROM information_schema.columns c
WHERE c.table_schema = 'public'
AND c.column_name LIKE '%\_id'
AND NOT EXISTS (
SELECT 1 FROM information_schema.key_column_usage k
JOIN information_schema.table_constraints t
ON t.constraint_name = k.constraint_name
WHERE k.table_name = c.table_name
AND k.column_name = c.column_name
AND t.constraint_type IN ('FOREIGN KEY','PRIMARY KEY'))
ORDER BY 1, 2;Final checklist
| # | Check | Passed? |
|---|---|---|
| 1 | The 12 queries in the requirements can be answered | Yes |
| 2 | Every table has a primary key | Yes |
| 3 | Every *_id column is a declared PK or FK |
Yes |
| 4 | Every entity in the diagram has a table | Yes, 21 of 21 |
| 5 | Every relationship in the diagram is represented | Yes |
| 6 | Multivalued attributes are in tables of their own | Yes (2) |
| 7 | Derived attributes are NOT columns | Yes (5, in views) |
| 8 | Natural keys have UNIQUE |
Yes (isbn, issn+number, code, email, room name) |
| 9 | Every referential action is reasoned out | Yes (table in section 12) |
| 10 | Rules BR1–BR10 are assigned | Pending → 04-04 |
Nine out of ten. The tenth is the next lesson.
Common Mistakes and Tips
Putting the foreign key on the 1 side. The most serious structural mistake and the easiest to detect: if the FK would need to hold several values, it is on the wrong side. Reread the cardinality in the diagram and ask yourself "how many values do I need to store here?".
Creating a junction table for a 1:N relationship. It works, but it allows states the business forbids: if events_rooms were a junction table, nothing would stop two rows for the same event, that is, one event in two rooms. The structure must make what is forbidden impossible, not merely allow what is correct.
Forgetting the attributes of the N:M relationship. You create registrations (event_id, member_id) and call it done. The date, the status and the companions have nowhere to go, and they end up appearing in members or in events, where they mean nothing.
Storing a derived value without realizing it. events.branch_id "to avoid the JOIN" creates the possibility of an event claiming to be in Central while its room is in North. If a value can be deduced from the schema, storing it means creating a potential contradiction.
Applying CASCADE for convenience. ON DELETE CASCADE on every foreign key avoids errors while developing and deletes half the schema in production. A DELETE FROM members WHERE member_id = 14 with cascades everywhere takes loans, registrations and fines down with it. Each action is decided separately, with the criterion from 02-06.
Decomposing composite attributes that nobody is going to query by parts. Four address columns for an external speaker whose address is only printed on an envelope is permanent work in exchange for nothing.
Choosing a single table for a hierarchy with many specific attributes. It is the temptation of simplicity, and it produces eleven null columns and eleven conditional CHECKs that nobody maintains. Count the specific attributes before deciding: with more than two or three per subtype, table per subclass almost always wins.
Tip: apply the rules in order and do not improvise. The algorithm works precisely because each step builds on the previous one. Skipping rule 1 and starting with the relationships produces FKs to tables that do not exist yet.
Tip: write down next to each table which rule generated it. The script in section 12 carries those comments. When somebody asks why dvd_subtitles is a separate table, the answer is written down: rule 3, multivalued attribute, R1.
Tip: create the schema in an empty database and run it end to end before calling it good. Creation-order errors, duplicate constraint names and incompatible types in the FKs show up in seconds. A schema script nobody has run is a hypothesis.
Exercises
Exercise 1 — Applying the rules to a new fragment
BiblioRed incorporates the following fragment in v1.1:
R16 — Pickup points. As well as at the four branches, reserved materials can be collected at pickup points (automated lockers) installed in community centers. Each point has a code, an address (street, number, postal code), a number of lockers and the branch that supplies it. A member, when making a reservation, chooses where to collect it: at a branch or at a pickup point. Each point also has opening hours per day of the week (Monday to Sunday, with an opening and a closing time; some days it is closed).
Apply the corresponding rules and write the SQL. Explicitly state which rule you apply at each step, how you resolve the "a branch or a pickup point" part and which referential actions you choose, with your reason.
Exercise 2 — Choosing a strategy for a hierarchy
BiblioRed wants to model the notifications it sends to members. There are three types:
- Return reminder: it carries the associated loan and the number of days remaining.
- Reservation available notice: it carries the associated reservation and the collection deadline.
- Event reminder: it carries the associated event and the number of hours remaining.
They all share: recipient (member), channel (email, sms, push), send date, status (pending, sent, failed) and message text. About 3,000 are sent per month, they are almost always queried all together ("this member's notifications, sorted by date") and they are purged after six months.
- Determine whether the hierarchy is disjoint/overlapping and total/partial.
- Choose one of the three strategies and justify it with at least three arguments from the comparison table.
- Write the SQL for the chosen strategy.
Exercise 3 — Spotting transformation errors
A colleague hands in this part of the schema. Find at least five transformation errors, state which rule has been broken and write the corrected version.
CREATE TABLE events (
event_id SERIAL PRIMARY KEY,
title VARCHAR(200),
room_id INTEGER REFERENCES rooms(room_id),
room_capacity INTEGER,
branch_id INTEGER REFERENCES branches(branch_id),
speaker1_id INTEGER REFERENCES speakers(speaker_id),
speaker2_id INTEGER REFERENCES speakers(speaker_id),
start_time TIMESTAMPTZ,
offered_seats INTEGER,
free_seats INTEGER,
materials VARCHAR(300)
);
CREATE TABLE registrations (
event_id INTEGER REFERENCES events(event_id) ON DELETE CASCADE,
member_id INTEGER REFERENCES members(member_id) ON DELETE CASCADE
);Solutions
Solution to Exercise 1
Rule 1 (strong entity) + Rule 2 (composite attribute):
CREATE TABLE pickup_points (
pickup_point_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(15) NOT NULL,
addr_street VARCHAR(120) NOT NULL,
addr_number VARCHAR(10),
addr_postal_code VARCHAR(5) NOT NULL,
locker_count INTEGER NOT NULL,
branch_id INTEGER NOT NULL, -- Rule 5: 1:N
CONSTRAINT pk_pickup_points PRIMARY KEY (pickup_point_id),
CONSTRAINT uq_pickup_points_code UNIQUE (code),
CONSTRAINT fk_pickup_points_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT ON UPDATE CASCADE
);The address is broken down (rule 2) because the statement lists it in pieces and because the website already searches by postal code (R13). branch_id goes here (rule 5, N side) and it is RESTRICT: a branch with points it supplies is not deleted.
Rule 3 (multivalued attribute): the opening hours. They are multivalued —seven simultaneous values— and composite —opening and closing. They become a table, with the day as the partial identifier:
CREATE TABLE point_hours (
pickup_point_id INTEGER NOT NULL,
day_of_week INTEGER NOT NULL, -- 1 = Monday ... 7 = Sunday
opening_time TIME, -- NULL = closed that day
closing_time TIME,
CONSTRAINT pk_point_hours PRIMARY KEY (pickup_point_id, day_of_week),
CONSTRAINT chk_point_hours_day CHECK (day_of_week BETWEEN 1 AND 7),
CONSTRAINT fk_point_hours_point
FOREIGN KEY (pickup_point_id) REFERENCES pickup_points (pickup_point_id)
ON DELETE CASCADE ON UPDATE CASCADE
);CASCADE because the opening hours do not exist outside their point.
The "a branch or a pickup point": an exclusive relationship. It is the interesting case in the exercise and it admits two solutions:
Option 1 — two nullable foreign keys with a mutual-exclusion CHECK:
ALTER TABLE reservations ADD COLUMN pickup_branch_id INTEGER;
ALTER TABLE reservations ADD COLUMN pickup_point_id INTEGER;
ALTER TABLE reservations
ADD CONSTRAINT fk_reservations_pickup_branch
FOREIGN KEY (pickup_branch_id) REFERENCES branches (branch_id)
ON DELETE RESTRICT,
ADD CONSTRAINT fk_reservations_pickup_point
FOREIGN KEY (pickup_point_id) REFERENCES pickup_points (pickup_point_id)
ON DELETE RESTRICT,
ADD CONSTRAINT chk_reservations_pickup_exclusive
CHECK ((pickup_branch_id IS NOT NULL AND pickup_point_id IS NULL)
OR (pickup_branch_id IS NULL AND pickup_point_id IS NOT NULL));Option 2 — generalize: create a service_points entity of which branches and pickup points are subtypes (rule 10), and have reservations reference it with a single FK.
Option 1 is simpler and sufficient with two alternatives. Option 2 is preferable if more collection places appear tomorrow (mobile library, post office). For v1.1 option 1 is chosen, and it is noted in the decision log that generalization is the plan if a third type shows up.
Solution to Exercise 2
1. Nature of the hierarchy. Disjoint: a notification is of a single type. Total: every notification is one of the three types; generic notifications do not exist.
2. Chosen strategy: single table. Arguments from the comparison table:
- Few specific attributes: each subtype contributes two columns (an FK and a number). With table per subclass there would be four tables to store six columns in total, and three of those tables would be almost trivial.
- The dominant query pattern is over the superclass: "this member's notifications sorted by date" does not need the subtype details. With table per subclass that query, by far the most frequent one, would require a triple
LEFT JOINor three queries. - Volume and life cycle: 3,000 a month with a six-month purge is about 18,000 live rows. Wasting two null columns per row is irrelevant, and the purge is a single
DELETEon one table instead of four coordinated ones. - Nobody references the notifications from outside, so the decisive argument from the materials case does not apply here.
The known price is that you cannot require NOT NULL on the specific FKs and you have to make up for it with conditional CHECKs. With three subtypes and one mandatory column each, that is three perfectly manageable CHECKs.
3. SQL:
CREATE TABLE notifications (
notification_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
channel VARCHAR(10) NOT NULL,
send_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(12) NOT NULL DEFAULT 'pending',
message VARCHAR(500) NOT NULL,
-- specific attributes per subtype
loan_id INTEGER,
days_remaining INTEGER,
reservation_id INTEGER,
deadline_date DATE,
event_id INTEGER,
hours_remaining INTEGER,
CONSTRAINT pk_notifications PRIMARY KEY (notification_id),
CONSTRAINT chk_notifications_type
CHECK (type IN ('return_reminder','reservation_notice','event_reminder')),
CONSTRAINT chk_notifications_channel CHECK (channel IN ('email','sms','push')),
CONSTRAINT chk_notifications_status
CHECK (status IN ('pending','sent','failed')),
-- consistency between the discriminant and the specific attributes
CONSTRAINT chk_notif_return
CHECK (type <> 'return_reminder' OR loan_id IS NOT NULL),
CONSTRAINT chk_notif_reservation
CHECK (type <> 'reservation_notice' OR reservation_id IS NOT NULL),
CONSTRAINT chk_notif_event
CHECK (type <> 'event_reminder' OR event_id IS NOT NULL),
CONSTRAINT fk_notifications_member
FOREIGN KEY (member_id) REFERENCES members (member_id) ON DELETE CASCADE,
CONSTRAINT fk_notifications_loan
FOREIGN KEY (loan_id) REFERENCES loans (loan_id) ON DELETE CASCADE,
CONSTRAINT fk_notifications_reservation
FOREIGN KEY (reservation_id) REFERENCES reservations (reservation_id) ON DELETE CASCADE,
CONSTRAINT fk_notifications_event
FOREIGN KEY (event_id) REFERENCES events (event_id) ON DELETE CASCADE
);Note the contrast with the materials decision: the same question, a different answer, and both correct. The strategy does not depend on the theory of the hierarchy, but on the number of specific attributes, on the query pattern and on whether anybody references the subtypes.
Solution to Exercise 3
| # | Error | Rule broken | Concrete damage |
|---|---|---|---|
| 1 | room_capacity copied into events |
Rule 4 (derived) and "one thing, one place" | When a room is refurbished, 300 events are left with the old capacity; BR1 is validated against a stale value |
| 2 | branch_id in events |
Rule 4 (derived) | The branch is deduced via room_id. You can create an event claiming to be in Central with a room in North: a cycle in the diagram (04-02) |
| 3 | speaker1_id, speaker2_id |
Rule 7 (N:M) | R7 says "several speakers" and "several roles per person". Two columns leave no room for the third, there is nowhere to put the role or the fee, and "how many events did Elena Roig take part in?" needs a UNION |
| 4 | free_seats as a column |
Rule 4 (derived) | It goes out of sync as soon as somebody cancels; Q2 starts lying |
| 5 | materials VARCHAR(300) |
Rule 7 and the comma-separated-list anti-pattern | No FK, no integrity, no way to answer "which events has this book been discussed at?" |
| 6 | registrations with no primary key |
Rule 7 and check 2 | A member can register infinitely many times for the same event, violating R6 |
| 7 | registrations with no attributes of its own |
Rule 7 | There is nowhere to store the date, the status or the companions (R6) |
| 8 | registrations.member_id ON DELETE CASCADE |
Criterion from 02-06 | Deleting a member destroys the attendance history and falsifies Q5 |
| 9 | Missing NOT NULL on title, start_time, offered_seats |
Rule 1 + participation in the diagram | Events can be created with no title and no date |
Corrected version:
CREATE TABLE events (
event_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
title VARCHAR(200) NOT NULL,
event_type_id INTEGER NOT NULL,
room_id INTEGER,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
offered_seats INTEGER NOT NULL,
status VARCHAR(15) NOT NULL DEFAULT 'scheduled',
published BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT pk_events PRIMARY KEY (event_id),
CONSTRAINT fk_events_type FOREIGN KEY (event_type_id)
REFERENCES event_types (event_type_id) ON DELETE RESTRICT,
CONSTRAINT fk_events_room FOREIGN KEY (room_id)
REFERENCES rooms (room_id) ON DELETE RESTRICT
);
-- room_capacity, branch_id and free_seats: removed (derived, view v_events_occupancy)
-- speaker1_id / speaker2_id: replaced by the participations table
-- materials: replaced by the events_materials table
CREATE TABLE registrations (
event_id INTEGER NOT NULL,
member_id INTEGER NOT NULL,
registration_date TIMESTAMPTZ NOT NULL DEFAULT now(),
status VARCHAR(15) NOT NULL DEFAULT 'confirmed',
companions INTEGER NOT NULL DEFAULT 0,
CONSTRAINT pk_registrations PRIMARY KEY (event_id, member_id),
CONSTRAINT fk_registrations_event FOREIGN KEY (event_id)
REFERENCES events (event_id) ON DELETE CASCADE,
CONSTRAINT fk_registrations_member FOREIGN KEY (member_id)
REFERENCES members (member_id) ON DELETE RESTRICT
);Conclusion
This lesson has turned a diagram into an executable schema by means of a ten-rule algorithm.
- Rules 1 to 4 (attributes): every strong entity is a table with its surrogate PK and
UNIQUEon the natural key; composite attributes are broken down into columns —unless nobody queries the parts—; multivalued ones always become a separate table, which eliminates the numbered-column and comma-separated-list anti-patterns at the root; and derived ones are not stored, with two named exceptions: performance (05-04) and historical facts that must be frozen. - Rule 5 (1:N): the foreign key always goes on the N side, because it is the only one that holds a single value. The participation in the diagram translates literally into
NOT NULLor its absence. - Rule 6 (1:1): three options. Merge if both sides are total; FK with a shared PK on the optional side if one is partial —what we chose for
event_reports, because it tells "not written" from "zero attendees"—; an intermediate table only if both are partial. - Rule 7 (N:M): a junction table with two FKs and, above all, with the relationship's own attributes, which is what gets forgotten most. The composite PK implements the business rule without a line of code.
- Rule 8 (weak entity): a composite PK with the owner's. In practice, a weak entity may carry a surrogate key provided its composite natural key is declared
UNIQUE: what is not negotiable is the uniqueness. - Rule 9 (ternary): three FKs, but the decomposition test first. Four out of five apparent ternary relationships are something else; BiblioRed's turned out to be an N:M with the role in the key.
- Rule 10 (hierarchy): three strategies with a comparison table. BiblioRed chose table per subclass because
copies,loansandreservationsneed a common entity, because each subtype has mandatory attributes of its own and because adding a new type touches nothing existing. Discriminant consistency is closed with a composite foreign key(material_id, material_type). - Compatibility with the existing code was solved by turning
booksinto a view: the cleanest example of logical independence in the whole course. - The referential actions were reasoned out one by one, and the conclusion is that the shape of the relationship suggests the action, but the value of the data decides it:
registrations.member_idisRESTRICTwhilereservations.member_idisCASCADE, andpaymentsis a weak entity that must never cascade because it is an accounting record. - The post-review —twelve queries answered, no table without a primary key, no
*_idcolumn without a declared FK— closed nine of the ten points on the checklist.
The tenth is left, and it is the most important one: the ten business rules BR1–BR10 are still nowhere in the schema. Nothing today prevents a fine of −€40, an event that ends before it starts, a capacity of zero or a status of 'confimed' with a typo. And the data types are provisional: there are INTEGERs where SMALLINT would do, VARCHAR(15) sizes set by eye, and the fine amount is in NUMERIC for good reasons we have not yet explained.
In the next lesson, 04-04 Data Types and Constraints, we review the schema column by column: which integer type to choose and when it falls short, why money never goes in floating point —with a demonstration that comes as a surprise—, TIMESTAMP versus TIMESTAMPTZ and the time zone problem, ENUM versus lookup table versus CHECK, the collation that decides whether "Àngels" comes before or after "Angel" when searching titles, and the complete catalog of constraints: NOT NULL, DEFAULT, UNIQUE with its surprising behavior in the face of NULL, single- and multi-column CHECK, generated columns, reusable domains and how to add constraints to a table that already has data without locking it. The result will be the definitive, hardened version of the BiblioRed schema.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
