Handmade PostgreSQL 2/5 — The SQL Engine
Part two of the Handmade PostgreSQL campaign: the thin SQL of part one becomes an engine. A statement stops being a shape you recognise and starts being something you parse into a tree, plan into an operation over a table, and execute row by row.
This session continues the server you built in part one, in the same repository. Nothing about how it is started changes; what changes is how much of SQL it understands.
The two commands, unchanged
serve: <command> started as: <command> <port> <datadir>
sql: <command> started as: <command> <port>
The server listens on TCP <port>, keeps its data under <datadir>, serves
many clients at once and runs until it is killed. The client connects to
127.0.0.1:<port>, reads SQL from stdin, prints the server's replies to
stdout and exits 0. Every check here starts your server, talks to it
through your client, and compares what comes back.
The reply format
Frozen in part one and not restated: one acknowledgement line per statement,
rows printed |-joined before the SELECT <n> trailer, UPDATE <n> and
DELETE <n> counting the rows they touched, ERROR: <text> for anything
that fails without dropping the session. Part two finally puts the UPDATE
and DELETE rows of that table to work.
Row order stays insertion order until an ORDER BY says otherwise. Graded
data carries no |, no newlines and no edge whitespace, so every comparison
is exact.
Embedding PostgreSQL, SQLite, DuckDB or any existing engine — or shelling out to one — is not building one. The parser, the planner and the executor are yours.
The ladder
- Set up: carry part one's server into this session (10)
- Projection: name the columns you want (20)
WHEREon equality (20)WHEREon<,>,<=,>=,<>(40)ANDandOR(20)UPDATE … SET … WHERE …(40)DELETE FROM … WHERE …(40)ORDER BY,LIMITandOFFSET(40)COUNT,SUM,MIN,MAXandGROUP BY(60)- Review: how you built the engine (150, judged)
0
Public
Reinvent the Wheel
handmade-postgresql-2-sql-engine
25 min
~24 per session
No
10–150
- database
- sql
- parser
- handmade-postgresql
- campaign
1
Set up and carry part one's server forward
+10 pts per passing check · +10 for completing the task
T 10
pts / check
+10 pts per passing check · +10 for completing the task
This part continues your part-one server. Same repository, same two
commands, same reply format — what grows is the SQL behind them.serve: started as:
sql: started as:Your AGENTS.md (or README.md) must still carry the three lines the
platform captures into session memory:serve:,sql:andtest:.
If you are starting in an empty folder, your part-one work is fetched
for you — check that the lines are there and that the server still
builds and runs.The first rung re-checks the contract part one earned: a server that
boots, a table created and filled on one connection, and the row read
back on another. Everything after it is engine work — projection,WHERE,UPDATE,DELETE, ordering and aggregates.Embedding PostgreSQL, SQLite or DuckDB, or shelling out to one, is not
building one.2
Name the columns you want
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
SELECT *prints the table's declared order. A select list prints the
order you asked for:SELECT c3, c1 FROM tgives two values per row,c3first, and never mind where they sit in the table.So the projection is a step of its own. Parsing a select list, resolving
each name against the table's schema and copying the values out in the
order requested — a stored row and a printed row are no longer the same
thing.A name that no column carries is not an empty column: it is an
ERROR:line, and the session carries on.3
WHERE, on equality
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
A
SELECTstops meaning "every row".WHERE col = <value>filters, and
the value comes in two shapes the engine must tell apart: a bare number
is an INT literal, a'quoted'run of characters is a TEXT literal.
Comparing one to the other matches nothing.The count in the trailer is the count of rows printed, so a predicate
that matches nothing prints exactly one line:SELECT 0. Not a blank
line, not an error — an empty result is a perfectly good result.Predicates match rows, not the first row: two rows carrying the same
text both come back, in insertion order.4
WHERE, on comparisons
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Equality was one operator. Now the predicate carries five more —
<,>,<=,>=and<>— and the difference between them is a single
row: the one sitting exactly on the threshold.<leaves it out,<=takes it in,<>drops it and keeps everything
else. The checks always plant a row on the boundary, so an off-by-one in
the comparison shows up immediately.Rows come back in insertion order, not sorted — filtering decides which
rows, not in which order. Ordering arrives further up the ladder.5
AND and OR
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Predicates combine.
WHERE a AND bkeeps the rows both accept;WHERE a OR bkeeps the rows either accepts, and a row both accept is
still one row, not two.This is where a
WHEREclause stops being a comparison and becomes a
small tree: two operands, an operator between them, evaluated per row.
The two sides need not talk about the same column, or even the same
type — an INT test on one side, a TEXT test on the other.A combination nothing satisfies is
SELECT 0, exactly as before.6
UPDATE, counted honestly
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
UPDATE <table> SET <col> = <value> WHERE <predicate>— the first
statement that changes a row that already exists. The reply isUPDATE <n>, and<n>is the number of rows the statement actually
changed. Not the number in the table, not the number the parser hoped
for.The predicate is the same one
SELECTuses; the difference is what
happens to the rows it accepts. A predicate nothing satisfies isUPDATE 0and a table that looks exactly as it did before.The rows keep their places: an updated row is the same row with a new
value, so insertion order does not change. And because the data belongs
to the server, the next client to connect reads the new values.7
DELETE, and the rows that survive it
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
DELETE FROM <table> WHERE <predicate>removes the rows the predicate
accepts and answersDELETE <n>with how many went. The rows it does
not accept stay exactly as they were, in the order they were inserted —
a delete is not a rewrite of the table.The threshold in the checks always sits on a real row, so the boundary
matters here too: a row on the line survives a strict comparison.A predicate nothing satisfies is
DELETE 0. Deleting nothing is not an
error, and the nextSELECTproves the table is intact.8
ORDER BY, LIMIT and OFFSET
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Until now the answer came out in insertion order because nothing asked
otherwise.ORDER BY <col>asks: the rows are sorted by that column,
ascending by default, descending when the query saysDESC. Numbers
sort as numbers, text sorts as text.Sorting happens after filtering and before printing — it is a stage of
the plan, not a property of the table. The rows on disk never move.LIMIT <k>then prints at most the firstkof them, andOFFSET <j>skipsjbefore counting thosek. The trailer counts
what was printed, so a limited query saysSELECT k, not the size of
the table.9
COUNT, SUM, MIN, MAX and GROUP BY
+60 pts per passing check · +10 for completing the task
60
pts / check
+60 pts per passing check · +10 for completing the task
An aggregate is the first query whose answer is not a row of the table.
SELECT COUNT(*) FROM treads every row and prints one line holding one
number — and then, because the contract counts printed rows, the trailer
isSELECT 1.Several aggregates in one select list share one pass and one line:
SELECT SUM(v), MIN(v), MAX(v) FROM tprints<sum>|<min>|<max>. AWHEREstill runs first; the aggregate sees only the rows that survived
it.GROUP BY <col>turns one line into one line per distinct value of that
column, with the aggregates computed inside each group. The checks always
addORDER BY <col>so the groups come back in a defined order.10
Review: how you built the engine
Open-ended — a panel of 5 judges splits a 150-pt budget
P
T 150
pt budget
Open-ended — a panel of 5 judges splits a 150-pt budget
The rungs above proved the engine answers correctly. This one asks how
it is built, and a panel of judges reads the repository to answer:
architecture, performance, code craft, tests, and the way the project is
run.Nothing new to implement. Spend the time you have left where the panel
looks:- Architecture. Are parsing, planning and execution three things or
one? A statement should become a tree, the tree should become a plan,
and the plan should run — each step testable without a socket. If the
executor re-parses text per row, or the parser reaches into storage,
parts three through five will have to undo it. - Performance. What does one row cost as it flows through a filter,
a sort and an aggregate? Are you materialising the whole table to
answerLIMIT 3, or sorting it again for every query? Nothing here
needs an index yet — that is part four — but the shape you chose now
decides whether one can be dropped in. - Code quality. One operator added should mean one place changed.
Look for theifchain that grew a branch per keyword, the duplicated
comparison logic betweenSELECT,UPDATEandDELETE, the
functions that parse and execute in the same breath. The measurement
probes report duplication; the judge reads the rest. - Tests. A parser is the cheapest thing in this repository to test
directly: a statement in, a tree out, no server needed. Does the suite
do that, and does it cover the boundaries the ladder hammered on —
the row exactly on the threshold, the predicate that matches nothing,
the empty result? - Technical governance. Write down the decisions a successor would
otherwise have to guess: the grammar you support, how the plan is
represented, where type checking happens and what it does with a
mismatch. Declare your conventions in tooling — a formatter, a linter,
thetest:command that really runs.
When you are done, write
.ololo/sql-engine-done.mdwith a short
description of what you built and the decisions you made (at least 10
words).- Architecture. Are parsing, planning and execution three things or