std::db Is the Protocol, the Dialect Is a Package, and the Compiler Adds One IntrinsicStatus: 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)
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.
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.
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.
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.
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.
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.
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.
u.email is checked, completed and documented.std::db is a page of traits and stays one; the drivers carry the weight.dsl block; std::db’s traits; and per driver a
dialect grammar, a DDL grammar and a connection — the first of which, for
SQLite, is the roadmap’s “query DSL”, now with a content.SELECT … INTO User);
today it is a literal or with, and that is enough until it is not.sqlite driver stands on; the driver’s record
decides it, by ADR-038 §1’s standard, and not this one.open.Nothing. The order of work, after std::db’s protocol and before the rest of
the “query DSL”:
meta::column and the derived row type, on any grammar that declares one;dsl block, resolved from comptime values;std::db’s traits and values;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;#89 carries it.