From 5c61be731cd2b73eb051b98dccbfd38c466a6fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aybars=20Mete=20Kele=C5=9F?= Date: Tue, 4 Aug 2026 17:01:07 +0300 Subject: [PATCH] docs: document the engine, its SQL surface, and the design decisions --- README.md | 219 ++++++++++++++++++++++++++++++++++++++++++- docs/design-notes.md | 139 +++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 docs/design-notes.md diff --git a/README.md b/README.md index 08231bc..11c3b17 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,220 @@ # sql-query-engine -A small SQL query engine in Go: parser, volcano-model executor, and a hash join. Query CSV files from a REPL. +A small SQL query engine in Go. It lexes and parses a `SELECT` subset, plans it, +and executes it with a volcano (pull-based) operator model including a real hash +join. Tables are CSV files described by a small schema; you query them from a +REPL. -Work in progress. +**What it is:** a readable, tested implementation of how a query gets from text +to rows — parsing, planning, and execution — with its results checked against +SQLite on tens of thousands of generated queries. + +**What it is not:** a database. There is no storage engine, no persistence, no +transactions, no indexes, and no query optimizer. See [Non-goals](#non-goals). + +## Features + +- **Volcano execution model** — every operator pulls rows from its child through + `Next()`, so `LIMIT` stops the scan early without any operator knowing that + `LIMIT` exists. +- **Hash join** — `INNER JOIN` runs build/probe over a hash table rather than a + nested loop, and handles duplicate and NULL keys correctly. +- **Three-valued NULL logic** — comparisons involving NULL are unknown, and + `WHERE` keeps only rows that are exactly true. +- **Aggregates** — `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` with `GROUP BY` and + `HAVING`. +- **Plan-time type checking** — unknown columns, ambiguous references, and type + mismatches are rejected before a single row is read. +- **Differential testing against SQLite** — a seeded generator produces queries + and both engines must agree. + +## Quick start + +```bash +go run ./cmd/minisql -data examples +``` + +The bundled `examples/` directory holds this schema: + +``` +users(id INT, name TEXT, age INT, city TEXT) +orders(id INT, user_id INT, total INT) +``` + +backed by `users.csv` and `orders.csv`: + +```csv +1,alice,30,berlin +2,bob,15,paris +3,carol,40,berlin +4,dan,,london +``` + +A session (output is verbatim): + +``` +minisql — one SQL statement per line (Ctrl-D to exit) +SELECT name, age FROM users WHERE age >= 18 ORDER BY age DESC +name | age +------+---- +carol | 40 +alice | 30 +(2 rows) + +SELECT city, COUNT(id), AVG(age) FROM users GROUP BY city HAVING COUNT(id) > 1 +city | COUNT(id) | AVG(age) +-------+-----------+--------- +berlin | 2 | 35 +(1 row) + +SELECT users.name, orders.total FROM users JOIN orders ON users.id = orders.user_id ORDER BY orders.total DESC +name | total +------+------ +alice | 300 +carol | 250 +alice | 100 +(3 rows) + +SELECT name, age FROM users WHERE age IS NULL +name | age +-----+----- +dan | NULL +(1 row) + +SELECT bogus FROM users +error: unknown column "bogus" +``` + +Note what the join left out: order 13 has an empty `user_id`, and a NULL key +never matches, so it appears in no result row. + +## Supported SQL + +``` +SELECT <* | expr [, expr ...]> +FROM +[[INNER] JOIN
ON = ] +[WHERE ] +[GROUP BY [HAVING ]] +[ORDER BY [ASC | DESC] [, ...]] +[LIMIT ] +``` + +Expressions support column references (`name`, `users.name`), literals, +`= <> < <= > >=`, `+ - * /`, `AND OR NOT`, `IS [NOT] NULL`, and the aggregate +functions `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` (including `COUNT(*)`). + +A projection that is not a bare column is labelled with its source expression, +so `SELECT COUNT(id)` reports a column named `COUNT(id)`. + +## Data format + +A schema file declares one table per line as `name(col TYPE, ...)` with types +`INT`, `FLOAT`, `TEXT`, or `BOOL`. Each table reads `.csv` from the same +directory. CSV files have **no header row**, and an **empty field is NULL** — +which is how the examples above get a NULL age and a NULL join key. + +## Architecture + +``` +SQL text + │ lexer tokens + ▼ + parser ─────────────▶ AST + │ + ▼ planner (resolve names, check types against the catalog) +operator tree + │ + ▼ executor (volcano: each Next() pulls from its child) + rows ─▶ REPL prints a table +``` + +The planner resolves every column reference to an index and type before +execution, so operators work with positions rather than names and cannot fail on +an unknown column mid-scan. + +The reasoning behind the execution model, the hash join, the NULL semantics, and +oracle-based testing is written up in +[docs/design-notes.md](docs/design-notes.md). + +``` +cmd/minisql/ entrypoint +internal/lexer/ tokenizer +internal/parser/ recursive-descent parser +internal/ast/ syntax tree and its rendering +internal/catalog/ table schemas +internal/csv/ schema-aware CSV reader +internal/plan/ AST → operator tree, name and type resolution +internal/exec/ operators (scan, filter, project, hash join, sort, limit, aggregate) +internal/value/ typed values and three-valued logic +internal/repl/ read-eval-print loop +internal/difftest/ test-only: SQLite oracle, query generator, differential runner +``` + +## Testing + +```bash +go test -race ./... +``` + +Three layers: + +- **Golden tests** per package — a query and fixture in, exact rows out. +- **A NULL semantics suite** (`internal/plan/null_test.go`) stating three-valued + behaviour explicitly: unknown comparisons excluded by `WHERE`, `NOT unknown` + still excluded, NULL join keys dropped, aggregates skipping NULLs, and + `GROUP BY` collapsing NULL keys into one group. +- **Differential tests against SQLite** — a seeded generator emits queries from + a bounded grammar, both engines run them over identical data, and the results + must match as multisets. + +Run a wider sweep or replay a specific run: + +```bash +go test ./internal/difftest/ -run Differential -seed 31337 -queries 5000 +``` + +Every failure prints the exact query and seed. The engine has been checked +across seeds 1, 2, 3, 99, 777, 12345, and 31337 at 5,000 queries each — 35,000 +comparisons with no disagreements. Roughly a quarter of the generated queries +are joins, so the hash join is genuinely covered rather than incidentally +touched. + +Comparison ignores row order, because SQL leaves it unspecified without +`ORDER BY`, but duplicate counts still have to match. `ORDER BY` correctness is +covered by the deterministic golden tests instead. + +`modernc.org/sqlite` is pure Go and is imported only by `internal/difftest`, +which nothing outside its own tests imports, so the engine binary carries no +SQLite driver: + +```bash +go list -deps ./cmd/minisql | grep -c sqlite # 0 +``` + +## Known divergences from SQLite + +These are deliberate design choices, not bugs, and the query generator avoids +them so they cannot mask a real disagreement: + +| Area | SQLite | This engine | +|---|---|---| +| `/` on two integers | integer division (`5/2` = 2) | float division (2.5) | +| comparing a text column to a number | coerced dynamically | rejected at plan time | +| booleans | stored as integers 0/1 | a distinct `BOOL` type | + +## Non-goals + +No storage engine or persistence, no transactions, no indexes, no query +optimizer, no subqueries, no `OUTER`/`CROSS` joins, no `DISTINCT`, no window +functions, and no DDL or DML. The engine reads CSV files and answers `SELECT` +queries. + +## Requirements + +Go 1.25 or newer. The engine itself uses only the standard library; the SQLite +driver used by the differential tests is what sets the Go version floor. + +## License + +MIT diff --git a/docs/design-notes.md b/docs/design-notes.md new file mode 100644 index 0000000..087653e --- /dev/null +++ b/docs/design-notes.md @@ -0,0 +1,139 @@ +# Design notes: how a query becomes rows + +Most of what makes a query engine interesting is invisible from the outside. The +SQL surface here is small on purpose — the point was never to support a lot of +syntax, it was to build the machinery that turns text into rows and to be able +to defend every part of it. These are the decisions worth writing down, and the +ones I would want to be asked about. + +## Operators pull, they do not push + +Execution is a tree of operators, and the obvious way to run one is bottom-up: +scan the table into a slice, filter the slice, sort it, take the first five. +That works and it is easy to reason about. It also reads a million rows to +answer `LIMIT 5`. + +Instead every operator implements one method: + +```go +type Operator interface { + Schema() Schema + Next() (value.Row, bool) +} +``` + +`Next()` returns one row and pulls whatever it needs from its child. `Limit` +counts to five and then returns `false`; `Filter` above the scan stops being +asked for rows; the scan stops reading. No operator knows that `LIMIT` exists, +and nobody had to write the optimization — it falls out of the shape. This is +the volcano model, and the property that makes it worth the indirection is +exactly this: **the consumer controls how much work the producer does.** + +The cost is that some operators cannot be lazy. `Sort` has to see every row +before it can emit the first one, so it materializes. So does the build side of +a join. Being explicit about which operators block and which stream is most of +what it means to understand an execution plan. + +## Hash join, and why the obvious join is the wrong one + +A nested-loop join is four lines: for each row on the left, walk the right side +looking for matches. It is also O(n·m), and the constant is a full re-scan of +one input per row of the other. + +The hash join splits the work into two phases. Drain the right input once, +hashing each row by its join key into a table. Then stream the left input, +hashing each row's key and probing. One pass over each input, O(n+m). The price +is memory: the entire build side is resident while probing. That is the real +trade, and it is why the build side should be the smaller input — a choice a +query optimizer would make and this engine does not, since it always builds from +the right. + +Two details that are easy to get wrong and that the tests pin down: + +**Duplicate keys.** A key maps to a *slice* of rows, not one row. If two orders +belong to the same user, probing that user must emit both. Getting this wrong +produces a join that silently drops rows, which is the kind of bug that survives +casual testing because the result still looks like a plausible table. + +**NULL keys never match.** A row whose join key is NULL is dropped during build +and skipped during probe. This is not an optimization, it is the semantics: +`NULL = NULL` is unknown, not true, so a NULL key matches nothing — including +another NULL. The hash table would happily group them together if you let it, +which is exactly why it is worth being deliberate. + +## Three-valued logic, and the one place SQL breaks its own rule + +NULL is not a value, it is the absence of one, so any comparison with it answers +"unknown" rather than true or false. That gives three truth values, and the +connectives follow from it: `false AND unknown` is `false` (nothing can rescue +it), while `true AND unknown` is `unknown`. `NOT unknown` is still `unknown` — +negating ignorance does not produce knowledge. + +The consequence that surprises people is what `WHERE` does with it. A predicate +that evaluates to unknown does not pass: + +```go +if !v.IsNull() && v.Type == value.TBool && v.B { + return row, true +} +``` + +So `WHERE age > 10` and `WHERE NOT (age > 10)` both exclude a row with a NULL +age. Between them they do not cover the table, which looks like a bug until you +remember that neither statement is true when the age is unknown. + +Then `GROUP BY` does the opposite. Grouping puts every NULL city in a single +group, even though `NULL = NULL` is not true. SQL treats NULL as a value here +and as an unknown everywhere else, and the reason is pragmatic rather than +principled: a `GROUP BY` that scattered each NULL into its own group would be +useless. The engine implements both behaviours because both are correct — they +are just correct about different things. `internal/plan/null_test.go` states all +of it as executable specification, because this is the part of SQL where "I +think it works" is not good enough. + +## Resolving names before reading rows + +The planner walks the AST and turns every column reference into an index into +the row, checking types as it goes. An unknown column, an ambiguous bare name +across two joined tables, or a comparison between text and a number all fail +here — before the first row is read. + +This buys two things. Errors are reported against the query rather than +appearing halfway through a scan, which is the difference between "unknown +column `bogus`" and a partial result followed by a failure. And operators become +simpler: they index into a row instead of looking up names, so the hot path has +no map lookups and no error handling for something the planner already proved +cannot happen. + +## Testing against something that already knows the answer + +Golden tests only find the bugs you thought of. You write the query, you work +out the expected rows, and you assert them — so the test encodes the same +understanding that produced the code, including its mistakes. + +An oracle breaks that loop. The same data is loaded into SQLite, a generator +emits queries from a bounded grammar, both engines run them, and the results +have to match. SQLite has been correct for two decades; when it disagrees with +this engine, this engine is almost certainly wrong. Around 35,000 generated +queries have gone through this without a disagreement. + +Two things make it honest rather than decorative. + +The generator has to reach the hard parts. My first version only queried a +single table, which meant the hash join — the most intricate operator here — +never appeared in a single comparison. "Verified against SQLite" would have been +technically true and substantially misleading. The runner now asserts that joins +are a real share of what gets checked, so that particular self-deception cannot +come back quietly. + +And when a disagreement does turn up, there are exactly two honest responses: +the engine is wrong and gets fixed, or the divergence is deliberate and gets +written down in the README. Widening the numeric tolerance until the failure +disappears is a third option that is always available and always wrong. The +comparison does round to six significant digits, which absorbs the last-bit +noise between two different ways of computing an average — but the cells carry +type tags, so the text `35` never compares equal to the number 35, and NULL uses +a sentinel no generated literal can produce. Where the two engines genuinely +disagree by design — integer division, cross-type comparison, booleans — the +generator does not emit the construct at all, so a real bug cannot hide behind a +known one.