Handmade PostgreSQL 3/5 — Order and Speed
Part three of the Handmade PostgreSQL campaign: Order and Speed. The
engine that learned to speak SQL in part one and to keep rows on disk in
part two now learns ORDER BY, LIMIT / OFFSET, aggregates, GROUP BY —
and a real index that a hundred thousand rows cannot embarrass.
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 here. This session continues parts one and two
directly: the first check replays their ground (create, insert, a restart,
WHERE, UPDATE) before anything new is graded, so bring the engine you
already built.
Part three adds exactly two acknowledgements and nothing else:
CREATE INDEX <name> ON <table> (<col>) prints exactly CREATE INDEX, and
creating an index whose name already exists prints an ERROR: line — after
which, as always, execution continues and the process exits 0.
Embedding SQLite, DuckDB, psql or any existing SQL engine is not building
one. The parser, the executor and the storage are yours.
The ladder
- Re-establish the engine; parts one and two still hold (10)
ORDER BY: scrambled in, sorted out (20)DESC, and TEXT in bytewise order (20)LIMITandOFFSETslice the sorted result (20)COUNT(*)tells the truth (20)SUM,MIN,MAXin one row (40)GROUP BY, counted and ordered (40)CREATE INDEX, remembered across a restart (20)- A hundred thousand rows without grinding (60)
1
Public
Reinvent the Wheel
handmade-postgresql-3-indexes
25 min
~19 per session
No
10–60
- database
- sql
- handmade-postgresql
- campaign
1
Re-establish the engine; parts one and two 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
Part three continues the engine from parts one and two. The invocation
has not changed:SQL on stdin, results on stdout, exit 0. Declare (or re-declare) your
commands in AGENTS.md (or README.md): arun:line with the exact
command that starts the engine, e.g.run: sh mydb.shorrun: python3 db.py, and atest:line with the command that runs
your test suite, e.g.test: sh test.sh. Both are captured into
session memory; every later check invokes exactly what you declared,
with a data directory appended. AGENTS.md wins when both declare one.The first check replays the ground already earned:
CREATE TABLE, two
inserts, then the process is restarted on the same data directory and
must still answer aWHEREand honour anUPDATE— persistence from
part two has to hold before anything new is graded. The output
contract is the one frozen in part one and it does not change here.Embedding SQLite, DuckDB or any existing SQL engine is not building
one — the parser, the executor and the storage are yours.2
ORDER BY: scrambled in, sorted out
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Implement
ORDER BY <col>on a single INT column, ascending. The rows
go in scrambled;SELECT <col> FROM <table> ORDER BY <col>prints
them smallest first, one value per line, thenSELECT <n>— the
contract from part one, only the row order is new.The check inserts random unique integers in an order that matches
neither ascending nor descending, so only a real sort passes. Worth
20 points.3
DESC, and TEXT in bytewise order
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Two more faces of
ORDER BY. WithDESCthe same INT column prints
largest first. And a TEXT column sorts too: plain bytewise comparison,
the waystrcmpwould — graded names are lowercase letters and digits
only, so there is no locale to argue with.Both checks insert in an order that matches neither direction of the
sort. Worth 20 points.4
LIMIT and OFFSET slice the sorted result
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Implement
LIMIT <k>andOFFSET <j>on the end of aSELECT ... ORDER BY .... Sort first, skipjrows, print at mostk, thenSELECT <n>wherenis the number of rows actually printed — the
count reflects the slice, not the table.The check inserts unique random integers scrambled and asks for a
random slice of the sorted result. Worth 20 points.5
COUNT(*) tells the truth
+20 pts per passing check · +10 for completing the task
20
pts / check
+20 pts per passing check · +10 for completing the task
Implement
SELECT COUNT(*) FROM <table> WHERE <cond>. An aggregate is
still aSELECT: it returns exactly one row — the count, printed as a
plain decimal on its own line — followed bySELECT 1, because one
row came back. A count of zero is the line0, notSELECT 0.The check inserts random values on both sides of a random threshold
and asks how many land above it. Worth 20 points.6
SUM, MIN, MAX in one row
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Implement
SUM,MINandMAXover an INT column, all three in one
select list:SELECT SUM(v), MIN(v), MAX(v) FROM <table>;returns a
single row — the three numbers joined by|in select-list order —
followed bySELECT 1. Plain decimals, no formatting. (NoAVGin
this campaign: integer division is a fight for another day.)Worth 40 points.
7
GROUP BY, counted and ordered
+40 pts per passing check · +10 for completing the task
40
pts / check
+40 pts per passing check · +10 for completing the task
Implement
GROUP BYwith a count per group:SELECT dept, COUNT(*) FROM <table> GROUP BY dept ORDER BY dept;
prints onedept|countrow per distinct value, groups sorted
bytewise ascending, thenSELECT <n>wherenis the number of
groups.The check interleaves rows from three random departments so
insertion order proves nothing — only real grouping and counting
passes. Worth 40 points.8
CREATE INDEX, remembered across 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
Implement
CREATE INDEX <name> ON <table> (<col>). A successful
statement prints exactlyCREATE INDEX— the first new
acknowledgement since part one. The index is part of the database:
after a restart it still exists, queries on the indexed column still
answer exactly, and creating an index under a name that already
exists prints anERROR:line — while the process, as always, exits
0 and keeps going.How you store and use the index is your business; that it survives
and that names collide honestly is graded. Worth 20 points.9
A hundred thousand rows without grinding
+60 pts per passing check · +10 for completing the task
60
pts / check
+60 pts per passing check · +10 for completing the task
The index earns its keep. The check loads one hundred thousand rows
in batched multi-row inserts, builds an index on the id column,
restarts the process, and fires a hundred random point lookups at it.
Every answer must be exact — the frozen contract, at scale.The whole exchange has to finish inside a generous two-minute bound.
This is a sanity bound, not a stopwatch: the check will not time your
algorithm to the millisecond, but it will notice a database that
grinds. If a lookup walks all hundred thousand rows through a parser
every time, you will feel it. Worth 60 points.