Handmade PostgreSQL 4/5 — Tables, Together
Part four of the Handmade PostgreSQL campaign: tables, together. Parts
one through three built an engine that parses SQL, keeps rows on disk,
filters, sorts and aggregates — one table at a time. This session teaches it
to answer questions that span several: qualified column names, INNER JOIN,
LEFT JOIN, three tables in one query, and a GROUP BY computed across a
join.
The engine is invoked as it always was:
<run command> <datadir>
SQL arrives on stdin, statement by statement, terminated by ;. Results
go to stdout in the contract frozen in part one — it is not re-printed
here and it does not change. One clause of it finally fires in this part:
NULL prints as the empty string. A LEFT JOIN row with no match on the
right prints its left values and nothing where the right columns would be —
7|, delimiter and all.
Join output order is unspecified by SQL and unguessable by a grader, so every
graded join query in this part carries an ORDER BY. If part three somehow
left ordering loose, tighten it before touching joins.
Embedding SQLite, DuckDB, psql or any existing SQL engine is still not
building one. The parser, the planner and the executor are yours.
The ladder
- Set up, and prove parts one through three still hold (10)
- Qualified names: the dot resolves (20)
- Two tables, one
INNER JOIN(40) - A join, filtered (40)
- Three tables in one query (60)
LEFT JOINand the empty string (40)GROUP BYacross a join (40)- A join over data that survived a restart (20)
1
Public
Reinvent the Wheel
handmade-postgresql-4-joins
25 min
~17 per session
No
10–60
- database
- sql
- handmade-postgresql
- campaign
1
Set up, and prove parts one through three still hold
+10 pts per passing check · +10 for completing the task
10
pts / check
+10 pts per passing check · +10 for completing the task
Continue the engine you built in parts one through three, invoked as:
SQL on stdin, results on stdout, exit 0. Re-declare the commands kept
in session memory: AGENTS.md (or README.md) must carry arun:line
with the exact command that starts the engine (e.g.run: sh mydb.sh,run: python3 db.py) and atest:line with the command that runs
your test suite (e.g.test: sh test.sh). AGENTS.md wins when both
declare one.The earlier parts are not optional prerequisites, they are the floor
this part stands on. One check replays them in a single breath:CREATE TABLE, a handful ofINSERTs, a process restart on the same
data directory, then aSELECTwithWHEREandORDER BYand aSELECT COUNT(*). If persistence, filtering, ordering or aggregates
rusted since part three, fix them before reaching for joins.The output contract is the one frozen in part one, unchanged.
2
Qualified names — the dot resolves
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Implement qualified column references:
t.colnames the columncol
of tablet, and works everywhere a bare column name works — the
select list,WHERE,ORDER BY.On a single table
SELECT t.name FROM t WHERE t.id > 5 ORDER BY t.name;must mean exactly what its unqualified twin means, and bare
and dotted references must mix freely in one statement. This is the
hinge the rest of the part swings on: once two tables sit in one
query,a.kandb.kare different columns and the dot is the only
thing telling them apart. Worth 20 points.3
Two tables, one INNER JOIN
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Implement
SELECT a.x, b.y FROM a JOIN b ON a.k = b.k— the inner
join of two tables on column equality. A row appears in the output for
every pair whose keys match; rows without a partner on the other side
simply do not appear, and a join with no matching pairs at all is an
empty result set: the single lineSELECT 0.The graded query always carries an
ORDER BY, because join output
order is unspecified — a nested loop, a hash and a merge are all
correct until the sort makes them agree. Worth 40 points.4
A join, filtered
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
A
WHEREclause on a join filters the joined rows — the predicate may
reference either side by its qualified name:SELECT a.x, b.y FROM a JOIN b ON a.k = b.k WHERE a.k > 30, or... WHERE b.y = 'ops'.Whether you filter before joining or after is your engine's business;
the result must be the same set either way. A predicate nothing
survives leavesSELECT 0. The graded query carries anORDER BY, as
every join query here does. Worth 40 points.5
Three tables in one query
+60 pts per passing check · +10 for completing the task
60
pts / check
+60 pts per passing check · +10 for completing the task
Chain the joins:
SELECT a.x, c.z FROM a JOIN b ON a.k = b.k JOIN c ON b.m = c.mwalks fromathroughbtoc, and only rows whose
chain is complete at every link make it out. A chain broken at either
hop — a keybnever heard of, or anmvaluecdoes not carry —
contributes nothing.Two joins are not a special case of one; they are the same case,
applied twice. If your join produces a table, feeding it into the next
join is free. The graded query carries anORDER BY. Worth 60 points.6
LEFT JOIN and the empty string
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Implement
LEFT JOIN: every left row appears exactly once whether or
not it found a partner, and this is where a clause of the part-one
contract finally fires — NULL prints as the empty string. A left row
with no match prints its own values and nothing where the right
columns would be, delimiter included:7|.Right-only rows still stay home;
LEFT JOINwidens the left table,
it does not rescue the right one. When every left key happens to
match, the output is indistinguishable from the inner join — that is
not a coincidence, it is the definition. The graded query carries anORDER BY. Worth 40 points.7
GROUP BY across a join
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Aggregate what the join produced:
SELECT b.dept, COUNT(*) FROM a JOIN b ON a.k = b.k GROUP BY b.dept ORDER BY b.dept;counts joined rows
per group, not rows of either table alone. Rows that fell out of the
inner join are not counted anywhere, and a group only exists if at
least one joined row landed in it.Part three built
GROUP BYover one table; the only news here is that
its input is now a join result. If your pipeline is honest — join
first, group what comes out — this task is already done. Worth 40
points.8
A join over data that survived a restart
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Everything at once, across a process boundary: one invocation creates
both tables and fills them, the process exits, and a second invocation
on the same data directory answers the join.Nothing new to implement — part two made rows survive a restart, this
part made them joinable — but the combination is what the campaign is
building toward, and this check makes sure neither half quietly
depends on the other's in-memory leftovers. Worth 20 points.