Multi-Table Support & Foreign Keys
Building a Database Engine: Transactions & Concurrency
Chapter 7 · Multi-Table Support & Foreign Keys
Course 1's own Database class could already hold more than one table — that was never the missing piece. What's missing is any guarantee about how those tables relate to each other: nothing stops an orders row from pointing at a customer_id that doesn't exist anywhere in customers. This chapter builds a real foreign key constraint — checked on every insert, using the exact same B-tree index machinery Course 1 already built for a completely different reason.
A Real Catalog, and a Real Constraint
Finding 1: A Real Foreign Key, Verified Both Ways
id=1 (a real, existing customer) inserts normally. An order referencing customer id=999 (which doesn't exist) raises ForeignKeyViolation — and a real scan of the orders table afterward confirms it: only the valid order is present. The rejected insert never reached table.insert() at all.
Finding 2: Reusing Course 1's Own Index Makes This Fast
Checking whether a value exists in another table means either scanning it in full, or — if an index already exists on the referenced column — a real btree_search() call.
customers table, always referencing the last row (the worst case for a scan): 0.617s with no index. The identical test with an index on customers.id: 0.003s — ≈180× faster. A foreign key check is nothing more than a repeated existence lookup — exactly the operation Course 1's own Storage & Query Fundamentals Chapters 5–6 built a B-tree index to make fast.
Finding 3: A Real Bug — Referencing a Table That Doesn't Exist Yet
The first, most natural version of create_table() just stores whatever foreign keys it's given, with no check that the referenced table actually exists.
orders with a foreign key to customers — before customers has ever been created — succeeds with no complaint at all. The real failure only shows up later, on the very first insert into orders, as a raw KeyError: 'customers' — a confusing error with no indication of what actually went wrong or when the real mistake happened.
create_table() now checks every foreign key's own ref_table against the catalog's existing tables (allowing ref_table == name for a genuine self-reference) before registering anything. The identical mistake now raises a clear SchemaError — "foreign key references unknown table 'customers' — it must be created first" — at the exact moment it's made, and orders is never left half-registered in the catalog.
Finding 4: A Real, Working Multi-Table System
customer_id values — all three rejected, none reaching the heap file. A manual join (matching each order's own customer_id against the customers table) never has to handle a dangling reference, because the foreign key constraint guarantees none can exist.
A Genuine Structural Discovery: Self-Reference Under Check-Before-Insert
Building an exercise around a self-referencing table (an employees row whose own manager_id points at another row in the same table) surfaced something worth explaining honestly rather than working around quietly.
manager_id equals its own not-yet-assigned id — for instance, a CEO row that reports to itself — is always rejected, no matter which row number it is. This isn't a bug specific to self-reference: the foreign key check always runs before table.insert(), so the row genuinely doesn't exist yet at the moment its own value is checked against itself. Any row whose foreign key equals its own id is structurally impossible under this ordering.
Reordering to insert first and validate second — physically writing the row, then checking, then rolling it back if invalid — resolves this cleanly, reusing Chapter 4's own real Transaction and TransactionalHeapFile completely unmodified:
manager_id pointing at its own id now inserts successfully — by the time the check runs, the row is already physically present, so it can find itself. A genuinely invalid row (referencing a manager who doesn't exist at all) is still correctly rejected, and Chapter 4's own rollback() cleanly removes the physical write it made moments earlier — the invalid row never appears in a final scan.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| Index-backed FK checks, 180× faster | Building a Database Engine: Storage & Query Fundamentals Chapters 5–6, 9 (Course 1) — the B-tree index and query planner reused here for a genuinely new purpose |
| Insert-then-validate-then-rollback for self-reference | Chapter 4 (this course) — real, unmodified reuse of Transaction/TransactionalHeapFile, in a context that chapter never anticipated |
| Referencing a not-yet-created table | An honest, deliberate ordering constraint this engine now enforces — parent tables must exist before a child table can reference them, matching real SQL practice |
Hands-On Exercises
Create a table with no foreign_keys argument at all, insert a few rows into it, and confirm both that the inserts succeed exactly as they would in Course 1's own plain HeapTable, and that catalog.foreign_keys[table_name] is an empty list rather than None or missing entirely.
Build the self-referencing employees example from this chapter yourself: confirm a row referencing its own not-yet-inserted id is rejected under the normal insert() path, then use insert_allow_self_reference() to successfully insert it, followed by a second, genuinely invalid row that should be rolled back.
Build an index on a referenced column before any data exists in that table (on a genuinely empty table), then insert real rows into both tables afterward. Confirm the index stays accurate as rows are added, and that a foreign-key check against it still correctly catches an invalid reference.
📄 View solutionChapter 7 Quick Reference
- ForeignKey(column, ref_table, ref_column): checked on every insert, before the row is written
- Verified Finding 1: a valid insert succeeds; an invalid one is rejected and never reaches the heap file
- Verified Finding 2: routing the check through an existing B-tree index is ≈180× faster than a full scan
- Verified Finding 3: a foreign key to a not-yet-created table crashed confusingly on first insert — fixed with immediate validation at CREATE TABLE time
- Genuine discovery: a row can never reference its own not-yet-inserted id under check-before-insert — resolved by inserting first and rolling back on failure, reusing Chapter 4's own real transaction machinery
- Next chapter: Joins — nested-loop and hash join, replacing this chapter's manual dictionary-based join with real join algorithms