This is where you learn. Not by reading these solutions, but by comparing them with yours: seeing where you agree, where you've chosen an equally good route and where you've left out a LEFT JOIN that was needed. If you've arrived here without having attempted the project, go back to 12-03: the solution set read cold teaches you almost nothing. Two warnings: a solution that differs from the reference one can be just as valid —what's marked isn't how closely it resembles this text, but whether it meets 12-02 and whether the decision is justified—, and every result is computed over 12-03's data set (36 loans, 20 copies, 15 members) with DATE '2026-06-30' as the reference date.
Contents
- The reference schema: decisions and alternatives
- The 15 solved queries
- The reference indexes
- The view, the procedure and the trigger
- Frequent mistakes in this project
- Common Mistakes and Tips
- Exercises
- Conclusion
- The reference schema: decisions and alternatives
The complete DDL is in 12-03. Here are the decisions that get marked, each with the alternative that would also be correct and when to choose it.
| Reference decision | Valid alternative | When to choose the alternative |
|---|---|---|
works + copies in two tables, and copies.status without the value on_loan |
(none) | Never. They're DR-05/DR-07, and adding on_loan would duplicate what loans already says |
works_authors with a composite PK (work_id, author_id) |
A surrogate id + UNIQUE (work_id, author_id) |
If another table had to reference the credit (royalties per author and work). Until that exists, the composite PK expresses the rule better (05-01) |
due_date stored, and the loan's state derived from the dates |
Computing it from the member's type; a status column with a CHECK maintained overnight |
If the term could change neither by type nor by renewal (here it changes for both reasons), or if states that can't be deduced were needed (in_claim, waived) |
fines as a 1-to-0..1 table, and the queue by reservation_date |
fine_amount/fine_paid columns in loans; a position column maintained by a trigger |
If the fine were a number with no payment date and no waiver; and if the queue had to be reordered by hand (priorities). With pure FIFO, computing the position is strictly better |
| A partial unique index for IR-03 | An EXCLUDE constraint with btree_gist |
When historical overlaps also have to be prevented: see below |
The strong alternative to IR-03: EXCLUDE
The partial index prevents two active loans of the same copy, but not two past loans that overlap. If that matters —and in a migration of old data it matters a great deal—, PostgreSQL has a constraint for exactly this:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE loans ADD CONSTRAINT excl_loans_overlap EXCLUDE USING gist (copy_id WITH =,
daterange(loan_date, COALESCE(return_date, DATE 'infinity'), '[]') WITH &&);It reads: "there can't be two rows with the same copy_id whose loan intervals overlap", and the COALESCE(..., 'infinity') turns the open loan into an interval with no end, so it also covers IR-03. It's more powerful and more expensive: it needs an extension, it uses a GiST index and it's no help for WHERE return_date IS NULL. The criterion: a partial index if you only care about the present; EXCLUDE if you're going to load history or if loans are registered retroactively. Both are correct; you have to be able to say which one you chose.
- The 15 solved queries
Block A — basics and aggregation (QR-01 to QR-08)
-- QR-01 · Works published since 2015
SELECT w.id, w.title, w.publication_year, w.isbn
FROM works AS w
WHERE w.publication_year >= 2015 ORDER BY w.publication_year DESC, w.title;Nine rows, led by Notebook of Shadows (2024) and The Forest of Names (2023). The key decision is the tie-breaker: a bare ORDER BY publication_year DESC would leave ties at the mercy of the plan and the result would change between runs. And Distributed Networks and Systems shows up, the work with no copies at all: it's catalogued, so it appears in the catalogue.
-- QR-02 · Copies at one branch, with their work and its subject
SELECT c.barcode, w.title, s.name AS subject, c.status, c.acquired_date
FROM copies AS c JOIN works AS w ON w.id = c.work_id
JOIN subjects AS s ON s.id = w.subject_id JOIN branches AS b ON b.id = c.branch_id
WHERE b.name = 'Biblioteca Infantil do Parque' ORDER BY w.title, c.barcode;| barcode | title | subject | status | acquired_date |
|---|---|---|---|---|
| ALV-0014 | The Girl Who Counted Stars | Children | available | 2024-02-05 |
| ALV-0015 | The Girl Who Counted Stars | Children | available | 2024-02-05 |
(2 of 3 rows.) Two rows with the same title, and it isn't a mistake: they're two different copies of the same work, which is the heart of the project. Anyone who wrote SELECT DISTINCT w.title has lost exactly the information that was being asked for.
-- QR-03 · Active loans, with days out and days late as of the reference date
SELECT l.id, m.name || ' ' || m.last_name AS member, w.title, b.name AS branch,
l.due_date, DATE '2026-06-30' - l.loan_date AS days_out,
GREATEST(DATE '2026-06-30' - l.due_date, 0) AS days_late
FROM loans AS l JOIN members AS m ON m.id = l.member_id
JOIN copies AS c ON c.id = l.copy_id JOIN works AS w ON w.id = c.work_id
JOIN branches AS b ON b.id = c.branch_id
WHERE l.return_date IS NULL ORDER BY days_late DESC, l.due_date;| id | member | title | branch | due_date | days_out | days_late |
|---|---|---|---|---|---|---|
| 29 | Lena Fuentes | The Garden of Hours | Biblioteca de Vila Nova | 2026-05-25 | 57 | 36 |
| 34 | Nuno Barros | A Brief History of Alvorada | Biblioteca de Vila Nova | 2026-06-29 | 22 | 1 |
(2 of 6 rows; Alba Rey is also 1 day late, and the other three are within term, with 0 days late.) Six active loans, and three are of the same work —the three copies of The Garden of Hours are all out, and that's where QR-10's queue comes from—. The key decision is GREATEST(..., 0): without it, loans within term would come out with a negative delay, which means nothing and pollutes any later sum. And return_date IS NULL, never = NULL: 04-03's mistake that returns zero rows without warning.
-- QR-04 · The system's three gaps, in a single result
SELECT 'member without loans' AS gap, m.id, m.name || ' ' || m.last_name AS description
FROM members AS m LEFT JOIN loans AS l ON l.member_id = m.id WHERE l.id IS NULL
UNION ALL SELECT 'work without copies', w.id, w.title
FROM works AS w LEFT JOIN copies AS c ON c.work_id = w.id WHERE c.id IS NULL
UNION ALL SELECT 'copy never lent', c.id, c.barcode || ' — ' || w.title
FROM copies AS c JOIN works AS w ON w.id = c.work_id
LEFT JOIN loans AS l ON l.copy_id = c.id WHERE l.id IS NULL ORDER BY 1, 2;| gap | id | description |
|---|---|---|
| member without loans | 12 | Irene Sampaio |
| work without copies | 12 | Distributed Networks and Systems |
(2 of 7 rows: 3 copies never lent, 1 work without copies and 3 members without loans.) Three different business readings: copies taking up shelf space without ever going out, a catalogued title that hasn't arrived yet, and cards never used. The technique is 03-03's anti-join (LEFT JOIN + WHERE ... IS NULL), equivalent to NOT EXISTS (07-03) and not to NOT IN, which with a NULL in the subquery would silently return zero rows. UNION ALL and not UNION, because there are no duplicates to remove; and the three branches need the same number of columns and compatible types (03-07), hence the gap label.
-- QR-05 · Subjects with 5+ loans and their average duration. Definition: a loan = any
-- row of loans; duration = days until return, or until today if it's still open
SELECT s.name AS subject, COUNT(l.id) AS loans, COUNT(DISTINCT w.id) AS works,
ROUND(AVG(COALESCE(l.return_date, DATE '2026-06-30')
- l.loan_date), 1) AS avg_days
FROM loans AS l
JOIN copies AS c ON c.id = l.copy_id JOIN works AS w ON w.id = c.work_id
JOIN subjects AS s ON s.id = w.subject_id
GROUP BY s.id, s.name HAVING COUNT(l.id) >= 5 ORDER BY loans DESC, s.name;Three subjects make the cut: Fiction (12 loans, 2 works, 32.3 days on average), Children (8, 2, 14.6) and Computing (6, 1, 26.8). The filter goes in HAVING and not in WHERE because it applies to the already aggregated group: WHERE COUNT(*) >= 5 is a syntax error, and it's 04-06's most repeated failure. The six subjects share out the 36 loans (12 + 8 + 6 + 4 + 4 + 2). And the COALESCE in the duration is a definition decision: without it, AVG would ignore the six open loans and Fiction's average would drop, because the three longest loans right now are precisely the ones that haven't come back.
-- QR-06 · Availability by work. Enabled = with status 'available' (excludes repair,
-- lost and withdrawn); available now = enabled minus the ones currently on loan
SELECT w.title, COUNT(c.id) AS copies, COUNT(la.id) AS on_loan,
COUNT(c.id) FILTER (WHERE c.status = 'available') AS enabled,
COUNT(c.id) FILTER (WHERE c.status = 'available') - COUNT(la.id) AS available
FROM works AS w
LEFT JOIN copies AS c ON c.work_id = w.id
LEFT JOIN loans AS la ON la.copy_id = c.id AND la.return_date IS NULL
GROUP BY w.id, w.title ORDER BY available, w.title;| title | copies | on_loan | enabled | available |
|---|---|---|---|---|
| Distributed Networks and Systems | 0 | 0 | 0 | 0 |
| The Garden of Hours | 3 | 3 | 3 | 0 |
(2 of 12 rows.) The two rows say 0 available for opposite reasons, and an honest report distinguishes them: of the first there isn't a single copy; of the second there are three copies and all three are out on loan. That's why all four columns are published and not just the last one. Three decisions: COUNT(c.id) and not COUNT(*), or the work with no copies would come out with 1; the condition of the LEFT JOIN to loans goes in the ON; and Relational Databases comes out with 3 copies but only 2 enabled, because one is in repair — that difference is exactly what a single counter couldn't express.
-- QR-07 · Works signed by more than one author, in credit order
SELECT w.title, COUNT(*) AS n_authors,
string_agg(a.name || ' ' || a.last_name, ', ' ORDER BY wa.credit_order) AS authors
FROM works AS w JOIN works_authors AS wa ON wa.work_id = w.id
JOIN authors AS a ON a.id = wa.author_id
GROUP BY w.id, w.title HAVING COUNT(*) > 1 ORDER BY w.title;| title | n_authors | authors |
|---|---|---|
| A Brief History of Alvorada | 2 | Ruy Castelo, Tomás Vega |
| Relational Databases | 2 | Pere Aymà, Nora Ibáñez |
(2 of 3 rows; "The Forest of Names", by Clara Meireles and Ada Quiroga, is missing.) The ORDER BY wa.credit_order inside the string_agg is the key decision, and almost everybody skips it: without it, the order of the authors inside the cell is whatever the engine feels like, and a cover credited "Aymà and Ibáñez" could come out reversed. It's the reason works_authors has a credit_order column. And notice that the bridge table carries data of its own (role and credit_order): that turns it into a fully fledged entity and not a mere pair of keys.
-- QR-08 · Fines by member type. A fine = a row of fines, which only exists after the return
-- (BR-10); outstanding = with no paid_date. Does NOT include the potential debt of overdue loans
SELECT m.type, COUNT(f.id) AS fines,
COALESCE(SUM(f.amount), 0) AS total_amount,
COALESCE(SUM(f.amount) FILTER (WHERE f.paid_date IS NOT NULL), 0) AS collected,
COALESCE(SUM(f.amount) FILTER (WHERE f.paid_date IS NULL), 0) AS outstanding
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.id LEFT JOIN fines AS f ON f.loan_id = l.id
GROUP BY m.type ORDER BY total_amount DESC;| type | fines | total_amount | collected | outstanding |
|---|---|---|---|---|
| general | 5 | 25.80 | 12.40 | 13.40 |
| child | 1 | 1.40 | 1.40 | 0.00 |
| senior | 0 | 0.00 | 0.00 | 0.00 |
The €13.40 outstanding belongs to a single member, Diego Andrade, and it's exactly what pushes him over BR-11's €10 threshold and explains his suspended status. The control figure: 6 fines out of 30 returned loans is a late rate of 20.00 %, and 25.80 + 1.40 = €27.20 is the system total. The COALESCE is essential —without it, the senior row would show NULL in a money column, which somebody will read as "no data" (06-04)— and that row must appear: the LEFT JOIN is what saves it.
Block B — subqueries, queues, windows and recursion (QR-09 to QR-15)
-- QR-09 · Members with more loans than the average for their type
SELECT m.id, m.name || ' ' || m.last_name AS member, m.type,
(SELECT COUNT(*) FROM loans AS l WHERE l.member_id = m.id) AS loans,
ROUND((SELECT COUNT(l2.id)::numeric / COUNT(DISTINCT m2.id) FROM members AS m2
LEFT JOIN loans AS l2 ON l2.member_id = m2.id
WHERE m2.type = m.type), 2) AS type_average
FROM members AS m
WHERE (SELECT COUNT(*) FROM loans AS l WHERE l.member_id = m.id)
> (SELECT COUNT(l2.id)::numeric / COUNT(DISTINCT m2.id) FROM members AS m2
LEFT JOIN loans AS l2 ON l2.member_id = m2.id WHERE m2.type = m.type)
ORDER BY m.type, loans DESC, m.id;| id | member | type | loans | type_average |
|---|---|---|---|---|
| 1 | Marta Coelho | general | 4 | 2.56 |
| 5 | Alba Rey | child | 4 | 2.33 |
(2 of 10 rows: 6 general, 2 child and 2 senior.) The subquery is correlated because of the WHERE m2.type = m.type: it's evaluated once per member, with their type (07-02). And the detail that decides whether the figure is right is COUNT(l2.id) versus COUNT(*): with COUNT(*), members with no loans would each contribute a phantom row and the general average would come out at 2.67 instead of 2.56 — close enough for nobody to notice. An equally valid and more readable alternative: a CTE with the averages per type and a JOIN against it; with fifteen members it makes no difference, with fifteen thousand the CTE is evaluated once instead of once per row.
-- QR-10 · The waiting reservation queue, with each member's position
SELECT w.title, m.name || ' ' || m.last_name AS member, r.reservation_date, b.name AS pickup_at,
ROW_NUMBER() OVER (PARTITION BY r.work_id ORDER BY r.reservation_date, r.id) AS position
FROM reservations AS r
JOIN works AS w ON w.id = r.work_id JOIN members AS m ON m.id = r.member_id
JOIN branches AS b ON b.id = r.branch_id WHERE r.status = 'waiting'
ORDER BY w.title, position;| title | member | reservation_date | pickup_at | position |
|---|---|---|---|---|
| The Garden of Hours | Nuno Barros | 2026-06-10 | Biblioteca Central de Alvorada | 1 |
| The Garden of Hours | Manuel Otero | 2026-06-18 | Biblioteca Central de Alvorada | 2 |
| The Garden of Hours | Óscar Vilar | 2026-06-22 | Biblioteca de Vila Nova | 3 |
The position isn't in any column: ROW_NUMBER() computes it. That's the whole solution to the queue problem, and that's why PARTITION BY r.work_id is mandatory: each work has its own queue and the numberings mustn't get mixed up. The r.id as a second criterion isn't optional —two reservations on the same day would end up in arbitrary order, and a member's position would change between two queries—. It's ROW_NUMBER and not RANK on purpose: in a queue there can't be two firsts. And the WHERE leaves out the reservation already collected and the expired one, which are history.
-- QR-11 · The 3 most-borrowed works at each branch
SELECT b.name AS branch, t.rank_, t.title, t.loans
FROM branches AS b LEFT JOIN LATERAL (
SELECT w.title, COUNT(*) AS loans,
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC, w.title) AS rank_
FROM copies AS c JOIN works AS w ON w.id = c.work_id
JOIN loans AS l ON l.copy_id = c.id
WHERE c.branch_id = b.id GROUP BY w.id, w.title
ORDER BY loans DESC, w.title LIMIT 3) AS t ON TRUE
ORDER BY b.id, t.rank_;| branch | rank_ | title | loans |
|---|---|---|---|
| Biblioteca Central de Alvorada | 1 | The Garden of Hours | 6 |
| Biblioteca Infantil do Parque | 1 | The Girl Who Counted Stars | 4 |
(2 of 8 rows; at Vila Nova first place goes to "Relational Databases", with 2.) LATERAL is what lets the subquery see b.id from the outer row; without it, a LIMIT 3 inside an ordinary subquery would give the top 3 for the whole system, repeated at all three branches (07-04). The LEFT JOIN LATERAL ... ON TRUE instead of CROSS JOIN LATERAL is what saves a branch with no loans. An equally valid alternative: a CTE with a partitioned ROW_NUMBER() and a WHERE rank_ <= 3 outside — more portable, because LATERAL isn't in every engine. LATERAL wins when the outer table is small and the inner one huge, because it only reads what it needs from each group.
-- QR-12 · Loans per month for the last 12 months, with no gaps
WITH calendar AS (SELECT generate_series(DATE '2025-07-01', DATE '2026-06-01',
INTERVAL '1 month')::date AS month),
monthly AS (SELECT date_trunc('month', loan_date)::date AS month, COUNT(*) AS n
FROM loans GROUP BY 1)
SELECT to_char(c.month, 'YYYY-MM') AS month, COALESCE(mo.n, 0) AS loans,
SUM(COALESCE(mo.n, 0)) OVER (ORDER BY c.month) AS running_total,
ROUND(AVG(COALESCE(mo.n, 0)) OVER (ORDER BY c.month
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS moving_avg_3
FROM calendar AS c LEFT JOIN monthly AS mo ON mo.month = c.month ORDER BY c.month;
-- Without the calendar, the series would have 10 rows instead of 12 and would start in September| month | loans | running_total | moving_avg_3 |
|---|---|---|---|
| 2025-07 | 0 | 0 | 0.00 |
| 2026-06 | 4 | 36 | 4.00 |
(2 of 12 rows: the first and the last; 2025-07 and 2025-08 are empty.) The two months with zero are the reason for the query. Without the calendar, a GROUP BY over loans would return ten rows and the chart would start in September as if the service hadn't existed before; the LEFT JOIN against generate_series makes them visible (11-01). The COALESCE isn't cosmetic: without it the window would drag a NULL along and the running total would be useless from the first empty row onwards. The running total closes at 36, the total number of loans: the control figure that validates the whole series (11-04).
-- QR-13 · The 3 heaviest readers at each branch, with an explicit tie-breaker
WITH ranking AS (
SELECT b.id AS branch_id, b.name AS branch, m.name || ' ' || m.last_name AS member,
COUNT(l.id) AS loans, MAX(l.loan_date) AS last_loan,
ROW_NUMBER() OVER (PARTITION BY b.id ORDER BY COUNT(l.id) DESC,
MAX(l.loan_date) DESC, m.id) AS rank_
FROM members AS m
JOIN branches AS b ON b.id = m.branch_id LEFT JOIN loans AS l ON l.member_id = m.id
WHERE m.status <> 'closed' GROUP BY m.id, b.id, b.name, m.name, m.last_name)
SELECT branch, rank_, member, loans, last_loan FROM ranking
WHERE rank_ <= 3 ORDER BY branch_id, rank_;| branch | rank_ | member | loans | last_loan |
|---|---|---|---|---|
| Biblioteca Central de Alvorada | 1 | Iván Losada | 4 | 2026-05-12 |
| Biblioteca Infantil do Parque | 3 | Carla Nieto | 0 | (null) |
(2 of 9 rows; Marta Coelho is second at the Central, also with 4.) The tie-breaker is the decision that gets marked. Iván and Marta have the same 4 loans: with RANK() both would be first, and at Vila Nova, where there's a three-way tie on 3, a "top 3" would return three firsts. With ROW_NUMBER() and the criterion most loans → most recent → lowest id, the order is total and stable. Neither of the two is incorrect —RANK is what you want in a sports table—, but you have to choose deliberately and say so. And the second row is a lesson in honesty: the Infantil branch only has three members, so its "third heaviest reader" has zero loans. It's 11-04's warning about publishing rankings from tiny samples.
-- QR-14 · Librarian org chart, with each person's level and hierarchical path
WITH RECURSIVE tree AS (
SELECT lb.id, lb.name || ' ' || lb.last_name AS librarian, lb.job_title,
1 AS level, lb.last_name::text AS path
FROM librarians AS lb WHERE lb.supervisor_id IS NULL -- base case: the director
UNION ALL
SELECT ch.id, ch.name || ' ' || ch.last_name, ch.job_title, t.level + 1, t.path || ' > ' || ch.last_name
FROM librarians AS ch JOIN tree AS t ON t.id = ch.supervisor_id) -- recursive step
SELECT level, repeat(' ', level - 1) || librarian AS org_chart, job_title, path
FROM tree ORDER BY path; -- ORDER BY path = each person below their supervisor| level | org_chart | job_title | path |
|---|---|---|---|
| 1 | Helena Corvo | Network director | Corvo |
| 3 | ········Fátima Cordero | Loans assistant | Corvo > Nogueira > Cordero |
(2 of 8 rows: 1 at level 1, 3 at level 2 and 4 at level 3.) The path does two jobs at once, and that's the trick worth knowing: it can be read at a glance and, above all, it's what makes ORDER BY path put each person below their supervisor. Ordering by level would give you all the supervisors together and then all the assistants, which isn't an org chart. The base case is supervisor_id IS NULL, and that's why the data set needs a librarian with no supervisor: without one the recursion doesn't start and the query returns zero rows. With real data you should also accumulate the visited ids and cut the cycles, because a looping supervisor_id would spin the query forever (10-02).
-- QR-15 · Pivoted report: loans by branch and subject, without losing a single branch
SELECT b.name AS branch,
COUNT(l.id) FILTER (WHERE s.name = 'Fiction') AS fiction,
COUNT(l.id) FILTER (WHERE s.name = 'Poetry') AS poetry,
COUNT(l.id) FILTER (WHERE s.name = 'History') AS history,
COUNT(l.id) FILTER (WHERE s.name = 'Science') AS science,
COUNT(l.id) FILTER (WHERE s.name = 'Children') AS children,
COUNT(l.id) FILTER (WHERE s.name = 'Computing') AS computing, COUNT(l.id) AS total
FROM branches AS b
LEFT JOIN copies AS c ON c.branch_id = b.id LEFT JOIN works AS w ON w.id = c.work_id
LEFT JOIN subjects AS s ON s.id = w.subject_id LEFT JOIN loans AS l ON l.copy_id = c.id
GROUP BY b.id, b.name ORDER BY total DESC;| branch | fiction | poetry | history | science | children | computing | total |
|---|---|---|---|---|---|---|---|
| Biblioteca Central de Alvorada | 8 | 2 | 2 | 2 | 2 | 4 | 20 |
| Biblioteca de Vila Nova | 4 | 0 | 2 | 2 | 0 | 2 | 10 |
| Biblioteca Infantil do Parque | 0 | 0 | 0 | 0 | 6 | 0 | 6 |
Validation by two routes: the rows add up to 20 + 10 + 6 = 36, the total number of loans, and the columns add up to 12 + 2 + 4 + 4 + 8 + 6 = 36 as well. If either of the two didn't work out, the query would be wrong even if the figures looked reasonable (11-04). The chain of four LEFT JOINs is deliberate: it only takes one of them being INNER for a branch with no copies to disappear. And the business reading is immediate: the Infantil do Parque lends only children's books, while the Central is the only one with holdings in all six subjects — an imbalance no overall total would show.
- The reference indexes
Seven indexes, each with the query that justifies it (PR-05). Not one more:
| Index | Query that uses it | What changes in the plan |
|---|---|---|
uq_active_loan_per_copy (unique, partial) |
QR-03, QR-06, the overdue view | It's constraint IR-03 and the access path to the active loans: an Index Scan over a handful of entries instead of a Seq Scan over the whole history |
idx_loans_member |
QR-09, QR-13, the member's record | PostgreSQL does not index FKs (08-01): without it, a member's record reads the whole table. The same goes for idx_copies_work and idx_copies_branch, the two most frequent navigations in the system (QR-02, QR-06, QR-11, QR-15) |
idx_loans_copy_date on (copy_id, loan_date), and idx_loans_date |
QR-11, QR-12, QR-15 and period reports | The composite one filters by copy and throws in the date ordering with no sort (08-02); the second turns the scan of the history into a range read |
idx_works_title_lower on LOWER(title) |
Catalogue search | An expression index: a WHERE LOWER(title) = ... can't use an index on title (08-03) |
What is deliberately not indexed (PR-07): members.type and status (three and four values, the planner will prefer the scan), subjects, branches and publishers in their entirety (they fit in one page), and works_authors, whose composite PK already serves for going from the work to the author — although not the other way round: if "all the works by an author" were needed, you'd have to add (author_id, work_id). That nuance is the column ordering of a composite index that 08-02 explained. And on title search (PR-04): LOWER(title) resolves equality and prefixes (LIKE 'garden%'), but not a word in the middle; for that you need trigrams (pg_trgm with GIN) or full text (to_tsvector). The right answer in the report isn't "I use GIN", it's saying what kind of search is needed and choosing accordingly.
- The view, the procedure and the trigger
CREATE OR REPLACE VIEW v_overdue_loans AS
SELECT l.id AS loan_id, m.id AS member_id, m.name || ' ' || m.last_name AS member,
m.email, w.title, c.barcode, b.name AS branch, l.due_date,
CURRENT_DATE - l.due_date AS days_late,
LEAST(0.20 * (CURRENT_DATE - l.due_date), 20.00) AS estimated_fine
FROM loans AS l JOIN copies AS c ON c.id = l.copy_id
JOIN works AS w ON w.id = c.work_id JOIN branches AS b ON b.id = c.branch_id
JOIN members AS m ON m.id = l.member_id
WHERE l.return_date IS NULL AND l.due_date < CURRENT_DATE;With the reference date it returns three rows: Lena Fuentes with 36 days and €7.20, and Nuno Barros and Alba Rey with 1 day and €0.20 each. Two decisions: it's called estimated_fine because the fine doesn't exist until the return (BR-10), and the LEAST applies the €20 cap inside the view, so nobody has to remember it.
CREATE OR REPLACE PROCEDURE register_return(p_loan_id INTEGER)
LANGUAGE plpgsql AS $$
DECLARE v_due DATE; v_work_id INTEGER; v_late INTEGER;
BEGIN UPDATE loans SET return_date = CURRENT_DATE -- 1. close it
WHERE id = p_loan_id AND return_date IS NULL RETURNING due_date INTO v_due;
IF NOT FOUND THEN RAISE EXCEPTION 'Loan % had already been returned', p_loan_id; END IF;
v_late := CURRENT_DATE - v_due; -- 2. the fine (BR-09)
IF v_late > 0 THEN INSERT INTO fines (loan_id, amount, days_late, issued_date)
VALUES (p_loan_id, LEAST(0.20 * v_late, 20.00), v_late, CURRENT_DATE);
END IF;
SELECT c.work_id INTO v_work_id -- 3. notify (BR-08)
FROM loans AS l JOIN copies AS c ON c.id = l.copy_id WHERE l.id = p_loan_id;
UPDATE reservations SET status = 'available', notified_date = CURRENT_DATE
WHERE id = (SELECT r.id FROM reservations AS r WHERE r.work_id = v_work_id
AND r.status = 'waiting' ORDER BY r.reservation_date, r.id LIMIT 1);
END; $$;Four details worth copying: the UPDATE ... RETURNING reads and writes in a single step (05-02); the AND return_date IS NULL makes the operation idempotent, so calling it twice doesn't close it twice or issue two fines; the IF NOT FOUND turns a silent failure into an exception that rolls the whole transaction back; and the ORDER BY r.reservation_date, r.id respects the queue with a tie-breaker, just like QR-10. With several desks open at once, that SELECT ... LIMIT 1 would also want FOR UPDATE SKIP LOCKED (09-05), so two simultaneous returns of the same work don't notify the same member.
CREATE OR REPLACE FUNCTION fn_check_member() RETURNS TRIGGER LANGUAGE plpgsql AS $$
DECLARE v_status TEXT;
BEGIN SELECT status INTO v_status FROM members WHERE id = NEW.member_id;
IF v_status <> 'active' THEN
RAISE EXCEPTION 'Member % has status "%": they cannot borrow (BR-11)',
NEW.member_id, v_status;
END IF; RETURN NEW;
END; $$;
CREATE TRIGGER trg_loan_member_active BEFORE INSERT ON loans
FOR EACH ROW EXECUTE FUNCTION fn_check_member();With Diego Andrade (member 6, suspended over his €13.40 unpaid), any INSERT into loans ends in ERROR: Member 6 has status "suspended": they cannot borrow (BR-11). It's the only trigger in the project, and its justification is 10-05's: the rule depends on another table, so a CHECK can't express it (05-01), and it must hold wherever the INSERT comes from.
- Frequent mistakes in this project
| Mistake | How it shows up | How it's fixed |
|---|---|---|
| Confusing work and copy, or not being able to express "active loan" | A books table with num_copies, or loans.work_id; an active BOOLEAN that ends up contradicting return_date |
Redo the model —it's the failure that wipes out the whole rubric block—; and remember that return_date IS NULL is the state, protected by the partial index |
| Subtracting dates wrongly, or adding closed and potential debt together | Negative delays, fines computed from loan_date, or an outstanding figure larger than the sum of fines |
GREATEST(diff, 0), LEAST(amount, cap), the delay always measured against the due date, and two separate metrics (BR-10) |
| Deleting instead of closing the record, or modelling the queue with a boolean | A CASCADE that takes the history with it; an is_next that two processes set to TRUE at the same time |
status = 'closed' with ON DELETE RESTRICT (DR-10), and reservation_date + ROW_NUMBER() (QR-10) |
COUNT(*) after a LEFT JOIN, or filtering the right-hand table in the WHERE |
Members with no loans showing 1; or the LEFT JOIN turned into a JOIN and the zeros gone |
COUNT(right_hand_column), and the right-hand table's condition in the ON |
Common Mistakes and Tips
- Reading these solutions instead of comparing against them. Open your file alongside and go query by query: where you agree, confirm that you know why; where you differ, decide which is better and note it in the report. That's the part that gets marked.
- Copying a solution you don't understand. In the defence (12-05) they'll ask you why
LATERALand not a CTE, or whyROW_NUMBERand notRANK; if you don't know, it shows in ten seconds. And taking a query as good because it returns rows. Returning rows isn't returning the right ones. Validate by two routes: QR-15 adds up to 36 by rows and by columns, QR-12 closes the running total at 36 and QR-08 squares with the 6 fines. - Tip: save the output of the fifteen queries to a file. When you touch the schema or the data, a
diffwill tell you instantly what has moved and whether it was what you expected (11-04). And write next to each query the lesson it applies: it turns the project into an index of what you know how to do.
Exercises
Exercise 1
On QR-08. (1) Write the query for each member's real total debt: unpaid fines plus the estimated fine on their overdue loans not yet returned. (2) How much does Diego Andrade owe under that definition and how much under QR-08's? (3) Which one would you publish to the treasury and which to management?
Exercise 2
QR-11 uses LATERAL. (1) Rewrite it with a CTE and ROW_NUMBER(). (2) Give one reason to prefer each version. (3) What happens to each one if a branch has no loans at all?
Exercise 3
A colleague hands this in as "most-borrowed works": SELECT w.title, COUNT(*) FROM works w JOIN copies c ON c.work_id = w.id JOIN loans l ON l.copy_id = c.id GROUP BY w.title ORDER BY 2 DESC LIMIT 5; (1) What three problems does it have? (2) Fix it. (3) Which of the three shows up today with this data?
Solutions
Solution 1 — Two correlated subqueries, one per metric, and never added together in the same column:
SELECT m.id, m.name || ' ' || m.last_name AS member, m.status,
COALESCE((SELECT SUM(f.amount) FROM fines AS f JOIN loans AS l2 ON l2.id = f.loan_id
WHERE l2.member_id = m.id AND f.paid_date IS NULL), 0) AS issued_debt,
COALESCE((SELECT SUM(LEAST(0.20 * (DATE '2026-06-30' - l3.due_date), 20.00))
FROM loans AS l3 WHERE l3.member_id = m.id
AND l3.return_date IS NULL
AND l3.due_date < DATE '2026-06-30'), 0) AS potential_debt
FROM members AS m ORDER BY issued_debt + potential_debt DESC, m.id;(2) Diego Andrade owes €13.40 under both definitions, because he has no overdue loans still out. The ones who change are others: Lena Fuentes goes from €0.00 to €7.20, and Nuno Barros and Alba Rey from €0.00 to €0.20; the total potential debt is €7.60. (3) To the treasury, QR-08's: those are the only euros collectable today, with an issued fine behind them. To management, both columns together, because the potential one anticipates what will come in and points at who to call before the debt grows. What you can never do is add them up in a column called "debt": that's the mistake in section 5.
Solution 2 — The CTE is QR-13's applied to works: ROW_NUMBER() OVER (PARTITION BY b.id ORDER BY COUNT(*) DESC, w.title) over a GROUP BY b.id, w.id, and a WHERE rank_ <= 3 outside. (2) In favour of the CTE: it's standard, portable SQL —LATERAL doesn't exist in MySQL before 8.0.14 or in SQLite— and it reads from top to bottom. In favour of LATERAL: it only brings back 3 rows per branch instead of computing the ranking of every work only to discard almost all of them, which with a hundred thousand titles is the difference between milliseconds and seconds. (3) The CTE loses the branch with no loans, because there'd be nothing to group; the LEFT JOIN LATERAL ... ON TRUE version keeps it with NULL in the subquery's columns. To make them equivalent you'd have to start from branches with a LEFT JOIN against the count.
Solution 3 — (1) It groups by title instead of by id: two different works with the same title —two editions of a classic, a common thing in a library— would be merged into one row with the sum of both. ORDER BY 2 DESC with no tie-breaker: with LIMIT 5 and several works tied, which five come out depends on the plan and the report stops being reproducible. And COUNT(*) with no alias: the column will be called count and whoever reads the result won't know whether it counts loans, copies or rows of the JOIN (11-02). The definition is missing too: it counts all loans, including the open ones and those of members whose accounts were closed. (2) The correct version:
SELECT w.id, w.title, COUNT(l.id) AS loans
FROM works AS w JOIN copies AS c ON c.work_id = w.id JOIN loans AS l ON l.copy_id = c.id
GROUP BY w.id, w.title ORDER BY loans DESC, w.title LIMIT 5;(3) The tie-breaker one. With this data there are no two works with the same title, so the first problem doesn't show up — and that's the danger: the query passes the tests and fails the day somebody catalogues a second edition. On the other hand A Brief History of Alvorada, Letters from the Lighthouse, The Atom and the Doubt and The Girl Who Counted Stars all four have 4 loans, so fifth place in the LIMIT 5 is currently a lottery between four candidates.
Conclusion
You now have something to compare yourself against:
- The reference schema and its decisions, each with its valid alternative: the composite PK of
works_authorsversus the surrogateid, the storeddue_date, derived versus stored state, fines as a table, the computed queue, and above all the partial unique index versus theEXCLUDEconstraint — the first if only the present matters, the second if historical overlaps have to be prevented as well. - The 15 solved queries, each with its key decision: the
ORDER BYtie-breaker (QR-01, QR-13), the two copies of the same title that aren't a duplicate (QR-02), theGREATEST(..., 0)for the delay (QR-03), the anti-join that isn'tNOT IN(QR-04), theHAVINGthat isn't aWHERE(QR-05), theCOUNT(c.id)that isn'tCOUNT(*)(QR-06, QR-09), theORDER BYinside thestring_agg(QR-07), theCOALESCEthat turns theNULLinto0.00(QR-08), the position computed withROW_NUMBER(QR-10), theLATERALthat sees the outer row (QR-11), the calendar that makes the empty months visible (QR-12), thepaththat orders the org chart (QR-14) and the pivot that squares by rows and by columns (QR-15). - Seven indexes with the query that justifies each one and the list of what is deliberately not indexed; plus one view, one procedure and one trigger, and not one more: the view that defines "overdue" and calls
estimated_finewhat isn't a fine yet, the idempotent and atomic procedure, and the only trigger that expresses a rule noCHECKcan. And the frequent mistakes in the project, led by the one that wipes it out —confusing work with copy—, with the reminder that a different solution can be just as valid if it meets the requirements and is justified.
The last part is still missing, and it's the one that decides how everything above is judged. In the next lesson, Presenting the Project, you'll see how a piece of technical work is communicated: the structure of the report section by section, how data results are presented without misleading in good faith, the questions you'll be asked in the defence and how to prepare them, how the project is published in a repository somebody can run in five minutes, the self-assessment with the rubric turned into a checklist — and the close of the whole course.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
