Status: Accepted
Date: October 3, 2026 (D12-D14: October 5, 2026; D15-D19: October 6, 2026, #114; D20: October 6, 2026, #485)
History: the records this one replaces are archived in docs/history/adr; read them only for how the decision was reached
Answers: the ask for “LINQ and an ORM”, read as LINQ to SQL; ADR-304
§3’s open question, expression capture, closed by not adding it;
open-decisions.md’s Where the connection goes when a checked statement
runs - the owner chose the connection is the subject, and added that the
connection is typed by its schema. Carried by #89
Related: Part II 10.5 (the dsl protocol and its hybrid binding) ·
Part I 5.1 (Subject ; Config) ·
ADR-289 (http leaves std; the argument read from the other side) ·
ADR-287 (no live database while the program is built) ·
ADR-310 (asset("…") in a comptime initialiser) ·
ADR-105 (Seq[T]) ·
ADR-133 (an argument list of options alone) ·
ADR-296 D5 (the typed spread, which a driver does not write for execute) ·
ADR-010 D1 (fail closed)
How does a program talk to a SQL database so that a mistake in a query is found while the program is built, and the rows it gets back have fields?
Two halves:
dsl protocol of Part II 10.5: cut the
block out at eod, hand the bytes to a grammar, take what the grammar
declares. The protocol alone has no schema and no result.where, select, orderBy over a collection in
memory - is filter, map and fold over a Vec and a Seq here, with
lambdas; nothing is missing but a keyword spelling.u.age > 18 as an expression tree, and a provider
translates it to SQL at runtime. An expression the provider cannot translate
fails at runtime, the SQL it emits is nobody’s, and an N+1 query is
invisible in the source.dsl mysql { … } eod, parsed while the program is built, with
every :hole a typed named parameter - but a column the table lacks is
found only by the database.| Question | Option | Verdict |
|---|---|---|
| Who checks the SQL | the compiler, with a built-in dialect | rejected - a dozen dialects, and a vendor’s own would need a compiler change |
| the driver’s grammar, run by the compiler as every grammar is | chosen (D1) | |
| Queries from code | expression capture and a provider (LINQ to SQL) | rejected - fails at runtime, SQL nobody wrote; ADR-304 closed without it (D5) |
| an ORM | rejected - the row type is the mapping (D5) | |
| Where the dialect lives | in std |
rejected - nothing a second party needs |
in a driver package; std::db only the protocol two drivers share |
chosen (D4) | |
| Where the connection goes | the statement’s subject, by_age.execute(db; …) |
chosen by the owner (D7) |
| a method of the connection | not chosen | |
| a build-time argument of the block | not chosen - a connection is a runtime value | |
| Statement and connection agree | by a check at runtime | rejected - the error the build promised to find |
| by the connection’s type carrying its schema | chosen by the owner (D9-D11) |
Changed by D20: a block names the package and the grammar, dsl sqlite::Sql.
dsl sqlite::Sql { … } eod names the grammar Sql of the sqlite package,
dsl postgres::Sql the grammar of another, and a vendor’s database is a
package its vendor writes without a change here. The compiler 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.Changed by ADR-323 D1-D2: the type is a std::meta::Type; and by D12-D14 below: how the row’s fields are named.
Beside meta::capture (this variable, here, at build time) and
meta::parameter (a named argument at runtime), the third and last
intrinsic:
| 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 a
connection as its subject (D7) and 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 name: String and
email: String? where the schema says the column is nullable, and u.emial
is NK1117. SELECT * declares every column, in the schema’s order. A
computed column without a name (count(*)) is a build error from the grammar
asking for AS. The row type is not a struct the program can declare a second
time, and it needs no use.
Changed by ADR-287 D20: the schema is bound as comptime APP = asset(…); there is no comptime expression.
comptime APP = asset("schema.sql") // ADR-310: read while the program is built
let db = sqlite::open("app.db"; schema: APP) // D9
let by_age = dsl sqlite::Sql(schema: APP) {
SELECT name, email FROM users WHERE age >= :min_age
} eod
for u in by_age.execute(db; min_age: 18) { println(u.email ?? "-") }
A dsl block may take build-time arguments, named, in parentheses after
the grammar’s name - options only, so no ; (ADR-133). Each
resolves at build time to a comptime value of the surrounding scope:
meta::capture’s meaning given an explicit place. The grammar declares which
arguments it takes and their types; 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 parses schema with the driver’s own DDL grammar (tables,
columns, types, 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 declared columns carry the schema’s
types. A live database is never opened while the program is built
(ADR-287), which is why the schema is a file in the repository.
Whether the database opened at runtime still matches that file is the driver’s
to verify at open, throwing 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.Changed by ADR-338 D2: the protocol is the official package db, not a module of std.
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 numbers, bool, String, Bytes, and T? for NULL. No grammar,
no SQL, no dialect, no binding to a database library. A driver package 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). http left
std because nothing in std depended on it (ADR-289); db’s
protocol stays because a second party - two drivers and every program that
changes drivers - depends on it.
No expression is captured as a tree and no provider translates one; a query in
memory is the Seq combinators. 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 DDL grammar can read. Dynamic SQL is not refused: a
driver offers raw(text), which returns untyped rows and is Untrusted on the
way in.
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.
by_age.execute(db; min_age: 18): the statement is the receiver, the
connection stands before the ; and the parameters after it, as Part I 5.1’s
Subject ; Config reads anywhere else. A statement is not tied to a
connection for its life, so one prepared statement serves every connection of
a pool.
std::db declares the Connection trait, and the compiler writes the conversion.Changed by ADR-338 D2: the protocol is the official package db, not a module of std.
The trait has one method: run this text with these named db::Values, and
hand back rows of db::Value. A driver implements it. The compiler turns a
row of values into the row type it derived (D2), in the one place that derived
it, so a driver never names a per-statement type and the conversion cannot
disagree with the row type. A driver does not write execute(self;
...args: Self::dsl) for a checked statement; the typed spread of
ADR-296 D5 stays for every other DSL a library drives.
A driver’s open takes the schema - sqlite::open("app.db"; schema: APP) -
the same build-time value a statement’s block names (D3), and the connection
it hands back is typed by that schema. D3’s check at open that the database
matches the file is then part of the type.
by_age was checked against a schema and db is typed by one; where they are
not the same schema, by_age.execute(db; …) is refused at the call, naming
both. Swapping two connections in a program with two databases fails the
build.
Two blocks that name APP are checked against one schema; a block that names
APP and a connection opened with reporting are two, even where the two
files say the same thing - the fail-closed reading (ADR-010 D1).
A program that means one database names one schema.
let q = dsl sqlite::Sql(schema: APP) {
SELECT a.id, b.id, a.name FROM accounts AS a JOIN banks AS b ON a.bank = b.id
} eod
for u in q.execute(db) {
println(f"{u.name} {u.a.id} {u.b.id}") // u.id: NK1107, two tables have one
}
The row has one field per table of the query, named by its alias or, without
one, by the table’s name, holding that table’s columns in the result; and one
flat field per column whose name nothing else in the row has. SELECT name,
email FROM users gives u.name and u.users.name. A column named with AS
is a flat field of that name.
Where two columns share a name, or a column’s name is a table’s or an alias’s,
neither is a flat field. Reading one is NK1107, and the help names each
qualified path and AS: id is in two tables here: write u.a.id or
u.b.id, or name it with AS. A field is never renamed by the compiler.
AS.A name with a character a field cannot have ("created-at", "Order Date")
is refused by the grammar while the program is built, as an unnamed computed
column is (D2). A reserved word is a field like any other name: after . it is
a name (ADR-298 D3). There is no automatic spelling of an
unusual name.
dsl … -> T { … } eod, and the compiler checks the columns against it.Added October 6, 2026 (#114).
struct Contact { name: String, email: String? }
let q = dsl sqlite::Sql(schema: APP) -> Contact {
SELECT email, name FROM users
} eod
for c in q.execute(db) { send(c) } // c is a Contact
-> T stands where a function’s result type does, after the build-time
arguments. T is a struct the program declares; each row is a T, so a
function that takes a T takes it as it is. The compiler matches the columns
the grammar declared (D2) against T’s fields, while the program is built; the
grammar is not told about T. Without -> T the row is D12’s.
Rejected: the row struct written by the compiler as a second, structurally
equal type, which the program converts field by field - a type is nominal here
(NK1102), and the conversion is code nobody needs to read.
D16-D18 are one mapping rule with a grammar’s members[S], stated once in
Part II 10.8 (ADR-grammar-2 D3, D9); SQL keeps its strict
settings and checks them while building.
The order of the columns is not read. A field matches the flat column of its name (D12); a field whose type is a struct and whose name is an alias or a table of the query matches that table’s group, and its fields are matched against that table’s columns by the same rules, recursively:
struct Account { id: i64, name: String }
struct Bank { id: i64 }
struct Line { a: Account, b: Bank }
dsl sqlite::Sql(schema: APP) -> Line {
SELECT a.id, a.name, b.id FROM accounts AS a JOIN banks AS b ON a.bank = b.id
} eod
What a field is decides which it matches: a column is a scalar, so a field of a
scalar type is a column and a field of a struct type is a group. A flat field
whose name two tables’ columns share is refused (NK1222), and the help names
AS, a group field, and the query without -> T. The compiler never guesses
by position or type.
A column without a field is NK1219; a field without a column is NK1220. Two
build-time arguments relax it, each false by default:
extra_columns: true - a column without a field is not read.missing_fields: true - a field without a column is null. Only a T? field
can be; a field that is not one stays NK1220.dsl sqlite::Sql(schema: APP, extra_columns: true) -> Contact { SELECT * FROM users } eod
Both belong to the compiler, not to the grammar: they are taken from any dsl
block with -> T, never handed to the grammar, and a grammar that declares
either name is refused (NK1223). Without -> T either reaches the grammar,
which refuses it as an argument it does not know (D3).
Rejected: relaxed by default, which turns a renamed column into a field
that is silently null; and one flag for both directions, which grants the
risky direction to a block that only wanted the harmless one.
NULL goes only into a T? field, and a NULL that arrives anyway is an error.A column the grammar declares Maybe(T) (ADR-323) into a field
of type T is NK1221, and the help names T?, COALESCE(…) and
WHERE … IS NOT NULL. A column that cannot be NULL may go into a T? field.
Otherwise a column goes into a field of its own type.
How exactly a driver knows that a column cannot be NULL is the driver’s
(the schema, an outer join, a WHERE); a more precise driver refuses less and
never changes the rule. Where the conversion (D8) meets a NULL for a field
that is not a T? while the program runs, execute fails with an error that
names the field, the column and its table - a driver’s mistake becomes a
named error, never a value the type promised away.
A scalar ADR-323 D1 names, its T?, or a struct for a group
(D16). Any other field type is NK1218, Shared[T] among them: whether the
conversion may share equal values is
#475’s, and a refusal
can later become a permission without changing a program.
package::Grammar, always.Added October 6, 2026 (#485).
use sqlite
let q = dsl sqlite::Sql(schema: APP) { SELECT name FROM users } eod
The grammar is reached as every other name from a package is (Part I 9.2): the
package, ::, the pub grammar’s name. There is no short form, not even where
the package and its grammar share a name or the package has one grammar: a
package that gains a second grammar, or renames its only one, changes no
program that names the one it has. A grammar the program declares itself is
named bare, as any of its own names. A block that names a package where a
grammar stands is NK1228, and the help names the package’s pub grammars.
Rejected: a bare name for a package’s grammar, the package’s name standing
for it - a block then reads differently by what the package contains; and use
bringing a grammar’s name in, the one exception to Part I 9.2.
main.dsl Json::value { … } eod runs the entry value, written as the call
Json::value(text) is; from a package, dsl json::Json::value { … } eod. A
grammar with one entry needs no name: dsl Num { 42 } eod. A grammar with
several may mark one main entry rule, and dsl Json { … } eod runs it. A
block over a grammar with several entries and none marked main is NK1242,
naming the entries and both ways out; a grammar with two main entries is
NK1243. A::B is a rule where A is a grammar and a grammar where a is a
package, and one name denotes one thing (Part I 9.1), so the path is never read
both ways.
Why. The block is the call with its text written in place, so it is spelled as the call is. The short form stays where it cannot choose wrong: one entry, or the one the author named. A grammar that gains a second entry and marks none breaks its bare blocks loudly, naming the entries, never silently.
Decided October 9, 2026 (#518).
u.email is checked, completed and documented.std::db is a page of traits and stays one; its first trait is
Connection (D8), declared where the checker reads it, since an impl of an
undeclared trait is a refusal.dsl block; std::db’s traits; a connection type
that carries a schema; and per driver a dialect grammar, a DDL grammar and a
connection.db::Value carries grows the
Connection trait by a method - an addition, not a break.-> T
(D18 asks for the column’s own type until it is decided).std::db declares when it is built.sqlite driver stands on - the driver’s record,
by ADR-303’s standard.open.open, and its spelling is not decided
here.app itself is written at file level - a top-level let is not an
item; see open-decisions.md, How a schema is bound while the program is
built.