Programming with PythonSQL and Relational Databases › Day 91

Hands-on lab — Day 91: Designing and Querying a Real Schema

Commands

Setup

cd labs/sections/programming-with-python/day-091-designing-and-querying-a-real-schema
python3 --version
sqlite3 --version
sqlite3 :memory: "SELECT sqlite_version(); SELECT row_number() OVER ();"

Run

bash tests/run_tests.sh
bash starter/03_check.sh
sqlite3 library.db < examples/01_schema.sql
sqlite3 library.db < examples/02_seed.sql
sqlite3 library.db < examples/03_questions.sql
sqlite3 rejected.db < examples/04_rejected_design.sql
python3 examples/05_report.py library.db
python3 examples/05_report.py library.db '2026-09-01T09:00:00Z'
sqlite3 library.db < examples/06_answers.sql

Test

bash tests/run_tests.sh

File tree

examples/01_schema.sql
examples/02_seed.sql
examples/03_questions.sql
examples/04_rejected_design.sql
examples/05_report.py
examples/06_answers.sql
expected-output/answers.txt
expected-output/FIELDS.md
expected-output/questions.txt
expected-output/rejected-design.txt
expected-output/report.txt
expected-output/starter-progress.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/00_brief.md
starter/01_schema.sql
starter/02_questions.sql
starter/03_check.sh
tests/run_tests.sh
troubleshooting.md

Lab README

Day 091 lab — From Requirements to Report

Lesson

Purpose

You are handed a paragraph of prose from somebody who runs a small library and has never heard the word "schema". By the end of this lab you have turned it into a working database that answers ten real questions and prints a report a trustee could read.

That is the whole of the week arriving at once. Day 85 gave you the relational model, Day 86 SELECT, Day 87 keys and joins, Day 88 writing data and constraints, Day 89 indexes, Day 90 SQLite from Python. None of that is re-taught here. What is new is the part nobody teaches: the decisions.

Nine of them, and every one has a defensible answer on both sides:

  1. Surrogate key or natural key — and the ISBN makes the case interesting, because it is a genuinely good natural key that is still the wrong primary key.
  2. Is this thing an entity or an attribute? An author is an entity. A published year is not. There is a test.
  3. Does the junction table need columns of its own? A book-author link does, because the cover credits them in an order.
  4. How do you store a date in a database that has no date type?
  5. How do you store money in a language whose floats cannot represent 0.10?
  6. Enumeration as a CHECK constraint or as a lookup table?
  7. Which columns may be NULL — deliberately, meaning something?
  8. Soft delete or hard delete, and who pays for it afterwards?
  9. What do you store, and what do you derive? This one you get wrong first, on purpose, and then fix.

Then the querying half, where the design either pays off or does not: subqueries, EXISTS against IN against a join, common table expressions, one genuinely recursive CTE, window functions for the top-N-per-group problem a GROUP BY cannot solve, and views.

Learning objectives

By the end of this lab you will be able to:

  • Read a paragraph of requirements and list the entities, the relationships and the cardinality of each, before writing any SQL.
  • Choose a surrogate key while keeping the natural key as a UNIQUE constraint, and say what each choice costs.
  • Model a many-to-many relationship as a junction table keyed on the pair, and recognise when that table needs an attribute of its own.
  • Store timestamps as ISO 8601 text in UTC and explain why that makes string comparison chronological, then use that fact in a CHECK constraint.
  • Store money as integer minor units and keep it in integer arithmetic until the moment of display.
  • Decide between a CHECK constraint and a lookup table for an enumeration, with a reason.
  • Make nullability a decision rather than an accident, and say what each NULL in your schema means.
  • Implement soft delete and then pay its price honestly in every present-tense query.
  • Recognise derived data and refuse to store it, having watched a stored version break silently.
  • Write scalar and correlated subqueries, and choose between EXISTS, IN and a join by what reads best and what cannot go wrong.
  • Use a CTE to make an unreadable query readable, and a recursive CTE to answer a hierarchical question no fixed number of joins can.
  • Use ROW_NUMBER, RANK and SUM ... OVER and state exactly what a window function does that GROUP BY cannot.
  • Create a view and describe what it does and does not buy you.
  • Wrap the whole thing in a repository class and print a report a person would actually read.

Prerequisites

  • Day 85 — the relational model, tables, types, and SQLite from the shell.
  • Day 86SELECT, WHERE, ORDER BY, GROUP BY, HAVING, aggregates, and NULL's three-valued logic.
  • Day 87 — primary and foreign keys, one-to-many, many-to-many, LEFT JOIN, the anti-join idiom, and PRAGMA foreign_keys being off by default.
  • Day 88INSERT, UPDATE, transactions, CHECK and UNIQUE constraints, normalization, and migrations.
  • Day 89 — indexes, EXPLAIN QUERY PLAN, and measuring rather than guessing.
  • Day 90 — SQLite from Python, parameter binding, and the repository pattern.
  • Day 70 — floating point, which is why money is an integer here.
  • Day 43 — a working python3 on your PATH.

Supported operating systems

System Status
macOS (Apple Silicon or Intel) Captured here — macOS 26.5.2, arm64
Linux (any current distribution) Expected identical, given the versions below
Windows Use WSL and follow the Linux path. The two shell scripts use mktemp -d; native Windows was not tested and no output is claimed for it

Hardware requirements

Anything. The whole database is a few kilobytes and the longest script finishes in well under a second. No GPU, no network, no disk to speak of.

Required software

Tool Minimum Used here Why
sqlite3 shell 3.25.0 3.51.0 Window functions arrived in 3.25.0 (2018); questions 6, 7 and 8 need them
sqlite3 shell 3.8.3 3.51.0 WITH RECURSIVE arrived in 3.8.3 (2014); question 9 needs it
python3 3.11 3.14.0 Standard library only — sqlite3, sys, pathlib
bash 3.2 3.2.57 The two harness scripts

Check all of it in one line:

sqlite3 :memory: "SELECT sqlite_version(); SELECT row_number() OVER ();"
python3 --version

Free and open-source options

Everything here is free, and two of the three are unusually so.

  • SQLite is in the public domain — not merely open source but released without copyright. Nothing to buy, no licence to accept, no server to run.
  • Python is under the PSF licence, and this lab uses only its standard library, so there is nothing to install.
  • PostgreSQL (PostgreSQL licence) is the free alternative if you want to see the same schema with real types — a native date, a real boolean, a native enum, and numeric for money. The lesson's Alternatives section works through what changes. You do not need it for this lab.
  • DB Browser for SQLite (GPL / MPL, free) will open library.db and draw the tables if you would rather look at the schema than read it. Optional.

No account, no key, no paid tier, and no part of this lab is degraded without one.

Installation

None. Clone or download the repository, change into this directory, and start.

cd labs/sections/programming-with-python/day-091-designing-and-querying-a-real-schema
python3 --version
sqlite3 --version

If either tool lives somewhere unusual, both scripts take an override rather than guessing:

PYTHON=/path/to/python3 SQLITE3=/path/to/sqlite3 bash tests/run_tests.sh

File structure

day-091-designing-and-querying-a-real-schema/
├── README.md                     this file
├── metadata.yml                  lab metadata and the recorded run
├── security.md                   what this lab does to your machine, and the
│                                 privacy decisions the schema itself makes
├── troubleshooting.md            grouped by the message you actually see
├── requirements/
│   ├── README.md                 versions, the two SQLite floors, and what is
│   │                             deliberately absent (no ORM, no server)
│   └── requirements.txt          empty of packages, on purpose
├── starter/                      YOUR work happens here
│   ├── 00_brief.md               the requirements document, in prose
│   ├── 01_schema.sql             3 tables written for you, 6 exercises for you
│   ├── 02_questions.sql          the ten questions, exercises 7-16
│   └── 03_check.sh               "N of 16 exercises complete."
├── examples/                     the reference. Read AFTER you have tried
│   ├── 01_schema.sql             the finished schema, with the reasoning
│   ├── 02_seed.sql               invented data with the awkward cases in it
│   ├── 03_questions.sql          the ten answers, narrated
│   ├── 04_rejected_design.sql    a decision made, broken, and revised
│   ├── 05_report.py              repository class + the printed report
│   └── 06_answers.sql            the same ten answers, machine-readable
├── tests/
│   └── run_tests.sh              72 checks of real values
└── expected-output/              captured from a real run on 2026-08-16
    ├── FIELDS.md                 what must match and what may differ
    ├── answers.txt               the ten answers, pipe-separated
    ├── questions.txt             the ten answers, formatted for reading
    ├── rejected-design.txt       the queue position breaking, silently
    ├── report.txt                the finished report
    ├── starter-progress.txt      0 of 16 before, 16 of 16 after
    └── test-run.txt              the full harness run

How to run

## 1. The whole thing. Start here — it should be green before you change
##    anything, and green again when you have finished.
bash tests/run_tests.sh
echo "exit code: $?"

## 2. Read the brief. Twice. Before writing any SQL.
##    starter/00_brief.md

## 3. Find out where you stand. It will say 0 of 16, and say why.
bash starter/03_check.sh

## 4. Now do the work: write your schema in starter/01_schema.sql and your
##    queries in starter/02_questions.sql, re-running step 3 as you go.

## --- everything below is the reference. Look after you have tried. ---

## 5. Build the reference database.
rm -f library.db
sqlite3 library.db < examples/01_schema.sql
sqlite3 library.db < examples/02_seed.sql

## 6. The ten answers, formatted for reading, with a comment above each query
##    explaining which construct the question forced.
sqlite3 library.db < examples/03_questions.sql

## 7. The decision that was made, tried, and thrown away.
rm -f rejected.db
sqlite3 rejected.db < examples/04_rejected_design.sql

## 8. The report a person would actually read.
python3 examples/05_report.py library.db

## 9. The same report as of a different instant, to prove nothing reads a clock.
python3 examples/05_report.py library.db '2026-09-01T09:00:00Z'

## 10. Clean up the two databases step 5 and step 7 created.
rm -f library.db rejected.db

What the commands do

bash tests/run_tests.sh builds the reference schema, seeds it, and then checks 72 real values: that the schema encodes the decisions it claims to, that eleven impossible rows are actually refused, that all ten questions return the exact expected rows, that the rejected design really does break silently, that the report prints the right numbers, and that the starter reports honest progress. Everything happens in a temporary directory that is removed on exit.

bash starter/03_check.sh builds your schema, loads the shared seed into it, runs your queries and the reference queries against the same data, and compares the ten answer blocks. It never looks at how you wrote a query — only at whether the rows are right. For the schema it uses introspection (pragma_table_info, pragma_foreign_key_list, sqlite_master), so it checks the decisions rather than the text.

sqlite3 library.db < examples/01_schema.sql creates seven tables, nine indexes and two views. Read the comments: every one of them records a decision and its alternative.

sqlite3 library.db < examples/02_seed.sql inserts data chosen so the questions have interesting answers — a member who has never borrowed, a member who left owing money, a book with three authors, a book printed before ISBNs existed, an author whose birth year is genuinely unknown, a withdrawn book, two overdue loans, and a reservation queue with a cancellation in the middle of it.

sqlite3 library.db < examples/03_questions.sql answers the ten questions in .mode column, with a comment above each explaining the choice of construct and why the obvious alternative is worse.

sqlite3 rejected.db < examples/04_rejected_design.sql builds a reservations table that stores the queue position, breaks it in three ordinary statements with no error raised, and then shows the version that derives the position instead.

python3 examples/05_report.py library.db opens the database through LibraryRepository — one method per question, every value bound, never interpolated — and prints the finished report. The report instant is an argument with a default, not a clock reading.

Expected output

The harness ends with a real captured line:

72 checks, 0 failure(s).

and exits 0. The starter reports 0 of 16 exercises complete. with exit 1 before you begin and 16 of 16 exercises complete. with exit 0 when you are done.

The ten answers, exactly:

#### 1
7|4
#### 2
Eli Nakamura|student
#### 3
Structure and Interpretation of Computer Programs|3|Harold Abelson, Gerald Jay Sussman, Julie Sussman
The C Programming Language|2|Brian W. Kernighan, Dennis M. Ritchie
The Practice of Programming|2|Brian W. Kernighan, Rob Pike
#### 4
Bruno Salgado|The Left Hand of Darkness|10
Chandra Iyer|Neuromancer|5
#### 5
Ada Okafor|current|4.10
Farida Haddad|left|3.00
#### 6
staff|1|Chandra Iyer|4
standard|1|Ada Okafor|3
standard|2|Dana Whitfield|2
student|1|Bruno Salgado|4
student|2|Eli Nakamura|0
#### 7
Neuromancer|1|Bruno Salgado
Neuromancer|2|Ada Okafor
The Left Hand of Darkness|1|Ada Okafor
The Left Hand of Darkness|2|Chandra Iyer
The Left Hand of Darkness|3|Dana Whitfield
#### 8
2026-01|1|1
2026-02|1|2
2026-03|1|3
2026-04|1|4
2026-05|2|6
2026-06|3|9
2026-07|3|12
2026-08|2|14
#### 9
0|Fiction|0
1|Gothic|1
1|Science Fiction|1
2|Cyberpunk|1
#### 10
Donald E. Knuth

And the report:

================================================================
FENWICK ROAD COMMUNITY LIBRARY — collection and lending report
as of 2026-08-16T09:00:00Z   (all figures invented for this exercise)
================================================================

1. The collection
-----------------
  7 books on the shelves, 4 of them out on loan.
  1 withdrawn book kept in the record so old loans still resolve.

The full capture is in expected-output/report.txt, and expected-output/FIELDS.md says which values must match on any machine and which are allowed to differ on yours.

The rejected design ends with two members holding the same queue position, and no error raised anywhere:

--- the damage, stated as a number: duplicate positions in one queue ---
queue_position  members_at_this_position
--------------  ------------------------
3               2                       

Validation steps

  1. bash tests/run_tests.sh ends with 72 checks, 0 failure(s). and exits 0.
  2. The schema creates seven tables and two views, and nine explicit indexes — a foreign key creates none of its own.
  3. book_authors is keyed on the pair (book_id, author_id) and carries author_position; books has no author column at all.
  4. isbn13 is UNIQUE and nullable, and exactly one book — Frankenstein, 1818 — legitimately has none.
  5. Eleven impossible rows are refused: a loan due before it was borrowed, a negative fine, a mis-shaped timestamp, an unknown membership tier, an unknown reservation status, a reservation against a book that does not exist, the same author credited twice on one book, two authors credited second, a second waiting reservation by the same member, a malformed ISBN, and a hard delete of a book that has loan history.
  6. Question 1 answers 7 and 4 — and SELECT count(*) FROM books answers 8, which is the cost of soft delete made visible.
  7. Question 6 includes Eli Nakamura with 0, which requires both a LEFT JOIN and count(l.loan_id) rather than count(*).
  8. Question 7 puts Ada Okafor second on the Neuromancer queue, not third: the cancelled reservation occupies no slot.
  9. Question 9 finds Cyberpunk at depth 2, which no fixed number of joins could reach.
  10. examples/04_rejected_design.sql exits 0 — nothing errored — and ends with two members at position 3.
  11. python3 examples/05_report.py library.db totals the fines at GBP 7.10 and never imports datetime; running it with '2026-09-01T09:00:00Z' changes the overdue figures from 10 and 5 days to 26 and 21 and adds two more loans.
  12. After the harness finishes, ls library.db finds nothing — everything was built and removed in a temporary directory.

Tests

bash tests/run_tests.sh
echo "exit code: $?"

72 checks, exit 0 when they all pass and non-zero otherwise. They are value checks, not file-existence checks: the suite asserts the exact rows each of the ten questions returns, introspects the schema to confirm the design decisions, and attempts eleven inserts that must fail.

One check is worth pointing out because it is the one that catches the mistake this day exists to prevent. If the many-to-many relationship is modelled wrongly — an author column on books, a junction table with its own surrogate id instead of the pair as its key, or no credit order — then book_authors is keyed on the PAIR (book_id, author_id) fails, and so does question 3.

The suite also proves it is not vacuous: it deliberately breaks one reference query by removing a soft-delete filter and confirms the checker catches it.

Overrides, if your tools are somewhere unusual:

PYTHON=/path/to/python3 SQLITE3=/path/to/sqlite3 bash tests/run_tests.sh

Cleanup

rm -f library.db rejected.db
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +

Both tests/run_tests.sh and starter/03_check.sh build everything inside mktemp -d and remove it in a trap, so if you only ran those there is nothing to clean up — and the suite asserts as much. The two databases above exist only if you ran the optional walkthrough commands by hand.

To reset your own work and start the exercises again:

git checkout -- starter/

Troubleshooting

troubleshooting.md has the full list, grouped by the message you actually see. The ones you are most likely to meet:

  • near "OVER": syntax error — your sqlite3 predates 3.25.0 and has no window functions.
  • no such table — SQLite silently created an empty database from a mistyped filename rather than complaining. Run .tables before assuming your schema failed.
  • A CHECK that never fires — a column-level check can only see its own column, so a rule comparing two columns must be written at table level; and CHECK (x <> 'bad') is unknown, not false, when x is NULL.
  • CHECK constraint failed on a timestamp — the shape is exactly YYYY-MM-DDTHH:MM:SSZ. A space instead of the T, or a single-digit month, is refused on purpose.
  • misuse of window function — window functions cannot appear in WHERE. Compute in a CTE, filter outside it.
  • SUM ... OVER giving the same number on every row — the ORDER BY inside OVER (...) is missing.
  • A recursive CTE returning one row — the join in the recursive part is the wrong way round.
  • A total of 7.099999999999999 — money left integer arithmetic somewhere.

Security notes

security.md has the full account. In short: nothing here opens a socket, runs sudo, needs a credential, or installs anything, and the test suite checks each of those rather than promising them — including that no URL appears anywhere in the lab's scripts.

The data point worth repeating: the books and authors are real published works, and everything else is invented — the library, the six members, their email addresses, the loans, the fines, the reservations. Every invented address is on the reserved .invalid domain so it can never be delivered to, and the suite fails if any address is not.

The design point, which is this day's own: what you choose to store is the ceiling on what can ever leak, soft delete keeps data you may have been asked to remove, and the disclosure boundary is the query rather than the table — question 4 produces a named person's reading record in a single row.

Extension exercises

  1. Add a copies table and find out what it breaks. The brief quietly assumes one physical copy per title. Real libraries own three copies of the popular ones. Introduce copies(copy_id, book_id, acquired_at, condition), move the loan's foreign key from book_id to copy_id, and then rewrite every one of the ten questions. Some are unchanged, some need one more join, and at least one becomes genuinely ambiguous — "how many books are out?" now has two different correct answers. Write down which, and what you would ask the library.
  2. Turn the tier enumeration into a lookup table, migration and all. Give tiers a display label, a loan allowance and a sort order. Write the migration as Day 88 would: create the table, backfill it, add the foreign key, drop the CHECK. Then answer honestly whether the schema is better, and say what specifically made it so.
  3. Answer question 6 without a window function. It can be done — a correlated subquery counting how many members in the same tier borrowed more is the classic route. Write it, check it gives the same five rows, then run EXPLAIN QUERY PLAN on both and write a paragraph on which you would rather maintain and why.
  4. Make the recursive CTE safe against a cycle. Move Fiction under Cyberpunk so the tree eats itself, and watch what happens. Then add a depth guard, and separately work out what constraint would have prevented the cycle in the first place. Decide whether that constraint is worth having.
  5. Cost the soft delete. Count every query in this lab that had to filter withdrawn_at IS NULL or left_at IS NULL, and every one that deliberately did not. Then design the hard-delete alternative — an archive table, or a deletion job with a retention window — and write down what each version costs at query time, at write time, and in the conversation where somebody asks you to delete their data.
  • Previous day: Day 90 — SQLite from Python (labs/sections/programming-with-python/day-090-sqlite-from-python/).
  • Next day: Day 92 — Beyond Tables: NoSQL and Key-Value Stores (labs/sections/programming-with-python/day-092-beyond-tables-nosql-and-key-value/).
  • Week 13 project: the week's project directory (labs/sections/programming-with-python/projects/week-13/), which builds directly on the schema you designed here.

Expected output

FIELDS.md

# What must match, and what may differ

Every file in this directory was captured from a real run on the authoring
machine on 2026-08-16: macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0,
bash 3.2.57, the `sqlite3` shell 3.51.0, and the SQLite library 3.53.3 that
Python's `sqlite3` module is linked against.

Two of those versions matter for this lab specifically. Window functions —
`ROW_NUMBER`, `RANK`, `SUM ... OVER` — need SQLite 3.25.0 (2018) or newer, and
the test suite checks for them by running one before it does anything else.
Recursive common table expressions need 3.8.3 (2014). Both shells above are far
past those.

## Must match exactly, on any machine

These are values, not formatting, and a difference means something is wrong.

| Value | Where | Must be |
| --- | --- | --- |
| Seeded row counts | all | 8 categories, 11 authors, 8 books, 12 credits, 6 members, 14 loans, 7 reservations |
| Q1 — the collection | `answers.txt` §1 | `7\|4` — 7 books on the shelves, 4 out on loan |
| Books table row count | `test-run.txt` | 8, because the withdrawn book is still a row |
| Q2 — never borrowed | `answers.txt` §2 | exactly `Eli Nakamura\|student` |
| Q3 — multi-author books | `answers.txt` §3 | 3 books; SICP has 3 authors in the order Abelson, Sussman, Sussman |
| Q4 — overdue | `answers.txt` §4 | Bruno Salgado 10 days, Chandra Iyer 5 days |
| Q5 — fines | `answers.txt` §5 | Ada Okafor 4.10 (current), Farida Haddad 3.00 (left) |
| Q6 — top two per tier | `answers.txt` §6 | 5 rows; **Eli Nakamura appears with 0** |
| Q7 — reservation queues | `answers.txt` §7 | Neuromancer 2 deep, The Left Hand of Darkness 3 deep |
| Q8 — monthly running total | `answers.txt` §8 | 8 months, ending at 14 |
| Q9 — the Fiction subtree | `answers.txt` §9 | Fiction (0), Gothic (1), Science Fiction (1), Cyberpunk (1) at depth 2 |
| Q10 — never-borrowed authors | `answers.txt` §10 | exactly `Donald E. Knuth` |
| Total fines | `report.txt` | `GBP 7.10  TOTAL` |
| The stored-position design | `rejected-design.txt` | ends with **two members at position 3**, and no error raised |
| The derived version | `rejected-design.txt` | renumbers to 1, 2 after a cancellation |
| Harness total | `test-run.txt` | `72 checks, 0 failure(s).`, exit 0 |
| Starter before | `starter-progress.txt` | `0 of 16 exercises complete.`, exit 1 |
| Starter after | `starter-progress.txt` | `16 of 16 exercises complete.`, exit 0 |

## Expected to differ on your machine

- **The version banner in `test-run.txt`.** It prints whatever `python3` and
  `sqlite3` you actually have. The `sqlite3` shell and the SQLite library
  Python is linked against are two separate copies and are often two different
  versions; on this machine they were 3.51.0 and 3.53.3.
- **Column padding in `questions.txt`.** `.mode column` sizes each column to
  the widest value it has seen, so alignment shifts if a value changes length.
  `answers.txt` exists precisely because it does not have this problem: it is
  pipe-separated with no padding, which is why the tests compare against that
  file and not this one.
- **The wording of `sqlite3` parse errors** quoted in
  `starter-progress.txt`. Older shells word the prefix differently. The part
  that matters is that the seed cannot load into an unfinished schema.

## Deliberately stable, and why

Every "as of now" answer in this lab is computed against the fixed instant
`2026-08-16T09:00:00Z`, passed as a parameter. Nothing here reads a clock. That
is not a testing convenience bolted on afterwards — it is the design decision
that makes a report reproducible, comparable with last month's copy, and
testable at all. `05_report.py` takes the instant as its second argument, and
the test suite runs it a second time with `2026-09-01T09:00:00Z` to prove the
answers move when the parameter moves: the two overdue loans grow from 10 and 5
days to 26 and 21, and two more loans join them.

## Platform notes

- **Linux** — identical output, given Python 3.11+ and a `sqlite3` shell of
  3.25.0 or newer.
- **Windows** — use WSL and follow the Linux path. `tests/run_tests.sh` and
  `starter/03_check.sh` are bash scripts and `mktemp -d` is a Unix utility;
  neither was run on native Windows here, so no capture is claimed for it.

answers.txt

### 1
7|4
### 2
Eli Nakamura|student
### 3
Structure and Interpretation of Computer Programs|3|Harold Abelson, Gerald Jay Sussman, Julie Sussman
The C Programming Language|2|Brian W. Kernighan, Dennis M. Ritchie
The Practice of Programming|2|Brian W. Kernighan, Rob Pike
### 4
Bruno Salgado|The Left Hand of Darkness|10
Chandra Iyer|Neuromancer|5
### 5
Ada Okafor|current|4.10
Farida Haddad|left|3.00
### 6
staff|1|Chandra Iyer|4
standard|1|Ada Okafor|3
standard|2|Dana Whitfield|2
student|1|Bruno Salgado|4
student|2|Eli Nakamura|0
### 7
Neuromancer|1|Bruno Salgado
Neuromancer|2|Ada Okafor
The Left Hand of Darkness|1|Ada Okafor
The Left Hand of Darkness|2|Chandra Iyer
The Left Hand of Darkness|3|Dana Whitfield
### 8
2026-01|1|1
2026-02|1|2
2026-03|1|3
2026-04|1|4
2026-05|2|6
2026-06|3|9
2026-07|3|12
2026-08|2|14
### 9
0|Fiction|0
1|Gothic|1
1|Science Fiction|1
2|Cyberpunk|1
### 10
Donald E. Knuth
### end

questions.txt

=== 1. How many books are on the shelves, and how many are out? ===
in_collection  on_loan_now  rows_in_books
-------------  -----------  -------------
7              4            8            

=== 2. Which current members have never borrowed anything? ===
member_id  full_name     tier   
---------  ------------  -------
5          Eli Nakamura  student

=== 3. Books with more than one author, in credited order ===
title                                              author_count  credited_order                                   
-------------------------------------------------  ------------  -------------------------------------------------
Structure and Interpretation of Computer Programs  3             Harold Abelson, Gerald Jay Sussman, Julie Sussman
The C Programming Language                         2             Brian W. Kernighan, Dennis M. Ritchie            
The Practice of Programming                        2             Brian W. Kernighan, Rob Pike                     

=== 4. Loans overdue as of 2026-08-16T09:00:00Z, and by how many days ===
loan_id  member         title                      due_at                days_overdue
-------  -------------  -------------------------  --------------------  ------------
4        Bruno Salgado  The Left Hand of Darkness  2026-08-05T13:00:00Z  10          
5        Chandra Iyer   Neuromancer                2026-08-10T10:00:00Z  5           

=== 5. Fines owed per member, in pounds — including members who left ===
full_name      standing  fine_pence  fine_pounds
-------------  --------  ----------  -----------
Ada Okafor     current   410         4.10       
Farida Haddad  left      300         3.00       

=== 6. Top two borrowers in each tier (current members only) ===
tier      position  tier_rank  full_name       loan_count
--------  --------  ---------  --------------  ----------
staff     1         1          Chandra Iyer    4         
standard  1         1          Ada Okafor      3         
standard  2         2          Dana Whitfield  2         
student   1         1          Bruno Salgado   4         
student   2         2          Eli Nakamura    0         

=== 7. The reservation queue for every book with people waiting ===
title                      queue_position  full_name       reserved_at         
-------------------------  --------------  --------------  --------------------
Neuromancer                1               Bruno Salgado   2026-08-05T12:00:00Z
Neuromancer                2               Ada Okafor      2026-08-10T15:00:00Z
The Left Hand of Darkness  1               Ada Okafor      2026-08-01T09:00:00Z
The Left Hand of Darkness  2               Chandra Iyer    2026-08-03T10:00:00Z
The Left Hand of Darkness  3               Dana Whitfield  2026-08-07T11:00:00Z

=== 8. Loans started per month, with a running total ===
month    loans_started  running_total
-------  -------------  -------------
2026-01  1              1            
2026-02  1              2            
2026-03  1              3            
2026-04  1              4            
2026-05  2              6            
2026-06  3              9            
2026-07  3              12           
2026-08  2              14           

=== 9. Everything under Fiction, at any depth, with book counts ===
depth  category         books_in_collection
-----  ---------------  -------------------
0      Fiction          0                  
1      Gothic           1                  
1      Science Fiction  1                  
2      Cyberpunk        1                  

=== 10. Authors none of whose books have ever been borrowed ===
author_id  name           
---------  ---------------
10         Donald E. Knuth

rejected-design.txt

=== ATTEMPT 1: store the queue position as a column ===
reservation_id  member_id  queue_position  status 
--------------  ---------  --------------  -------
1               1          1               waiting
2               3          2               waiting
3               4          3               waiting

--- the member at position 2 cancels ---
reservation_id  member_id  queue_position  status 
--------------  ---------  --------------  -------
1               1          1               waiting
3               4          3               waiting

--- so the queue now reads 1, 3: there is no position 2 ---
positions_now
-------------
1, 3         

--- a fourth member joins, and the code appends "count of waiting + 1" ---
reservation_id  member_id  queue_position  status 
--------------  ---------  --------------  -------
1               1          1               waiting
3               4          3               waiting
4               2          3               waiting

--- the damage, stated as a number: duplicate positions in one queue ---
queue_position  members_at_this_position
--------------  ------------------------
3               2                       

=== WHY IT FAILED ===
queue_position is DERIVED data: it is a function of reserved_at and status.
Storing derived data means promising to recompute it everywhere either
input changes, forever, in every code path, including the ones written
next year by somebody who has not read this file. That promise is not
enforceable by the database, so it is not a promise — it is a hope.
No error was raised at any point above.

=== ATTEMPT 2: do not store it. Derive it. ===
member_id  queue_position  reserved_at         
---------  --------------  --------------------
1          1               2026-08-01T09:00:00Z
4          2               2026-08-07T11:00:00Z
2          3               2026-08-09T09:00:00Z

--- and it stays right when another cancellation happens ---
member_id  queue_position
---------  --------------
1          1             
2          2             

=== THE RULE THIS BOUGHT ===
Store what you are told. Derive what follows from it. A column that can
be computed from other columns is a column that can disagree with them.
The exception is deliberate denormalisation for measured performance —
and then you write down every path that must maintain it (Day 88).

report.txt

================================================================
FENWICK ROAD COMMUNITY LIBRARY — collection and lending report
as of 2026-08-16T09:00:00Z   (all figures invented for this exercise)
================================================================

1. The collection
-----------------
  7 books on the shelves, 4 of them out on loan.
  1 withdrawn book kept in the record so old loans still resolve.

2. Current members who have never borrowed
------------------------------------------
  Eli Nakamura     (student)

3. Books with more than one author
----------------------------------
  Structure and Interpretation of Computer Programs
      3 authors: Harold Abelson, Gerald Jay Sussman, Julie Sussman
  The C Programming Language
      2 authors: Brian W. Kernighan, Dennis M. Ritchie
  The Practice of Programming
      2 authors: Brian W. Kernighan, Rob Pike

4. Overdue loans
----------------
   10 days  Bruno Salgado    The Left Hand of Darkness
            was due 2026-08-05T13:00:00Z
    5 days  Chandra Iyer     Neuromancer
            was due 2026-08-10T10:00:00Z

5. Fines outstanding
--------------------
    GBP 4.10  Ada Okafor       (current)
    GBP 3.00  Farida Haddad    (left)
    GBP 7.10  TOTAL

6. Most active borrowers in each tier
-------------------------------------
  staff:
      1. Chandra Iyer     4 loans
  standard:
      1. Ada Okafor       3 loans
      2. Dana Whitfield   2 loans
  student:
      1. Bruno Salgado    4 loans
      2. Eli Nakamura     0 loans

7. Reservation queues
---------------------
  Neuromancer:
      1. Bruno Salgado
      2. Ada Okafor
  The Left Hand of Darkness:
      1. Ada Okafor
      2. Chandra Iyer
      3. Dana Whitfield

8. Loans started per month
--------------------------
  2026-01   1 #     running total  1
  2026-02   1 #     running total  2
  2026-03   1 #     running total  3
  2026-04   1 #     running total  4
  2026-05   2 ##    running total  6
  2026-06   3 ###   running total  9
  2026-07   3 ###   running total 12
  2026-08   2 ##    running total 14

9. The Fiction shelves, at every depth
--------------------------------------
  Fiction  (0 books)
      Gothic  (1 book)
      Science Fiction  (1 book)
          Cyberpunk  (1 book)

10. Authors never borrowed
--------------------------
  Donald E. Knuth

================================================================
end of report

starter-progress.txt

BEFORE — the starter as it ships
$ bash starter/03_check.sh
Day 091 — from requirements to report

Schema (exercises 1-6)
  still to do: 1. books — see the column list in starter/01_schema.sql. isbn13 must be
               nullable, because the 1818 book in the seed has no ISBN.
  still to do: 2. book_authors — not created yet
  still to do: 3. loans — seven columns, returned_at nullable meaning 'still out', and a
               table CHECK that due_at is later than borrowed_at.
  still to do: 4. reservations — not created yet
  still to do: 5. indexes — 0 of the 9 expected explicit indexes exist
               (a foreign key creates none of its own), and you still need
               the partial index on outstanding loans and the partial
               UNIQUE index on waiting reservations.
  still to do: 6. views — create current_collection and current_members

Questions (exercises 7-16)
  the shared seed will not load into your schema yet, so the ten answers
  cannot be checked. sqlite3 said:
      Parse error near line 57: no such table: books
      Parse error near line 71: no such table: book_authors
      Parse error near line 92: no such table: loans
      Parse error near line 113: no such table: reservations

0 of 16 exercises complete.
exit code: 1

AFTER — with the reference schema and answers in place
$ bash starter/03_check.sh
Day 091 — from requirements to report

Schema (exercises 1-6)
  done      1. books — seven columns, a nullable isbn13, a foreign key to categories
  done      2. book_authors — keyed on the pair, with author_position
  done      3. loans — returned_at nullable, and CHECK (due_at > borrowed_at)
  done      4. reservations — status checked, and no stored queue position
  done      5. indexes — every foreign key covered, plus both partial indexes
  done      6. views — current_collection and current_members

Questions (exercises 7-16)
  done      7. question 1
  done      8. question 2
  done      9. question 3
  done      10. question 4
  done      11. question 5
  done      12. question 6
  done      13. question 7
  done      14. question 8
  done      15. question 9
  done      16. question 10

16 of 16 exercises complete.
exit code: 0

test-run.txt

$ bash tests/run_tests.sh
Day 091 — Designing and Querying a Real Schema
python3: 3.14.0
sqlite3: 3.51.0
sqlite (python): 3.53.3
work:    a temporary directory, removed when this script exits

  ok: the sqlite3 shell supports window functions (3.25.0 or newer)

1. The schema builds, and encodes the decisions it claims to
  ok: 01_schema.sql runs without error
  ok: 02_seed.sql runs without error
  ok: seven tables and two views exist
  ok: row counts: 8 categories, 11 authors, 8 books, 12 credits, 6 members, 14 loans, 7 reservations
  ok: book_authors is keyed on the PAIR (book_id, author_id)
  ok: the junction table carries the relationship's own attribute
  ok: books has no author column: the relationship is not an attribute
  ok: book_authors references both parents
  ok: isbn13 is UNIQUE but nullable, so the 1818 book can exist
  ok: exactly one book legitimately has no ISBN
  ok: email is UNIQUE but is not the primary key
  ok: fine_pence is declared INTEGER, not REAL
  ok: acquisition_cost_pence is declared INTEGER, not REAL
  ok: nine explicit indexes, one per foreign key plus the two partial ones
  ok: the partial index on outstanding loans exists

2. The constraints actually refuse the impossible rows
  ok: a loan due before it was borrowed is refused
  ok: a negative fine is refused
  ok: a timestamp that is not ISO 8601 UTC is refused
  ok: a membership tier nobody has heard of is refused
  ok: a reservation status outside the four documented values is refused
  ok: a reservation against a book we do not own is refused
  ok: crediting the same author twice on one book is refused
  ok: two authors credited second on the same book is refused
  ok: a second WAITING reservation by the same member on the same book is refused
  ok: an ISBN that is not thirteen digits is refused
  ok: hard-deleting a book that has loan history is refused, so history survives
  ok: and nothing above actually got in: the seed row counts are unchanged

3. The ten reporting questions return the right answers
  ok: Q1: 7 books on the shelves, 4 of them out on loan
  ok: Q1: the withdrawn book is excluded — 8 rows in books, 7 in the collection
  ok: Q2: exactly one current member has never borrowed
  ok: Q3: three books have more than one author, names in credited order
  ok: Q4: two loans are overdue, by 10 and 5 whole days
  ok: Q5: fines are 4.10 and 3.00, and the member who left is still counted
  ok: Q6: top two per tier, including the student who has borrowed nothing
  ok: Q7: the queues, with the cancelled reservation occupying no slot
  ok: Q8: eight months of loans, running total reaching 14
  ok: Q9: the recursive walk finds Fiction and its three descendants, to depth 2
  ok: Q10: one author has never been borrowed
  ok: forgetting the soft-delete filter reports 8 books instead of 7
  ok: an INNER JOIN in Q6 would drop the member with no loans entirely
  ok: count(*) instead of count(l.loan_id) reports 1 loan for Eli, not 0
  ok: a GROUP BY alone cannot do top-2-per-tier: it collapses to 3 rows
  ok: EXISTS and the LEFT JOIN anti-join agree on Q2
  ok: money never leaves integer arithmetic: 410 + 300 pence is exactly 710
  ok: and binary floating point is why: 0.1 + 0.2 is not 0.3 in SQLite either

4. The rejected design really does break, silently
  ok: 04_rejected_design.sql runs to completion with no error raised
  ok: the stored-position design ends with two members at the same position
  ok: the v1 queue really does contain a duplicate position
  ok: the derived version renumbers itself correctly after a cancellation

5. The report script prints the report
  ok: 05_report.py exits 0
  ok: the report states 7 books on the shelves and 4 out on loan
  ok: the report totals the fines at GBP 7.10
  ok: the report shows Eli Nakamura with 0 loans rather than omitting her
  ok: the report shows the three-deep queue for The Left Hand of Darkness
  ok: the report indents Cyberpunk two levels under Fiction
  ok: the report refuses to run against a database that does not exist
  ok: with the instant moved forward, two more loans become overdue
  ok: and the two already-overdue loans grow from 10 and 5 days to 26 and 21
  ok: the report reads no clock: it never imports datetime

6. The starter reports honest progress
  ok: the untouched starter reports 0 of 16 exercises complete
  ok: and exits non-zero, so it cannot be mistaken for finished
  ok: it names the many-to-many table among the work still to do
  ok: with the reference schema and answers in place it reports 16 of 16
  ok: and exits 0
  ok: a query that forgets the soft-delete filter is caught, not waved through

7. Hygiene: offline, no sudo, no leaked paths, nothing left behind
  ok: no URL appears anywhere in the lab's scripts
  ok: no line in this lab would actually invoke sudo
  ok: nothing in this lab imports a networking module
  ok: no captured output leaks an absolute home path
  ok: every invented email address uses the reserved .invalid domain
  ok: this suite created no database inside the lab directory

72 checks, 0 failure(s).
exit code: 0

Source files

examples/01_schema.sql (12710 bytes)
-- Day 091 — the reference physical schema for the Fenwick Road brief.
--
-- Read this AFTER you have written your own. Every decision below is one you
-- were asked to make, and the comment says why it went that way and what the
-- alternative would have cost.
--
-- Conventions used throughout, decided once so they never have to be argued
-- about again:
--
--   * Surrogate integer primary keys. In SQLite an INTEGER PRIMARY KEY is an
--     alias for the rowid, so it is the storage key as well as the logical one.
--   * Timestamps are ISO 8601 text in UTC: 'YYYY-MM-DDTHH:MM:SSZ'. SQLite has
--     no date type. Text in this exact shape sorts chronologically as a string,
--     which is the property the whole schema leans on.
--   * Money is an INTEGER count of pence. Never a REAL: 0.1 + 0.2 is not 0.3
--     in binary floating point, and a fines ledger that drifts is a fines
--     ledger nobody trusts.
--   * Small closed sets of values are CHECK constraints. Sets that will grow,
--     or that need a label and a sort order, get a lookup table instead.

PRAGMA foreign_keys = ON;   -- per connection, every connection (Day 87)

DROP TABLE IF EXISTS reservations;
DROP TABLE IF EXISTS loans;
DROP TABLE IF EXISTS book_authors;
DROP TABLE IF EXISTS books;
DROP TABLE IF EXISTS authors;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS categories;

-- ---------------------------------------------------------------------------
-- categories — a hierarchy, modelled with a self-referencing parent
-- ---------------------------------------------------------------------------
-- "Fiction contains Science Fiction, which contains Cyberpunk" is a tree of
-- unknown depth. The adjacency-list model (one nullable parent_id) is the
-- simplest thing that holds it, and a recursive CTE walks it. The alternative
-- — a fixed set of columns, genre and subgenre — breaks the first time
-- somebody adds a third level, and moving a branch would mean rewriting rows
-- all the way down.
CREATE TABLE categories (
  category_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  -- NULL parent means "top level". This is a nullable column on purpose: it
  -- encodes a real fact about the world, not a missing value.
  parent_id   INTEGER REFERENCES categories(category_id) ON DELETE RESTRICT,
  -- Two siblings may not share a name. Two categories in different branches
  -- may: "Classics" under Fiction and "Classics" under Non-fiction are
  -- different shelves.
  --
  -- Honest caveat: SQL treats NULLs as distinct in a UNIQUE constraint, so
  -- this does NOT stop two top-level categories both being called "Fiction".
  -- The fix, if the library ever needs it, is a unique index on
  -- (coalesce(parent_id, -1), name). It is left as written here so that the
  -- limitation is visible rather than papered over.
  UNIQUE (parent_id, name),
  CHECK (parent_id IS NULL OR parent_id <> category_id)
);

-- ---------------------------------------------------------------------------
-- authors — an entity, not an attribute of a book
-- ---------------------------------------------------------------------------
-- The test for "is this an entity?": does it have a life of its own? An author
-- exists before we catalogue their first book and after we withdraw their
-- last, has attributes of its own, and is referenced by more than one book.
-- All three say entity.
CREATE TABLE authors (
  author_id  INTEGER PRIMARY KEY,
  name       TEXT    NOT NULL,
  -- Nullable, and the null means something specific: we do not know. Julie
  -- Sussman's year of birth is not published, and inventing one to avoid a
  -- NULL would put a false fact in the database to satisfy a preference about
  -- column definitions.
  birth_year INTEGER CHECK (birth_year IS NULL OR birth_year BETWEEN 1400 AND 2100)
);

-- ---------------------------------------------------------------------------
-- books — surrogate key, with the natural key kept as a UNIQUE constraint
-- ---------------------------------------------------------------------------
-- The ISBN is a genuine natural key: it is assigned by an external authority
-- and identifies the edition. It is still not the primary key, for two
-- reasons the brief states outright. Books printed before 1970 do not have
-- one, and a primary key cannot be NULL. And catalogue staff mistype them,
-- which under a natural key means updating every child row to correct one
-- character.
--
-- Keeping it as UNIQUE gets the integrity guarantee without the coupling.
CREATE TABLE books (
  book_id               INTEGER PRIMARY KEY,
  isbn13                TEXT    UNIQUE
                        CHECK (isbn13 IS NULL OR
                               isbn13 GLOB '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]'),
  title                 TEXT    NOT NULL,
  published_year        INTEGER CHECK (published_year IS NULL OR published_year BETWEEN 1400 AND 2100),
  category_id           INTEGER NOT NULL REFERENCES categories(category_id) ON DELETE RESTRICT,
  -- Money as integer minor units. £12.99 is 1299.
  acquisition_cost_pence INTEGER NOT NULL CHECK (acquisition_cost_pence >= 0),
  -- Soft delete. NULL means "on the shelves". A timestamp means withdrawn, and
  -- the row stays so that old loans still resolve to a title. The price is
  -- that every query about the current collection must remember to filter, and
  -- the one that forgets is wrong without erroring.
  withdrawn_at          TEXT    CHECK (withdrawn_at IS NULL OR withdrawn_at LIKE '____-__-__T__:__:__Z')
);

-- ---------------------------------------------------------------------------
-- book_authors — a junction table that earns columns of its own
-- ---------------------------------------------------------------------------
-- Many-to-many, so the relationship gets a table. The primary key is the pair,
-- which makes it impossible to credit the same author on the same book twice.
-- author_position is the point of this table in a design lesson: a junction
-- table is not always a pure link. The moment the relationship itself has an
-- attribute — here, the order the cover credits them in — that attribute has
-- nowhere else to live.
CREATE TABLE book_authors (
  book_id         INTEGER NOT NULL REFERENCES books(book_id)     ON DELETE CASCADE,
  author_id       INTEGER NOT NULL REFERENCES authors(author_id) ON DELETE RESTRICT,
  author_position INTEGER NOT NULL CHECK (author_position >= 1),
  PRIMARY KEY (book_id, author_id),
  -- Two authors cannot both be credited second on the same book.
  UNIQUE (book_id, author_position)
);

-- ---------------------------------------------------------------------------
-- members
-- ---------------------------------------------------------------------------
CREATE TABLE members (
  member_id INTEGER PRIMARY KEY,
  -- The other natural key in this schema, and the same treatment: UNIQUE, not
  -- PRIMARY KEY. People change their email address; a key you have to update
  -- is not a key.
  email     TEXT    NOT NULL UNIQUE CHECK (email LIKE '_%@_%._%'),
  full_name TEXT    NOT NULL,
  -- An enumeration of three values that changes about once a decade. A CHECK
  -- constraint costs one line and no join. If the library later wants a label,
  -- a loan allowance and a display order per tier, this becomes a lookup table
  -- — and that migration is one ALTER TABLE plus a backfill (Day 88).
  tier      TEXT    NOT NULL DEFAULT 'standard'
                    CHECK (tier IN ('standard', 'student', 'staff')),
  joined_at TEXT    NOT NULL CHECK (joined_at LIKE '____-__-__T__:__:__Z'),
  -- Soft delete again, and for a different reason from books: the brief says
  -- the money they owe has to survive. NULL means a current member.
  left_at   TEXT    CHECK (left_at IS NULL OR left_at LIKE '____-__-__T__:__:__Z'),
  CHECK (left_at IS NULL OR left_at >= joined_at)
);

-- ---------------------------------------------------------------------------
-- loans
-- ---------------------------------------------------------------------------
-- ON DELETE RESTRICT on both parents, deliberately. A hard DELETE of a book or
-- a member that still has loan history should fail loudly, because the brief
-- says that history must survive. Withdrawal and leaving are soft, and the
-- soft path is the one the application is supposed to take.
CREATE TABLE loans (
  loan_id     INTEGER PRIMARY KEY,
  book_id     INTEGER NOT NULL REFERENCES books(book_id)     ON DELETE RESTRICT,
  member_id   INTEGER NOT NULL REFERENCES members(member_id) ON DELETE RESTRICT,
  borrowed_at TEXT    NOT NULL CHECK (borrowed_at LIKE '____-__-__T__:__:__Z'),
  due_at      TEXT    NOT NULL CHECK (due_at      LIKE '____-__-__T__:__:__Z'),
  -- NULL means "still out". Not a sentinel date, not a separate boolean that
  -- could disagree with the date. One column, one meaning.
  returned_at TEXT             CHECK (returned_at IS NULL OR returned_at LIKE '____-__-__T__:__:__Z'),
  fine_pence  INTEGER NOT NULL DEFAULT 0 CHECK (fine_pence >= 0),
  -- Because timestamps are ISO 8601 in UTC, string comparison IS chronological
  -- comparison. This constraint is the payoff for that format decision.
  CHECK (due_at > borrowed_at),
  CHECK (returned_at IS NULL OR returned_at >= borrowed_at)
);

-- ---------------------------------------------------------------------------
-- reservations
-- ---------------------------------------------------------------------------
-- A queue. There is no position column, and that is the design decision worth
-- arguing about: a stored position has to be renumbered every time somebody
-- cancels, and a renumbering you forget leaves two people at position 3.
-- The position is derived at query time from reserved_at with ROW_NUMBER.
CREATE TABLE reservations (
  reservation_id INTEGER PRIMARY KEY,
  book_id        INTEGER NOT NULL REFERENCES books(book_id)     ON DELETE CASCADE,
  member_id      INTEGER NOT NULL REFERENCES members(member_id) ON DELETE CASCADE,
  reserved_at    TEXT    NOT NULL CHECK (reserved_at LIKE '____-__-__T__:__:__Z'),
  status         TEXT    NOT NULL DEFAULT 'waiting'
                         CHECK (status IN ('waiting', 'collected', 'cancelled', 'expired')),
  -- One member may not hold two live reservations on the same book. Partial
  -- indexes make this enforceable only for the status that matters.
  UNIQUE (book_id, member_id, reserved_at)
);

-- ---------------------------------------------------------------------------
-- Indexes (Day 89)
-- ---------------------------------------------------------------------------
-- Declaring a foreign key creates NO index. Every one below exists because a
-- query in 03_questions.sql joins or filters on that column.
CREATE INDEX idx_books_category      ON books(category_id);
CREATE INDEX idx_categories_parent   ON categories(parent_id);
CREATE INDEX idx_book_authors_author ON book_authors(author_id);
CREATE INDEX idx_loans_book          ON loans(book_id);
CREATE INDEX idx_loans_member        ON loans(member_id);
CREATE INDEX idx_reservations_book   ON reservations(book_id);
CREATE INDEX idx_reservations_member ON reservations(member_id);

-- A partial index: only the rows that are still out. Questions 1 and 4 are
-- both "what is out right now", and outstanding loans are a small and roughly
-- constant fraction of a loans table that grows forever.
CREATE INDEX idx_loans_outstanding ON loans(due_at) WHERE returned_at IS NULL;

-- Only one member may be at the front of a queue for a book at a time is NOT
-- what this says; it says one member may hold at most one *waiting*
-- reservation per book, while leaving their cancelled and expired history
-- alone. A plain UNIQUE could not express that.
CREATE UNIQUE INDEX idx_reservations_one_waiting
  ON reservations(book_id, member_id) WHERE status = 'waiting';

-- ---------------------------------------------------------------------------
-- A view: the saved query the reports lean on
-- ---------------------------------------------------------------------------
-- A view stores the query text, not its result. It buys a name for a piece of
-- reasoning — "what does the collection currently consist of" — and it buys
-- consistency, because every report that uses it filters withdrawn books the
-- same way. It does not buy speed: it is expanded into whatever query uses it
-- and runs afresh every time.
CREATE VIEW current_collection AS
  SELECT book_id, isbn13, title, published_year, category_id, acquisition_cost_pence
    FROM books
   WHERE withdrawn_at IS NULL;

CREATE VIEW current_members AS
  SELECT member_id, email, full_name, tier, joined_at
    FROM members
   WHERE left_at IS NULL;
examples/02_seed.sql (7150 bytes)
-- Day 091 — seed data for the Fenwick Road brief.
--
-- The books and the authors are real published works and their real authors.
-- EVERYTHING ELSE IS INVENTED: the library does not exist, the six members do
-- not exist, their email addresses are on the reserved .invalid domain so they
-- can never be delivered to anybody, and no real borrowing record was used.
-- A library loan record ties a named person to what they read, which is
-- exactly the kind of data you do not practise on.
--
-- The data deliberately contains the awkward cases, because a seed where every
-- question has a tidy answer teaches nothing:
--
--   * a member who has never borrowed anything      (Eli Nakamura)
--   * a member who has left but still owes money    (Farida Haddad)
--   * a book with two authors and a book with three (101 and 103)
--   * a book with no ISBN, printed before they existed (108, Frankenstein)
--   * an author whose year of birth is not published (Julie Sussman)
--   * a withdrawn book that still has to resolve in history (107)
--   * two overdue loans and two that are out but not yet due
--   * a reservation queue three deep, plus a cancelled and a collected one
--
-- The ISBN-13 values below are the identifiers of widely held editions of
-- these works, and each one passes the ISBN-13 checksum. They are here to give
-- the natural-key discussion something real to argue about; if you are
-- cataloguing your own shelf, take the numbers off the books in front of you
-- rather than from this file.

PRAGMA foreign_keys = ON;

BEGIN;

-- Categories: a tree. Fiction > Science Fiction > Cyberpunk is three levels
-- deep, which is what makes question 9 need recursion rather than a join.
INSERT INTO categories (category_id, name, parent_id) VALUES
  (1, 'Fiction',              NULL),
  (2, 'Non-fiction',          NULL),
  (3, 'Science Fiction',      1),
  (4, 'Cyberpunk',            3),
  (5, 'Gothic',               1),
  (6, 'Computing',            2),
  (7, 'Programming',          6),
  (8, 'Software Engineering', 6);

INSERT INTO authors (author_id, name, birth_year) VALUES
  (1,  'Brian W. Kernighan',      1942),
  (2,  'Dennis M. Ritchie',       1941),
  (3,  'Frederick P. Brooks Jr.', 1931),
  (4,  'Harold Abelson',          1947),
  (5,  'Gerald Jay Sussman',      1947),
  (6,  'Julie Sussman',           NULL),   -- not published; NULL means unknown
  (7,  'Rob Pike',                1956),
  (8,  'Ursula K. Le Guin',       1929),
  (9,  'William Gibson',          1948),
  (10, 'Donald E. Knuth',         1938),
  (11, 'Mary Shelley',            1797);

INSERT INTO books
  (book_id, isbn13, title, published_year, category_id, acquisition_cost_pence, withdrawn_at)
VALUES
  (101, '9780131103627', 'The C Programming Language',                        1978, 7, 3499, NULL),
  (102, '9780201835953', 'The Mythical Man-Month',                            1975, 8, 2899, NULL),
  (103, '9780262510875', 'Structure and Interpretation of Computer Programs',  1985, 7, 5250, NULL),
  (104, '9780201615869', 'The Practice of Programming',                        1999, 7, 3199, NULL),
  (105, '9780441478125', 'The Left Hand of Darkness',                          1969, 3,  899, NULL),
  (106, '9780441569595', 'Neuromancer',                                        1984, 4,  999, NULL),
  -- Withdrawn: off the shelves, still referenced by nothing yet, still a row.
  (107, '9780201896831', 'The Art of Computer Programming, Volume 1',          1968, 7, 6995, '2026-06-01T10:00:00Z'),
  -- Printed in 1818. There is no ISBN, and no honest value to put here but NULL.
  (108, NULL,            'Frankenstein',                                       1818, 5,  650, NULL);

INSERT INTO book_authors (book_id, author_id, author_position) VALUES
  (101, 1, 1), (101, 2, 2),
  (102, 3, 1),
  (103, 4, 1), (103, 5, 2), (103, 6, 3),
  (104, 1, 1), (104, 7, 2),
  (105, 8, 1),
  (106, 9, 1),
  (107, 10, 1),
  (108, 11, 1);

-- Invented people. The .invalid top-level domain is reserved precisely so that
-- an address using it cannot resolve or be delivered to.
INSERT INTO members (member_id, email, full_name, tier, joined_at, left_at) VALUES
  (1, 'ada.okafor@fenwick.invalid',     'Ada Okafor',     'standard', '2024-01-15T10:00:00Z', NULL),
  (2, 'bruno.salgado@fenwick.invalid',  'Bruno Salgado',  'student',  '2024-03-02T10:00:00Z', NULL),
  (3, 'chandra.iyer@fenwick.invalid',   'Chandra Iyer',   'staff',    '2023-11-20T10:00:00Z', NULL),
  (4, 'dana.whitfield@fenwick.invalid', 'Dana Whitfield', 'standard', '2025-02-10T10:00:00Z', NULL),
  (5, 'eli.nakamura@fenwick.invalid',   'Eli Nakamura',   'student',  '2025-06-01T10:00:00Z', NULL),
  -- Left in March, and still owes £3.00 from a book returned two weeks late.
  (6, 'farida.haddad@fenwick.invalid',  'Farida Haddad',  'staff',    '2022-09-05T10:00:00Z', '2026-03-01T10:00:00Z');

INSERT INTO loans
  (loan_id, book_id, member_id, borrowed_at, due_at, returned_at, fine_pence)
VALUES
  (1,  101, 1, '2026-05-04T10:15:00Z', '2026-05-25T10:15:00Z', '2026-05-20T09:00:00Z',   0),
  (2,  102, 1, '2026-06-02T11:00:00Z', '2026-06-23T11:00:00Z', '2026-07-04T16:30:00Z', 220),
  (3,  103, 2, '2026-06-10T09:30:00Z', '2026-07-01T09:30:00Z', '2026-06-28T14:00:00Z',   0),
  -- Out and overdue as of the report time.
  (4,  105, 2, '2026-07-15T13:00:00Z', '2026-08-05T13:00:00Z', NULL,                     0),
  (5,  106, 3, '2026-07-20T10:00:00Z', '2026-08-10T10:00:00Z', NULL,                     0),
  -- Out, not yet due.
  (6,  101, 3, '2026-08-01T09:00:00Z', '2026-08-22T09:00:00Z', NULL,                     0),
  (7,  102, 4, '2026-04-12T15:00:00Z', '2026-05-03T15:00:00Z', '2026-05-01T10:00:00Z',   0),
  (8,  104, 1, '2026-07-02T10:00:00Z', '2026-07-23T10:00:00Z', '2026-08-01T11:00:00Z', 190),
  (9,  105, 3, '2026-03-05T09:00:00Z', '2026-03-26T09:00:00Z', '2026-03-20T12:00:00Z',   0),
  (10, 108, 2, '2026-05-18T14:00:00Z', '2026-06-08T14:00:00Z', '2026-06-05T09:00:00Z',   0),
  (11, 103, 4, '2026-08-03T11:00:00Z', '2026-08-24T11:00:00Z', NULL,                     0),
  -- Farida's, from before she left. The fine survives her membership.
  (12, 106, 6, '2026-01-10T10:00:00Z', '2026-01-31T10:00:00Z', '2026-02-15T10:00:00Z', 300),
  (13, 101, 2, '2026-02-14T10:00:00Z', '2026-03-07T10:00:00Z', '2026-03-02T10:00:00Z',   0),
  (14, 104, 3, '2026-06-20T10:00:00Z', '2026-07-11T10:00:00Z', '2026-07-09T10:00:00Z',   0);

INSERT INTO reservations (reservation_id, book_id, member_id, reserved_at, status) VALUES
  -- Book 105 is out until further notice; three people are waiting for it.
  (1, 105, 1, '2026-08-01T09:00:00Z', 'waiting'),
  (2, 105, 3, '2026-08-03T10:00:00Z', 'waiting'),
  (3, 105, 4, '2026-08-07T11:00:00Z', 'waiting'),
  -- Book 106: one waiting, one cancelled (which must not occupy a queue slot),
  -- and a later one that is therefore second and not third.
  (4, 106, 2, '2026-08-05T12:00:00Z', 'waiting'),
  (5, 106, 4, '2026-08-06T08:00:00Z', 'cancelled'),
  (6, 106, 1, '2026-08-10T15:00:00Z', 'waiting'),
  -- Already collected, so book 103 has nobody waiting at all.
  (7, 103, 1, '2026-07-28T09:00:00Z', 'collected');

COMMIT;
examples/03_questions.sql (10197 bytes)
-- Day 091 — the ten questions from the brief, answered.
--
-- Run against a database built by 01_schema.sql and 02_seed.sql:
--   sqlite3 library.db < examples/03_questions.sql
--
-- Every "as of now" question uses the fixed report instant
-- 2026-08-16T09:00:00Z so the answers are reproducible and testable.
--
-- Read the comment above each query before the query. The interesting part is
-- which construct each question forced, and why the obvious alternative is
-- either wrong or unreadable.

PRAGMA foreign_keys = ON;
.mode column
.headers on
.width 0

.print '=== 1. How many books are on the shelves, and how many are out? ==='
-- Two scalar subqueries in the SELECT list. Each returns exactly one row and
-- one column, which is the only thing a scalar subquery is allowed to do.
-- Neither depends on the other, so neither is correlated. Written as a join
-- this would need a cross join of two aggregates to say the same thing less
-- clearly.
--
-- Note what "on the shelves" had to mean: withdrawn_at IS NULL. That filter is
-- the running cost of soft delete, and the query that forgets it reports 8.
SELECT
  (SELECT count(*) FROM books WHERE withdrawn_at IS NULL)          AS in_collection,
  (SELECT count(*) FROM loans WHERE returned_at IS NULL)           AS on_loan_now,
  (SELECT count(*) FROM books)                                     AS rows_in_books;

.print ''
.print '=== 2. Which current members have never borrowed anything? ==='
-- NOT EXISTS, and this is the shape to reach for by default. It is a
-- correlated subquery: the inner query mentions m.member_id from the outer
-- one, so it is conceptually re-evaluated per member. It stops at the first
-- matching row rather than building a list, it multiplies no rows, and —
-- unlike NOT IN — it is immune to a NULL in the subquery's column.
SELECT m.member_id, m.full_name, m.tier
  FROM members AS m
 WHERE m.left_at IS NULL
   AND NOT EXISTS (SELECT 1 FROM loans AS l WHERE l.member_id = m.member_id)
 ORDER BY m.member_id;

.print ''
.print '=== 3. Books with more than one author, in credited order ==='
-- The junction table earning its extra column. author_position is not derivable
-- from anything else — not from the author id, not from the name, not from the
-- insertion order, which SQL does not promise to preserve.
--
-- HAVING rather than WHERE, because the condition is on the aggregate.
SELECT b.title,
       count(*)                                              AS author_count,
       group_concat(a.name, ', ')                            AS credited_order
  FROM books        AS b
  JOIN book_authors AS ba ON ba.book_id  = b.book_id
  JOIN authors      AS a  ON a.author_id = ba.author_id
 GROUP BY b.book_id, b.title
HAVING count(*) > 1
 ORDER BY author_count DESC, b.title;

.print ''
.print '=== 4. Loans overdue as of 2026-08-16T09:00:00Z, and by how many days ==='
-- ISO 8601 in UTC makes "overdue" a string comparison, and julianday() reads
-- the same text to give the difference in days. CAST to INTEGER truncates
-- towards zero, which is what "whole days late" means.
--
-- The WITH clause here holds one value. That is not overkill: it puts the
-- report instant in exactly one place, so changing it cannot leave two halves
-- of the query disagreeing about what "now" means.
WITH report(now) AS (VALUES ('2026-08-16T09:00:00Z'))
SELECT l.loan_id,
       m.full_name                                                  AS member,
       b.title,
       l.due_at,
       CAST(julianday((SELECT now FROM report)) - julianday(l.due_at) AS INTEGER)
                                                                    AS days_overdue
  FROM loans   AS l
  JOIN members AS m ON m.member_id = l.member_id
  JOIN books   AS b ON b.book_id   = l.book_id
 WHERE l.returned_at IS NULL
   AND l.due_at < (SELECT now FROM report)
 ORDER BY days_overdue DESC;

.print ''
.print '=== 5. Fines owed per member, in pounds — including members who left ==='
-- Money lives as an integer count of pence and is divided by 100 exactly once,
-- at the very last moment, for display. Every sum, every comparison and every
-- stored value above this line is an integer.
--
-- This question deliberately does NOT filter left_at: a debt does not stop
-- existing because somebody cancelled their membership. Question 6 does filter
-- it. Which soft-delete filter applies is a property of the question, not of
-- the table, and that is the entire cost of soft delete.
SELECT m.full_name,
       CASE WHEN m.left_at IS NULL THEN 'current' ELSE 'left' END   AS standing,
       sum(l.fine_pence)                                            AS fine_pence,
       printf('%.2f', sum(l.fine_pence) / 100.0)                    AS fine_pounds
  FROM members AS m
  JOIN loans   AS l ON l.member_id = m.member_id
 GROUP BY m.member_id, m.full_name
HAVING sum(l.fine_pence) > 0
 ORDER BY fine_pence DESC;

.print ''
.print '=== 6. Top two borrowers in each tier (current members only) ==='
-- The top-N-per-group problem, and the reason window functions exist.
--
-- A plain GROUP BY can give you the count per member, or the maximum count per
-- tier, but it cannot give you the top two *rows* per tier: aggregation
-- collapses the rows it aggregates, so the member's name is gone by the time
-- you know the count is a winner. ROW_NUMBER computes a value across a
-- partition without collapsing anything, so every column survives to be
-- filtered on in the outer query.
--
-- The LEFT JOIN inside the CTE is what keeps Eli Nakamura, who has borrowed
-- nothing, in her tier's ranking at all. count(l.loan_id) rather than count(*)
-- is what makes her count 0 rather than 1 (Day 87).
WITH per_member AS (
  SELECT m.member_id,
         m.full_name,
         m.tier,
         count(l.loan_id) AS loan_count
    FROM members AS m
    LEFT JOIN loans AS l ON l.member_id = m.member_id
   WHERE m.left_at IS NULL
   GROUP BY m.member_id, m.full_name, m.tier
),
ranked AS (
  SELECT tier,
         full_name,
         loan_count,
         ROW_NUMBER() OVER (PARTITION BY tier ORDER BY loan_count DESC, full_name) AS position,
         RANK()       OVER (PARTITION BY tier ORDER BY loan_count DESC)            AS tier_rank
    FROM per_member
)
SELECT tier, position, tier_rank, full_name, loan_count
  FROM ranked
 WHERE position <= 2
 ORDER BY tier, position;

.print ''
.print '=== 7. The reservation queue for every book with people waiting ==='
-- The queue position is derived, not stored. ROW_NUMBER over a partition of
-- book_id, ordered by the time the reservation was made, renumbers itself for
-- free every time somebody cancels — which is exactly the bug a stored
-- position column produces the first time a cancellation is missed.
--
-- Note that the cancelled reservation on Neuromancer occupies no slot, and the
-- member who reserved it later is therefore second rather than third.
SELECT b.title,
       ROW_NUMBER() OVER (PARTITION BY r.book_id ORDER BY r.reserved_at) AS queue_position,
       m.full_name,
       r.reserved_at
  FROM reservations AS r
  JOIN books        AS b ON b.book_id   = r.book_id
  JOIN members      AS m ON m.member_id = r.member_id
 WHERE r.status = 'waiting'
 ORDER BY b.title, queue_position;

.print ''
.print '=== 8. Loans started per month, with a running total ==='
-- SUM(...) OVER (ORDER BY ...) is a running total: for each row, the sum of
-- every row up to and including this one in that order. The default frame when
-- ORDER BY is present is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW,
-- which is precisely a cumulative sum — worth stating rather than memorising,
-- because omitting the ORDER BY silently gives you the grand total on every
-- row instead.
--
-- The aggregate happens first, in the CTE; the window function then runs over
-- the aggregated rows. A window function cannot be nested inside an aggregate,
-- and this two-step shape is how you combine them.
WITH monthly AS (
  SELECT strftime('%Y-%m', borrowed_at) AS month,
         count(*)                       AS loans_started
    FROM loans
   GROUP BY month
)
SELECT month,
       loans_started,
       sum(loans_started) OVER (ORDER BY month) AS running_total
  FROM monthly
 ORDER BY month;

.print ''
.print '=== 9. Everything under Fiction, at any depth, with book counts ==='
-- A recursive CTE. This is the question a join cannot answer, because the
-- number of joins you would need is the depth of the tree, and you do not know
-- the depth of the tree.
--
-- The anchor selects the starting row. The recursive part joins the CTE to
-- itself to find the children of everything found so far, and stops when a
-- pass adds no rows. The depth column is carried along by hand: SQL will not
-- tell you how many passes it took.
WITH RECURSIVE subtree(category_id, name, depth) AS (
      SELECT category_id, name, 0
        FROM categories
       WHERE name = 'Fiction'
  UNION ALL
      SELECT c.category_id, c.name, s.depth + 1
        FROM categories AS c
        JOIN subtree    AS s ON c.parent_id = s.category_id
)
SELECT s.depth,
       s.name                              AS category,
       count(b.book_id)                    AS books_in_collection
  FROM subtree AS s
  LEFT JOIN books AS b
         ON b.category_id  = s.category_id
        AND b.withdrawn_at IS NULL
 GROUP BY s.category_id, s.depth, s.name
 ORDER BY s.depth, s.name;

.print ''
.print '=== 10. Authors none of whose books have ever been borrowed ==='
-- NOT EXISTS again, this time with a two-table correlated subquery. Compare
-- the alternatives honestly:
--
--   NOT IN (SELECT author_id FROM ...)  — right answer here, but returns
--     nothing at all if that subquery ever yields a single NULL.
--   LEFT JOIN ... WHERE loan_id IS NULL — also correct, but it builds every
--     author-book-loan pair and then throws almost all of them away.
--   NOT EXISTS                          — says what the sentence says.
SELECT a.author_id, a.name
  FROM authors AS a
 WHERE NOT EXISTS (
         SELECT 1
           FROM book_authors AS ba
           JOIN loans        AS l ON l.book_id = ba.book_id
          WHERE ba.author_id = a.author_id
       )
 ORDER BY a.author_id;
examples/04_rejected_design.sql (5317 bytes)
-- Day 091 — the design that was written first, tried, and thrown away.
--
--   sqlite3 rejected.db < examples/04_rejected_design.sql
--
-- The reference schema stores no queue position. That was not obvious at the
-- time; the first attempt stored one, because "position 1, 2, 3" is how a
-- human describes a queue and copying the human's words into columns is the
-- most natural mistake in schema design.
--
-- This file builds that first attempt, breaks it in three lines of ordinary
-- application traffic, and then shows the revision. Nothing here is
-- hypothetical: every output below is produced by running this file.

.mode column
.headers on
.width 0

.print '=== ATTEMPT 1: store the queue position as a column ==='

CREATE TABLE reservations_v1 (
  reservation_id INTEGER PRIMARY KEY,
  book_id        INTEGER NOT NULL,
  member_id      INTEGER NOT NULL,
  reserved_at    TEXT    NOT NULL,
  -- This is the decision under test.
  queue_position INTEGER NOT NULL CHECK (queue_position >= 1),
  status         TEXT    NOT NULL DEFAULT 'waiting'
);

-- Three members join the queue for book 105, in order. So far it looks fine,
-- and it looks fine for as long as nothing ever changes.
INSERT INTO reservations_v1 VALUES
  (1, 105, 1, '2026-08-01T09:00:00Z', 1, 'waiting'),
  (2, 105, 3, '2026-08-03T10:00:00Z', 2, 'waiting'),
  (3, 105, 4, '2026-08-07T11:00:00Z', 3, 'waiting');

SELECT reservation_id, member_id, queue_position, status FROM reservations_v1 ORDER BY queue_position;

.print ''
.print '--- the member at position 2 cancels ---'
-- The application does the obvious thing: mark it cancelled. It does NOT
-- renumber, because renumbering is a second statement that somebody has to
-- remember to write, in every code path that can cancel a reservation.
UPDATE reservations_v1 SET status = 'cancelled' WHERE reservation_id = 2;

SELECT reservation_id, member_id, queue_position, status
  FROM reservations_v1 WHERE status = 'waiting' ORDER BY queue_position;

.print ''
.print '--- so the queue now reads 1, 3: there is no position 2 ---'
SELECT group_concat(queue_position, ', ') AS positions_now
  FROM reservations_v1 WHERE status = 'waiting';

.print ''
.print '--- a fourth member joins, and the code appends "count of waiting + 1" ---'
-- A perfectly reasonable line of application code: there are two people
-- waiting, so the new one is third. It is also wrong, because the positions
-- stopped being 1..n the moment a cancellation punched a hole in them.
INSERT INTO reservations_v1 (reservation_id, book_id, member_id, reserved_at, queue_position, status)
  SELECT 4, 105, 2, '2026-08-09T09:00:00Z',
         (SELECT count(*) + 1 FROM reservations_v1 WHERE book_id = 105 AND status = 'waiting'),
         'waiting';

SELECT reservation_id, member_id, queue_position, status
  FROM reservations_v1 WHERE status = 'waiting' ORDER BY queue_position, reservation_id;

.print ''
.print '--- the damage, stated as a number: duplicate positions in one queue ---'
SELECT queue_position, count(*) AS members_at_this_position
  FROM reservations_v1
 WHERE status = 'waiting'
 GROUP BY queue_position
HAVING count(*) > 1;

.print ''
.print '=== WHY IT FAILED ==='
.print 'queue_position is DERIVED data: it is a function of reserved_at and status.'
.print 'Storing derived data means promising to recompute it everywhere either'
.print 'input changes, forever, in every code path, including the ones written'
.print 'next year by somebody who has not read this file. That promise is not'
.print 'enforceable by the database, so it is not a promise — it is a hope.'
.print 'No error was raised at any point above.'

.print ''
.print '=== ATTEMPT 2: do not store it. Derive it. ==='

CREATE TABLE reservations_v2 (
  reservation_id INTEGER PRIMARY KEY,
  book_id        INTEGER NOT NULL,
  member_id      INTEGER NOT NULL,
  reserved_at    TEXT    NOT NULL,
  status         TEXT    NOT NULL DEFAULT 'waiting'
                          CHECK (status IN ('waiting','collected','cancelled','expired'))
);

INSERT INTO reservations_v2 VALUES
  (1, 105, 1, '2026-08-01T09:00:00Z', 'waiting'),
  (2, 105, 3, '2026-08-03T10:00:00Z', 'cancelled'),
  (3, 105, 4, '2026-08-07T11:00:00Z', 'waiting'),
  (4, 105, 2, '2026-08-09T09:00:00Z', 'waiting');

-- One window function, and the numbering is right by construction. There is
-- no update to forget, because there is nothing stored to be wrong.
SELECT member_id,
       ROW_NUMBER() OVER (PARTITION BY book_id ORDER BY reserved_at) AS queue_position,
       reserved_at
  FROM reservations_v2
 WHERE status = 'waiting'
 ORDER BY queue_position;

.print ''
.print '--- and it stays right when another cancellation happens ---'
UPDATE reservations_v2 SET status = 'cancelled' WHERE reservation_id = 3;
SELECT member_id,
       ROW_NUMBER() OVER (PARTITION BY book_id ORDER BY reserved_at) AS queue_position
  FROM reservations_v2
 WHERE status = 'waiting'
 ORDER BY queue_position;

.print ''
.print '=== THE RULE THIS BOUGHT ==='
.print 'Store what you are told. Derive what follows from it. A column that can'
.print 'be computed from other columns is a column that can disagree with them.'
.print 'The exception is deliberate denormalisation for measured performance —'
.print 'and then you write down every path that must maintain it (Day 88).'
examples/05_report.py (12638 bytes)
#!/usr/bin/env python3
"""Day 091 — the monthly report for the Fenwick Road brief.

    python3 examples/05_report.py library.db

This is the last stage of the pipeline the lesson describes: requirements in
English became entities, entities became a schema, and the schema answers the
questions. Here the answers become something a trustee would actually read.

Two things are on purpose.

The SQL lives in a repository class, one method per question, exactly as on
Day 90. Nothing outside ``LibraryRepository`` knows that SQLite exists, so the
formatting code below cannot accidentally build a query out of string
concatenation, and every query has one place to be fixed.

The report instant is a *parameter with a default*, not ``datetime.now()``.
A report whose answer changes depending on when it runs cannot be tested,
cannot be reproduced, and cannot be compared with last month's copy.
"""

from __future__ import annotations

import sqlite3
import sys
from pathlib import Path

REPORT_INSTANT = "2026-08-16T09:00:00Z"


class LibraryRepository:
    """Every question the report asks, and nothing else.

    The connection is opened with ``PRAGMA foreign_keys = ON`` as the first
    statement, before any transaction can be open — the pragma is a documented
    no-op inside one (Day 87), and it is per connection, every connection.
    """

    def __init__(self, path: str | Path) -> None:
        self.connection = sqlite3.connect(str(path))
        self.connection.execute("PRAGMA foreign_keys = ON")
        # Rows arrive as mappings, so the report reads row["title"] rather than
        # row[2] and stays correct when a column is added to a SELECT.
        self.connection.row_factory = sqlite3.Row

    def __enter__(self) -> "LibraryRepository":
        return self

    def __exit__(self, *_exc: object) -> None:
        self.close()

    def close(self) -> None:
        self.connection.close()

    def _rows(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
        # Every value that varies is bound, never interpolated. The report
        # instant below is data, and data goes through a placeholder.
        return self.connection.execute(sql, params).fetchall()

    # -- Question 1 ---------------------------------------------------------
    def collection_summary(self) -> sqlite3.Row:
        return self._rows(
            """
            SELECT (SELECT count(*) FROM books WHERE withdrawn_at IS NULL) AS in_collection,
                   (SELECT count(*) FROM loans WHERE returned_at IS NULL)  AS on_loan_now,
                   (SELECT count(*) FROM books WHERE withdrawn_at IS NOT NULL) AS withdrawn
            """
        )[0]

    # -- Question 2 ---------------------------------------------------------
    def members_who_never_borrowed(self) -> list[sqlite3.Row]:
        return self._rows(
            """
            SELECT m.full_name, m.tier
              FROM members AS m
             WHERE m.left_at IS NULL
               AND NOT EXISTS (SELECT 1 FROM loans AS l WHERE l.member_id = m.member_id)
             ORDER BY m.full_name
            """
        )

    # -- Question 3 ---------------------------------------------------------
    def multi_author_books(self) -> list[sqlite3.Row]:
        return self._rows(
            """
            SELECT b.title,
                   count(*)                   AS author_count,
                   group_concat(a.name, ', ') AS credited_order
              FROM books        AS b
              JOIN book_authors AS ba ON ba.book_id  = b.book_id
              JOIN authors      AS a  ON a.author_id = ba.author_id
             GROUP BY b.book_id, b.title
            HAVING count(*) > 1
             ORDER BY author_count DESC, b.title
            """
        )

    # -- Question 4 ---------------------------------------------------------
    def overdue_loans(self, now: str) -> list[sqlite3.Row]:
        return self._rows(
            """
            SELECT m.full_name,
                   b.title,
                   l.due_at,
                   CAST(julianday(?) - julianday(l.due_at) AS INTEGER) AS days_overdue
              FROM loans   AS l
              JOIN members AS m ON m.member_id = l.member_id
              JOIN books   AS b ON b.book_id   = l.book_id
             WHERE l.returned_at IS NULL
               AND l.due_at < ?
             ORDER BY days_overdue DESC
            """,
            (now, now),
        )

    # -- Question 5 ---------------------------------------------------------
    def fines_owed(self) -> list[sqlite3.Row]:
        # sum() stays in pence. The division by 100 happens in the formatter,
        # once, at the edge.
        return self._rows(
            """
            SELECT m.full_name,
                   CASE WHEN m.left_at IS NULL THEN 'current' ELSE 'left' END AS standing,
                   sum(l.fine_pence) AS fine_pence
              FROM members AS m
              JOIN loans   AS l ON l.member_id = m.member_id
             GROUP BY m.member_id, m.full_name
            HAVING sum(l.fine_pence) > 0
             ORDER BY fine_pence DESC
            """
        )

    # -- Question 6 ---------------------------------------------------------
    def top_borrowers_per_tier(self, limit: int) -> list[sqlite3.Row]:
        return self._rows(
            """
            WITH per_member AS (
              SELECT m.member_id, m.full_name, m.tier, count(l.loan_id) AS loan_count
                FROM members AS m
                LEFT JOIN loans AS l ON l.member_id = m.member_id
               WHERE m.left_at IS NULL
               GROUP BY m.member_id, m.full_name, m.tier
            ),
            ranked AS (
              SELECT tier, full_name, loan_count,
                     ROW_NUMBER() OVER (PARTITION BY tier
                                        ORDER BY loan_count DESC, full_name) AS position
                FROM per_member
            )
            SELECT tier, position, full_name, loan_count
              FROM ranked
             WHERE position <= ?
             ORDER BY tier, position
            """,
            (limit,),
        )

    # -- Question 7 ---------------------------------------------------------
    def reservation_queues(self) -> list[sqlite3.Row]:
        return self._rows(
            """
            SELECT b.title,
                   ROW_NUMBER() OVER (PARTITION BY r.book_id
                                      ORDER BY r.reserved_at) AS queue_position,
                   m.full_name
              FROM reservations AS r
              JOIN books        AS b ON b.book_id   = r.book_id
              JOIN members      AS m ON m.member_id = r.member_id
             WHERE r.status = 'waiting'
             ORDER BY b.title, queue_position
            """
        )

    # -- Question 8 ---------------------------------------------------------
    def loans_per_month(self) -> list[sqlite3.Row]:
        return self._rows(
            """
            WITH monthly AS (
              SELECT strftime('%Y-%m', borrowed_at) AS month, count(*) AS loans_started
                FROM loans
               GROUP BY month
            )
            SELECT month, loans_started,
                   sum(loans_started) OVER (ORDER BY month) AS running_total
              FROM monthly
             ORDER BY month
            """
        )

    # -- Question 9 ---------------------------------------------------------
    def category_subtree(self, root: str) -> list[sqlite3.Row]:
        return self._rows(
            """
            WITH RECURSIVE subtree(category_id, name, depth) AS (
                  SELECT category_id, name, 0 FROM categories WHERE name = ?
              UNION ALL
                  SELECT c.category_id, c.name, s.depth + 1
                    FROM categories AS c
                    JOIN subtree    AS s ON c.parent_id = s.category_id
            )
            SELECT s.depth, s.name AS category, count(b.book_id) AS books_in_collection
              FROM subtree AS s
              LEFT JOIN books AS b
                     ON b.category_id  = s.category_id
                    AND b.withdrawn_at IS NULL
             GROUP BY s.category_id, s.depth, s.name
             ORDER BY s.depth, s.name
            """,
            (root,),
        )

    # -- Question 10 --------------------------------------------------------
    def never_borrowed_authors(self) -> list[sqlite3.Row]:
        return self._rows(
            """
            SELECT a.name
              FROM authors AS a
             WHERE NOT EXISTS (
                     SELECT 1
                       FROM book_authors AS ba
                       JOIN loans        AS l ON l.book_id = ba.book_id
                      WHERE ba.author_id = a.author_id
                   )
             ORDER BY a.name
            """
        )


def pounds(pence: int) -> str:
    """Integer pence to a display string. The only place money divides."""
    return f"GBP {pence // 100}.{pence % 100:02d}"


def plural(count: int, singular: str) -> str:
    """English, not `book(s)`. A report a person reads is written for a person."""
    return singular if count == 1 else singular + "s"


def rule(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def render(repo: LibraryRepository, now: str) -> None:
    print("=" * 64)
    print("FENWICK ROAD COMMUNITY LIBRARY — collection and lending report")
    print(f"as of {now}   (all figures invented for this exercise)")
    print("=" * 64)

    summary = repo.collection_summary()
    rule("1. The collection")
    print(f"  {summary['in_collection']} books on the shelves, "
          f"{summary['on_loan_now']} of them out on loan.")
    print(f"  {summary['withdrawn']} {plural(summary['withdrawn'], 'withdrawn book')} "
          f"kept in the record so old loans still resolve.")

    rule("2. Current members who have never borrowed")
    for row in repo.members_who_never_borrowed():
        print(f"  {row['full_name']:<16} ({row['tier']})")

    rule("3. Books with more than one author")
    for row in repo.multi_author_books():
        print(f"  {row['title']}")
        print(f"      {row['author_count']} authors: {row['credited_order']}")

    rule("4. Overdue loans")
    for row in repo.overdue_loans(now):
        print(f"  {row['days_overdue']:>3} days  {row['full_name']:<16} {row['title']}")
        print(f"            was due {row['due_at']}")

    rule("5. Fines outstanding")
    total = 0
    for row in repo.fines_owed():
        total += row["fine_pence"]
        print(f"  {pounds(row['fine_pence']):>10}  {row['full_name']:<16} ({row['standing']})")
    print(f"  {pounds(total):>10}  TOTAL")

    rule("6. Most active borrowers in each tier")
    current_tier = None
    for row in repo.top_borrowers_per_tier(limit=2):
        if row["tier"] != current_tier:
            current_tier = row["tier"]
            print(f"  {current_tier}:")
        loans = row["loan_count"]
        print(f"      {row['position']}. {row['full_name']:<16} {loans} {plural(loans, 'loan')}")

    rule("7. Reservation queues")
    current_title = None
    for row in repo.reservation_queues():
        if row["title"] != current_title:
            current_title = row["title"]
            print(f"  {current_title}:")
        print(f"      {row['queue_position']}. {row['full_name']}")

    rule("8. Loans started per month")
    for row in repo.loans_per_month():
        bar = "#" * row["loans_started"]
        print(f"  {row['month']}  {row['loans_started']:>2} {bar:<4}  running total {row['running_total']:>2}")

    rule("9. The Fiction shelves, at every depth")
    for row in repo.category_subtree("Fiction"):
        indent = "    " * row["depth"]
        count = row["books_in_collection"]
        print(f"  {indent}{row['category']}  ({count} {plural(count, 'book')})")

    rule("10. Authors never borrowed")
    for row in repo.never_borrowed_authors():
        print(f"  {row['name']}")

    print()
    print("=" * 64)
    print("end of report")


def main(argv: list[str]) -> int:
    if len(argv) < 2:
        print("usage: python3 05_report.py <database> [report-instant]", file=sys.stderr)
        return 2
    database = Path(argv[1])
    if not database.exists():
        print(f"no such database: {database}", file=sys.stderr)
        print("build it first with 01_schema.sql and 02_seed.sql", file=sys.stderr)
        return 1
    now = argv[2] if len(argv) > 2 else REPORT_INSTANT
    with LibraryRepository(database) as repo:
        render(repo, now)
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
examples/06_answers.sql (3923 bytes)
-- Day 091 — the same ten answers, in machine-readable form.
--
-- 03_questions.sql is the version to read: column mode, headers, and a comment
-- above every query explaining which construct the question forced. THIS file
-- is the version to compare against: no headers, no padding, pipe-separated,
-- and a '### n' marker before each answer so a script can split the output
-- into ten blocks.
--
-- starter/03_check.sh runs this file and the learner's starter/02_questions.sql
-- against the same database and compares the blocks. The reference answers are
-- therefore computed, never typed in — if the seed changes, both sides move
-- together and nothing silently rots.
--
--   sqlite3 library.db < examples/06_answers.sql

.mode list
.separator '|'
.headers off

.print '### 1'
SELECT (SELECT count(*) FROM books WHERE withdrawn_at IS NULL),
       (SELECT count(*) FROM loans WHERE returned_at IS NULL);

.print '### 2'
SELECT m.full_name, m.tier
  FROM members AS m
 WHERE m.left_at IS NULL
   AND NOT EXISTS (SELECT 1 FROM loans AS l WHERE l.member_id = m.member_id)
 ORDER BY m.full_name;

.print '### 3'
SELECT b.title, count(*), group_concat(a.name, ', ')
  FROM books        AS b
  JOIN book_authors AS ba ON ba.book_id  = b.book_id
  JOIN authors      AS a  ON a.author_id = ba.author_id
 GROUP BY b.book_id, b.title
HAVING count(*) > 1
 ORDER BY count(*) DESC, b.title;

.print '### 4'
SELECT m.full_name, b.title,
       CAST(julianday('2026-08-16T09:00:00Z') - julianday(l.due_at) AS INTEGER)
  FROM loans   AS l
  JOIN members AS m ON m.member_id = l.member_id
  JOIN books   AS b ON b.book_id   = l.book_id
 WHERE l.returned_at IS NULL
   AND l.due_at < '2026-08-16T09:00:00Z'
 ORDER BY 3 DESC;

.print '### 5'
SELECT m.full_name,
       CASE WHEN m.left_at IS NULL THEN 'current' ELSE 'left' END,
       printf('%.2f', sum(l.fine_pence) / 100.0)
  FROM members AS m
  JOIN loans   AS l ON l.member_id = m.member_id
 GROUP BY m.member_id, m.full_name
HAVING sum(l.fine_pence) > 0
 ORDER BY sum(l.fine_pence) DESC;

.print '### 6'
WITH per_member AS (
  SELECT m.member_id, m.full_name, m.tier, count(l.loan_id) AS loan_count
    FROM members AS m
    LEFT JOIN loans AS l ON l.member_id = m.member_id
   WHERE m.left_at IS NULL
   GROUP BY m.member_id, m.full_name, m.tier
),
ranked AS (
  SELECT tier, full_name, loan_count,
         ROW_NUMBER() OVER (PARTITION BY tier ORDER BY loan_count DESC, full_name) AS position
    FROM per_member
)
SELECT tier, position, full_name, loan_count
  FROM ranked
 WHERE position <= 2
 ORDER BY tier, position;

.print '### 7'
SELECT b.title,
       ROW_NUMBER() OVER (PARTITION BY r.book_id ORDER BY r.reserved_at),
       m.full_name
  FROM reservations AS r
  JOIN books        AS b ON b.book_id   = r.book_id
  JOIN members      AS m ON m.member_id = r.member_id
 WHERE r.status = 'waiting'
 ORDER BY b.title, 2;

.print '### 8'
WITH monthly AS (
  SELECT strftime('%Y-%m', borrowed_at) AS month, count(*) AS loans_started
    FROM loans
   GROUP BY month
)
SELECT month, loans_started, sum(loans_started) OVER (ORDER BY month)
  FROM monthly
 ORDER BY month;

.print '### 9'
WITH RECURSIVE subtree(category_id, name, depth) AS (
      SELECT category_id, name, 0 FROM categories WHERE name = 'Fiction'
  UNION ALL
      SELECT c.category_id, c.name, s.depth + 1
        FROM categories AS c
        JOIN subtree    AS s ON c.parent_id = s.category_id
)
SELECT s.depth, s.name, count(b.book_id)
  FROM subtree AS s
  LEFT JOIN books AS b
         ON b.category_id  = s.category_id
        AND b.withdrawn_at IS NULL
 GROUP BY s.category_id, s.depth, s.name
 ORDER BY s.depth, s.name;

.print '### 10'
SELECT a.name
  FROM authors AS a
 WHERE NOT EXISTS (
         SELECT 1
           FROM book_authors AS ba
           JOIN loans        AS l ON l.book_id = ba.book_id
          WHERE ba.author_id = a.author_id
       )
 ORDER BY a.name;

.print '### end'
metadata.yml (1256 bytes)
lesson_id: D091
day: 91
kind: guided-build
languages: [sql, python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-091-designing-and-querying-a-real-schema
  - python3 --version
  - sqlite3 --version
  - 'sqlite3 :memory: "SELECT sqlite_version(); SELECT row_number() OVER ();"'
run_commands:
  - bash tests/run_tests.sh
  - bash starter/03_check.sh
  - sqlite3 library.db < examples/01_schema.sql
  - sqlite3 library.db < examples/02_seed.sql
  - sqlite3 library.db < examples/03_questions.sql
  - sqlite3 rejected.db < examples/04_rejected_design.sql
  - python3 examples/05_report.py library.db
  - "python3 examples/05_report.py library.db '2026-09-01T09:00:00Z'"
  - sqlite3 library.db < examples/06_answers.sql
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -f library.db rejected.db
  - find . -type d -name __pycache__ -prune -exec rm -rf -- {} +
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: false
requires_api_key: false
estimated_minutes: 35
last_executed: '2026-08-16'
executed_on: 'macOS 26.5.2 (Apple Silicon, arm64), Python 3.14.0, bash 3.2.57, sqlite3 shell 3.51.0, SQLite library 3.53.3 via Python — bash tests/run_tests.sh -> 72 checks, 0 failure(s), exit 0'
requirements/README.md (3477 bytes)
# Dependencies

**None.** This lab installs nothing, and `requirements.txt` is deliberately
empty of packages. The whole point of the day is that a schema and the queries
over it are the deliverable; no library is needed to produce either.

| Tool | Version used here | Where it comes from | Licence |
| --- | --- | --- | --- |
| `python3` | 3.14.0 | Whatever Python you installed on Day 43. Only the standard library is used, and only the `sqlite3`, `sys` and `pathlib` modules from it | PSF licence |
| `sqlite3` (the shell) | 3.51.0 | Preinstalled on macOS at `/usr/bin/sqlite3`; `apt install sqlite3` or the equivalent on Linux | Public domain |

Python's `sqlite3` module wraps a copy of the SQLite library compiled into your
Python. It is often a **different version** from the `sqlite3` shell on your
`PATH` — on the authoring machine the shell reported 3.51.0 while Python
reported 3.53.3. That does not matter for anything in this lab, but it is worth
knowing that they are two separate copies, because one day a feature will exist
in one and not the other.

Check what you have:

```bash
python3 --version
sqlite3 --version
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
```

## Minimum versions, and why these two in particular

**Python 3.11 or newer** — the type hints in `examples/05_report.py` use the
`str | Path` syntax.

**SQLite 3.25.0 (2018) or newer**, for window functions. Questions 6, 7 and 8
use `ROW_NUMBER`, `RANK` and `SUM ... OVER`, and without them those three
answers cannot be written at all in the shape this lab uses. `tests/run_tests.sh`
runs `SELECT row_number() OVER ()` as its very first check so that an old shell
fails with one clear line rather than a wall of syntax errors.

**SQLite 3.8.3 (2014) or newer**, for `WITH RECURSIVE`. Question 9 walks the
category tree and cannot be answered by any fixed number of joins.

Confirm both in one line:

```bash
sqlite3 :memory: "SELECT sqlite_version(); SELECT row_number() OVER (); WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM c WHERE n<3) SELECT count(*) FROM c;"
```

## If the tools are somewhere unusual

Both scripts take overrides rather than guessing:

```bash
PYTHON=/path/to/python3 SQLITE3=/path/to/sqlite3 bash tests/run_tests.sh
SQLITE3=/path/to/sqlite3 bash starter/03_check.sh
```

They fail loudly with that instruction if they cannot find either one, rather
than quietly skipping the checks that need them.

## What is deliberately absent

**No modelling tool.** Draw the entity diagram on paper, or in whichever
diagramming tool you already have. The lesson's Alternatives section covers the
dedicated tools honestly, including what they buy on a team and what they cost
you when the diagram and the database drift apart. Nothing here needs one.

**No ORM.** SQLAlchemy, Django's ORM and Peewee would all generate a version of
this schema from Python classes, and Day 93 covers that. Doing it in that order
would mean meeting the abstraction before the thing it abstracts, and the first
time a generated migration did something surprising you would have no way in.

**No database server.** PostgreSQL is covered in the lesson's Alternatives
section, with the type differences that actually change the design — a real
`date`, a real `boolean`, a native `enum`, and `numeric` for money. Installing
one would turn this lab from thirty-five minutes of schema design into an
afternoon of administration, and nothing in today's material needs it.
requirements/requirements.txt (513 bytes)
# Day 091 — Designing and Querying a Real Schema
#
# This lab has no third-party dependencies. It uses python3 (standard library
# only, including the sqlite3 module) and the sqlite3 command-line shell, both
# of which you already have.
#
# There is nothing to install. See requirements/README.md for the versions used
# when the expected output was captured, for the two SQLite version floors this
# lab does have (window functions and recursive CTEs), and for why no ORM and
# no database server appear here.
starter/00_brief.md (4712 bytes)
# The brief — Fenwick Road Community Library

This is the document you were handed. It is written the way requirements
actually arrive: in prose, by somebody who knows the library and not the
database. Read it twice before you write a single `CREATE TABLE`.

Everything below about the library, its members and its loans is **invented for
this exercise**. The books and their authors are real published works; the
people, email addresses, dates, fines and reservations are not, and no real
borrowing record was used anywhere in this lab.

---

## What the library does

> We lend books. We own a few thousand titles, though for now we only need the
> handful we have catalogued so far.
>
> Every book has a title and, usually, a thirteen-digit ISBN — except the very
> old ones, which were printed before ISBNs existed. We would like to record
> what we paid for each book, because the trustees ask about it, and we would
> like to know roughly when it was published.
>
> Books are shelved by category, and the categories nest: Fiction contains
> Science Fiction, which contains Cyberpunk. We add and rename categories
> fairly often, and we sometimes move a whole branch under a different parent.
>
> Some books have more than one author. *The C Programming Language* has two,
> and the one everybody calls SICP has three. The order matters — the cover
> credits them in a particular order and so should we.
>
> When a book falls apart or goes missing we *withdraw* it. It leaves the
> shelves, but we must still be able to see it in old loan records, so please
> do not simply delete it.
>
> Members join, give us an email address we use to contact them, and are on one
> of three membership tiers: standard, student, or staff. Members also leave.
> When they do we stop counting them as members, but their loan history and any
> money they still owe us has to survive.
>
> A loan records that a member took a book out on a particular date, that it is
> due back on a particular date, and — once it comes back — the date it came
> back. If it comes back late we charge a fine, in pounds and pence.
>
> If a book is out on loan, a member can reserve it. Several members can be
> waiting for the same book, and they are served in the order they asked. A
> reservation can also be cancelled, expire, or be collected.
>
> We are a small charity with volunteers on the desk, so it must be difficult
> to record something impossible: a loan due before it was borrowed, a negative
> fine, a membership tier nobody has heard of, or a reservation against a book
> we do not own.

---

## The ten questions the library asks

Your schema exists to answer these. If a question is awkward to answer, that is
information about the schema, not about the question.

1. How many books are on the shelves right now, and how many of them are out on
   loan?
2. Which current members have never borrowed anything?
3. Which books have more than one author, and who are they in credited order?
4. Which loans are overdue as of the report time, and by how many whole days?
5. How much does each member owe us in fines, in pounds — including members who
   have since left?
6. Who are the two most active borrowers in each membership tier, counting only
   current members, and including someone who has borrowed nothing if that is
   what the tier looks like?
7. For every book with people waiting, what is the reservation queue, in order?
8. How many loans did we start each month, and what is the running total across
   the year so far?
9. What sits underneath the Fiction category, at any depth, and how many books
   are in each of those categories?
10. Which authors have never had any of their books borrowed?

---

## Reporting time

Every "as of now" question in this lab is answered as of a fixed instant:

```text
2026-08-16T09:00:00Z
```

That is deliberate. A report whose answer depends on when you happen to run it
cannot be tested, and cannot be compared against a colleague's. The report
script takes the instant as a parameter with that value as its default.

---

## What to do

1. Read `01_schema.sql` and write the schema. The numbered comments say what
   each table must hold and which decision you are being asked to make.
2. Run `bash 03_check.sh` — it will tell you which of the ten questions your
   schema and queries currently answer correctly.
3. Write the queries in `02_questions.sql`, one per question.
4. When `03_check.sh` reports `10 of 10`, compare your schema with
   `../examples/01_schema.sql` and read the reasoning in the lesson.

Do not read the examples directory first. The comparison is worth far more
after you have made your own choices and found out which of them hurt.
starter/01_schema.sql (10830 bytes)
-- Day 091 — YOUR schema for the Fenwick Road brief.
--
-- Read starter/00_brief.md first. Then work down this file.
--
-- Three of the seven tables are already written, in full, as worked examples.
-- They establish the conventions the rest of the schema follows, and each one
-- carries the reasoning in its comments. Copy the style, not just the syntax.
--
-- Four tables, the indexes and the views are yours: exercises 1 to 6.
--
-- Check your progress at any point with:
--
--   bash starter/03_check.sh
--
-- It reports "N of 16 exercises complete." and exits non-zero until N is 16.
-- Before you start it will say 0, and it will tell you exactly what is missing.
--
-- IMPORTANT: the column names below are not suggestions. starter/03_check.sh
-- loads the shared seed file (examples/02_seed.sql) into your schema, so your
-- column names have to be the ones the seed inserts into. The DESIGN decisions
-- — which keys, which constraints, which columns may be NULL, what happens on
-- delete — are entirely yours, and are what you are being marked on.

PRAGMA foreign_keys = ON;   -- per connection, every connection (Day 87)

-- ===========================================================================
-- WORKED EXAMPLE 1 — categories
-- ===========================================================================
-- The brief says categories nest to an unknown depth and get moved around.
-- That is a tree, and the adjacency-list model — one nullable parent_id
-- pointing at another row of the same table — is the simplest thing that holds
-- one. A recursive CTE walks it later, in question 9.
--
-- parent_id IS NULL is not a missing value. It means "this is a top-level
-- shelf", which is a real fact about the world.
CREATE TABLE categories (
  category_id INTEGER PRIMARY KEY,
  name        TEXT    NOT NULL,
  parent_id   INTEGER REFERENCES categories(category_id) ON DELETE RESTRICT,
  UNIQUE (parent_id, name),
  CHECK (parent_id IS NULL OR parent_id <> category_id)
);

-- ===========================================================================
-- WORKED EXAMPLE 2 — authors
-- ===========================================================================
-- An author is an entity, not an attribute of a book: it exists before we
-- catalogue their first book, it has attributes of its own, and more than one
-- book refers to it. Any one of those three would be enough.
--
-- birth_year is nullable ON PURPOSE. One author in the seed has no published
-- year of birth, and inventing one to avoid a NULL would put a false fact in
-- the database to satisfy a preference about column definitions.
CREATE TABLE authors (
  author_id  INTEGER PRIMARY KEY,
  name       TEXT    NOT NULL,
  birth_year INTEGER CHECK (birth_year IS NULL OR birth_year BETWEEN 1400 AND 2100)
);

-- ===========================================================================
-- WORKED EXAMPLE 3 — members
-- ===========================================================================
-- Note three decisions you are about to have to make for yourself:
--
--   * email is a real natural key — unique, externally meaningful — and it is
--     still not the PRIMARY KEY, because people change their email address and
--     a key you have to update is not a key. UNIQUE gets the guarantee without
--     the coupling.
--   * tier is an enumeration of three values that changes about once a decade,
--     so it is a CHECK constraint rather than a lookup table. One line, no
--     join. If it ever needs a label and a loan allowance per tier, that
--     becomes a table, and the migration is ordinary (Day 88).
--   * left_at is a SOFT DELETE. NULL means a current member. The brief says
--     the money they owe has to survive them leaving, so the row cannot go.
CREATE TABLE members (
  member_id INTEGER PRIMARY KEY,
  email     TEXT    NOT NULL UNIQUE CHECK (email LIKE '_%@_%._%'),
  full_name TEXT    NOT NULL,
  tier      TEXT    NOT NULL DEFAULT 'standard'
                    CHECK (tier IN ('standard', 'student', 'staff')),
  joined_at TEXT    NOT NULL CHECK (joined_at LIKE '____-__-__T__:__:__Z'),
  left_at   TEXT    CHECK (left_at IS NULL OR left_at LIKE '____-__-__T__:__:__Z'),
  CHECK (left_at IS NULL OR left_at >= joined_at)
);

-- ===========================================================================
-- EXERCISE 1 — books
-- ===========================================================================
-- Write CREATE TABLE books. Columns, in this order and with these names:
--
--   book_id                 the surrogate primary key
--   isbn13                  the ISBN. The brief says the very old books have
--                           none, so decide what that means for NOT NULL and
--                           for whether this could ever be the primary key.
--                           Add a UNIQUE constraint, and a CHECK that it is
--                           thirteen digits when present — GLOB '[0-9]' thirteen
--                           times is the SQLite spelling.
--   title                   never absent
--   published_year          may be unknown
--   category_id             which shelf. Not null; references categories.
--                           Decide the ON DELETE behaviour and say why.
--   acquisition_cost_pence  money. INTEGER pence, never REAL pounds (Day 70).
--                           Constrain it to be non-negative.
--   withdrawn_at            soft delete: NULL means on the shelves, otherwise
--                           the ISO 8601 UTC instant it was withdrawn. The
--                           brief says withdrawn books must still resolve in
--                           old loan records, which is what rules out DELETE.
--
-- The timestamp shape check used above is: LIKE '____-__-__T__:__:__Z'
-- (underscore matches exactly one character in LIKE).



-- ===========================================================================
-- EXERCISE 2 — book_authors
-- ===========================================================================
-- Many-to-many: a book has several authors, an author has several books.
-- Neither table can hold the key, so the relationship needs a table.
--
-- Columns: book_id, author_id, author_position.
--
-- Three decisions, and the checker tests all three:
--
--   a) The PRIMARY KEY is the PAIR (book_id, author_id). That is what makes it
--      impossible to credit the same author twice on one book.
--   b) author_position exists because the brief says the credit order matters.
--      This is the point of the exercise: a junction table is not always a
--      pure link. When the relationship itself has an attribute, that
--      attribute has nowhere else to live.
--   c) Deleting a book should take its authorship rows with it (CASCADE);
--      deleting an author who still has books should be refused (RESTRICT).
--      Write both, and be able to say why they differ.
--
-- Consider also: can two authors both be credited second on the same book?



-- ===========================================================================
-- EXERCISE 3 — loans
-- ===========================================================================
-- Columns: loan_id, book_id, member_id, borrowed_at, due_at, returned_at,
--          fine_pence.
--
--   * returned_at NULL means "still out". Resist the temptation to add an
--     is_returned boolean as well: two columns that encode one fact are two
--     columns that can disagree.
--   * fine_pence is money. Integer, non-negative, default 0.
--   * Because timestamps are ISO 8601 in UTC, string comparison IS
--     chronological comparison — so you can write a table-level
--     CHECK (due_at > borrowed_at). Add it, and a second one saying a book
--     cannot be returned before it was borrowed. This is the payoff for the
--     format decision, and it is why the format decision was not cosmetic.
--   * The brief says loan history must survive a book being withdrawn or a
--     member leaving. What does that imply about ON DELETE here?



-- ===========================================================================
-- EXERCISE 4 — reservations
-- ===========================================================================
-- Columns: reservation_id, book_id, member_id, reserved_at, status.
--
-- status is an enumeration: waiting, collected, cancelled, expired. CHECK it.
--
-- The decision worth arguing about: there is NO queue_position column, and
-- there must not be. The position is a function of reserved_at and status, so
-- storing it means promising to recompute it in every code path that can ever
-- cancel a reservation, forever. Run examples/04_rejected_design.sql to watch
-- that promise break in three ordinary statements, with no error raised.
--
-- Question 7 derives the position with ROW_NUMBER instead.



-- ===========================================================================
-- EXERCISE 5 — indexes
-- ===========================================================================
-- Declaring a foreign key creates NO index (Day 87), so every join across one
-- scans the whole table (Day 89). Create an index on every foreign-key column
-- in the schema:
--
--   books(category_id), categories(parent_id), book_authors(author_id),
--   loans(book_id), loans(member_id),
--   reservations(book_id), reservations(member_id)
--
-- book_authors(book_id) and the other leading columns of composite primary
-- keys already have one — the primary key index — so they are not repeated.
--
-- Then two indexes that are about the questions rather than the keys:
--
--   * A PARTIAL index on loans(due_at) WHERE returned_at IS NULL. Questions 1
--     and 4 both ask what is out right now, and outstanding loans stay a small
--     fraction of a loans table that grows forever.
--   * A partial UNIQUE index on reservations(book_id, member_id)
--     WHERE status = 'waiting', so one member cannot hold two live
--     reservations on the same book while their cancelled history is left
--     alone. A plain UNIQUE constraint could not express that.



-- ===========================================================================
-- EXERCISE 6 — two views
-- ===========================================================================
-- Soft delete has a running cost: every query about the present has to
-- remember to filter. A view gives that filter a name and one definition.
--
--   CREATE VIEW current_collection AS ... books that are not withdrawn
--   CREATE VIEW current_members    AS ... members who have not left
--
-- Be clear about what this does and does not buy you. It buys a name for a
-- piece of reasoning and it buys consistency between reports. It does not buy
-- speed: a view stores the query text, not the result, and runs afresh every
-- time it is used.
starter/02_questions.sql (8618 bytes)
-- Day 091 — YOUR answers to the ten questions in starter/00_brief.md.
--
-- Exercises 7 to 16. Replace each placeholder SELECT with a query that answers
-- the question. The file runs as it stands, so you can check your progress
-- after every single one:
--
--   bash starter/03_check.sh
--
-- The checker runs this file and examples/06_answers.sql against the same
-- database and compares the ten blocks. It never looks at how you wrote the
-- query — only at whether the rows are right, in the right order, with the
-- right columns. There is more than one correct query for several of these.
--
-- Output contract, so the blocks can be compared:
--   * the dot-commands below set list mode with a pipe separator and no
--     headers. Leave them alone.
--   * each answer must produce EXACTLY the columns named in its comment, in
--     that order, and in the stated row order. No extra columns.
--   * the '### n' markers separate the blocks. Leave those alone too.

.mode list
.separator '|'
.headers off

-- ---------------------------------------------------------------------------
-- EXERCISE 7 (question 1) — How many books are on the shelves, and how many
-- of them are out on loan?
--
-- Columns: books_in_collection, loans_outstanding      Rows: exactly 1
--
-- Approach: two scalar subqueries in the SELECT list. A scalar subquery
-- returns one row and one column and can go anywhere a value can. Remember
-- that "on the shelves" excludes withdrawn books — the running cost of soft
-- delete is that every present-tense query has to say so.
-- ---------------------------------------------------------------------------
.print '### 1'
SELECT 'exercise 7 not answered yet', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 8 (question 2) — Which current members have never borrowed
-- anything?
--
-- Columns: full_name, tier      Order: by full_name
--
-- Approach: NOT EXISTS with a correlated subquery — the inner query mentions
-- the outer query's member_id. NOT IN would give the right answer here and
-- would return nothing at all the day a NULL appears in the subquery; the
-- LEFT JOIN ... IS NULL idiom from Day 87 also works. Pick one and be able to
-- say why.
-- ---------------------------------------------------------------------------
.print '### 2'
SELECT 'exercise 8 not answered yet', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 9 (question 3) — Which books have more than one author, and who
-- are they in credited order?
--
-- Columns: title, author_count, credited_order
-- Order: author_count descending, then title
--
-- Approach: join books to authors through your junction table, GROUP BY the
-- book, filter the aggregate with HAVING, and assemble the names with
-- group_concat(a.name, ', '). Getting the ORDER of the names right is the part
-- that depends on your schema having stored the credit position.
-- ---------------------------------------------------------------------------
.print '### 3'
SELECT 'exercise 9 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 10 (question 4) — Which loans are overdue as of
-- 2026-08-16T09:00:00Z, and by how many whole days?
--
-- Columns: full_name, title, days_overdue      Order: days_overdue descending
--
-- Approach: a loan is out when returned_at IS NULL and overdue when due_at is
-- earlier than the report instant — which, with ISO 8601 in UTC, is a plain
-- string comparison. For the number of days,
-- CAST(julianday(<now>) - julianday(l.due_at) AS INTEGER).
-- ---------------------------------------------------------------------------
.print '### 4'
SELECT 'exercise 10 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 11 (question 5) — How much does each member owe in fines, in
-- pounds, including members who have left?
--
-- Columns: full_name, standing, fine_pounds      Order: by fines, descending
--   standing is the text 'current' or 'left' — a CASE expression on left_at.
--   fine_pounds is printf('%.2f', sum(...) / 100.0).
-- Only rows where the total is greater than zero.
--
-- Approach: sum stays in integer pence right up to the display. Note that this
-- question deliberately does NOT filter out members who have left: a debt does
-- not stop existing because somebody cancelled their membership. Exercise 12
-- does filter them. Which soft-delete filter applies is a property of the
-- question, not of the table.
-- ---------------------------------------------------------------------------
.print '### 5'
SELECT 'exercise 11 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 12 (question 6) — Who are the two most active borrowers in each
-- membership tier, counting current members only?
--
-- Columns: tier, position, full_name, loan_count      Order: tier, position
--
-- Approach: this is top-N-per-group, and it is the question a plain GROUP BY
-- cannot answer — aggregation collapses the rows, so the name is gone by the
-- time you know the count is a winner. Use two CTEs: one that counts loans per
-- current member, one that adds
--   ROW_NUMBER() OVER (PARTITION BY tier ORDER BY loan_count DESC, full_name)
-- and then filter that to <= 2 in the outer query.
--
-- Two traps from Day 87 are waiting here: an INNER JOIN drops the member who
-- has borrowed nothing, and count(*) reports 1 for her instead of 0.
-- ---------------------------------------------------------------------------
.print '### 6'
SELECT 'exercise 12 not answered yet', '', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 13 (question 7) — For every book with people waiting, what is the
-- reservation queue, in order?
--
-- Columns: title, queue_position, full_name      Order: title, queue_position
--
-- Approach: ROW_NUMBER() OVER (PARTITION BY book_id ORDER BY reserved_at),
-- over the waiting reservations only. Notice that a cancelled reservation must
-- occupy no slot at all — which is exactly what a stored position column gets
-- wrong.
-- ---------------------------------------------------------------------------
.print '### 7'
SELECT 'exercise 13 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 14 (question 8) — How many loans started each month, and what is
-- the running total?
--
-- Columns: month, loans_started, running_total      Order: by month
--   month is strftime('%Y-%m', borrowed_at).
--
-- Approach: aggregate per month in a CTE, then run
-- sum(loans_started) OVER (ORDER BY month) across the aggregated rows. A
-- window function cannot be nested inside an aggregate, so the two steps have
-- to be separate. Omit the ORDER BY inside OVER and you silently get the grand
-- total on every row instead of a running one.
-- ---------------------------------------------------------------------------
.print '### 8'
SELECT 'exercise 14 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 15 (question 9) — What sits underneath the Fiction category, at any
-- depth, and how many books are in each?
--
-- Columns: depth, category, books_in_collection      Order: depth, category
--   Fiction itself is depth 0 and is included.
--   Withdrawn books do not count.
--
-- Approach: WITH RECURSIVE. The anchor selects the Fiction row with depth 0;
-- the recursive part joins categories to the CTE to find the children of
-- everything found so far. Then LEFT JOIN books to count them — LEFT, because
-- a category with no books must still appear with a count of 0, and the
-- withdrawn filter belongs in the ON clause rather than the WHERE clause for
-- exactly the reason Day 87 gave.
-- ---------------------------------------------------------------------------
.print '### 9'
SELECT 'exercise 15 not answered yet', '', '';

-- ---------------------------------------------------------------------------
-- EXERCISE 16 (question 10) — Which authors have never had any of their books
-- borrowed?
--
-- Columns: name      Order: by name
--
-- Approach: NOT EXISTS again, this time with a two-table subquery inside it —
-- from the author, through the junction table, to loans.
-- ---------------------------------------------------------------------------
.print '### 10'
SELECT 'exercise 16 not answered yet';

.print '### end'
starter/03_check.sh (8861 bytes)
#!/usr/bin/env bash
# Day 091 — how far through the sixteen exercises are you?
#
#   bash starter/03_check.sh
#
# Builds YOUR schema (starter/01_schema.sql) in a temporary directory, loads
# the shared seed into it, runs YOUR queries (starter/02_questions.sql) and the
# reference queries (examples/06_answers.sql) against the same data, and
# compares the ten answer blocks.
#
# It reports "N of 16 exercises complete." and exits 0 only when N is 16.
# Nothing is written inside the lab directory, and the temporary directory is
# removed on the way out, whether the run succeeded or not.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work=""
done_count=0
total=16

cleanup() { [ -n "${work}" ] && [ -d "${work}" ] && rm -rf "${work}"; }
trap cleanup EXIT INT TERM

sqlite_bin="${SQLITE3:-$(command -v sqlite3 || true)}"
if [ -z "${sqlite_bin}" ] || [ ! -x "${sqlite_bin}" ]; then
  echo "sqlite3 was not found. Install it, or set SQLITE3=/path/to/sqlite3."
  exit 1
fi

work="$(mktemp -d)"
db="${work}/yours.db"
ref="${work}/reference.db"

pass() { echo "  done      ${1}"; done_count=$((done_count + 1)); }
todo() { echo "  still to do: ${1}"; }

# q SQL — one scalar from the learner's database, empty string on any error.
q() { "${sqlite_bin}" "${db}" ".mode list" ".headers off" "$1" 2>/dev/null; }

echo "Day 091 — from requirements to report"
echo

# ---------------------------------------------------------------------------
# Exercises 1-6: the schema.
# ---------------------------------------------------------------------------
echo "Schema (exercises 1-6)"
"${sqlite_bin}" "${db}" < "${lab_dir}/starter/01_schema.sql" 2>"${work}/schema.err"
if [ -s "${work}/schema.err" ]; then
  echo "  your schema did not build cleanly. sqlite3 said:"
  sed 's/^/      /' "${work}/schema.err"
  echo
fi

# 1. books, with the decisions the brief forces.
books_ok=no
if [ "$(q "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='books'")" = "1" ]; then
  cols="$(q "SELECT group_concat(name) FROM (SELECT name FROM pragma_table_info('books') ORDER BY cid)")"
  isbn_nullable="$(q "SELECT \"notnull\" FROM pragma_table_info('books') WHERE name='isbn13'")"
  fk_cat="$(q "SELECT count(*) FROM pragma_foreign_key_list('books') WHERE \"table\"='categories'")"
  if [ "${cols}" = "book_id,isbn13,title,published_year,category_id,acquisition_cost_pence,withdrawn_at" ] \
     && [ "${isbn_nullable}" = "0" ] && [ "${fk_cat}" = "1" ]; then
    books_ok=yes
  fi
fi
if [ "${books_ok}" = "yes" ]; then
  pass "1. books — seven columns, a nullable isbn13, a foreign key to categories"
else
  todo "1. books — see the column list in starter/01_schema.sql. isbn13 must be"
  echo "               nullable, because the 1818 book in the seed has no ISBN."
fi

# 2. book_authors — the many-to-many. This is the check that fails if the
#    relationship is modelled wrongly.
ba_ok=no
ba_note="not created yet"
if [ "$(q "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='book_authors'")" = "1" ]; then
  ba_pk="$(q "SELECT group_concat(name) FROM (SELECT name FROM pragma_table_info('book_authors') WHERE pk > 0 ORDER BY pk)")"
  ba_pos="$(q "SELECT count(*) FROM pragma_table_info('book_authors') WHERE name='author_position'")"
  ba_fks="$(q "SELECT group_concat(t) FROM (SELECT \"table\" AS t FROM pragma_foreign_key_list('book_authors') ORDER BY t)")"
  if [ "${ba_pk}" != "book_id,author_id" ]; then
    ba_note="the primary key must be the PAIR (book_id, author_id) — yours is '${ba_pk}'"
  elif [ "${ba_pos}" != "1" ]; then
    ba_note="add author_position: the credit order is an attribute of the relationship"
  elif [ "${ba_fks}" != "authors,books" ]; then
    ba_note="it needs a foreign key to books AND one to authors"
  else
    ba_ok=yes
  fi
fi
if [ "${ba_ok}" = "yes" ]; then
  pass "2. book_authors — keyed on the pair, with author_position"
else
  todo "2. book_authors — ${ba_note}"
fi

# 3. loans, including the two ordering CHECKs the ISO 8601 format makes possible.
loans_ok=no
if [ "$(q "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='loans'")" = "1" ]; then
  cols="$(q "SELECT group_concat(name) FROM (SELECT name FROM pragma_table_info('loans') ORDER BY cid)")"
  ret_nullable="$(q "SELECT \"notnull\" FROM pragma_table_info('loans') WHERE name='returned_at'")"
  ddl="$(q "SELECT sql FROM sqlite_master WHERE name='loans'")"
  if [ "${cols}" = "loan_id,book_id,member_id,borrowed_at,due_at,returned_at,fine_pence" ] \
     && [ "${ret_nullable}" = "0" ] \
     && printf '%s' "${ddl}" | grep -q "due_at > borrowed_at"; then
    loans_ok=yes
  fi
fi
if [ "${loans_ok}" = "yes" ]; then
  pass "3. loans — returned_at nullable, and CHECK (due_at > borrowed_at)"
else
  todo "3. loans — seven columns, returned_at nullable meaning 'still out', and a"
  echo "               table CHECK that due_at is later than borrowed_at."
fi

# 4. reservations — and specifically the ABSENCE of a stored queue position.
res_ok=no
res_note="not created yet"
if [ "$(q "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='reservations'")" = "1" ]; then
  cols="$(q "SELECT group_concat(name) FROM (SELECT name FROM pragma_table_info('reservations') ORDER BY cid)")"
  ddl="$(q "SELECT sql FROM sqlite_master WHERE name='reservations'")"
  if printf '%s' "${cols}" | grep -q "position"; then
    res_note="remove the stored queue position — run examples/04_rejected_design.sql"
  elif [ "${cols}" != "reservation_id,book_id,member_id,reserved_at,status" ]; then
    res_note="columns must be reservation_id, book_id, member_id, reserved_at, status"
  elif ! printf '%s' "${ddl}" | grep -q "waiting"; then
    res_note="status needs a CHECK constraining it to the four documented values"
  else
    res_ok=yes
  fi
fi
if [ "${res_ok}" = "yes" ]; then
  pass "4. reservations — status checked, and no stored queue position"
else
  todo "4. reservations — ${res_note}"
fi

# 5. indexes: one per foreign key, plus the two partial ones.
idx_count="$(q "SELECT count(*) FROM sqlite_master WHERE type='index' AND sql IS NOT NULL")"
partial_plain="$(q "SELECT count(*) FROM sqlite_master WHERE type='index' AND sql LIKE '%returned_at IS NULL%'")"
partial_unique="$(q "SELECT count(*) FROM sqlite_master WHERE type='index' AND sql LIKE '%UNIQUE%' AND sql LIKE '%waiting%'")"
if [ "${idx_count:-0}" -ge 9 ] && [ "${partial_plain}" = "1" ] && [ "${partial_unique}" = "1" ]; then
  pass "5. indexes — every foreign key covered, plus both partial indexes"
else
  todo "5. indexes — ${idx_count:-0} of the 9 expected explicit indexes exist"
  echo "               (a foreign key creates none of its own), and you still need"
  echo "               the partial index on outstanding loans and the partial"
  echo "               UNIQUE index on waiting reservations."
fi

# 6. the two views.
views="$(q "SELECT group_concat(name) FROM (SELECT name FROM sqlite_master WHERE type='view' ORDER BY name)")"
if [ "${views}" = "current_collection,current_members" ]; then
  pass "6. views — current_collection and current_members"
else
  todo "6. views — create current_collection and current_members"
fi

# ---------------------------------------------------------------------------
# Exercises 7-16: the ten questions.
# ---------------------------------------------------------------------------
echo
echo "Questions (exercises 7-16)"

"${sqlite_bin}" "${db}" < "${lab_dir}/examples/02_seed.sql" 2>"${work}/seed.err"
if [ -s "${work}/seed.err" ]; then
  echo "  the shared seed will not load into your schema yet, so the ten answers"
  echo "  cannot be checked. sqlite3 said:"
  sed 's/^/      /' "${work}/seed.err"
else
  # The reference database is built from the reference schema, so a mistake in
  # your schema can never quietly change what the right answer is.
  "${sqlite_bin}" "${ref}" < "${lab_dir}/examples/01_schema.sql" 2>/dev/null
  "${sqlite_bin}" "${ref}" < "${lab_dir}/examples/02_seed.sql"   2>/dev/null

  "${sqlite_bin}" "${db}"  < "${lab_dir}/starter/02_questions.sql" > "${work}/yours.txt"  2>&1
  "${sqlite_bin}" "${ref}" < "${lab_dir}/examples/06_answers.sql"  > "${work}/theirs.txt" 2>&1

  # block N FILE — everything between the '### N' marker and the next marker.
  block() {
    awk -v want="### $1" '
      $0 == want          { grab = 1; next }
      /^### /             { grab = 0 }
      grab                { print }
    ' "$2"
  }

  for n in 1 2 3 4 5 6 7 8 9 10; do
    exercise=$((n + 6))
    if [ "$(block "${n}" "${work}/yours.txt")" = "$(block "${n}" "${work}/theirs.txt")" ]; then
      pass "${exercise}. question ${n}"
    else
      todo "${exercise}. question ${n} — your answer does not match the reference yet"
    fi
  done
fi

echo
echo "${done_count} of ${total} exercises complete."
[ "${done_count}" -eq "${total}" ]
tests/run_tests.sh (20859 bytes)
#!/usr/bin/env bash
# Tests for the Day 091 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# Every check below compares a REAL VALUE against the answer the brief asks
# for. The questions this suite asks are the ones the lesson claims answers to:
#
#   * does the schema actually enforce the decisions it claims to — the
#     junction table keyed on the pair, money as integers, the timestamp
#     format, the enumerations, the ordering constraints?
#   * do the ten reporting queries return the right rows, in the right order?
#   * does the design that stored a queue position really break, silently?
#   * does the report script print the numbers the lesson quotes?
#   * does the starter really report 0 of 16 before you start and 16 of 16
#     once the reference answers are in place?
#
# Nothing here touches the network. Nothing needs sudo. Everything is built in
# a temporary directory that is removed in a trap, so a completed run leaves
# your lab directory exactly as it found it — no database is left behind.
set -u

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work=""
checks=0
failures=0

cleanup() { [ -n "${work}" ] && [ -d "${work}" ] && rm -rf "${work}"; }
trap cleanup EXIT INT TERM

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# check_eq LABEL EXPECTED ACTUAL — prints what it wanted when it does not match.
check_eq() {
  local label="$1" expected="$2" actual="$3"
  checks=$((checks + 1))
  if [ "${expected}" = "${actual}" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    echo "        expected: ${expected}"
    echo "        actual:   ${actual}"
    failures=$((failures + 1))
  fi
}

python_bin="${PYTHON:-$(command -v python3 || true)}"
sqlite_bin="${SQLITE3:-$(command -v sqlite3 || true)}"
if [ -z "${python_bin}" ] || [ ! -x "${python_bin}" ]; then
  echo "python3 was not found. Install Python 3.11+ or set PYTHON=/path/to/python3."
  exit 1
fi
if [ -z "${sqlite_bin}" ] || [ ! -x "${sqlite_bin}" ]; then
  echo "sqlite3 was not found. Install it or set SQLITE3=/path/to/sqlite3."
  exit 1
fi

export PYTHONDONTWRITEBYTECODE=1
work="$(mktemp -d)"
db="${work}/library.db"

# q SQL — one scalar or one column, no headers, no padding.
q() { "${sqlite_bin}" "${db}" ".mode list" ".headers off" "$1"; }
# rows SQL — pipe-separated rows.
rows() { "${sqlite_bin}" "${db}" ".mode list" ".separator '|'" ".headers off" "$1"; }

echo "Day 091 — Designing and Querying a Real Schema"
echo "python3: $("${python_bin}" -c 'import sys; print(sys.version.split()[0])')"
echo "sqlite3: $("${sqlite_bin}" --version | cut -d' ' -f1)"
echo "sqlite (python): $("${python_bin}" -c 'import sqlite3; print(sqlite3.sqlite_version)')"
echo "work:    a temporary directory, removed when this script exits"
echo

# Window functions need SQLite 3.25.0 and this lab uses them heavily. Fail
# loudly and early rather than producing a wall of syntax errors.
"${sqlite_bin}" :memory: "SELECT row_number() OVER ()" >/dev/null 2>&1
check "the sqlite3 shell supports window functions (3.25.0 or newer)" \
  "$([ $? -eq 0 ] && echo yes || echo no)"

# ---------------------------------------------------------------------------
echo
echo "1. The schema builds, and encodes the decisions it claims to"
# ---------------------------------------------------------------------------
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/01_schema.sql" 2>"${work}/schema.err"
check "01_schema.sql runs without error" \
  "$([ ! -s "${work}/schema.err" ] && echo yes || echo no)"
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/02_seed.sql" 2>"${work}/seed.err"
check "02_seed.sql runs without error" \
  "$([ ! -s "${work}/seed.err" ] && echo yes || echo no)"

check_eq "seven tables and two views exist" "7|2" \
  "$(q "SELECT (SELECT count(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%') || '|' || (SELECT count(*) FROM sqlite_master WHERE type='view')")"
check_eq "row counts: 8 categories, 11 authors, 8 books, 12 credits, 6 members, 14 loans, 7 reservations" \
  "8|11|8|12|6|14|7" \
  "$(q "SELECT (SELECT count(*) FROM categories) || '|' || (SELECT count(*) FROM authors) || '|' || (SELECT count(*) FROM books) || '|' || (SELECT count(*) FROM book_authors) || '|' || (SELECT count(*) FROM members) || '|' || (SELECT count(*) FROM loans) || '|' || (SELECT count(*) FROM reservations)")"

# THE MANY-TO-MANY CHECK. If book_authors is not keyed on the pair — if the
# author ended up as a column on books, or the junction has its own surrogate
# id, or the credit order was left out — this is the check that fails.
check_eq "book_authors is keyed on the PAIR (book_id, author_id)" \
  "book_id,author_id" \
  "$(q "SELECT group_concat(name) FROM (SELECT name FROM pragma_table_info('book_authors') WHERE pk > 0 ORDER BY pk)")"
check_eq "the junction table carries the relationship's own attribute" \
  "author_position" \
  "$(q "SELECT name FROM pragma_table_info('book_authors') WHERE name='author_position'")"
check_eq "books has no author column: the relationship is not an attribute" "0" \
  "$(q "SELECT count(*) FROM pragma_table_info('books') WHERE name LIKE '%author%'")"
check_eq "book_authors references both parents" "authors,books" \
  "$(q "SELECT group_concat(t) FROM (SELECT \"table\" AS t FROM pragma_foreign_key_list('book_authors') ORDER BY t)")"

# Surrogate keys, and the natural keys kept as UNIQUE rather than as the key.
check_eq "isbn13 is UNIQUE but nullable, so the 1818 book can exist" "0" \
  "$(q "SELECT \"notnull\" FROM pragma_table_info('books') WHERE name='isbn13'")"
check_eq "exactly one book legitimately has no ISBN" "Frankenstein" \
  "$(q "SELECT title FROM books WHERE isbn13 IS NULL")"
check_eq "email is UNIQUE but is not the primary key" "member_id" \
  "$(q "SELECT name FROM pragma_table_info('members') WHERE pk = 1")"

# Money is integer minor units. A REAL column here would be the bug.
check_eq "fine_pence is declared INTEGER, not REAL" "INTEGER" \
  "$(q "SELECT type FROM pragma_table_info('loans') WHERE name='fine_pence'")"
check_eq "acquisition_cost_pence is declared INTEGER, not REAL" "INTEGER" \
  "$(q "SELECT type FROM pragma_table_info('books') WHERE name='acquisition_cost_pence'")"

# Indexes: a foreign key creates none, so these had to be written.
check_eq "nine explicit indexes, one per foreign key plus the two partial ones" "9" \
  "$(q "SELECT count(*) FROM sqlite_master WHERE type='index' AND sql IS NOT NULL")"
check "the partial index on outstanding loans exists" \
  "$(q "SELECT count(*) FROM sqlite_master WHERE type='index' AND sql LIKE '%returned_at IS NULL%'" | grep -q '^1$' && echo yes || echo no)"

echo
# ---------------------------------------------------------------------------
echo "2. The constraints actually refuse the impossible rows"
# ---------------------------------------------------------------------------
# reject SQL — yes when the statement is refused, no when it succeeds.
reject() {
  "${sqlite_bin}" "${db}" "PRAGMA foreign_keys = ON; $1" >/dev/null 2>&1 && echo no || echo yes
}
check "a loan due before it was borrowed is refused" \
  "$(reject "INSERT INTO loans (loan_id, book_id, member_id, borrowed_at, due_at) VALUES (900, 101, 1, '2026-08-01T09:00:00Z', '2026-07-01T09:00:00Z')")"
check "a negative fine is refused" \
  "$(reject "INSERT INTO loans (loan_id, book_id, member_id, borrowed_at, due_at, fine_pence) VALUES (901, 101, 1, '2026-08-01T09:00:00Z', '2026-08-22T09:00:00Z', -1)")"
check "a timestamp that is not ISO 8601 UTC is refused" \
  "$(reject "INSERT INTO loans (loan_id, book_id, member_id, borrowed_at, due_at) VALUES (902, 101, 1, '01/08/2026', '2026-08-22T09:00:00Z')")"
check "a membership tier nobody has heard of is refused" \
  "$(reject "INSERT INTO members (member_id, email, full_name, tier, joined_at) VALUES (90, 'x@y.invalid', 'Test Person', 'platinum', '2026-01-01T00:00:00Z')")"
check "a reservation status outside the four documented values is refused" \
  "$(reject "INSERT INTO reservations (reservation_id, book_id, member_id, reserved_at, status) VALUES (90, 101, 1, '2026-08-01T09:00:00Z', 'pending')")"
check "a reservation against a book we do not own is refused" \
  "$(reject "INSERT INTO reservations (reservation_id, book_id, member_id, reserved_at) VALUES (91, 999, 1, '2026-08-01T09:00:00Z')")"
check "crediting the same author twice on one book is refused" \
  "$(reject "INSERT INTO book_authors (book_id, author_id, author_position) VALUES (101, 1, 3)")"
check "two authors credited second on the same book is refused" \
  "$(reject "INSERT INTO book_authors (book_id, author_id, author_position) VALUES (101, 7, 2)")"
check "a second WAITING reservation by the same member on the same book is refused" \
  "$(reject "INSERT INTO reservations (reservation_id, book_id, member_id, reserved_at, status) VALUES (92, 105, 1, '2026-08-12T09:00:00Z', 'waiting')")"
check "an ISBN that is not thirteen digits is refused" \
  "$(reject "INSERT INTO books (book_id, isbn13, title, category_id, acquisition_cost_pence) VALUES (900, '978-0131103627', 'Bad ISBN', 7, 100)")"
check "hard-deleting a book that has loan history is refused, so history survives" \
  "$(reject "DELETE FROM books WHERE book_id = 101")"
check_eq "and nothing above actually got in: the seed row counts are unchanged" \
  "8|12|6|14|7" \
  "$(q "SELECT (SELECT count(*) FROM books) || '|' || (SELECT count(*) FROM book_authors) || '|' || (SELECT count(*) FROM members) || '|' || (SELECT count(*) FROM loans) || '|' || (SELECT count(*) FROM reservations)")"

echo
# ---------------------------------------------------------------------------
echo "3. The ten reporting questions return the right answers"
# ---------------------------------------------------------------------------
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/06_answers.sql" > "${work}/answers.txt" 2>&1
block() {
  awk -v want="### $1" '
    $0 == want { grab = 1; next }
    /^### /    { grab = 0 }
    grab       { print }
  ' "${work}/answers.txt"
}

check_eq "Q1: 7 books on the shelves, 4 of them out on loan" "7|4" "$(block 1)"
check_eq "Q1: the withdrawn book is excluded — 8 rows in books, 7 in the collection" \
  "8|7" "$(q "SELECT (SELECT count(*) FROM books) || '|' || (SELECT count(*) FROM current_collection)")"
check_eq "Q2: exactly one current member has never borrowed" "Eli Nakamura|student" "$(block 2)"
check_eq "Q3: three books have more than one author, names in credited order" \
"Structure and Interpretation of Computer Programs|3|Harold Abelson, Gerald Jay Sussman, Julie Sussman
The C Programming Language|2|Brian W. Kernighan, Dennis M. Ritchie
The Practice of Programming|2|Brian W. Kernighan, Rob Pike" "$(block 3)"
check_eq "Q4: two loans are overdue, by 10 and 5 whole days" \
"Bruno Salgado|The Left Hand of Darkness|10
Chandra Iyer|Neuromancer|5" "$(block 4)"
check_eq "Q5: fines are 4.10 and 3.00, and the member who left is still counted" \
"Ada Okafor|current|4.10
Farida Haddad|left|3.00" "$(block 5)"
check_eq "Q6: top two per tier, including the student who has borrowed nothing" \
"staff|1|Chandra Iyer|4
standard|1|Ada Okafor|3
standard|2|Dana Whitfield|2
student|1|Bruno Salgado|4
student|2|Eli Nakamura|0" "$(block 6)"
check_eq "Q7: the queues, with the cancelled reservation occupying no slot" \
"Neuromancer|1|Bruno Salgado
Neuromancer|2|Ada Okafor
The Left Hand of Darkness|1|Ada Okafor
The Left Hand of Darkness|2|Chandra Iyer
The Left Hand of Darkness|3|Dana Whitfield" "$(block 7)"
check_eq "Q8: eight months of loans, running total reaching 14" \
"2026-01|1|1
2026-02|1|2
2026-03|1|3
2026-04|1|4
2026-05|2|6
2026-06|3|9
2026-07|3|12
2026-08|2|14" "$(block 8)"
check_eq "Q9: the recursive walk finds Fiction and its three descendants, to depth 2" \
"0|Fiction|0
1|Gothic|1
1|Science Fiction|1
2|Cyberpunk|1" "$(block 9)"
check_eq "Q10: one author has never been borrowed" "Donald E. Knuth" "$(block 10)"

# The two failure modes the lesson names, demonstrated rather than asserted.
check_eq "forgetting the soft-delete filter reports 8 books instead of 7" "8" \
  "$(q "SELECT count(*) FROM books")"
check_eq "an INNER JOIN in Q6 would drop the member with no loans entirely" "4" \
  "$(q "SELECT count(*) FROM (SELECT m.member_id FROM members m JOIN loans l ON l.member_id = m.member_id WHERE m.left_at IS NULL GROUP BY m.member_id)")"
check_eq "count(*) instead of count(l.loan_id) reports 1 loan for Eli, not 0" "1" \
  "$(q "SELECT count(*) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE m.full_name = 'Eli Nakamura'")"
check_eq "a GROUP BY alone cannot do top-2-per-tier: it collapses to 3 rows" "3" \
  "$(q "SELECT count(*) FROM (SELECT tier, max(c) FROM (SELECT m.tier AS tier, count(l.loan_id) AS c FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE m.left_at IS NULL GROUP BY m.member_id, m.tier) GROUP BY tier)")"
check_eq "EXISTS and the LEFT JOIN anti-join agree on Q2" "yes" \
  "$(q "SELECT CASE WHEN (SELECT count(*) FROM members m WHERE m.left_at IS NULL AND NOT EXISTS (SELECT 1 FROM loans l WHERE l.member_id = m.member_id)) = (SELECT count(*) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE m.left_at IS NULL AND l.loan_id IS NULL) THEN 'yes' ELSE 'no' END")"
check_eq "money never leaves integer arithmetic: 410 + 300 pence is exactly 710" "710" \
  "$(q "SELECT sum(fine_pence) FROM loans")"
check_eq "and binary floating point is why: 0.1 + 0.2 is not 0.3 in SQLite either" "no" \
  "$(q "SELECT CASE WHEN 0.1 + 0.2 = 0.3 THEN 'yes' ELSE 'no' END")"

echo
# ---------------------------------------------------------------------------
echo "4. The rejected design really does break, silently"
# ---------------------------------------------------------------------------
rej="${work}/rejected.db"
"${sqlite_bin}" "${rej}" < "${lab_dir}/examples/04_rejected_design.sql" > "${work}/rejected.txt" 2>&1
rej_status=$?
check_eq "04_rejected_design.sql runs to completion with no error raised" "0" "${rej_status}"
check "the stored-position design ends with two members at the same position" \
  "$(grep -qE '^3 +2( |$)' "${work}/rejected.txt" && echo yes || echo no)"
check_eq "the v1 queue really does contain a duplicate position" "1" \
  "$("${sqlite_bin}" "${rej}" ".mode list" ".headers off" "SELECT count(*) FROM (SELECT queue_position FROM reservations_v1 WHERE status='waiting' GROUP BY queue_position HAVING count(*) > 1)")"
check_eq "the derived version renumbers itself correctly after a cancellation" "1,2" \
  "$("${sqlite_bin}" "${rej}" ".mode list" ".headers off" "SELECT group_concat(p) FROM (SELECT ROW_NUMBER() OVER (PARTITION BY book_id ORDER BY reserved_at) AS p FROM reservations_v2 WHERE status='waiting')")"

echo
# ---------------------------------------------------------------------------
echo "5. The report script prints the report"
# ---------------------------------------------------------------------------
"${python_bin}" "${lab_dir}/examples/05_report.py" "${db}" > "${work}/report.txt" 2>&1
report_status=$?
check_eq "05_report.py exits 0" "0" "${report_status}"
check "the report states 7 books on the shelves and 4 out on loan" \
  "$(grep -q '7 books on the shelves, 4 of them out on loan' "${work}/report.txt" && echo yes || echo no)"
check "the report totals the fines at GBP 7.10" \
  "$(grep -q 'GBP 7.10  TOTAL' "${work}/report.txt" && echo yes || echo no)"
check "the report shows Eli Nakamura with 0 loans rather than omitting her" \
  "$(grep -q 'Eli Nakamura     0 loans' "${work}/report.txt" && echo yes || echo no)"
check "the report shows the three-deep queue for The Left Hand of Darkness" \
  "$(grep -q '3. Dana Whitfield' "${work}/report.txt" && echo yes || echo no)"
check "the report indents Cyberpunk two levels under Fiction" \
  "$(grep -q '^          Cyberpunk  (1 book)$' "${work}/report.txt" && echo yes || echo no)"
check "the report refuses to run against a database that does not exist" \
  "$("${python_bin}" "${lab_dir}/examples/05_report.py" "${work}/nothing.db" >/dev/null 2>&1 && echo no || echo yes)"

# The report instant is a parameter, not the wall clock — so a different
# instant gives a different, predictable answer.
"${python_bin}" "${lab_dir}/examples/05_report.py" "${db}" '2026-09-01T09:00:00Z' > "${work}/later.txt" 2>&1
check "with the instant moved forward, two more loans become overdue" \
  "$(grep -q '7 days  Dana Whitfield' "${work}/later.txt" && echo yes || echo no)"
check "and the two already-overdue loans grow from 10 and 5 days to 26 and 21" \
  "$(grep -q '26 days  Bruno Salgado' "${work}/later.txt" && grep -q '21 days  Chandra Iyer' "${work}/later.txt" && echo yes || echo no)"
check "the report reads no clock: it never imports datetime" \
  "$(grep -qE '^\s*(import|from)\s+datetime' "${lab_dir}/examples/05_report.py" && echo no || echo yes)"

echo
# ---------------------------------------------------------------------------
echo "6. The starter reports honest progress"
# ---------------------------------------------------------------------------
before="$(bash "${lab_dir}/starter/03_check.sh" 2>&1)"
before_status=$?
check "the untouched starter reports 0 of 16 exercises complete" \
  "$(printf '%s' "${before}" | grep -q '^0 of 16 exercises complete\.$' && echo yes || echo no)"
check_eq "and exits non-zero, so it cannot be mistaken for finished" "incomplete" \
  "$([ "${before_status}" -ne 0 ] && echo incomplete || echo "exit ${before_status}")"
check "it names the many-to-many table among the work still to do" \
  "$(printf '%s' "${before}" | grep -q 'book_authors' && echo yes || echo no)"

# Now solve it with the reference files, in a copy, and confirm 16 of 16.
solved="${work}/solved"
mkdir -p "${solved}"
cp -R "${lab_dir}/examples" "${lab_dir}/starter" "${solved}/"
cp "${solved}/examples/01_schema.sql"  "${solved}/starter/01_schema.sql"
cp "${solved}/examples/06_answers.sql" "${solved}/starter/02_questions.sql"
after="$(bash "${solved}/starter/03_check.sh" 2>&1)"
after_status=$?
check "with the reference schema and answers in place it reports 16 of 16" \
  "$(printf '%s' "${after}" | grep -q '^16 of 16 exercises complete\.$' && echo yes || echo no)"
check_eq "and exits 0" "0" "${after_status}"

# One deliberate wrong answer must be caught, or the checker proves nothing.
sed 's/WHERE m.left_at IS NULL$/WHERE 1 = 1/' "${solved}/examples/06_answers.sql" > "${solved}/starter/02_questions.sql"
broken="$(bash "${solved}/starter/03_check.sh" 2>&1)"
check "a query that forgets the soft-delete filter is caught, not waved through" \
  "$(printf '%s' "${broken}" | grep -q '^16 of 16 exercises complete\.$' && echo no || echo yes)"

echo
# ---------------------------------------------------------------------------
echo "7. Hygiene: offline, no sudo, no leaked paths, nothing left behind"
# ---------------------------------------------------------------------------
"${python_bin}" - "${lab_dir}" > "${work}/hygiene.txt" <<'PY'
import re, sys
from pathlib import Path

root = Path(sys.argv[1])
urls, sudo_lines = set(), []
comment = re.compile(r"^\s*(#|--)")
for path in sorted(root.rglob("*")):
    if not path.is_file() or path.suffix not in {".sql", ".py", ".sh"}:
        continue
    for number, line in enumerate(
        path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1
    ):
        urls.update(re.findall(r"https?://[^\s\"')]+", line))
        if re.search(r"(^|[;|&(]\s*)sudo\s", line) and not comment.match(line):
            sudo_lines.append(f"{path.name}:{number}")
print("URLS " + " ".join(sorted(urls)))
print("SUDO " + " ".join(sudo_lines))
PY
check_eq "no URL appears anywhere in the lab's scripts" "URLS" \
  "$(grep '^URLS ' "${work}/hygiene.txt" | sed 's/ *$//')"
check_eq "no line in this lab would actually invoke sudo" "SUDO" \
  "$(grep '^SUDO ' "${work}/hygiene.txt" | sed 's/ *$//')"
check "nothing in this lab imports a networking module" \
  "$(grep -rlE '^\s*(import|from)\s+(socket|urllib|http|requests)' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1 && echo no || echo yes)"
check "no captured output leaks an absolute home path" \
  "$(grep -rl '/Users/\|/home/' "${lab_dir}/expected-output" >/dev/null 2>&1 && echo no || echo yes)"
check "every invented email address uses the reserved .invalid domain" \
  "$(grep -oE '[A-Za-z0-9._-]+@[A-Za-z0-9._-]+' "${lab_dir}/examples/02_seed.sql" | grep -v '\.invalid$' >/dev/null 2>&1 && echo no || echo yes)"
check "this suite created no database inside the lab directory" \
  "$([ ! -f "${lab_dir}/library.db" ] && [ ! -f "${lab_dir}/starter/library.db" ] && echo yes || echo no)"

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 091

Grouped by the message you actually see. If your problem is not here, run bash tests/run_tests.sh first: it prints what it expected and what it got for every value it compares, which usually names the problem for you.

Setting up

sqlite3: command not found Install it (apt install sqlite3, brew install sqlite, or use the copy already at /usr/bin/sqlite3 on macOS), or point the scripts at the one you have: SQLITE3=/path/to/sqlite3 bash tests/run_tests.sh.

near "OVER": syntax error Your sqlite3 is older than 3.25.0 (2018) and has no window functions. Questions 6, 7 and 8 cannot be written this way without them. Check with sqlite3 --version. The test suite runs SELECT row_number() OVER () as its first check specifically so this fails with one clear line instead of a wall of errors. Python's bundled SQLite is usually newer than the shell's — compare them with python3 -c "import sqlite3; print(sqlite3.sqlite_version)".

no such table: books when you run the questions The schema has not been built into the database you are querying, or you built it into a different file. SQLite silently creates an empty database from a mistyped filename rather than complaining, which is the single most common cause. Check with sqlite3 library.db ".tables" before assuming your schema failed.

Building the schema

Parse error: near ")": syntax error in a CREATE TABLE Almost always a trailing comma after the last column or constraint. SQLite points at the closing bracket, not at the comma.

Parse error: unrecognized token: "'" An unclosed quote earlier in the file. Everything after it is being read as one enormous string, so the error appears a long way from the cause.

Your CHECK constraint is accepted but never fires Two likely causes. A column-level CHECK can only see its own column — a rule comparing due_at with borrowed_at has to be a table-level constraint, written after all the columns. And CHECK (x <> 'bad') is unknown, not false, when x is NULL, so it lets the row through; write CHECK (x IS NULL OR x <> 'bad') if that is not what you meant.

CHECK constraint failed on a timestamp you think is fine The shape check is LIKE '____-__-__T__:__:__Z' — exactly four, two, two, two, two, two characters with a literal T and a literal Z. 2026-8-16 has one digit where two are required. 2026-08-16 09:00:00 uses a space instead of a T. Both are refused, on purpose: the format is the reason string comparison is chronological comparison, and one row in a different shape breaks every range query silently.

FOREIGN KEY constraint failed while seeding Rows are being inserted before their parents. The seed file inserts categories, then authors, then books, then credits, then members, then loans, then reservations, in that order, for exactly this reason. Inside a transaction you can also defer the check, but ordering the inserts is simpler.

No error at all when you insert an obviously broken row PRAGMA foreign_keys is off. It is off by default on every new connection, it is not stored in the database file, and it is a documented no-op inside an open transaction (Day 87). Issue it as the first statement after connecting. PRAGMA foreign_key_check will list orphan rows that are already there.

The queries

Question 1 reports 8 books instead of 7 The soft-delete filter is missing: WHERE withdrawn_at IS NULL, or use the current_collection view. This is the running cost of soft delete, and it does not raise an error — it just quietly reports a number that is wrong.

Question 6 is missing the member who has borrowed nothing An INNER JOIN where a LEFT JOIN was needed. She has no loan rows, so an inner join has nothing to give her. Day 87, arriving with a bill.

Question 6 reports 1 loan for that member instead of 0 count(*) counts rows, and the outer join manufactured one NULL-filled row for her. count(l.loan_id) counts non-NULL values of that column, and aggregate functions skip NULLs, so it correctly reports 0.

misuse of window function ROW_NUMBER() A window function cannot appear in a WHERE clause or inside an aggregate. It is computed after WHERE and after GROUP BY, so to filter on its result you have to compute it in a subquery or CTE and filter in the query outside. That is why questions 6 and 8 are both written as two steps.

SUM ... OVER gives the same number on every row The ORDER BY inside the OVER (...) is missing. Without it there is no ordering to accumulate along, so every row gets the total of the whole partition. With it, the default frame is everything up to and including the current row — a running total.

Your recursive CTE returns only one row The recursive part is not finding children. Check the direction of the join: it should be categories c JOIN subtree s ON c.parent_id = s.category_id, reading "c is a child of something already found". Reversing it walks upwards to the root instead, which is a perfectly good query for a different question.

Your recursive CTE never finishes There is a cycle in the data, or the recursive part has no condition that eventually stops matching. The schema's CHECK (parent_id IS NULL OR parent_id <> category_id) blocks the one-row cycle, but not a longer one. Carrying a depth column and adding WHERE s.depth < 10 to the recursive part is the cheap, honest guard.

Question 5's total is 7.099999999999999 Money left integer arithmetic somewhere. Sum fine_pence as integers and divide by 100 exactly once, at the point of display. Day 70's floating-point lesson is the reason.

Question 4 says a loan is 0 days overdue when it is clearly late CAST(... AS INTEGER) truncates towards zero, so anything under 24 hours late is 0 whole days. That is what "whole days" means. If you want it to round up, use CAST(... AS INTEGER) + 1 only when the fractional part is non-zero, and be explicit about which one the library's fine policy actually uses — this is the kind of question worth asking before writing the query rather than after.

The report script

no such database: library.db Build it first: sqlite3 library.db < examples/01_schema.sql then sqlite3 library.db < examples/02_seed.sql. The script refuses to run against a missing file rather than creating an empty one, which is the opposite of what the sqlite3 shell does and is deliberate.

sqlite3.OperationalError: no such column: full_name The report reads rows by name through sqlite3.Row, so the column names in the SELECT and the names in the formatting code have to agree. If you renamed a column in your schema, rename it in the repository method's AS clause too.

The overdue answers change every time you run it Something is reading a clock. Nothing here should: the instant is a parameter with a default of 2026-08-16T09:00:00Z. The test suite checks that the script never imports datetime at all.

The checkers

0 of 16 exercises complete. with a wall of parse errors That is the correct starting state. The seed cannot load into a schema whose tables do not exist yet, so the ten question checks cannot run. Work down starter/01_schema.sql and they will start reporting.

The checker says your answer does not match, but it looks right The comparison is exact, including column order and row order. Re-read the Columns: and Order: lines in the comment above the exercise. Extra columns count as a mismatch even when the rows are correct.

03_check.sh reports the schema as done but the questions all fail Your column names differ from the ones the shared seed inserts into, so the seed loaded into a table shaped differently from what the queries expect. The column names are fixed for exactly this reason; the design decisions around them are yours.

Windows

tests/run_tests.sh and starter/03_check.sh are bash scripts and use mktemp -d, so run them under WSL and follow the Linux instructions. Neither was run on native Windows when the expected output was captured, and none is claimed for it.

Security notes

Security notes — Day 091

What this lab does to your machine

Almost nothing, and that is checked rather than promised.

  • No network. Nothing here opens a socket. The test suite asserts that no file in examples/ or starter/ imports socket, urllib, http or requests, and that no URL appears anywhere in the lab's .sql, .py or .sh files at all.
  • No privilege. Nothing runs sudo. The suite greps for a line that would actually invoke it, as opposed to a comment saying it does not.
  • No credentials. There is no account, no key, no token, and no service to log in to. SQLite is a file.
  • No installation. requirements.txt lists no packages. Nothing is added to your Python environment, your PATH, or any scheduler.
  • No mess. Both tests/run_tests.sh and starter/03_check.sh build every database inside mktemp -d and remove it in a trap, and the suite asserts afterwards that no database was created in the lab directory or in starter/.

The data in this lab

The books are real published works and the authors are their real authors, used as catalogue entries. Everything else is invented for this lab: the library, the six members, their email addresses, the fourteen loans, the fines and the reservations. No real person's borrowing history appears anywhere, and none should.

Every invented email address is on the .invalid top-level domain, which is reserved by RFC 2606 precisely so that it can never resolve or be delivered to. The test suite checks that: it extracts every address from the seed file and fails if any of them ends in anything else. That check exists because seed data has a way of escaping into screenshots, bug reports and public repositories, and an address that looks plausible eventually receives mail.

The reason to be careful is the same one Day 87 raised, and this day sharpens it. A library loan record ties a named individual to what they read and when. Each table here is fairly harmless alone — books is a catalogue, members is a mailing list, loans is integers and dates. It is the join that produces the sensitive record, and today's schema makes several of those joins one line long.

Schema design is a privacy decision, made early and hard to undo

This is the day's own security point, and it is a design point rather than a coding one.

What you choose to store is the ceiling on what can ever leak. The brief does not ask for a member's date of birth, home address, or reading interests, so the schema does not have columns for them. A column that does not exist cannot be joined, exported, indexed by mistake, or subpoenaed.

Soft delete keeps data you have been asked to remove. members.left_at means a member who has left is still a row, with their name and email address, indefinitely. The brief has a good reason — an outstanding fine survives the membership — but "we keep it because deleting is inconvenient" is not that reason. If you build this for real, decide in advance what a departed member's row is allowed to retain and how long for, and write the deletion job at the same time as the soft-delete column. The cost of not doing so is that the row is still there in five years, in every backup, and nobody remembers why.

The queries are the disclosure boundary, not the tables. Question 4 in this lab produces "this named person has this named book and is ten days late". That is a perfectly ordinary operational report and it is also, in one row, a reading record. When you decide who may run which report, reason about the query.

Constraints are integrity, not access control

Every CHECK, UNIQUE, NOT NULL and REFERENCES in this schema stops bad data. None of them stops a bad reader. Anyone who can read the database file can read every row in it, because a SQLite database is an ordinary file with ordinary filesystem permissions and no encryption.

And, from Day 87 and still true here: foreign-key enforcement is off unless you turn it on, per connection, every time. examples/01_schema.sql, examples/02_seed.sql and LibraryRepository.__init__ in examples/05_report.py each issue PRAGMA foreign_keys = ON as their first statement. If you write your own connection code, do the same, first, before anything opens a transaction.

SQL injection, and the one thing a placeholder cannot do

The report script takes two values from the command line: a database path and a report instant. The instant goes into the SQL, and it goes in as a bound parameter:

## never
self.connection.execute(f"... WHERE l.due_at < '{now}'")

## always — and this is what 05_report.py does
self.connection.execute("... WHERE l.due_at < ?", (now,))

The placeholder form sends the value to SQLite separately from the statement, so it can never be parsed as SQL no matter what it contains. That is not a clever escaping trick; the value never reaches the parser.

The limit worth knowing: table and column names cannot be parameterised. SELECT * FROM ? is not valid SQL. Nothing in this lab interpolates one, and if you ever need to, check the name against a hard-coded allow-list rather than against a pattern.

Note also what LibraryRepository buys here beyond tidiness. Every statement in the lab lives inside that one class, so the formatting code physically cannot build a query out of string concatenation — there is no connection object in scope for it to do so with. That is a security property of the structure, not of anybody's discipline.

Cleanup

rm -f library.db
find . -type d -name __pycache__ -prune -exec rm -rf -- {} +

Nothing else was created, nothing was installed, and nothing outside this directory was touched. If you never ran the optional walkthrough commands by hand, there is nothing to remove at all — both scripts clean up after themselves.