Programming with Python › SQL and Relational Databases › Day 87
Hands-on lab — Day 87: Joins and Relationships
- ← Back to the Day 87 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-087-joins-and-relationships/
Commands
Setup
cd labs/sections/programming-with-python/day-087-joins-and-relationships
python3 --version
sqlite3 --version Run
bash tests/run_tests.sh
sqlite3 anomalies.db < examples/01_wide_table.sql
sqlite3 library.db < examples/02_schema.sql
sqlite3 library.db < examples/03_seed.sql
sqlite3 library.db < examples/04_foreign_keys.sql # exits non-zero on purpose
sqlite3 library.db < examples/05_joins.sql
python3 examples/06_join_from_scratch.py library.db
python3 examples/07_foreign_keys_python.py
python3 examples/08_n_plus_one.py
sqlite3 library.db < examples/09_query_plans.sql
bash starter/01_build.sh
sqlite3 starter/library.db < starter/02_exercises.sql
python3 starter/03_join_from_scratch.py Test
bash tests/run_tests.sh File tree
examples/01_wide_table.sql examples/02_schema.sql examples/03_seed.sql examples/04_foreign_keys.sql examples/05_joins.sql examples/06_join_from_scratch.py examples/07_foreign_keys_python.py examples/08_n_plus_one.py examples/09_query_plans.sql expected-output/anomalies.txt expected-output/FIELDS.md expected-output/foreign-keys.txt expected-output/join-from-scratch.txt expected-output/joins.txt expected-output/n-plus-one.txt expected-output/query-plans.txt expected-output/starter-progress.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/01_build.sh starter/02_exercises.sql starter/03_join_from_scratch.py tests/run_tests.sh troubleshooting.md
Lab README
Day 087 lab — Connecting the Tables
Lesson
- Lesson title: Joins and Relationships
- Day number: 87 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-087-joins-and-relationships
- Lab files: everything you need is in this directory — follow “How to run” below.
- Browse the course locally: from the repository root, this lab also appears in the course website at
/labs/day-087-joins-and-relationshipswhen the site is running.
Purpose
Day 85 gave you the relational model and SQLite. Day 86 gave you SELECT —
filtering, sorting, grouping, aggregates, and the three-valued logic that makes
NULL behave the way it does. Both of those worked inside one table at a
time.
Today you find out why the data was in more than one table to begin with, and what it costs to put it back together.
The lab runs that argument end to end, as a sequence you can execute:
- Break it first. Build one wide table where each book row also carries its author's details, then watch three specific things go wrong — rename an author and miss a row, so the database now contradicts itself; try to record an author whose books you have not catalogued, and find there is no row shape that holds one; withdraw a book and lose the only record that its author existed.
- Split it. Five tables, each about one kind of thing:
books,authors, thebook_authorsjunction,members,loans. - Discover the enforcement is off. Insert a loan pointing at member 999,
who does not exist. Watch it succeed. Then turn on
PRAGMA foreign_keysand watch the identical statement be rejected. - Put the pieces back together. Inner, left outer, cross, self, and four-table joins, with the tests checking results by value — which rows survive, which columns come back NULL, and what the counts actually are.
- Build the join yourself. A nested-loop join and a hash join in plain Python over lists of dictionaries, both compared against SQLite for equality. 30 comparisons against 11 operations, for the same six rows.
The traps are the point. Three of them are built into the seeded data on purpose, because each one is a mistake you will otherwise make in production instead of here:
- One member has never borrowed anything, so
count(*)reports 1 for her wherecount(l.loan_id)correctly reports 0. - Moving one predicate from
ONtoWHEREon the sameLEFT JOINis wrong in both directions at once — it drops a member who genuinely has nothing out, and it keeps a member who has never borrowed at all. - One book has never been borrowed and one author has no catalogued book, so
the
LEFT JOINplusIS NULLidiom has something real to find.
All 75 checks run offline, install nothing, and need no privileges.
Learning objectives
- Reproduce the update, insertion and deletion anomalies mechanically, and explain why splitting the table removes all three.
- Model one-to-many by putting the foreign key on the many side, and many-to-many with a junction table keyed on the pair.
- State what a foreign key promises, and prove that SQLite promises nothing
until
PRAGMA foreign_keys = ONis issued on that connection. - Write
INNER JOINwith an explicitON, and say why it is better than the older comma-join-with-WHEREform that returns identical output. - Say exactly which rows a
LEFT OUTER JOINkeeps and which columns come back NULL, and useLEFT JOINplusIS NULLto find the rows with no match. - Recognise an accidental cartesian product from its row count and from its query plan.
- Write a self-join and a four-table join, and explain the row duplication a many-to-many introduces into the result.
- Produce a per-group count that shows a genuine zero, and name the two separate mistakes that stop it working.
- Explain why a predicate belongs in
ONrather thanWHEREfor an outer join. - Implement a nested-loop join and a hash join from scratch, compare their cost, and check both against the database's answer.
Prerequisites
- The Day 87 lesson (read it first).
- Day 85 — the relational model, tables, rows, types, and
sqlite3. - Day 86 —
SELECTwithWHERE,ORDER BY,GROUP BY, aggregates, andNULL's three-valued logic. Today leans on theNULLmaterial constantly. - Day 53 — dictionaries. The hash join is a dictionary, and knowing that a lookup is roughly constant-time is what makes the cost comparison land.
- Day 43 — a working
python3on yourPATH. - A terminal and a text editor. Nothing else.
Supported operating systems
- macOS — fully supported. Captures taken on macOS 26.5.2 (Apple Silicon,
arm64), Python 3.14.0, bash 3.2.57,
sqlite3shell 3.51.0. - Linux — fully supported. Any distribution with Python 3.11+, bash, and the
sqlite3shell. - Windows — use WSL and follow the Linux path.
tests/run_tests.shis a bash script and usesmktemp -d. Native Windows was not tested here, soexpected-output/FIELDS.mdrecords that honestly rather than guessing at a capture.
Hardware requirements
Any computer that runs Python 3.11 or newer. The largest database this lab builds has 2,500 rows and lives in memory. The whole suite finishes in a couple of seconds. No GPU, no meaningful memory or disk.
Required software
python3, version 3.11 or newer — standard library only.- The
sqlite3command-line shell, version 3.16.0 or newer. bash, for the test harness.
All three are already on macOS and on a typical Linux install. See
requirements/README.md for versions and for why
there are no third-party packages.
Free and open-source options
Everything here is free, and there is no paid tier of anything to be nudged toward. SQLite is in the public domain — not merely open source, but released without copyright by its author, which is why it is embedded in essentially every phone and browser you own. Python is under the PSF licence.
The lesson's Alternatives section covers PostgreSQL (PostgreSQL licence), MySQL (GPL-2.0 with a commercial option) and DuckDB (MIT) with the syntax differences that actually matter — including that both PostgreSQL and MySQL enforce foreign keys by default, unlike SQLite. All are free to install and run yourself; the paid products in that space are hosted convenience, not the database.
Installation
There is nothing to install.
cd labs/sections/programming-with-python/day-087-joins-and-relationships
python3 --version
sqlite3 --version
If both print a version, you are ready. If sqlite3 is missing, see
troubleshooting.md.
File structure
day-087-joins-and-relationships/
├── README.md ← you are here
├── metadata.yml
├── examples/ ← the worked answers, in running order
│ ├── 01_wide_table.sql ← the "before": one table, three anomalies
│ ├── 02_schema.sql ← the five-table split, with the keys
│ ├── 03_seed.sql ← real books, invented members and loans
│ ├── 04_foreign_keys.sql ← the pragma proof, in the shell
│ ├── 05_joins.sql ← every join type in the lesson
│ ├── 06_join_from_scratch.py ← nested-loop and hash joins, checked vs SQL
│ ├── 07_foreign_keys_python.py ← the pragma proof, plus the transaction trap
│ ├── 08_n_plus_one.py ← 501 queries against 1, measured
│ └── 09_query_plans.sql ← EXPLAIN QUERY PLAN: SCAN vs SEARCH
├── starter/ ← YOUR work: 9 numbered exercises
│ ├── 01_build.sh ← complete; builds starter/library.db
│ ├── 02_exercises.sql ← exercises 1-6, each runnable and wrong
│ └── 03_join_from_scratch.py ← exercises 7-9, with its own pass/fail report
├── tests/
│ └── run_tests.sh ← 75 checks; builds everything in mktemp -d
├── expected-output/
│ ├── test-run.txt ← the full harness run
│ ├── anomalies.txt ← the three anomalies happening
│ ├── foreign-keys.txt ← the pragma proof, shell and Python
│ ├── joins.txt ← all twelve queries and their real results
│ ├── join-from-scratch.txt ← the two algorithms agreeing with SQL
│ ├── n-plus-one.txt ← the measured comparison
│ ├── query-plans.txt ← what the planner chose
│ ├── starter-progress.txt ← the starter before and after
│ └── FIELDS.md ← what must match, what may differ
├── requirements/
│ ├── requirements.txt ← deliberately empty
│ └── README.md
├── troubleshooting.md
└── security.md
How to run
From this directory.
## 1. The whole thing. Start here.
bash tests/run_tests.sh
echo "exit code: $?"
Then walk the argument yourself, in order. Each step builds on the last.
## 2. The "before" picture: one wide table, and three things going wrong in it.
sqlite3 anomalies.db < examples/01_wide_table.sql
rm -f anomalies.db
## 3. Split it into five tables and fill them.
rm -f library.db
sqlite3 library.db < examples/02_schema.sql
sqlite3 library.db < examples/03_seed.sql
sqlite3 library.db ".tables"
## 4. The fact worth remembering from today. Note this must be ONE session:
## the pragma is per connection, so two sqlite3 invocations prove nothing.
sqlite3 library.db < examples/04_foreign_keys.sql; echo "exit: $?"
## 5. Every join type, against real data.
sqlite3 library.db < examples/05_joins.sql
## 6. Build the join yourself, and check it against the database.
python3 examples/06_join_from_scratch.py library.db
## 7. The same pragma fact from Python, including the trap that makes people
## think the pragma is broken.
python3 examples/07_foreign_keys_python.py
## 8. N+1 queries against one join, measured rather than asserted.
python3 examples/08_n_plus_one.py
## 9. Watch the planner choose an algorithm.
sqlite3 library.db < examples/09_query_plans.sql
## 10. Now your turn. Build your own copy to break.
bash starter/01_build.sh
## 11. Exercises 1-6: six queries that run, and are each wrong in one named way.
sqlite3 starter/library.db < starter/02_exercises.sql
## 12. Exercises 7-9: the two join algorithms. It reports its own pass/fail.
python3 starter/03_join_from_scratch.py
## 13. When all nine are done, the full harness should still be green.
bash tests/run_tests.sh
To poke at the data interactively:
sqlite3 library.db
sqlite> PRAGMA foreign_keys = ON;
sqlite> .mode column
sqlite> .headers on
sqlite> SELECT * FROM members;
sqlite> .quit
What the commands do
bash tests/run_tests.sh— the whole harness, in fourteen sections. It resolvespython3andsqlite3(honouringPYTHON=andSQLITE3=overrides), builds every database insidemktemp -d, runs all nine example files, compares 75 real values, and removes the temporary directory in atrap. One exit code: 0 if everything matched, non-zero otherwise.examples/01_wide_table.sql— builds the denormalized table and then commits all three anomalies against it. The update anomaly is the one to watch: it finishes with no error at all, leaving one human being recorded under two different names, which is precisely why it is dangerous.examples/02_schema.sql— the five tables. Read the comments onbook_authors(its primary key is the pair, which is what stops a duplicate attachment), onloans(the foreign keys live on the many side), onmembers(referred_bypoints back at the same table, which is what a self-join is for), and on the three indexes — a foreign key does not create an index, and without one every join on that column is a full table scan.examples/03_seed.sql— real books and their real authors; invented members and loans. Three gaps are deliberate: an author with no catalogued book, a book never borrowed, and a member who has never borrowed. They are what the outer joins later have to find.examples/04_foreign_keys.sql— the proof. It reads the pragma (0), inserts a loan for a member who does not exist (it succeeds), shows the orphan row, finds it withPRAGMA foreign_key_check, deletes it, turns enforcement on, and reruns the identical insert — which now fails. The script exits non-zero, on purpose. That final error is the result, not a problem.examples/05_joins.sql— twelve queries: inner join across the junction, the same thing in comma form, a cartesian product, left outer, the twoIS NULLanti-joins, the per-member count done right and then done wrong two ways,ONversusWHERE, a self-join, a four-table join, and the query the Python implementation is checked against.examples/06_join_from_scratch.py—nested_loop_joinandhash_joinover lists of dictionaries, each returning its own cost counter, then compared with SQLite's answer for exact equality. Also a left-outer hash join, where an unmatched row is paired withNone— which is whatNULLis.examples/07_foreign_keys_python.py— the same pragma fact from Python, and the trap: issued after your firstINSERT, the pragma is silently ignored, because Python has opened a transaction and SQLite documents the pragma as a no-op inside one. It reads back0. The habit that avoids it is to issue it as the first statement after connecting.examples/08_n_plus_one.py— 500 members, 2,000 loans, one question answered both ways: 501 queries against 1. It prints real timings and says plainly that timings vary and the query counts do not.examples/09_query_plans.sql—EXPLAIN QUERY PLAN.SCANmeans read every row;SEARCHmeans jump to the matching ones through an index. ASCANplus aSEARCHis an indexed nested-loop join — the algorithm from step 6 with the inner scan replaced by an index lookup. Two bareSCANs is a cartesian product.bash starter/01_build.sh— complete and working. Buildsstarter/library.dbfor you to break, and can be rerun any time to start over.
Expected output
The harness ends with a real captured line (all 75 in
expected-output/test-run.txt):
14. Hygiene: offline, no privilege, no mess left behind
ok: the only URL anywhere in the lab's scripts is the cited SQLite page
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: this suite created no database inside the lab directory
75 checks, 0 failure(s).
The foreign-key proof, in full — this is the part worth reading twice
(expected-output/foreign-keys.txt):
--- 1. what the pragma says on a brand-new connection ---
setting value
------------------- -----
PRAGMA foreign_keys 0
--- 2. insert a loan for member 999, who does not exist ---
rows_inserted
-------------
1
--- 3. the orphan row is really there ---
loan_id book_id member_id
------- ------- ---------
900 101 999
--- 4. and the database will tell you, if you ask it to check ---
table rowid parent fkid
----- ----- ------- ----
loans 900 members 0
--- 5. clean up, enable enforcement, and try the identical insert ---
setting value
------------------- -----
PRAGMA foreign_keys 1
--- 6. the same statement, now rejected ---
Runtime error near line 43: FOREIGN KEY constraint failed (19)
exit code: 1
The same fact from Python, with the transaction trap:
--- 2. the trap: the pragma is a no-op inside an open transaction ---
connection.in_transaction: True
pragma set, but it reads back as: 0
after commit(), setting it again reads back as: 1
the same insert now raises IntegrityError: FOREIGN KEY constraint failed
The zero-count trap, all three versions side by side
(expected-output/joins.txt §7, §7b, §7c):
=== 7. the LEFT JOIN trap — loans per member, zeroes included ===
member loans
-------------- -----
Ada Okafor 2
Bruno Salgado 2
Chandra Iyer 1
Dana Whitfield 1
Eli Nakamura 0
=== 7b. count(*) instead of count(l.loan_id) — the wrong answer ===
member loans_wrong
-------------- -----------
Eli Nakamura 1
=== 7c. INNER JOIN instead — the member with zero loans vanishes ===
(Eli Nakamura is absent entirely)
And ON against WHERE, which is wrong in both directions
(expected-output/joins.txt §8 and §8b):
=== 8. ON versus WHERE on an outer join — ON keeps every member ===
member loan_id returned_on
-------------- ------- -----------
Ada Okafor 2
Bruno Salgado 6
Chandra Iyer 4
Dana Whitfield
Eli Nakamura
=== 8b. the same predicate moved to WHERE — the outer join collapses ===
member loan_id returned_on
------------- ------- -----------
Ada Okafor 2
Bruno Salgado 6
Chandra Iyer 4
Eli Nakamura
Dana Whitfield is gone — she is a member and has genuinely returned everything.
Eli Nakamura is still there, showing an empty loan_id, as though she had a
book out. She has never borrowed anything in her life; her NULL-extended row
simply satisfies returned_on IS NULL. One misplaced predicate, two opposite
errors.
The two hand-written joins
(expected-output/join-from-scratch.txt):
nested-loop join: 6 rows, 30 key comparisons
hash join: 6 rows, 11 operations
(6 x 5 = 30 against 6 + 5 = 11; the gap widens as the square)
nested-loop == SQL: True
hash == SQL: True
nested-loop == hash: True
expected-output/FIELDS.md states which values must
be identical on your machine and which are expected to differ.
Validation steps
bash tests/run_tests.shends with75 checks, 0 failure(s).and exits 0.examples/01_wide_table.sqlleaves two different spellings of one author's name in the table, having raised no error, and zero rows mentioning Brooks after his only book is withdrawn.examples/04_foreign_keys.sqlprintsPRAGMA foreign_keys 0first, then inserts the orphan successfully, then printsPRAGMA foreign_keys 1, then fails withFOREIGN KEY constraint failed, and exits non-zero.- The inner join across the junction returns 7 rows;
books CROSS JOIN authorsreturns 28; dropping one join condition from the three-table join returns 49. - The left join of authors to books returns 8 rows, and the extra one is Donald E. Knuth with both right-hand columns NULL.
LEFT JOINplusIS NULLfinds exactlyDonald E. Knuth, exactly104 · The Practice of Programming, and exactlyEli Nakamura.- Loans per member reads Ada 2, Bruno 2, Chandra 1, Dana 1, Eli 0 with
count(l.loan_id); switching tocount(*)reports 1 for Eli; switching to an inner join drops her row entirely. - Moving the
returned_on IS NULLpredicate fromONtoWHEREtakes the result from 5 rows to 4 — losing Dana Whitfield and keeping Eli Nakamura. - The self-join keeps 5 members with
LEFT JOINand only 3 withJOIN. - The four-table join returns 4 rows for 3 outstanding loans, because one book has two authors.
python3 examples/06_join_from_scratch.py library.dbprintsTrueon all three comparison lines,30 key comparisons,11 operations, and exits 0.python3 examples/08_n_plus_one.pyprints501and1queries andsame answer: True.python3 starter/03_join_from_scratch.pyprints0 of 3 exercises complete.and exits 1 before you start;3 of 3and exit 0 when finished.- After the harness finishes,
ls library.dbfinds nothing — it built and removed everything in a temporary directory.
Tests
bash tests/run_tests.sh
Expected final line: 75 checks, 0 failure(s). Exits 0 on success, non-zero on
any failure.
Two sections are worth reading before you run it.
Section 3 is the foreign-key proof, and it is built so that it cannot pass by accident. It does not check that an error message exists somewhere; it checks that the identical insert statement succeeds with the pragma off and is rejected with the pragma on, and that the shell's exit status changes accordingly. A check that only ever looked for the error would still pass against a database that rejected everything.
Section 13 is the one that proves the starter is real. It runs the shipped
starter and requires it to report 0 of 3 and exit non-zero. Then it takes a
copy, patches in the three answers, and requires that copy to report 3 of 3
and exit 0. A starter whose exercises cannot actually be completed, or a checker
that would go green either way, is worth nothing — so both directions are
asserted rather than assumed.
A full captured run is in
expected-output/test-run.txt.
Cleanup
rm -f library.db anomalies.db starter/library.db
To discard your exercise answers and start the starter over:
git checkout -- starter/.
The test harness needs no cleanup: it creates its databases inside mktemp -d
and removes them in a trap, and the final section asserts that no database was
left in the lab directory. Nothing was installed, so there is nothing to
uninstall.
Troubleshooting
See troubleshooting.md. The ones you are most likely to
meet: no such table, which usually means SQLite silently created an empty
database from a mistyped filename rather than complaining; a
FOREIGN KEY constraint failed that never happens, which is the pragma being
off; a pragma that reads back as 0 in Python, which is it being ignored inside
an open transaction; a join returning far more rows than either table has, which
is a missing join condition; and a LEFT JOIN behaving like an inner one, which
is a predicate that has drifted into the WHERE clause.
Security notes
See security.md. Short version: no network, no sudo, no
credentials, nothing installed, and every one of those claims is asserted by the
suite rather than promised in prose. The members and loans are invented — no
real borrowing history appears anywhere, and the reason matters: books,
members and loans are each fairly harmless on their own, and the join is
what creates the sensitive record, tying a named person to what they read and
when. A join can produce a disclosure that none of its inputs contains, so the
unit to reason about when deciding who may see what is the query, not the table.
Foreign keys are an integrity control and not a security control; they stop a
loan pointing at a member who does not exist and stop nothing else.
Extension exercises
- Add a
publisherstable and givebooksapublisher_id. That is another one-to-many, and the key goes on the many side — onbooks, not a list of books onpublishers. Then write the five-table join that reports who has what out, by whom, published by whom. Notice how the row count behaves when you add a table on the one side compared with adding one on the many side. - Find the co-authors. For a given author, list everyone they have shared a
book with. This needs
book_authorsjoined to itself, and the join condition is subtler than themembersself-join: match onbook_idwhile requiringa1.author_id <> a2.author_id, or you will report every author as their own co-author. Then decide whether you want<>or<and explain the difference to the result. - Break referential integrity on purpose, then find it. With the pragma
off, insert three orphan loans. Run
PRAGMA foreign_key_checkand read what it tells you. Now try to turn enforcement on and re-run the check — does enabling the pragma retroactively reject the rows already there? Answer that by experiment, not by guessing, and write down what it implies about inheriting a database somebody else has been writing to for years. - Make the cartesian product hurt. Insert 5,000 rows into a scratch table
and cross join it with itself. Run
EXPLAIN QUERY PLANfirst, then put aLIMITon it before you run the query itself. Twenty-five million rows is not a rounding error, and feeling that once is worth more than reading about it. - Add a sort-merge join to
06_join_from_scratch.py. Sort both sides by the join key, then walk them with two pointers. Count its operations alongside the other two, and work out when a planner would prefer it — the answer involves whether the inputs are already sorted, which is exactly what an index gives you. - Make the N+1 comparison honest across a network. You cannot do that here,
so do it in writing instead. Read
https://www.sqlite.org/np1queryprob.html, then take the measured numbers
from
08_n_plus_one.pyand estimate the same workload with a 1 ms round trip per query. State which of the two designs you would choose for an embedded database and which for a client-server one, and say what changed between them. The point is that the correct answer depends on a fact about deployment, not on a rule about joins. - Denormalize on purpose. Add a
loan_countcolumn tomembersand keep it correct as loans are inserted and deleted. Then write down every way it can drift out of step with the truth, and what it would take to guarantee it does not. This is the exercise that explains why the wide table you deleted at the start of the lab is still, sometimes, the right answer.
Navigation
- Previous day: Day 86 — SELECT: filtering, sorting, and aggregating
(
labs/sections/programming-with-python/day-086-select-filtering-sorting-and-aggregating/). - Next day: Day 88 — inserting, updating, and schema design
(
labs/sections/programming-with-python/day-088-inserting-updating-and-schema-design/). - Week 13 project: the week's project directory
(
labs/sections/programming-with-python/projects/week-13/), which builds 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.
## Must match exactly, on any machine
These are values, not formatting, and a difference means something is wrong.
| Value | Where | Must be |
| --- | --- | --- |
| Row counts after seeding | all | 7 authors, 4 books, 7 pairs, 5 members, 6 loans |
| Inner join across the junction | `joins.txt` §1 | 7 rows |
| Cartesian product | `joins.txt` §3 | 28 rows (4 × 7) |
| Left join authors → books | `joins.txt` §4 | 8 rows; Donald E. Knuth's title and year blank |
| Authors with no catalogued book | `joins.txt` §5 | exactly `7 · Donald E. Knuth` |
| Books never borrowed | `joins.txt` §6 | exactly `104 · The Practice of Programming` |
| Loans per member | `joins.txt` §7 | Ada 2, Bruno 2, Chandra 1, Dana 1, **Eli 0** |
| `count(*)` version | `joins.txt` §7b | identical except **Eli 1** — the wrong answer |
| Inner-join version | `joins.txt` §7c | 4 rows; Eli gone entirely |
| Predicate in `ON` | `joins.txt` §8 | 5 rows, one per member |
| Predicate in `WHERE` | `joins.txt` §8b | 4 rows: Dana dropped, **Eli wrongly kept** |
| Self-join, LEFT | `joins.txt` §9 | 5 rows; Ada and Eli have a blank `referred_by` |
| Four-table join | `joins.txt` §10 | 4 rows; Chandra Iyer appears twice |
| Times borrowed per book | `joins.txt` §11 | 3, 2, 1, **0** |
| `PRAGMA foreign_keys` on a new connection | `foreign-keys.txt` | `0` |
| The orphan insert with the pragma off | `foreign-keys.txt` | succeeds, 1 row |
| The same insert with the pragma on | `foreign-keys.txt` | `FOREIGN KEY constraint failed` |
| `sqlite3` exit code after that rejection | `foreign-keys.txt` | non-zero (`1` here) |
| Pragma set inside an open transaction | `foreign-keys.txt` | reads back `0` — a no-op |
| Nested-loop cost | `join-from-scratch.txt` | 6 rows, 30 comparisons |
| Hash-join cost | `join-from-scratch.txt` | 6 rows, 11 operations |
| Agreement lines | `join-from-scratch.txt` | all three `True` |
| Query counts | `n-plus-one.txt` | `501` against `1` |
| Same answer both ways | `n-plus-one.txt` | `True` |
| Harness total | `test-run.txt` | `75 checks, 0 failure(s).`, exit 0 |
| Starter before | `starter-progress.txt` | `0 of 3 exercises complete.`, exit 1 |
| Starter after | `starter-progress.txt` | `3 of 3 exercises complete.`, exit 0 |
## Expected to differ on your machine
- **The two timings in `n-plus-one.txt`.** They were `0.79 ms` and `0.44 ms`
here. Yours will differ between machines and between consecutive runs on the
same machine; on this one the loop measured anywhere from 0.52 ms to 0.79 ms
across three runs. The **query counts** are the stable part of that
comparison, which is exactly why the test asserts the counts and not the
times. Do not treat the millisecond figures as a benchmark of anything.
- **The version banner in `test-run.txt`.** It prints whatever `python3` and
`sqlite3` you actually have.
- **The `sqlite3` error wording.** Here it reads
`Runtime error near line 43: FOREIGN KEY constraint failed (19)`. Older shells
word the prefix differently and may omit the `(19)`. The part that matters —
`FOREIGN KEY constraint failed` — is what the test greps for.
- **Column padding in the `.mode column` output.** The shell sizes columns to
the widest value it has seen, so alignment can shift. Values do not.
## Platform notes
- **Linux** — identical output, given Python 3.11+ and a `sqlite3` shell.
- **Windows** — use WSL and follow the Linux path. `tests/run_tests.sh` is a
bash script and `mktemp -d` is a Unix utility; neither was run on native
Windows here, so no capture is claimed for it.
- **A much older `sqlite3` shell** — `.print`, `pragma_table_info` and
`pragma_foreign_key_list` used as table-valued functions all need SQLite
3.16.0 (2017) or newer. Everything here is well within that.
anomalies.txt
$ sqlite3 library.db < examples/01_wide_table.sql
--- the wide table: note Kernighan appears twice ---
title published_year author_name author_birth_year
--------------------------- -------------- ----------------------- -----------------
The C Programming Language 1978 Brian W. Kernighan 1942
The C Programming Language 1978 Dennis M. Ritchie 1941
The Mythical Man-Month 1975 Frederick P. Brooks Jr. 1931
The Practice of Programming 1999 Brian W. Kernighan 1942
The Practice of Programming 1999 Rob Pike 1956
--- update anomaly: rename the author on one book only ---
author_name author_birth_year
------------------ -----------------
Brian Kernighan 1942
Brian W. Kernighan 1942
--- insertion anomaly: an author with no catalogued book has no home ---
no row can be written for Donald E. Knuth without inventing a book
--- deletion anomaly: withdraw a book, lose an author ---
brooks_rows_remaining
---------------------
0
foreign-keys.txt
$ sqlite3 library.db < examples/04_foreign_keys.sql
--- 1. what the pragma says on a brand-new connection ---
setting value
------------------- -----
PRAGMA foreign_keys 0
--- 2. insert a loan for member 999, who does not exist ---
rows_inserted
-------------
1
--- 3. the orphan row is really there ---
loan_id book_id member_id
------- ------- ---------
900 101 999
--- 4. and the database will tell you, if you ask it to check ---
table rowid parent fkid
----- ----- ------- ----
loans 900 members 0
--- 5. clean up, enable enforcement, and try the identical insert ---
setting value
------------------- -----
PRAGMA foreign_keys 1
--- 6. the same statement, now rejected ---
Runtime error near line 43: FOREIGN KEY constraint failed (19)
exit code: 0
$ python3 examples/07_foreign_keys_python.py
--- 1. a fresh connection does not enforce foreign keys ---
PRAGMA foreign_keys on a new connection: 0
orphan loan pointing at member 999 inserted: 1 row
--- 2. the trap: the pragma is a no-op inside an open transaction ---
connection.in_transaction: True
pragma set, but it reads back as: 0
after commit(), setting it again reads back as: 1
the same insert now raises IntegrityError: FOREIGN KEY constraint failed
--- 3. the habit: set it first, before anything else ---
PRAGMA foreign_keys: 1
orphan insert refused: IntegrityError: FOREIGN KEY constraint failed
with the parent row present, the same insert succeeds: 1 row
join-from-scratch.txt
$ python3 examples/06_join_from_scratch.py library.db
left side: 6 loans
right side: 5 members
nested-loop join: 6 rows, 30 key comparisons
hash join: 6 rows, 11 operations
(6 x 5 = 30 against 6 + 5 = 11; the gap widens as the square)
nested-loop == SQL: True
hash == SQL: True
nested-loop == hash: True
loan 1 Ada Okafor 2026-05-04
loan 2 Ada Okafor 2026-05-20
loan 3 Bruno Salgado 2026-06-01
loan 4 Chandra Iyer 2026-06-03
loan 5 Dana Whitfield 2026-07-07
loan 6 Bruno Salgado 2026-07-15
left outer join keeps 7 rows from 5 members and 6 loans
members surviving with NULL on the right: ['Eli Nakamura']
the same question in SQL: ['Eli Nakamura']
outer join agrees with SQL: True
ALL THREE JOINS AGREE
joins.txt
$ sqlite3 library.db < examples/05_joins.sql
=== 1. INNER JOIN — books with their authors (many-to-many, two hops) ===
title author
------------------------------------------ -----------------------
Artificial Intelligence: A Modern Approach Peter Norvig
Artificial Intelligence: A Modern Approach Stuart J. Russell
The C Programming Language Brian W. Kernighan
The C Programming Language Dennis M. Ritchie
The Mythical Man-Month Frederick P. Brooks Jr.
The Practice of Programming Brian W. Kernighan
The Practice of Programming Rob Pike
=== 2. the same result written the old comma-join way ===
title author
------------------------------------------ -----------------------
Artificial Intelligence: A Modern Approach Peter Norvig
Artificial Intelligence: A Modern Approach Stuart J. Russell
The C Programming Language Brian W. Kernighan
The C Programming Language Dennis M. Ritchie
The Mythical Man-Month Frederick P. Brooks Jr.
The Practice of Programming Brian W. Kernighan
The Practice of Programming Rob Pike
=== 3. CROSS JOIN — the accidental cartesian product (4 x 7 = 28) ===
every_book_paired_with_every_author
-----------------------------------
28
=== 4. LEFT OUTER JOIN — every author, whether or not they have a book ===
author title published_year
----------------------- ------------------------------------------ --------------
Brian W. Kernighan The C Programming Language 1978
Brian W. Kernighan The Practice of Programming 1999
Dennis M. Ritchie The C Programming Language 1978
Donald E. Knuth
Frederick P. Brooks Jr. The Mythical Man-Month 1975
Peter Norvig Artificial Intelligence: A Modern Approach 1995
Rob Pike The Practice of Programming 1999
Stuart J. Russell Artificial Intelligence: A Modern Approach 1995
=== 5. LEFT JOIN + IS NULL — authors with no catalogued book ===
author_id name
--------- ---------------
7 Donald E. Knuth
=== 6. LEFT JOIN + IS NULL — books never borrowed ===
book_id title
------- ---------------------------
104 The Practice of Programming
=== 7. the LEFT JOIN trap — loans per member, zeroes included ===
member loans
-------------- -----
Ada Okafor 2
Bruno Salgado 2
Chandra Iyer 1
Dana Whitfield 1
Eli Nakamura 0
=== 7b. count(*) instead of count(l.loan_id) — the wrong answer ===
member loans_wrong
-------------- -----------
Ada Okafor 2
Bruno Salgado 2
Chandra Iyer 1
Dana Whitfield 1
Eli Nakamura 1
=== 7c. INNER JOIN instead — the member with zero loans vanishes ===
member loans
-------------- -----
Ada Okafor 2
Bruno Salgado 2
Chandra Iyer 1
Dana Whitfield 1
=== 8. ON versus WHERE on an outer join — ON keeps every member ===
member loan_id returned_on
-------------- ------- -----------
Ada Okafor 2
Bruno Salgado 6
Chandra Iyer 4
Dana Whitfield
Eli Nakamura
=== 8b. the same predicate moved to WHERE — the outer join collapses ===
member loan_id returned_on
------------- ------- -----------
Ada Okafor 2
Bruno Salgado 6
Chandra Iyer 4
Eli Nakamura
=== 9. SELF JOIN — who referred whom (LEFT, so the unreferred survive) ===
member referred_by
-------------- -------------
Ada Okafor
Bruno Salgado Ada Okafor
Chandra Iyer Ada Okafor
Dana Whitfield Bruno Salgado
Eli Nakamura
=== 10. FOUR tables at once — who has what out on loan right now ===
member title author borrowed_on
------------- ------------------------------------------ ----------------------- -----------
Ada Okafor The Mythical Man-Month Frederick P. Brooks Jr. 2026-05-20
Bruno Salgado The Mythical Man-Month Frederick P. Brooks Jr. 2026-07-15
Chandra Iyer Artificial Intelligence: A Modern Approach Peter Norvig 2026-06-03
Chandra Iyer Artificial Intelligence: A Modern Approach Stuart J. Russell 2026-06-03
=== 11. join + GROUP BY — times borrowed per book, zeroes included ===
title times_borrowed
------------------------------------------ --------------
The C Programming Language 3
The Mythical Man-Month 2
Artificial Intelligence: A Modern Approach 1
The Practice of Programming 0
=== 12. the query the Python join is checked against ===
loan_id member borrowed_on
------- -------------- -----------
1 Ada Okafor 2026-05-04
2 Ada Okafor 2026-05-20
3 Bruno Salgado 2026-06-01
4 Chandra Iyer 2026-06-03
5 Dana Whitfield 2026-07-07
6 Bruno Salgado 2026-07-15
n-plus-one.txt
$ python3 examples/08_n_plus_one.py
500 members, 2000 loans, in memory
N+1 loop: 501 queries 0.52 ms
one join: 1 queries 0.30 ms
same answer: True
first three rows: [('Member 0001', 4), ('Member 0002', 4), ('Member 0003', 4)]
Timings differ between machines and between runs. The query counts do not:
501 against 1, for the same answer.
Across a network the difference is one round trip per query. In an
embedded database it is one function call, which is why SQLite's own
documentation treats N+1 as a much smaller problem than the usual advice.
query-plans.txt
$ sqlite3 library.db < examples/09_query_plans.sql
--- inner join on an indexed foreign key ---
QUERY PLAN
|--SCAN l USING COVERING INDEX idx_loans_member
`--SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
--- the same shape, outer, with the grouping on top ---
QUERY PLAN
|--SCAN m
`--SEARCH l USING COVERING INDEX idx_loans_member (member_id=?) LEFT-JOIN
--- a cartesian product: two SCANs and nothing tying them together ---
QUERY PLAN
|--SCAN books
`--SCAN authors
starter-progress.txt
$ bash starter/01_build.sh
built starter/library.db
authors 7
books 4
book_authors 7
members 5
loans 6
$ python3 starter/03_join_from_scratch.py # before you start
SQLite says the inner join has 6 rows.
FAIL: exercise 7: nested-loop join matches SQL — 30 pairs in 30 comparisons (want 6 in 30)
FAIL: exercise 8: hash join matches SQL — 0 pairs in 11 operations (want 6 in 11)
FAIL: exercise 9: left outer join keeps the unmatched row — 6 pairs, unmatched=[] (want 7, ['Eli Nakamura'])
0 of 3 exercises complete.
exit code: 1
$ python3 starter/03_join_from_scratch.py # after exercises 7, 8 and 9
SQLite says the inner join has 6 rows.
ok : exercise 7: nested-loop join matches SQL — 6 pairs in 30 comparisons (want 6 in 30)
ok : exercise 8: hash join matches SQL — 6 pairs in 11 operations (want 6 in 11)
ok : exercise 9: left outer join keeps the unmatched row — 7 pairs, unmatched=['Eli Nakamura'] (want 7, ['Eli Nakamura'])
3 of 3 exercises complete.
exit code: 0
test-run.txt
Day 087 — Joins and Relationships
python3: 3.14.0
sqlite3: 3.51.0
work: a temporary directory, removed when this script exits
1. The wide table really does produce the anomalies
ok: 01_wide_table.sql runs clean — every anomaly below is silent
ok: 3 books needed 5 rows, because 2 authors are written down twice
ok: update anomaly: one author now has 2 different names in the table
ok: update anomaly: the database raised no error while contradicting itself
ok: deletion anomaly: withdrawing the book erased the author entirely
ok: insertion anomaly: all 4 columns are NOT NULL, so an author needs a book
2. The split schema builds, and models the relationships properly
ok: schema.sql runs without error
ok: seed.sql runs without error
ok: five tables exist
ok: row counts are 7 authors, 4 books, 7 pairs, 5 members, 6 loans
ok: book_authors is keyed on the PAIR (book_id, author_id)
ok: loans (the many side) carries both foreign keys
ok: members references itself, which is what makes a self-join possible
3. PRAGMA foreign_keys is OFF by default — proved, not asserted
ok: a fresh connection reports PRAGMA foreign_keys = 0
ok: with it off, a loan pointing at member 999 INSERTS SUCCESSFULLY
ok: pragma_foreign_key_check finds the orphan the insert created
ok: after PRAGMA foreign_keys = ON the setting reads back as 1
ok: the IDENTICAL insert is then rejected: FOREIGN KEY constraint failed
ok: sqlite3 exits non-zero when the constraint fires
ok: the orphan row is gone, so the rest of the suite sees clean data
ok: python: a new connection also reports foreign_keys 0
ok: python: the pragma is a no-op inside an open transaction (reads back 0)
ok: python: after commit() the same pragma takes effect (reads back 1)
ok: python: the orphan insert then raises IntegrityError
4. INNER JOIN, the comma form, and the cartesian product
ok: inner join across the junction returns 7 book-author pairs
ok: the co-authored C book yields both of its authors, not one row
ok: the old comma-join-with-WHERE form gives byte-identical output
ok: an unconstrained CROSS JOIN is 4 books x 7 authors = 28 rows
ok: forgetting ONE join condition turns 7 correct rows into 49
5. LEFT OUTER JOIN — exactly which rows survive, and what turns NULL
ok: left join authors->books keeps all 7 authors, giving 8 rows
ok: the unmatched author survives with NULL in every right-hand column
ok: the mirror INNER join drops that author, giving 7 rows
6. LEFT JOIN + IS NULL — the anti-join idiom
ok: authors with no catalogued book
ok: books never borrowed
ok: members who have never borrowed anything
ok: NOT IN answers the same question here, since no member_id is NULL
7. Joins with GROUP BY — the zero-count trap
ok: LEFT JOIN with count(l.loan_id) gives a genuine zero for Eli
ok: count(*) instead reports 1 for the member who borrowed nothing
ok: an INNER JOIN drops her from the report altogether: 4 rows, not 5
ok: times borrowed per book, with a real zero for the unread one
8. ON versus WHERE on an outer join — wrong in BOTH directions
ok: predicate in ON: all 5 members survive, unmatched ones showing none
ok: predicate in WHERE: only 4 rows — the outer join has collapsed
ok: WHERE drops Dana, who is a member and has genuinely returned everything
ok: and WHERE KEEPS Eli, who never borrowed at all — a false positive
9. Self-joins and three-or-more-table joins
ok: self-join with LEFT keeps the two members nobody referred
ok: self-join with INNER silently loses those two
ok: four tables at once: what is out on loan, with authors
ok: 3 loans are outstanding, but the author join makes 4 rows — not a bug
10. The join implemented from scratch in Python agrees with SQL
ok: 06_join_from_scratch.py exits 0
ok: nested-loop join equals the SQL result
ok: hash join equals the SQL result
ok: the two algorithms agree with each other
ok: nested loop costs 30 comparisons (6 x 5)
ok: the hash join costs 11 operations (6 + 5) for the same answer
ok: the hand-written outer join keeps Eli Nakamura, as SQL does
11. N+1 queries against one join — measured on this machine
ok: 08_n_plus_one.py exits 0
ok: the loop really issues 501 queries
ok: the join really issues 1
ok: both produce the same answer, so the comparison is fair
12. The planner really is choosing a join algorithm
ok: an indexed inner join plans as SCAN one side, SEARCH the other
ok: the outer join is planned with a LEFT-JOIN marker on the inner search
ok: a cartesian product plans as two bare SCANs and nothing else
13. The starter is runnable, and honest about being unfinished
ok: the SQL starter runs end to end before you have changed anything
ok: the SQL starter carries all six numbered exercises
ok: each SQL exercise names the check that verifies it
ok: the Python starter runs, and reports 0 of 3 exercises complete
ok: the Python starter exits non-zero while it is unfinished
ok: the three starter gaps are still exactly where the answer key expects
ok: completing the three exercises makes the starter pass
ok: and exit 0
14. Hygiene: offline, no privilege, no mess left behind
ok: the only URL anywhere in the lab's scripts is the cited SQLite page
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: this suite created no database inside the lab directory
75 checks, 0 failure(s).
Source files
examples/01_wide_table.sql (2651 bytes)
-- Day 087 · Step 1 — the single wide table, and the three anomalies it causes.
--
-- This is the "before" picture. One table holds everything the library knows
-- about a book AND everything it knows about that book's author, so an author
-- who wrote two books has their details written down twice.
--
-- Run with: sqlite3 library.db < examples/01_wide_table.sql
DROP TABLE IF EXISTS catalog_wide;
CREATE TABLE catalog_wide (
title TEXT NOT NULL,
published_year INTEGER NOT NULL,
author_name TEXT NOT NULL,
author_birth_year INTEGER NOT NULL
);
INSERT INTO catalog_wide (title, published_year, author_name, author_birth_year) VALUES
('The C Programming Language', 1978, 'Brian W. Kernighan', 1942),
('The C Programming Language', 1978, 'Dennis M. Ritchie', 1941),
('The Practice of Programming', 1999, 'Brian W. Kernighan', 1942),
('The Practice of Programming', 1999, 'Rob Pike', 1956),
('The Mythical Man-Month', 1975, 'Frederick P. Brooks Jr.', 1931);
.mode column
.headers on
.print ''
.print '--- the wide table: note Kernighan appears twice ---'
SELECT * FROM catalog_wide ORDER BY title, author_name;
-- ANOMALY 1 — UPDATE. The library learns the author prefers the shorter form
-- of his name. Someone updates "the C book" and stops, because that is the row
-- they were looking at.
.print ''
.print '--- update anomaly: rename the author on one book only ---'
UPDATE catalog_wide
SET author_name = 'Brian Kernighan'
WHERE title = 'The C Programming Language'
AND author_name = 'Brian W. Kernighan';
-- The database now holds two different names for one human being, with no
-- error, no warning and nothing marking which one is right.
SELECT DISTINCT author_name, author_birth_year
FROM catalog_wide
WHERE author_birth_year = 1942;
-- ANOMALY 2 — INSERT. Record an author the library has catalogued no books for.
-- There is no way to do it: every column about a book is NOT NULL, because a
-- row in this table IS a book. The author cannot exist without one.
.print ''
.print '--- insertion anomaly: an author with no catalogued book has no home ---'
.print 'no row can be written for Donald E. Knuth without inventing a book'
-- ANOMALY 3 — DELETE. The library withdraws its only copy of the Brooks book.
-- Deleting the book deletes the only record that Frederick P. Brooks Jr. exists.
.print ''
.print '--- deletion anomaly: withdraw a book, lose an author ---'
DELETE FROM catalog_wide WHERE title = 'The Mythical Man-Month';
SELECT count(*) AS brooks_rows_remaining
FROM catalog_wide
WHERE author_name LIKE '%Brooks%';
examples/02_schema.sql (2446 bytes)
-- Day 087 · Step 2 — the same information, split into five tables.
--
-- Each table holds facts about exactly one kind of thing, and every fact is
-- written down in exactly one place. The relationships between the things are
-- carried by foreign keys.
--
-- authors one row per person
-- books one row per book
-- book_authors the junction table: many books to many authors
-- members one row per library member (self-referencing: who referred whom)
-- loans one row per borrowing event (many loans to one book, many to one member)
--
-- Run with: sqlite3 library.db < examples/02_schema.sql
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS loans;
DROP TABLE IF EXISTS book_authors;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS books;
DROP TABLE IF EXISTS authors;
CREATE TABLE authors (
author_id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
birth_year INTEGER
);
CREATE TABLE books (
book_id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
published_year INTEGER NOT NULL
);
-- The junction table. Its primary key is the PAIR, which is what stops the
-- same author being attached to the same book twice.
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,
PRIMARY KEY (book_id, author_id)
);
-- referred_by points back at this same table: a self-referencing foreign key.
-- It is nullable, because the first members were referred by nobody.
CREATE TABLE members (
member_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
joined_on TEXT NOT NULL,
referred_by INTEGER REFERENCES members(member_id)
);
-- The key lives on the MANY side. One book has many loans, so book_id is here,
-- in loans — not a list of loans inside books.
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES books(book_id),
member_id INTEGER NOT NULL REFERENCES members(member_id),
borrowed_on TEXT NOT NULL,
returned_on TEXT
);
-- A foreign key does not create an index. Without these, every join and every
-- referential-integrity check on the child side is a full table scan.
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);
examples/03_seed.sql (2137 bytes)
-- Day 087 · Step 3 — seed the five tables.
--
-- The books and authors are real and checkable. The members and the loans are
-- invented for this lab; no real person's borrowing history is used anywhere.
--
-- Three deliberate gaps are built into this data, because they are what the
-- outer joins later in the lab are for:
--
-- * Donald E. Knuth is an author with NO catalogued book (the insertion
-- anomaly, now possible)
-- * "The Practice of Programming" has NEVER been borrowed
-- * Eli Nakamura is a member who has NEVER borrowed anything
--
-- Run with: sqlite3 library.db < examples/03_seed.sql
PRAGMA foreign_keys = ON;
DELETE FROM loans;
DELETE FROM book_authors;
DELETE FROM members;
DELETE FROM books;
DELETE FROM authors;
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, 'Stuart J. Russell', 1962),
(5, 'Peter Norvig', 1956),
(6, 'Rob Pike', 1956),
(7, 'Donald E. Knuth', 1938);
INSERT INTO books (book_id, title, published_year) VALUES
(101, 'The C Programming Language', 1978),
(102, 'The Mythical Man-Month', 1975),
(103, 'Artificial Intelligence: A Modern Approach', 1995),
(104, 'The Practice of Programming', 1999);
INSERT INTO book_authors (book_id, author_id) VALUES
(101, 1),
(101, 2),
(102, 3),
(103, 4),
(103, 5),
(104, 1),
(104, 6);
INSERT INTO members (member_id, name, joined_on, referred_by) VALUES
(1, 'Ada Okafor', '2026-01-05', NULL),
(2, 'Bruno Salgado', '2026-01-19', 1),
(3, 'Chandra Iyer', '2026-02-02', 1),
(4, 'Dana Whitfield', '2026-03-11', 2),
(5, 'Eli Nakamura', '2026-04-01', NULL);
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, returned_on) VALUES
(1, 101, 1, '2026-05-04', '2026-05-18'),
(2, 102, 1, '2026-05-20', NULL),
(3, 101, 2, '2026-06-01', '2026-06-14'),
(4, 103, 3, '2026-06-03', NULL),
(5, 101, 4, '2026-07-07', '2026-07-20'),
(6, 102, 2, '2026-07-15', NULL);
examples/04_foreign_keys.sql (1710 bytes)
-- Day 087 · Step 4 — the SQLite fact that surprises everybody.
--
-- SQLite compiles foreign-key support in, but leaves ENFORCEMENT OFF by
-- default, for backwards compatibility. Every connection starts with it off
-- and must turn it on for itself. A REFERENCES clause you never enforce is a
-- comment with punctuation.
--
-- This script proves it twice: once by inserting a loan pointing at a member
-- who does not exist, and once by trying the identical insert with the pragma
-- on. Run it as one shell session so both halves share ONE connection:
--
-- sqlite3 library.db < examples/04_foreign_keys.sql
.headers on
.mode column
.print ''
.print '--- 1. what the pragma says on a brand-new connection ---'
SELECT 'PRAGMA foreign_keys' AS setting, foreign_keys AS value FROM pragma_foreign_keys;
.print ''
.print '--- 2. insert a loan for member 999, who does not exist ---'
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, returned_on)
VALUES (900, 101, 999, '2026-08-16', NULL);
SELECT changes() AS rows_inserted;
.print ''
.print '--- 3. the orphan row is really there ---'
SELECT loan_id, book_id, member_id FROM loans WHERE loan_id = 900;
.print ''
.print '--- 4. and the database will tell you, if you ask it to check ---'
PRAGMA foreign_key_check;
.print ''
.print '--- 5. clean up, enable enforcement, and try the identical insert ---'
DELETE FROM loans WHERE loan_id = 900;
PRAGMA foreign_keys = ON;
SELECT 'PRAGMA foreign_keys' AS setting, foreign_keys AS value FROM pragma_foreign_keys;
.print ''
.print '--- 6. the same statement, now rejected ---'
INSERT INTO loans (loan_id, book_id, member_id, borrowed_on, returned_on)
VALUES (900, 101, 999, '2026-08-16', NULL);
examples/05_joins.sql (4429 bytes)
-- Day 087 · Step 5 — every join in the lesson, against the seeded library.
--
-- Run with: sqlite3 library.db < examples/05_joins.sql
PRAGMA foreign_keys = ON;
.headers on
.mode column
.print ''
.print '=== 1. INNER JOIN — books with their authors (many-to-many, two hops) ==='
SELECT b.title,
a.name AS author
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
ORDER BY b.title, a.name;
.print ''
.print '=== 2. the same result written the old comma-join way ==='
SELECT b.title,
a.name AS author
FROM books b, book_authors ba, authors a
WHERE ba.book_id = b.book_id
AND a.author_id = ba.author_id
ORDER BY b.title, a.name;
.print ''
.print '=== 3. CROSS JOIN — the accidental cartesian product (4 x 7 = 28) ==='
SELECT count(*) AS every_book_paired_with_every_author
FROM books CROSS JOIN authors;
.print ''
.print '=== 4. LEFT OUTER JOIN — every author, whether or not they have a book ==='
SELECT a.name AS author,
b.title AS title,
b.published_year
FROM authors AS a
LEFT JOIN book_authors AS ba ON ba.author_id = a.author_id
LEFT JOIN books AS b ON b.book_id = ba.book_id
ORDER BY a.name, b.title;
.print ''
.print '=== 5. LEFT JOIN + IS NULL — authors with no catalogued book ==='
SELECT a.author_id, a.name
FROM authors AS a
LEFT JOIN book_authors AS ba ON ba.author_id = a.author_id
WHERE ba.author_id IS NULL
ORDER BY a.name;
.print ''
.print '=== 6. LEFT JOIN + IS NULL — books never borrowed ==='
SELECT b.book_id, b.title
FROM books AS b
LEFT JOIN loans AS l ON l.book_id = b.book_id
WHERE l.loan_id IS NULL
ORDER BY b.title;
.print ''
.print '=== 7. the LEFT JOIN trap — loans per member, zeroes included ==='
SELECT m.name AS member,
count(l.loan_id) AS loans
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id, m.name
ORDER BY loans DESC, member;
.print ''
.print '=== 7b. count(*) instead of count(l.loan_id) — the wrong answer ==='
SELECT m.name AS member,
count(*) AS loans_wrong
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id, m.name
ORDER BY loans_wrong DESC, member;
.print ''
.print '=== 7c. INNER JOIN instead — the member with zero loans vanishes ==='
SELECT m.name AS member,
count(l.loan_id) AS loans
FROM members AS m
JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id, m.name
ORDER BY loans DESC, member;
.print ''
.print '=== 8. ON versus WHERE on an outer join — ON keeps every member ==='
SELECT m.name AS member, l.loan_id, l.returned_on
FROM members AS m
LEFT JOIN loans AS l
ON l.member_id = m.member_id
AND l.returned_on IS NULL
ORDER BY m.name, l.loan_id;
.print ''
.print '=== 8b. the same predicate moved to WHERE — the outer join collapses ==='
SELECT m.name AS member, l.loan_id, l.returned_on
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
WHERE l.returned_on IS NULL
ORDER BY m.name, l.loan_id;
.print ''
.print '=== 9. SELF JOIN — who referred whom (LEFT, so the unreferred survive) ==='
SELECT m.name AS member,
r.name AS referred_by
FROM members AS m
LEFT JOIN members AS r ON r.member_id = m.referred_by
ORDER BY m.member_id;
.print ''
.print '=== 10. FOUR tables at once — who has what out on loan right now ==='
SELECT m.name AS member,
b.title AS title,
a.name AS author,
l.borrowed_on
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
JOIN book_authors AS ba ON ba.book_id = b.book_id
JOIN authors AS a ON a.author_id = ba.author_id
WHERE l.returned_on IS NULL
ORDER BY m.name, a.name;
.print ''
.print '=== 11. join + GROUP BY — times borrowed per book, zeroes included ==='
SELECT b.title,
count(l.loan_id) AS times_borrowed
FROM books AS b
LEFT JOIN loans AS l ON l.book_id = b.book_id
GROUP BY b.book_id, b.title
ORDER BY times_borrowed DESC, b.title;
.print ''
.print '=== 12. the query the Python join is checked against ==='
SELECT l.loan_id, m.name AS member, l.borrowed_on
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id
ORDER BY l.loan_id;
examples/06_join_from_scratch.py (6327 bytes)
"""Day 087 · Step 6 — build the join yourself, two ways, and check both
against SQLite.
A join is not magic. It is one of two small algorithms, and the query planner
picks between them. Here they are, in plain Python over lists of dictionaries,
with no database involved until the final comparison.
nested-loop join for every left row, scan every right row O(n * m)
hash join index the right side once, then look up O(n + m)
Run with: python3 examples/06_join_from_scratch.py library.db
"""
from __future__ import annotations
import sqlite3
import sys
from collections import defaultdict
def rows_from(connection: sqlite3.Connection, table: str) -> list[dict]:
"""Read a whole table as a list of plain dictionaries."""
connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(f"SELECT * FROM {table}")]
def nested_loop_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> tuple[list[tuple[dict, dict]], int]:
"""The obvious algorithm: compare everything with everything.
Returns the matched pairs and the number of key comparisons performed, so
the cost is a number you can look at rather than a claim you have to trust.
"""
pairs: list[tuple[dict, dict]] = []
comparisons = 0
for left_row in left:
for right_row in right:
comparisons += 1
if left_row[left_key] == right_row[right_key]:
pairs.append((left_row, right_row))
return pairs, comparisons
def hash_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> tuple[list[tuple[dict, dict]], int]:
"""Build a dictionary from the smaller side, then probe it once per row.
The build phase touches every right row once. The probe phase touches every
left row once. Nothing is scanned repeatedly, which is the whole difference.
"""
index: dict[object, list[dict]] = defaultdict(list)
operations = 0
for right_row in right: # build phase
operations += 1
index[right_row[right_key]].append(right_row)
pairs: list[tuple[dict, dict]] = []
for left_row in left: # probe phase
operations += 1
for right_row in index.get(left_row[left_key], ()):
pairs.append((left_row, right_row))
return pairs, operations
def left_outer_hash_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> list[tuple[dict, dict | None]]:
"""The same probe, except a left row with no match still comes through —
paired with None, which is exactly what SQL calls NULL."""
index: dict[object, list[dict]] = defaultdict(list)
for right_row in right:
index[right_row[right_key]].append(right_row)
pairs: list[tuple[dict, dict | None]] = []
for left_row in left:
matches = index.get(left_row[left_key], [])
if matches:
pairs.extend((left_row, match) for match in matches)
else:
pairs.append((left_row, None))
return pairs
def as_comparable(pairs) -> list[tuple]:
"""Reduce join output to (loan_id, member name, borrowed_on) so it can be
compared with the SQL result by value."""
result = []
for loan, member in pairs:
name = member["name"] if member is not None else None
result.append((loan["loan_id"], name, loan["borrowed_on"]))
return sorted(result, key=lambda row: row[0])
def main(database: str) -> int:
connection = sqlite3.connect(database)
connection.execute("PRAGMA foreign_keys = ON")
loans = rows_from(connection, "loans")
members = rows_from(connection, "members")
print(f"left side: {len(loans)} loans")
print(f"right side: {len(members)} members")
print()
nested_pairs, comparisons = nested_loop_join(loans, members, "member_id", "member_id")
hashed_pairs, operations = hash_join(loans, members, "member_id", "member_id")
print(f"nested-loop join: {len(nested_pairs)} rows, {comparisons} key comparisons")
print(f"hash join: {len(hashed_pairs)} rows, {operations} operations")
print(f" (6 x 5 = {6 * 5} against 6 + 5 = {6 + 5}; the gap widens as the square)")
print()
# The reference: let SQLite do the same join.
connection.row_factory = None
sql_rows = connection.execute(
"""
SELECT l.loan_id, m.name, l.borrowed_on
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id
ORDER BY l.loan_id
"""
).fetchall()
sql_result = sorted((row[0], row[1], row[2]) for row in sql_rows)
nested_result = as_comparable(nested_pairs)
hash_result = as_comparable(hashed_pairs)
print("nested-loop == SQL:", nested_result == sql_result)
print("hash == SQL:", hash_result == sql_result)
print("nested-loop == hash:", nested_result == hash_result)
print()
for loan_id, name, borrowed_on in sql_result:
print(f" loan {loan_id} {name:<15} {borrowed_on}")
print()
# And the outer join, where the algorithms differ in what they keep.
outer = left_outer_hash_join(
rows_from(connection, "members"), rows_from(connection, "loans"),
"member_id", "member_id",
)
unmatched = [member["name"] for member, loan in outer if loan is None]
print(f"left outer join keeps {len(outer)} rows from 5 members and 6 loans")
print(f"members surviving with NULL on the right: {unmatched}")
sql_unmatched = [
row[0]
for row in connection.execute(
"""
SELECT m.name
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
WHERE l.loan_id IS NULL
ORDER BY m.name
"""
)
]
print(f"the same question in SQL: {sql_unmatched}")
print("outer join agrees with SQL:", unmatched == sql_unmatched)
connection.close()
everything_agrees = (
nested_result == sql_result
and hash_result == sql_result
and unmatched == sql_unmatched
)
print()
print("ALL THREE JOINS AGREE" if everything_agrees else "MISMATCH")
return 0 if everything_agrees else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "library.db"))
examples/07_foreign_keys_python.py (3278 bytes)
"""Day 087 · Step 7 — the same foreign-key proof from Python, plus the trap
that makes people think the pragma does not work.
The pragma is per-connection, and SQLite documents it as a no-op inside an
open transaction. Python's sqlite3 module opens transactions for you, so a
pragma issued after your first INSERT can be silently ignored. This script
shows both the failure and the fix, on a throwaway in-memory database.
Run with: python3 examples/07_foreign_keys_python.py
"""
from __future__ import annotations
import sqlite3
SCHEMA = """
CREATE TABLE members (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id)
);
"""
def read_pragma(connection: sqlite3.Connection) -> int:
return connection.execute("PRAGMA foreign_keys").fetchone()[0]
def part_one_default_is_off() -> None:
print("--- 1. a fresh connection does not enforce foreign keys ---")
connection = sqlite3.connect(":memory:")
connection.executescript(SCHEMA)
print(f"PRAGMA foreign_keys on a new connection: {read_pragma(connection)}")
connection.execute("INSERT INTO loans (loan_id, member_id) VALUES (1, 999)")
orphans = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
print(f"orphan loan pointing at member 999 inserted: {orphans} row")
connection.close()
def part_two_the_trap() -> None:
print()
print("--- 2. the trap: the pragma is a no-op inside an open transaction ---")
connection = sqlite3.connect(":memory:")
connection.executescript(SCHEMA)
connection.execute("INSERT INTO loans (loan_id, member_id) VALUES (1, 999)")
print(f"connection.in_transaction: {connection.in_transaction}")
connection.execute("PRAGMA foreign_keys = ON")
print(f"pragma set, but it reads back as: {read_pragma(connection)}")
connection.commit()
connection.execute("PRAGMA foreign_keys = ON")
print(f"after commit(), setting it again reads back as: {read_pragma(connection)}")
try:
connection.execute("INSERT INTO loans (loan_id, member_id) VALUES (2, 999)")
except sqlite3.IntegrityError as error:
print(f"the same insert now raises {type(error).__name__}: {error}")
connection.close()
def part_three_the_habit() -> None:
print()
print("--- 3. the habit: set it first, before anything else ---")
connection = sqlite3.connect(":memory:")
connection.execute("PRAGMA foreign_keys = ON")
print(f"PRAGMA foreign_keys: {read_pragma(connection)}")
connection.executescript(SCHEMA)
try:
connection.execute("INSERT INTO loans (loan_id, member_id) VALUES (1, 999)")
except sqlite3.IntegrityError as error:
print(f"orphan insert refused: {type(error).__name__}: {error}")
connection.execute("INSERT INTO members (member_id, name) VALUES (999, 'Real Member')")
connection.execute("INSERT INTO loans (loan_id, member_id) VALUES (1, 999)")
good = connection.execute("SELECT count(*) FROM loans").fetchone()[0]
print(f"with the parent row present, the same insert succeeds: {good} row")
connection.close()
if __name__ == "__main__":
part_one_default_is_off()
part_two_the_trap()
part_three_the_habit()
examples/08_n_plus_one.py (4423 bytes)
"""Day 087 · Step 8 — the N+1 query pattern, measured rather than asserted.
Looping in the application and asking the database one small question per row
is called the N+1 pattern: one query to get the list, then N more, one per item.
The alternative is one join.
This script builds a throwaway in-memory database, answers the same question
both ways, checks the two answers are identical, and reports the real query
count and the real elapsed time on THIS machine. Timings vary between machines
and between runs; the query counts do not.
Read https://www.sqlite.org/np1queryprob.html afterwards. SQLite's own
documentation argues that for an embedded database — where a query is a
function call into the same process, not a network round trip — N+1 is far less
costly than the usual advice implies. The measurement below is the honest
version of that argument, not a slogan in either direction.
Run with: python3 examples/08_n_plus_one.py
"""
from __future__ import annotations
import sqlite3
import time
MEMBERS = 500
LOANS_PER_MEMBER = 4
def build() -> sqlite3.Connection:
connection = sqlite3.connect(":memory:")
connection.execute("PRAGMA foreign_keys = ON")
connection.executescript(
"""
CREATE TABLE members (member_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
title TEXT NOT NULL
);
"""
)
connection.executemany(
"INSERT INTO members (member_id, name) VALUES (?, ?)",
[(i, f"Member {i:04d}") for i in range(1, MEMBERS + 1)],
)
connection.executemany(
"INSERT INTO loans (member_id, title) VALUES (?, ?)",
[
(member_id, f"Book {member_id:04d}-{n}")
for member_id in range(1, MEMBERS + 1)
for n in range(LOANS_PER_MEMBER)
],
)
connection.execute("CREATE INDEX idx_loans_member ON loans(member_id)")
connection.commit()
return connection
def n_plus_one(connection: sqlite3.Connection) -> tuple[list[tuple[str, int]], int]:
"""One query for the list, then one more per member. N+1 queries in total."""
queries = 0
members = connection.execute("SELECT member_id, name FROM members ORDER BY name").fetchall()
queries += 1
result = []
for member_id, name in members:
count = connection.execute(
"SELECT count(*) FROM loans WHERE member_id = ?", (member_id,)
).fetchone()[0]
queries += 1
result.append((name, count))
return result, queries
def one_join(connection: sqlite3.Connection) -> tuple[list[tuple[str, int]], int]:
"""The same answer, in one query, with the database doing the matching."""
rows = connection.execute(
"""
SELECT m.name, count(l.loan_id)
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id, m.name
ORDER BY m.name
"""
).fetchall()
return [(name, count) for name, count in rows], 1
def timed(function, connection):
started = time.perf_counter()
result, queries = function(connection)
return result, queries, time.perf_counter() - started
def main() -> int:
connection = build()
print(f"{MEMBERS} members, {MEMBERS * LOANS_PER_MEMBER} loans, in memory")
print()
loop_result, loop_queries, loop_seconds = timed(n_plus_one, connection)
join_result, join_queries, join_seconds = timed(one_join, connection)
print(f"N+1 loop: {loop_queries:>4} queries {loop_seconds * 1000:7.2f} ms")
print(f"one join: {join_queries:>4} queries {join_seconds * 1000:7.2f} ms")
print()
print("same answer:", loop_result == join_result)
print("first three rows:", join_result[:3])
print()
print("Timings differ between machines and between runs. The query counts do not:")
print(f" {loop_queries} against {join_queries}, for the same answer.")
print()
print("Across a network the difference is one round trip per query. In an")
print("embedded database it is one function call, which is why SQLite's own")
print("documentation treats N+1 as a much smaller problem than the usual advice.")
connection.close()
return 0 if loop_result == join_result else 1
if __name__ == "__main__":
raise SystemExit(main())
examples/09_query_plans.sql (1155 bytes)
-- Day 087 · Step 9 — watch the planner choose an algorithm.
--
-- EXPLAIN QUERY PLAN prints how SQLite intends to answer a query. Two words
-- carry most of the meaning:
--
-- SCAN read every row of this table
-- SEARCH jump straight to the matching rows using an index
--
-- A SCAN of the outer table with a SEARCH of the inner one is an indexed
-- nested-loop join: the algorithm from 06_join_from_scratch.py, with the inner
-- scan replaced by an index lookup. Two SCANs with no join condition is the
-- cartesian product.
--
-- Run with: sqlite3 library.db < examples/09_query_plans.sql
.print ''
.print '--- inner join on an indexed foreign key ---'
EXPLAIN QUERY PLAN
SELECT m.name, l.loan_id
FROM loans AS l
JOIN members AS m ON m.member_id = l.member_id;
.print ''
.print '--- the same shape, outer, with the grouping on top ---'
EXPLAIN QUERY PLAN
SELECT m.name, count(l.loan_id)
FROM members AS m
LEFT JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id;
.print ''
.print '--- a cartesian product: two SCANs and nothing tying them together ---'
EXPLAIN QUERY PLAN
SELECT * FROM books CROSS JOIN authors;
metadata.yml (1393 bytes)
lesson_id: D087
day: 87
kind: guided-build
languages: [sql, python, bash]
setup_commands:
- cd labs/sections/programming-with-python/day-087-joins-and-relationships
- python3 --version
- sqlite3 --version
run_commands:
- bash tests/run_tests.sh
- sqlite3 anomalies.db < examples/01_wide_table.sql
- sqlite3 library.db < examples/02_schema.sql
- sqlite3 library.db < examples/03_seed.sql
- 'sqlite3 library.db < examples/04_foreign_keys.sql # exits non-zero on purpose'
- sqlite3 library.db < examples/05_joins.sql
- python3 examples/06_join_from_scratch.py library.db
- python3 examples/07_foreign_keys_python.py
- python3 examples/08_n_plus_one.py
- sqlite3 library.db < examples/09_query_plans.sql
- bash starter/01_build.sh
- sqlite3 starter/library.db < starter/02_exercises.sql
- python3 starter/03_join_from_scratch.py
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -f library.db anomalies.db starter/library.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: 30
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 -> 75 checks, 0 failure(s), exit 0'
requirements/README.md (2492 bytes)
# Dependencies
**None.** This lab installs nothing, and `requirements.txt` is deliberately
empty of packages.
That is not laziness; it is the point of the day. Everything here runs on two
things you already have:
| 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`, `collections`, `time`, `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 is a wrapper around 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. Both behave identically for everything 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.** Python 3.11 or newer (the type-hint syntax in the Python
files uses `dict | None`). SQLite 3.16.0 or newer for the `pragma_table_info`
and `pragma_foreign_key_list` table-valued functions the test suite uses; any
SQLite shipped in the last several years is far past that.
## If the tools are somewhere unusual
The test harness takes overrides rather than guessing:
```bash
PYTHON=/path/to/python3 SQLITE3=/path/to/sqlite3 bash tests/run_tests.sh
```
It fails loudly with that instruction if it cannot find either one, rather than
quietly skipping the checks that need them.
## What is deliberately absent
**No ORM.** SQLAlchemy, Django's ORM and Peewee all generate the joins this lab
writes by hand, and all of them are worth using later. Learning them before you
can read the SQL they emit means that when a query is slow or wrong you have no
way in. Write the join first; let a library write it for you afterwards.
**No database server.** PostgreSQL and MySQL are covered in the lesson's
Alternatives section, with the syntax differences that actually matter. Neither
is installed here, because installing a server changes this lab from "twenty
minutes of joins" into "an afternoon of administration", and nothing in today's
material needs one.
requirements/requirements.txt (406 bytes)
# Day 087 — Joins and Relationships
#
# 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, and for why no ORM and no database
# server appear here.
starter/01_build.sh (986 bytes)
#!/usr/bin/env bash
# Day 087 starter — build a fresh library.db for your own work.
#
# This script is complete and working. Run it first, and run it again whenever
# you want to start over; it drops and recreates everything.
#
# bash starter/01_build.sh
#
# It writes starter/library.db, which is yours to break. Nothing else in the
# lab reads it.
set -eu
starter_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
lab_dir="$(cd "${starter_dir}/.." && pwd)"
database="${starter_dir}/library.db"
rm -f "${database}"
sqlite3 "${database}" < "${lab_dir}/examples/02_schema.sql"
sqlite3 "${database}" < "${lab_dir}/examples/03_seed.sql"
echo "built ${database#"${lab_dir}/"}"
sqlite3 "${database}" <<'SQL'
.mode list
.headers off
SELECT 'authors ' || count(*) FROM authors;
SELECT 'books ' || count(*) FROM books;
SELECT 'book_authors ' || count(*) FROM book_authors;
SELECT 'members ' || count(*) FROM members;
SELECT 'loans ' || count(*) FROM loans;
SQL
starter/02_exercises.sql (5575 bytes)
-- Day 087 starter — six numbered SQL exercises.
--
-- Every query below RUNS as it stands. Each one is also wrong in one specific,
-- named way. Your job is to make each one right. The comment above each
-- exercise says exactly what is wrong, what to change, and which check in
-- tests/run_tests.sh verifies it.
--
-- bash starter/01_build.sh # once
-- sqlite3 starter/library.db < starter/02_exercises.sql
--
-- Compare your output with examples/05_joins.sql when you are stuck. Reading
-- the answer after you have tried is learning; reading it first is not.
PRAGMA foreign_keys = ON;
.headers on
.mode column
-- ============================================================================
-- EXERCISE 1 — join two hops across the junction table.
--
-- Wrong how: it joins books to book_authors and stops, so you get author IDs
-- instead of author names.
-- Change: add a second JOIN onto authors, matching a.author_id = ba.author_id,
-- and select a.name instead of ba.author_id.
-- Correct result: 7 rows, one per book-author pair, with real names.
-- Checked by: "exercise 1: books joined to author NAMES, 7 rows"
-- ============================================================================
.print ''
.print '--- exercise 1 ---'
SELECT b.title,
ba.author_id AS author
FROM books AS b
JOIN book_authors AS ba ON ba.book_id = b.book_id
ORDER BY b.title, author;
-- ============================================================================
-- EXERCISE 2 — find the authors with no catalogued book.
--
-- Wrong how: an INNER JOIN can never show you a row that has no match, so this
-- returns every author who DOES have a book — the exact opposite.
-- Change: make it a LEFT JOIN and add "WHERE ba.author_id IS NULL".
-- Correct result: exactly one row — Donald E. Knuth.
-- Checked by: "exercise 2: authors with no catalogued book"
-- ============================================================================
.print ''
.print '--- exercise 2 ---'
SELECT a.author_id, a.name
FROM authors AS a
JOIN book_authors AS ba ON ba.author_id = a.author_id
ORDER BY a.name;
-- ============================================================================
-- EXERCISE 3 — books that have never been borrowed.
--
-- Wrong how: same shape as exercise 2, one table further out. This lists books
-- that HAVE been borrowed, and lists the popular ones several times over.
-- Change: LEFT JOIN loans, then keep only the rows where l.loan_id IS NULL.
-- Correct result: exactly one row — book 104, The Practice of Programming.
-- Checked by: "exercise 3: books never borrowed"
-- ============================================================================
.print ''
.print '--- exercise 3 ---'
SELECT b.book_id, b.title
FROM books AS b
JOIN loans AS l ON l.book_id = b.book_id
ORDER BY b.title;
-- ============================================================================
-- EXERCISE 4 — loans per member, INCLUDING the members who have none.
--
-- Wrong how: two separate mistakes stacked on each other. The INNER JOIN drops
-- Eli Nakamura entirely, and count(*) would report 1 for her even after you fix
-- the join, because the NULL-extended row is still a row.
-- Change: LEFT JOIN, and count a column from the RIGHT table -
-- count(l.loan_id) - so that the all-NULL row counts as zero.
-- Correct result: 5 rows. Ada 2, Bruno 2, Chandra 1, Dana 1, Eli 0.
-- Checked by: "exercise 4: loans per member with a real zero for Eli"
-- ============================================================================
.print ''
.print '--- exercise 4 ---'
SELECT m.name AS member,
count(*) AS loans
FROM members AS m
JOIN loans AS l ON l.member_id = m.member_id
GROUP BY m.member_id, m.name
ORDER BY loans DESC, member;
-- ============================================================================
-- EXERCISE 5 — who referred whom (a self-join).
--
-- Wrong how: it joins members to members with an INNER JOIN, which silently
-- drops the two members nobody referred.
-- Change: LEFT JOIN, so every member appears and referred_by comes back NULL
-- for the ones who joined on their own.
-- Correct result: 5 rows. Ada and Eli have an empty referred_by.
-- Checked by: "exercise 5: self-join keeps the members nobody referred"
-- ============================================================================
.print ''
.print '--- exercise 5 ---'
SELECT m.name AS member,
r.name AS referred_by
FROM members AS m
JOIN members AS r ON r.member_id = m.referred_by
ORDER BY m.member_id;
-- ============================================================================
-- EXERCISE 6 — everything currently out on loan, across four tables.
--
-- Wrong how: it stops at three tables, so you get the title but not who wrote
-- it, and it has no filter, so returned books are in the list too.
-- Change: add the two remaining joins (book_authors, then authors) and the
-- condition l.returned_on IS NULL.
-- Correct result: 4 rows. Chandra Iyer appears twice, because the book she has
-- out has two authors - that duplication is the many-to-many showing through,
-- not a bug.
-- Checked by: "exercise 6: four-table join of what is out on loan"
-- ============================================================================
.print ''
.print '--- exercise 6 ---'
SELECT m.name AS member,
b.title AS title,
l.borrowed_on
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
ORDER BY m.name;
starter/03_join_from_scratch.py (7249 bytes)
"""Day 087 starter — exercises 7, 8 and 9: build the join yourself.
This file runs as it stands. It builds the same data, calls your three
functions, compares them with what SQLite says, and prints a pass or fail line
for each. Right now all three fail, because all three are unfinished in one
specific, named way.
bash starter/01_build.sh
python3 starter/03_join_from_scratch.py
Finish the three functions marked EXERCISE. Do not change anything below the
line that says so - that part is the referee.
"""
from __future__ import annotations
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
# ============================================================================
# EXERCISE 7 — the nested-loop join.
#
# Unfinished how: the inner comparison is missing, so every left row is paired
# with every right row. That is a cartesian product: 30 pairs instead of 6.
# Change: only append the pair when left_row[left_key] == right_row[right_key].
# Also count one comparison per pair examined, so the cost is visible.
# Correct result: 6 pairs, 30 comparisons (6 loans x 5 members).
# Checked by: "exercise 7: nested-loop join matches SQL"
# ============================================================================
def nested_loop_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> tuple[list[tuple[dict, dict]], int]:
"""For every left row, scan every right row. O(n * m)."""
pairs: list[tuple[dict, dict]] = []
comparisons = 0
for left_row in left:
for right_row in right:
comparisons += 1
pairs.append((left_row, right_row)) # <- exercise 7 goes here
return pairs, comparisons
# ============================================================================
# EXERCISE 8 — the hash join.
#
# Unfinished how: the build phase is written for you. The probe phase is not,
# so no pairs come out at all.
# Change: for each left row, look its key up in `index` and append one pair per
# matching right row. Count one operation per left row probed, so the total
# comes to 11 rather than 30.
# Correct result: the same 6 pairs as exercise 7, in 11 operations (6 + 5).
# Checked by: "exercise 8: hash join matches SQL"
# ============================================================================
def hash_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> tuple[list[tuple[dict, dict]], int]:
"""Index the right side once, then look each left row up. O(n + m)."""
index: dict[object, list[dict]] = defaultdict(list)
operations = 0
for right_row in right: # build phase - complete, leave it alone
operations += 1
index[right_row[right_key]].append(right_row)
pairs: list[tuple[dict, dict]] = []
for left_row in left: # probe phase - exercise 8 goes here
operations += 1
return pairs, operations
# ============================================================================
# EXERCISE 9 — the LEFT OUTER version of the hash join.
#
# Unfinished how: it behaves exactly like an inner join, so a left row with no
# match is dropped instead of surviving.
# Change: when `matches` is empty, still append one pair - (left_row, None).
# None is what SQL calls NULL: the row survives, the right-hand columns do not.
# Correct result: 7 pairs from 5 members and 6 loans, and Eli Nakamura is the
# one member paired with None.
# Checked by: "exercise 9: left outer join keeps the unmatched row"
# ============================================================================
def left_outer_hash_join(
left: list[dict], right: list[dict], left_key: str, right_key: str
) -> list[tuple[dict, dict | None]]:
"""Every left row comes through, matched or not."""
index: dict[object, list[dict]] = defaultdict(list)
for right_row in right:
index[right_row[right_key]].append(right_row)
pairs: list[tuple[dict, dict | None]] = []
for left_row in left:
matches = index.get(left_row[left_key], [])
if matches:
pairs.extend((left_row, match) for match in matches)
# exercise 9: the missing `else` goes here
return pairs
# ===================== do not change anything below here =====================
def rows_from(connection: sqlite3.Connection, table: str) -> list[dict]:
connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(f"SELECT * FROM {table}")]
def as_comparable(pairs) -> list[tuple]:
result = []
for loan, member in pairs:
name = member["name"] if member is not None else None
result.append((loan["loan_id"], name, loan["borrowed_on"]))
return sorted(result, key=lambda row: row[0])
def report(label: str, passed: bool, detail: str = "") -> bool:
print(f" {'ok ' if passed else 'FAIL'}: {label}{(' — ' + detail) if detail else ''}")
return passed
def main(database: str) -> int:
if not Path(database).exists():
print(f"{database} does not exist. Run: bash starter/01_build.sh")
return 1
connection = sqlite3.connect(database)
connection.execute("PRAGMA foreign_keys = ON")
loans = rows_from(connection, "loans")
members = rows_from(connection, "members")
connection.row_factory = None
sql_inner = sorted(
(row[0], row[1], row[2])
for row in connection.execute(
"SELECT l.loan_id, m.name, l.borrowed_on FROM loans AS l"
" JOIN members AS m ON m.member_id = l.member_id"
)
)
sql_unmatched = [
row[0]
for row in connection.execute(
"SELECT m.name FROM members AS m"
" LEFT JOIN loans AS l ON l.member_id = m.member_id"
" WHERE l.loan_id IS NULL ORDER BY m.name"
)
]
connection.close()
nested_pairs, comparisons = nested_loop_join(loans, members, "member_id", "member_id")
hashed_pairs, operations = hash_join(loans, members, "member_id", "member_id")
outer_pairs = left_outer_hash_join(members, loans, "member_id", "member_id")
unmatched = [member["name"] for member, loan in outer_pairs if loan is None]
print(f"SQLite says the inner join has {len(sql_inner)} rows.")
print()
passed = [
report(
"exercise 7: nested-loop join matches SQL",
as_comparable(nested_pairs) == sql_inner and comparisons == 30,
f"{len(nested_pairs)} pairs in {comparisons} comparisons (want 6 in 30)",
),
report(
"exercise 8: hash join matches SQL",
as_comparable(hashed_pairs) == sql_inner and operations == 11,
f"{len(hashed_pairs)} pairs in {operations} operations (want 6 in 11)",
),
report(
"exercise 9: left outer join keeps the unmatched row",
len(outer_pairs) == 7 and unmatched == sql_unmatched,
f"{len(outer_pairs)} pairs, unmatched={unmatched} (want 7, ['Eli Nakamura'])",
),
]
print()
done = sum(passed)
print(f"{done} of 3 exercises complete.")
return 0 if done == 3 else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).parent / "library.db")))
tests/run_tests.sh (24699 bytes)
#!/usr/bin/env bash
# Tests for the Day 087 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# Every check below compares a REAL VALUE, not the existence of a file. The
# questions the suite asks are the ones the lesson claims answers to:
#
# * does the wide table really produce the update and deletion anomalies?
# * does SQLite really leave foreign-key enforcement OFF by default, and does
# the identical insert really get rejected once the pragma is on?
# * which rows does each join type actually keep?
# * does count(*) really give the wrong per-group count after a LEFT JOIN?
# * does moving a predicate from ON to WHERE really change the result set —
# in BOTH directions?
# * do a hand-written nested-loop join and a hand-written hash join really
# agree with SQLite, row for row?
#
# 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.
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
}
# Resolve the two tools this lab needs, allowing an override for people who
# keep them somewhere unusual. Fails loudly rather than skipping checks.
python_bin="${PYTHON:-}"
if [ -z "${python_bin}" ]; then
python_bin="$(command -v python3 || true)"
fi
sqlite_bin="${SQLITE3:-}"
if [ -z "${sqlite_bin}" ]; then
sqlite_bin="$(command -v sqlite3 || true)"
fi
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 newline-separated column, no headers, no padding.
q() { "${sqlite_bin}" "${db}" ".mode list" ".headers off" "$1"; }
echo "Day 087 — Joins and Relationships"
echo "python3: $("${python_bin}" -c 'import sys; print(sys.version.split()[0])')"
echo "sqlite3: $("${sqlite_bin}" --version | cut -d' ' -f1)"
echo "work: a temporary directory, removed when this script exits"
echo
# ---------------------------------------------------------------------------
echo "1. The wide table really does produce the anomalies"
# ---------------------------------------------------------------------------
wide_db="${work}/wide.db"
"${sqlite_bin}" "${wide_db}" < "${lab_dir}/examples/01_wide_table.sql" > "${work}/wide.txt" 2>&1
wide_status=$?
check_eq "01_wide_table.sql runs clean — every anomaly below is silent" "0" "${wide_status}"
check_eq "3 books needed 5 rows, because 2 authors are written down twice" \
"5" "$(grep -c '^The ' "${work}/wide.txt" | tr -d ' ')"
# The update anomaly: one human being, two spellings, no error raised.
names_1942="$("${sqlite_bin}" "${wide_db}" ".mode list" ".headers off" \
"SELECT count(DISTINCT author_name) FROM catalog_wide WHERE author_birth_year = 1942")"
check_eq "update anomaly: one author now has 2 different names in the table" \
"2" "${names_1942}"
check "update anomaly: the database raised no error while contradicting itself" \
"$(grep -qc 'Runtime error' "${work}/wide.txt" >/dev/null 2>&1 && echo no || echo yes)"
# The deletion anomaly: removing the book removed the only record of the author.
brooks="$("${sqlite_bin}" "${wide_db}" ".mode list" ".headers off" \
"SELECT count(*) FROM catalog_wide WHERE author_name LIKE '%Brooks%'")"
check_eq "deletion anomaly: withdrawing the book erased the author entirely" \
"0" "${brooks}"
# The insertion anomaly is structural: every book column is NOT NULL, so there
# is no row shape that records an author without inventing a book.
notnull="$("${sqlite_bin}" "${wide_db}" ".mode list" ".headers off" \
"SELECT count(*) FROM pragma_table_info('catalog_wide') WHERE \"notnull\" = 1")"
check_eq "insertion anomaly: all 4 columns are NOT NULL, so an author needs a book" \
"4" "${notnull}"
echo
# ---------------------------------------------------------------------------
echo "2. The split schema builds, and models the relationships properly"
# ---------------------------------------------------------------------------
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/02_schema.sql" 2>"${work}/schema.err"
check "schema.sql runs without error" \
"$([ ! -s "${work}/schema.err" ] && echo yes || echo no)"
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/03_seed.sql" 2>"${work}/seed.err"
check "seed.sql runs without error" \
"$([ ! -s "${work}/seed.err" ] && echo yes || echo no)"
check_eq "five tables exist" "5" \
"$(q "SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('authors','books','book_authors','members','loans')")"
check_eq "row counts are 7 authors, 4 books, 7 pairs, 5 members, 6 loans" \
"7|4|7|5|6" \
"$(q "SELECT (SELECT count(*) FROM authors) || '|' || (SELECT count(*) FROM books) || '|' || (SELECT count(*) FROM book_authors) || '|' || (SELECT count(*) FROM members) || '|' || (SELECT count(*) FROM loans)")"
# The junction table's primary key is the PAIR — that is what makes it a
# junction table rather than a table that happens to have two columns.
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)")"
# One-to-many is modelled by putting the key on the many side.
check_eq "loans (the many side) carries both foreign keys" "books|members" \
"$(q "SELECT group_concat(t, '|') FROM (SELECT \"table\" AS t FROM pragma_foreign_key_list('loans') ORDER BY t)")"
check_eq "members references itself, which is what makes a self-join possible" \
"members" "$(q "SELECT \"table\" FROM pragma_foreign_key_list('members')")"
echo
# ---------------------------------------------------------------------------
echo "3. PRAGMA foreign_keys is OFF by default — proved, not asserted"
# ---------------------------------------------------------------------------
fk_out="${work}/fk.txt"
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/04_foreign_keys.sql" > "${fk_out}" 2>&1
fk_status=$?
check "a fresh connection reports PRAGMA foreign_keys = 0" \
"$(grep -q 'PRAGMA foreign_keys 0' "${fk_out}" && echo yes || echo no)"
check "with it off, a loan pointing at member 999 INSERTS SUCCESSFULLY" \
"$(grep -qE '^900 +101 +999' "${fk_out}" && echo yes || echo no)"
check "pragma_foreign_key_check finds the orphan the insert created" \
"$(grep -qE '^loans +900 +members' "${fk_out}" && echo yes || echo no)"
check "after PRAGMA foreign_keys = ON the setting reads back as 1" \
"$(grep -q 'PRAGMA foreign_keys 1' "${fk_out}" && echo yes || echo no)"
check "the IDENTICAL insert is then rejected: FOREIGN KEY constraint failed" \
"$(grep -q 'FOREIGN KEY constraint failed' "${fk_out}" && echo yes || echo no)"
check_eq "sqlite3 exits non-zero when the constraint fires" "rejected" \
"$([ "${fk_status}" -ne 0 ] && echo rejected || echo "exit ${fk_status}")"
check_eq "the orphan row is gone, so the rest of the suite sees clean data" \
"6" "$(q "SELECT count(*) FROM loans")"
# The same fact from Python, including the transaction trap.
py_fk="${work}/fk_python.txt"
"${python_bin}" "${lab_dir}/examples/07_foreign_keys_python.py" > "${py_fk}" 2>&1
check "python: a new connection also reports foreign_keys 0" \
"$(grep -q 'new connection: 0' "${py_fk}" && echo yes || echo no)"
check "python: the pragma is a no-op inside an open transaction (reads back 0)" \
"$(grep -q 'reads back as: 0' "${py_fk}" && echo yes || echo no)"
check "python: after commit() the same pragma takes effect (reads back 1)" \
"$(grep -q 'setting it again reads back as: 1' "${py_fk}" && echo yes || echo no)"
check "python: the orphan insert then raises IntegrityError" \
"$(grep -q 'IntegrityError: FOREIGN KEY constraint failed' "${py_fk}" && echo yes || echo no)"
echo
# ---------------------------------------------------------------------------
echo "4. INNER JOIN, the comma form, and the cartesian product"
# ---------------------------------------------------------------------------
inner="SELECT b.title || ' / ' || a.name FROM books b
JOIN book_authors ba ON ba.book_id = b.book_id
JOIN authors a ON a.author_id = ba.author_id
ORDER BY b.title, a.name"
check_eq "inner join across the junction returns 7 book-author pairs" "7" \
"$(q "SELECT count(*) FROM (${inner})")"
check_eq "the co-authored C book yields both of its authors, not one row" \
"The C Programming Language / Brian W. Kernighan
The C Programming Language / Dennis M. Ritchie" \
"$(q "${inner}" | grep '^The C Programming Language')"
comma="SELECT b.title || ' / ' || a.name FROM books b, book_authors ba, authors a
WHERE ba.book_id = b.book_id AND a.author_id = ba.author_id
ORDER BY b.title, a.name"
check_eq "the old comma-join-with-WHERE form gives byte-identical output" \
"$(q "${inner}")" "$(q "${comma}")"
check_eq "an unconstrained CROSS JOIN is 4 books x 7 authors = 28 rows" "28" \
"$(q "SELECT count(*) FROM books CROSS JOIN authors")"
check_eq "forgetting ONE join condition turns 7 correct rows into 49" \
"49" "$(q "SELECT count(*) FROM books b JOIN book_authors ba ON ba.book_id = b.book_id JOIN authors a ON 1=1")"
echo
# ---------------------------------------------------------------------------
echo "5. LEFT OUTER JOIN — exactly which rows survive, and what turns NULL"
# ---------------------------------------------------------------------------
check_eq "left join authors->books keeps all 7 authors, giving 8 rows" "8" \
"$(q "SELECT count(*) FROM authors a LEFT JOIN book_authors ba ON ba.author_id = a.author_id LEFT JOIN books b ON b.book_id = ba.book_id")"
check_eq "the unmatched author survives with NULL in every right-hand column" \
"Donald E. Knuth|1" \
"$(q "SELECT a.name || '|' || (b.title IS NULL AND b.published_year IS NULL) FROM authors a LEFT JOIN book_authors ba ON ba.author_id = a.author_id LEFT JOIN books b ON b.book_id = ba.book_id WHERE b.book_id IS NULL")"
check_eq "the mirror INNER join drops that author, giving 7 rows" "7" \
"$(q "SELECT count(*) FROM authors a JOIN book_authors ba ON ba.author_id = a.author_id JOIN books b ON b.book_id = ba.book_id")"
echo
# ---------------------------------------------------------------------------
echo "6. LEFT JOIN + IS NULL — the anti-join idiom"
# ---------------------------------------------------------------------------
check_eq "authors with no catalogued book" "Donald E. Knuth" \
"$(q "SELECT a.name FROM authors a LEFT JOIN book_authors ba ON ba.author_id = a.author_id WHERE ba.author_id IS NULL ORDER BY a.name")"
check_eq "books never borrowed" "104|The Practice of Programming" \
"$(q "SELECT b.book_id || '|' || b.title FROM books b LEFT JOIN loans l ON l.book_id = b.book_id WHERE l.loan_id IS NULL ORDER BY b.title")"
check_eq "members who have never borrowed anything" "Eli Nakamura" \
"$(q "SELECT m.name FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE l.loan_id IS NULL ORDER BY m.name")"
check_eq "NOT IN answers the same question here, since no member_id is NULL" \
"Eli Nakamura" \
"$(q "SELECT name FROM members WHERE member_id NOT IN (SELECT member_id FROM loans) ORDER BY name")"
echo
# ---------------------------------------------------------------------------
echo "7. Joins with GROUP BY — the zero-count trap"
# ---------------------------------------------------------------------------
check_eq "LEFT JOIN with count(l.loan_id) gives a genuine zero for Eli" \
"Ada Okafor|2
Bruno Salgado|2
Chandra Iyer|1
Dana Whitfield|1
Eli Nakamura|0" \
"$(q "SELECT m.name || '|' || count(l.loan_id) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id GROUP BY m.member_id, m.name ORDER BY count(l.loan_id) DESC, m.name")"
check_eq "count(*) instead reports 1 for the member who borrowed nothing" "1" \
"$(q "SELECT count(*) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE m.name = 'Eli Nakamura' GROUP BY m.member_id")"
check_eq "an INNER JOIN drops her from the report altogether: 4 rows, not 5" "4" \
"$(q "SELECT count(*) FROM (SELECT m.member_id FROM members m JOIN loans l ON l.member_id = m.member_id GROUP BY m.member_id)")"
check_eq "times borrowed per book, with a real zero for the unread one" \
"The C Programming Language|3
The Mythical Man-Month|2
Artificial Intelligence: A Modern Approach|1
The Practice of Programming|0" \
"$(q "SELECT b.title || '|' || count(l.loan_id) FROM books b LEFT JOIN loans l ON l.book_id = b.book_id GROUP BY b.book_id, b.title ORDER BY count(l.loan_id) DESC, b.title")"
echo
# ---------------------------------------------------------------------------
echo "8. ON versus WHERE on an outer join — wrong in BOTH directions"
# ---------------------------------------------------------------------------
on_form="SELECT m.name || '|' || coalesce(l.loan_id, 'none')
FROM members m LEFT JOIN loans l
ON l.member_id = m.member_id AND l.returned_on IS NULL
ORDER BY m.name, l.loan_id"
where_form="SELECT m.name || '|' || coalesce(l.loan_id, 'none')
FROM members m LEFT JOIN loans l ON l.member_id = m.member_id
WHERE l.returned_on IS NULL
ORDER BY m.name, l.loan_id"
check_eq "predicate in ON: all 5 members survive, unmatched ones showing none" \
"Ada Okafor|2
Bruno Salgado|6
Chandra Iyer|4
Dana Whitfield|none
Eli Nakamura|none" \
"$(q "${on_form}")"
check_eq "predicate in WHERE: only 4 rows — the outer join has collapsed" "4" \
"$(q "SELECT count(*) FROM (${where_form})")"
check_eq "WHERE drops Dana, who is a member and has genuinely returned everything" \
"0" "$(q "SELECT count(*) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE l.returned_on IS NULL AND m.name = 'Dana Whitfield'")"
check_eq "and WHERE KEEPS Eli, who never borrowed at all — a false positive" \
"1" "$(q "SELECT count(*) FROM members m LEFT JOIN loans l ON l.member_id = m.member_id WHERE l.returned_on IS NULL AND m.name = 'Eli Nakamura'")"
echo
# ---------------------------------------------------------------------------
echo "9. Self-joins and three-or-more-table joins"
# ---------------------------------------------------------------------------
check_eq "self-join with LEFT keeps the two members nobody referred" \
"Ada Okafor|
Bruno Salgado|Ada Okafor
Chandra Iyer|Ada Okafor
Dana Whitfield|Bruno Salgado
Eli Nakamura|" \
"$(q "SELECT m.name || '|' || coalesce(r.name, '') FROM members m LEFT JOIN members r ON r.member_id = m.referred_by ORDER BY m.member_id")"
check_eq "self-join with INNER silently loses those two" "3" \
"$(q "SELECT count(*) FROM members m JOIN members r ON r.member_id = m.referred_by")"
check_eq "four tables at once: what is out on loan, with authors" \
"Ada Okafor|The Mythical Man-Month|Frederick P. Brooks Jr.
Bruno Salgado|The Mythical Man-Month|Frederick P. Brooks Jr.
Chandra Iyer|Artificial Intelligence: A Modern Approach|Peter Norvig
Chandra Iyer|Artificial Intelligence: A Modern Approach|Stuart J. Russell" \
"$(q "SELECT m.name || '|' || b.title || '|' || a.name FROM loans l JOIN members m ON m.member_id = l.member_id JOIN books b ON b.book_id = l.book_id JOIN book_authors ba ON ba.book_id = b.book_id JOIN authors a ON a.author_id = ba.author_id WHERE l.returned_on IS NULL ORDER BY m.name, a.name")"
check_eq "3 loans are outstanding, but the author join makes 4 rows — not a bug" \
"3|4" \
"$(q "SELECT (SELECT count(*) FROM loans WHERE returned_on IS NULL) || '|' || (SELECT count(*) FROM loans l JOIN books b ON b.book_id = l.book_id JOIN book_authors ba ON ba.book_id = b.book_id WHERE l.returned_on IS NULL)")"
echo
# ---------------------------------------------------------------------------
echo "10. The join implemented from scratch in Python agrees with SQL"
# ---------------------------------------------------------------------------
scratch="${work}/scratch.txt"
"${python_bin}" "${lab_dir}/examples/06_join_from_scratch.py" "${db}" > "${scratch}" 2>&1
scratch_status=$?
check_eq "06_join_from_scratch.py exits 0" "0" "${scratch_status}"
check "nested-loop join equals the SQL result" \
"$(grep -q 'nested-loop == SQL: True' "${scratch}" && echo yes || echo no)"
check "hash join equals the SQL result" \
"$(grep -q 'hash == SQL: True' "${scratch}" && echo yes || echo no)"
check "the two algorithms agree with each other" \
"$(grep -q 'nested-loop == hash: True' "${scratch}" && echo yes || echo no)"
check "nested loop costs 30 comparisons (6 x 5)" \
"$(grep -q '30 key comparisons' "${scratch}" && echo yes || echo no)"
check "the hash join costs 11 operations (6 + 5) for the same answer" \
"$(grep -q '11 operations' "${scratch}" && echo yes || echo no)"
check "the hand-written outer join keeps Eli Nakamura, as SQL does" \
"$(grep -q "outer join agrees with SQL: True" "${scratch}" && echo yes || echo no)"
echo
# ---------------------------------------------------------------------------
echo "11. N+1 queries against one join — measured on this machine"
# ---------------------------------------------------------------------------
n1="${work}/n1.txt"
"${python_bin}" "${lab_dir}/examples/08_n_plus_one.py" > "${n1}" 2>&1
n1_status=$?
check_eq "08_n_plus_one.py exits 0" "0" "${n1_status}"
check "the loop really issues 501 queries" \
"$(grep -qE 'N\+1 loop: +501 queries' "${n1}" && echo yes || echo no)"
check "the join really issues 1" \
"$(grep -qE 'one join: +1 queries' "${n1}" && echo yes || echo no)"
check "both produce the same answer, so the comparison is fair" \
"$(grep -q 'same answer: True' "${n1}" && echo yes || echo no)"
echo
# ---------------------------------------------------------------------------
echo "12. The planner really is choosing a join algorithm"
# ---------------------------------------------------------------------------
plans="${work}/plans.txt"
"${sqlite_bin}" "${db}" < "${lab_dir}/examples/09_query_plans.sql" > "${plans}" 2>&1
check "an indexed inner join plans as SCAN one side, SEARCH the other" \
"$(grep -q 'SCAN l USING COVERING INDEX' "${plans}" && grep -q 'SEARCH m USING INTEGER PRIMARY KEY' "${plans}" && echo yes || echo no)"
check "the outer join is planned with a LEFT-JOIN marker on the inner search" \
"$(grep -q 'LEFT-JOIN' "${plans}" && echo yes || echo no)"
check "a cartesian product plans as two bare SCANs and nothing else" \
"$(grep -q 'SCAN books' "${plans}" && grep -q 'SCAN authors' "${plans}" && echo yes || echo no)"
echo
# ---------------------------------------------------------------------------
echo "13. The starter is runnable, and honest about being unfinished"
# ---------------------------------------------------------------------------
starter_db="${work}/starter.db"
"${sqlite_bin}" "${starter_db}" < "${lab_dir}/examples/02_schema.sql" 2>/dev/null
"${sqlite_bin}" "${starter_db}" < "${lab_dir}/examples/03_seed.sql" 2>/dev/null
"${sqlite_bin}" "${starter_db}" < "${lab_dir}/starter/02_exercises.sql" > "${work}/starter_sql.txt" 2>&1
starter_sql_status=$?
check_eq "the SQL starter runs end to end before you have changed anything" \
"0" "${starter_sql_status}"
check "the SQL starter carries all six numbered exercises" \
"$([ "$(grep -c '^-- EXERCISE [1-6] ' "${lab_dir}/starter/02_exercises.sql")" -eq 6 ] && echo yes || echo no)"
check "each SQL exercise names the check that verifies it" \
"$([ "$(grep -c '^-- Checked by:' "${lab_dir}/starter/02_exercises.sql")" -eq 6 ] && echo yes || echo no)"
"${python_bin}" "${lab_dir}/starter/03_join_from_scratch.py" "${starter_db}" > "${work}/starter_py.txt" 2>&1
starter_py_status=$?
check_eq "the Python starter runs, and reports 0 of 3 exercises complete" "0 of 3 exercises complete." \
"$(grep -o '. of 3 exercises complete.' "${work}/starter_py.txt")"
check_eq "the Python starter exits non-zero while it is unfinished" "unfinished" \
"$([ "${starter_py_status}" -ne 0 ] && echo unfinished || echo "exit ${starter_py_status}")"
# The most important check in this section: fill the three gaps in a COPY of
# the starter and confirm it then passes. A starter whose exercises cannot be
# completed, or a checker that would pass anyway, is worth nothing.
solved="${work}/solved.py"
"${python_bin}" - "${lab_dir}/starter/03_join_from_scratch.py" "${solved}" <<'PY'
import sys
source, target = sys.argv[1], sys.argv[2]
text = open(source, encoding="utf-8").read()
replacements = [
(
" pairs.append((left_row, right_row)) # <- exercise 7 goes here",
" if left_row[left_key] == right_row[right_key]:\n"
" pairs.append((left_row, right_row))",
),
(
" for left_row in left: # probe phase - exercise 8 goes here\n"
" operations += 1\n",
" for left_row in left:\n"
" operations += 1\n"
" for right_row in index.get(left_row[left_key], ()):\n"
" pairs.append((left_row, right_row))\n",
),
(
" # exercise 9: the missing `else` goes here",
" else:\n pairs.append((left_row, None))",
),
]
for old, new in replacements:
assert old in text, f"starter text drifted: {old!r}"
text = text.replace(old, new)
open(target, "w", encoding="utf-8").write(text)
PY
check "the three starter gaps are still exactly where the answer key expects" \
"$([ -f "${solved}" ] && echo yes || echo no)"
"${python_bin}" "${solved}" "${starter_db}" > "${work}/solved.txt" 2>&1
solved_status=$?
check_eq "completing the three exercises makes the starter pass" "3 of 3 exercises complete." \
"$(grep -o '. of 3 exercises complete.' "${work}/solved.txt")"
check_eq "and exit 0" "0" "${solved_status}"
echo
# ---------------------------------------------------------------------------
echo "14. Hygiene: offline, no privilege, no mess left behind"
# ---------------------------------------------------------------------------
# Every URL anywhere in the lab's scripts, and every line that would actually
# RUN sudo (as opposed to a comment saying this lab does not need it).
"${python_bin}" - "${lab_dir}" > "${work}/hygiene.txt" 2>&1 <<'PY'
import re
import 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 "the only URL anywhere in the lab's scripts is the cited SQLite page" \
"URLS https://www.sqlite.org/np1queryprob.html" \
"$(grep '^URLS ' "${work}/hygiene.txt")"
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 "this suite created no database inside the lab directory" \
"$([ ! -f "${lab_dir}/library.db" ] && echo yes || echo no)"
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 087
sqlite3: command not found
macOS ships it at /usr/bin/sqlite3. On Debian or Ubuntu, sudo apt install sqlite3 installs the shell (the library is already there — the package you
want is the command-line one). On Fedora, sudo dnf install sqlite.
You can also do the whole lab through Python without the shell at all:
python3 -c "import sqlite3; print(sqlite3.sqlite_version)"
The test harness needs the shell, but takes an override if yours lives
somewhere unusual: SQLITE3=/opt/local/bin/sqlite3 bash tests/run_tests.sh.
no such table: authors
You are pointing sqlite3 at a database that has not been built, or at a
database that does not exist — which SQLite quietly creates, empty, rather
than complaining. That is the single most common source of this message: a
typo in the filename gives you a brand-new, blank database instead of an error.
Rebuild:
bash starter/01_build.sh
sqlite3 starter/library.db ".tables"
You should see all five table names. If .tables prints nothing, you are
looking at an empty file you just created by accident.
My FOREIGN KEY constraint failed never happens
This is the lesson, arriving as a bug report. Foreign-key enforcement is off
by default on every new SQLite connection, so a REFERENCES clause you never
switched on is documentation, not a constraint.
PRAGMA foreign_keys; -- prints 0 on a fresh connection
PRAGMA foreign_keys = ON; -- and it only lasts for THIS connection
Three things make this bite:
- It is per connection. Every new
sqlite3invocation, every newsqlite3.connect(), starts fresh with it off. Setting it in one shell session does nothing for the next. - It is not stored in the file. There is no way to mark a database as "always enforce". The application has to say so every time it connects.
- It is a no-op inside an open transaction. See the next entry.
I set the pragma in Python and it still reads back as 0
Real behaviour, reproducible with examples/07_foreign_keys_python.py:
connection.in_transaction: True
pragma set, but it reads back as: 0
after commit(), setting it again reads back as: 1
SQLite documents PRAGMA foreign_keys as a no-op inside a transaction, and
Python's sqlite3 module opens transactions for you around INSERT, UPDATE
and DELETE. So a pragma issued after your first write is silently ignored —
no exception, no warning, just no effect.
The fix is a habit, not a workaround: issue it as the first statement after connecting, before anything else touches the database.
connection = sqlite3.connect("library.db")
connection.execute("PRAGMA foreign_keys = ON") # first, always
My join returns far more rows than either table has
You have written a cartesian product. Either a join condition is missing entirely, or one of them is wrong so nothing matches the way you meant.
Count first, then look:
SELECT count(*) FROM books; -- 4
SELECT count(*) FROM authors; -- 7
SELECT count(*) FROM books CROSS JOIN authors; -- 28
The rule of thumb: joining N tables needs at least N−1 join conditions. Three
tables with only one ON clause will multiply. examples/09_query_plans.sql
shows what this looks like to the planner — two bare SCAN lines with nothing
tying them together.
My join returns rows I did not expect to see twice
Look for a many-to-many in the path. examples/05_joins.sql §10 joins loans to
books to book_authors to authors, and Chandra Iyer appears twice — because
the book she has out has two authors. Three outstanding loans become four rows.
That is not a bug, and SELECT DISTINCT is usually the wrong fix because it
hides the question rather than answering it. Decide what one row is supposed to
mean. If it means "one loan", do not join to authors at all, or aggregate them
with group_concat.
My LEFT JOIN behaves like an INNER JOIN
Almost always a predicate on the right-hand table that has migrated into the
WHERE clause. Once the outer join has filled the right-hand columns with
NULLs, nearly every WHERE test on those columns is false, and the unmatched
rows you went to the trouble of keeping get thrown away again.
-- keeps all 5 members
... LEFT JOIN loans l ON l.member_id = m.member_id AND l.returned_on IS NULL
-- keeps 4, and they are the wrong 4
... LEFT JOIN loans l ON l.member_id = m.member_id WHERE l.returned_on IS NULL
Run both against the seeded data (joins.txt §8 and §8b). The WHERE version
drops Dana Whitfield, who has returned everything, and keeps Eli Nakamura,
who has never borrowed anything — because her NULL-extended row does satisfy
returned_on IS NULL. Wrong in both directions at once.
The exception that is not an exception: WHERE right_table.key IS NULL is
deliberate. That is the anti-join idiom, and it works precisely because it
collapses the outer join down to the unmatched rows.
My counts are all 1 instead of 0
count(*) after a LEFT JOIN counts rows, and the NULL-extended row for an
unmatched group is still a row. Count a column from the right-hand table
instead — count(l.loan_id) — because aggregate functions skip NULLs.
count(*) -- Eli Nakamura: 1 wrong
count(l.loan_id) -- Eli Nakamura: 0 right
My self-join loses rows
Same cause as the LEFT JOIN entry, one level subtler. members joined to
itself on referred_by with an inner join drops everybody who was referred by
nobody — here that is Ada Okafor and Eli Nakamura, so five members become
three. Use LEFT JOIN unless you specifically want only the referred ones.
Error: near "AS": syntax error on my self-join
Both sides of a self-join need distinct aliases, and every column reference needs to say which alias it means:
FROM members AS m LEFT JOIN members AS r ON r.member_id = m.referred_by
Without the aliases SQLite cannot tell member_id from member_id.
database is locked
Another process has a write transaction open — most often a sqlite3 shell you
left sitting at its prompt in another terminal, or an editor with a database
viewer attached. Close it. Nothing in this lab writes concurrently, so if you
see this, something outside the lab is holding the file.
The test harness fails on one section only
Read the two lines it prints under the failure: expected: and actual:. Every
value check names the query it disagrees about. The usual cause is an edited
examples/03_seed.sql — the checks assert exact names and counts against the
shipped data, so changing the seed changes the answers. Restore it with
git checkout -- examples/03_seed.sql.
The harness passes but my own queries disagree with it
The harness builds its database in a temporary directory and removes it
afterwards. Your starter/library.db is a separate file, and if you ran the
anomaly script or the foreign-key script against it, it may no longer hold the
seeded data. Rebuild with bash starter/01_build.sh.
Nothing works and I want to start over
rm -f starter/library.db library.db
bash starter/01_build.sh
git checkout -- starter/
The last line discards your exercise answers, so do it only when you mean it.
Security notes
Security notes — Day 087
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/orstarter/importssocket,urllib,httporrequests, and that the only URL anywhere in the lab's scripts is the SQLite documentation page cited in a comment. - 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.txtlists no packages. Nothing is added to your Python environment, yourPATH, or any scheduler. - No mess. The harness builds every database inside
mktemp -dand removes it in atrap, then asserts that no database was created in the lab directory. Your ownstarter/library.dbis the only file you create, and Cleanup removes it.
The data in this lab
The books and authors are real, published works and their real authors, used as catalogue entries. The five members and six loans are invented for this lab. No real person's borrowing history appears anywhere, and none should: a library loan record is one of the more sensitive things a small institution holds. It ties a named individual to what they read and when.
That is worth a moment, because it is the security lesson hiding inside a
lesson about joins. Each of the five tables on its own is fairly harmless.
books is a catalogue. members is a mailing list. loans is a table of
integers and dates. The join is what makes it sensitive — one query across
three tables produces "this named person read this named book on this date",
which is exactly the record that reading-privacy law in several countries
exists to protect.
The general form: a join can create a disclosure that none of its inputs contains. When you are reasoning about who may see what, the unit to reason about is the query, not the table.
Foreign keys are an integrity control, not a security control
Enabling PRAGMA foreign_keys = ON stops a loan pointing at a member who does
not exist. It stops nothing else. It is not an access control, it does not
authenticate anybody, and it does not protect the file — anyone who can read
library.db can read every row in it, because a SQLite database is an ordinary
file with ordinary filesystem permissions and no encryption.
If a database on disk holds anything you would not hand to whoever gets the laptop, the protection is filesystem permissions, full-disk encryption, or not storing it — not a constraint inside the schema.
Worth stating plainly because the default surprises people: enforcement is
off unless you turn it on, per connection, every time. A schema full of
REFERENCES clauses on a system where nobody ever issued the pragma has been
accumulating orphan rows silently, possibly for years. PRAGMA foreign_key_check will tell you; it is a good thing to run against any
inherited SQLite database before you trust its relationships.
SQL injection, and why you do not see it here
Every query in this lab is a literal string written by you, with no user input anywhere, so there is nothing to inject into. The moment a value comes from outside — a form, a filename, an API parameter — that stops being true.
The rule, in Python:
## never
connection.execute(f"SELECT * FROM members WHERE name = '{name}'")
## always
connection.execute("SELECT * FROM members WHERE name = ?", (name,))
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. This is not a string-escaping technique that a clever input might defeat; the value never passes through the parser at all.
One thing the placeholder cannot do: table and column names are not
parameterisable. SELECT * FROM ? is not valid. examples/06_join_from_scratch.py
does interpolate a table name into a query — f"SELECT * FROM {table}" — and it
is safe there only because the caller passes a constant from that same file. If
a table name ever comes from outside your program, check it against a hard-coded
allow-list of names you accept, and reject anything else.
Cartesian products as a denial of service
A missing join condition is a correctness bug at this scale and a resource problem at any real one. Two tables of ten thousand rows joined with no condition is a hundred million rows, and the database will honestly try to produce them. On a shared server that is an outage, caused by a query that looked fine in review.
Two habits that cost nothing: run EXPLAIN QUERY PLAN before running anything
unfamiliar against a large table (examples/09_query_plans.sql shows what a
cartesian product looks like — two bare SCANs), and put a LIMIT on
exploratory queries until you trust them.
Cleanup
rm -f starter/library.db library.db
Nothing else was created, nothing was installed, and nothing outside this directory was touched.