ADR-143: The Database Driver Checks the SQL at Build Time Against the Schema — std::db Is the Protocol, the Dialect Is a Package, and the Compiler Adds One Intrinsic

Status: Superseded by ADR-299 — its D1-D6 are D1-D6 there Amended by: ADR-254 — execute takes the connection as its subject, by_age.execute(db; min_age: 18), and the connection’s type carries its schema Date: September 19, 2026 Target Version: Nikaia 0.0.25 Related: Part II 10.5 (the dsl protocol and its hybrid binding), ADR-088 §3 (capturing an expression, left open there and closed here), ADR-069 (http leaves std), ADR-116 (asset("…") in a comptime initialiser), ADR-075 (what a build-time body may do), ADR-105 (Seq[T]), ADR-133 (an argument list of options alone)

1. Context

The ask, in marketing’s words, is “LINQ and an ORM”. Read carefully, LINQ is two things. LINQ to objects — where, select, orderBy over a collection in memory — is filter, map and fold over a Vec and a Seq, with lambdas, and nothing is missing but a keyword spelling nobody needs. LINQ to SQL is the interesting half: the compiler keeps u.age > 18 as an expression tree and a provider translates it into SQL at runtime. That is the half every user learns to distrust — the expression the provider cannot translate fails at runtime, the SQL it emits is nobody’s and reads that way, and the N+1 query is invisible in the source. ADR-088 §3 named expression capture as the one capability that would be genuinely new, needed by exactly this use case, and left it open.

Part II 10.5 already does the thing LINQ to SQL was for, and does it the other way round: the SQL is written as SQL, in the dialect the database runs, inside dsl mysql { … } eod; a grammar parses it while the program is built, so a typo is a build error; every :hole is a typed parameter the call passes by name. What it lacked was the schema — a row was a row, and a column the table does not have was found by the database.

One thing has to be said plainly first, because a draft of this answer got it wrong: the compiler knows no SQL. It cannot check a query, because it does not know the dialect, and it must not learn one — there are a dozen, and a company’s own database is a thirteenth. What the compiler knows is the dsl protocol: cut the block out at eod, hand the bytes to a grammar, take what the grammar declares. The grammar is library code in a package. So the dialect is already where it belongs; the record’s job is to put the schema and the result there too, and to keep std out of it.

2. Decisions

D1 — The dialect is a grammar in a driver package, and the compiler runs it as it runs every grammar.

dsl sqlite { … } eod names the grammar of the sqlite package, dsl postgres the grammar of another, and a vendor’s own database is a package its vendor writes without a change here. The compiler does for the block what it does for every DSL: it parses the rest of the file in parallel, hands the slice to the grammar at build time, and builds the statement’s types from what the grammar declares. Nothing SQL-shaped is in the compiler, and nothing will be.

D2 — A grammar declares a result column with meta::column(name, type), and the compiler derives the row type.

The hybrid binding of Part II 10.5 has two intrinsics — meta::capture for this variable, here, at build time and meta::parameter for a named argument at runtime — and the compiler builds the statement’s parameter type from the second. This record adds the third and last:

intrinsic when it resolves what the compiler builds from it
meta::column(name, type) build time, declared by the grammar the statement’s row type: one field per declared column, named and typed

A statement’s value is a prepared statement whose execute takes the parameters by name and returns Seq[Row] (ADR-105), where Row is a struct the compiler wrote from the columns: SELECT name, email FROM users gives a row with name: String and email: String? if the schema says the column is nullable, and u.emial is NK1117, nothing declares emial on the row type. SELECT * declares every column of the table, in the schema’s order. A computed column without a name (count(*)) is a build-time error from the grammar asking for AS, because a field needs a name. The row type has the field names as its whole interface; it is not a struct the program can declare a second time, and it needs no use.

D3 — The schema is a build-time input of the block, and the driver’s grammar reads it.

let app = comptime asset("schema.sql")            // ADR-116: read while the program is built

let by_age = dsl sqlite(schema: app) {
    SELECT name, email FROM users WHERE age >= :min_age
} eod

for u in by_age.execute(min_age: 18) { println(u.email ?? "-") }

A dsl block may take build-time arguments, named, in parentheses after the grammar’s name — options and nothing else, so no ; (ADR-133). Each resolves at build time to a comptime value of the surrounding scope: it is meta::capture’s meaning given an explicit place, for a text like SQL that has no room for a name inside it. The grammar declares which arguments it takes and their types, as it declares its parameter holes, and a block that leaves one out or passes one it does not know is refused at the block in the grammar’s words.

The sqlite grammar takes schema, parses it with the driver’s own DDL grammar — the small subset that declares tables, columns, types and nullability — and checks the statement against it: a table or column the schema lacks is a build error at the query, a hole compared with a column of another type is one, and the columns it declares carry the schema’s types. A live database is never opened while the program is built; that is ADR-075’s rule, and the schema is a file in the repository for that reason. Whether the database a program opens at runtime still matches that file is the driver’s to verify at open, and a driver that does so throws a named error there rather than failing on the first query.

D4 — std::db is the protocol and nothing more; every dialect lives in a driver package.

std::db holds what two drivers must agree on without depending on one another: the Connection and Transaction traits, the Statement protocol the compiler’s prepared statement speaks, and the values a row may carry — the language’s own numbers, bool, String, Bytes, and T? for NULL. No grammar, no SQL, no dialect, no binding to any database library. A driver package — sqlite, postgres, a vendor’s own — brings the dialect grammar, the DDL grammar, the connection, and the target adapter (the runtime’s I/O thread on native, a Web Worker on the web, as the section already says).

This is ADR-069’s argument read from the other side. http left std because nothing in std depended on it. db’s protocol stays because a second party depends on it: two drivers and every program that wants to change drivers need the same traits, and they must live where neither driver owns them. The protocol is small, the dialects are not, and the line between them is the line between std and a package.

D5 — No expression capture, and no ORM.

The capability ADR-088 §3 left open is not added: no expression is captured as a tree, no provider translates one, and the readability argument of Part II 10.4 — you can tell what a piece of code does by reading it — holds for a query as for everything else. A query in memory is the Seq combinators. There is no object-relational mapper: the row type is the mapping, a program that wants a User writes User { name: u.name, … } or with, and a migration is a SQL file the driver’s DDL grammar can read.

Dynamic SQL — a WHERE assembled at runtime — is not refused: a driver offers raw(text) that returns untyped rows and is Untrusted on the way in, and a program that needs it says so by writing it.

D6 — The sentence for marketing.

Nikaia does not translate your code into SQL. You write the SQL your database runs, and your database driver checks it while the program is built — the syntax, every parameter, every column against your schema — and the compiler turns the result into a type, so a misspelled column is a compile error and a row has fields. What LINQ promised, without the provider; and a vendor adds its database by writing a package.

3. Consequences

4. What this does not decide

5. What is built

Nothing. The order of work, after std::db’s protocol and before the rest of the “query DSL”:

  1. meta::column and the derived row type, on any grammar that declares one;
  2. build-time arguments on a dsl block, resolved from comptime values;
  3. std::db’s traits and values;
  4. the sqlite driver package: the dialect grammar with meta::parameter and meta::column, the DDL grammar, the check against the schema, the connection on the I/O thread;
  5. an example: a schema file, two statements, a typed row printed, and a misspelled column refused at build time.

#89 carries it.

This site is open source. Improve this page.