ADR-299: A Database Statement Is SQL the Driver Checks at Build Time Against a Schema, and It Runs on a Connection Typed by That Schema

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)

1. Question

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:

2. How others do it

3. Options, weighed

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)

4. Decision

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

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.

D2 — A grammar declares a result column with 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.

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

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.

D4 — 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.

D5 — No expression capture, and no ORM.

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.

D6 — The sentence for the reader who asked for LINQ.

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.

D7 — The connection is the statement’s subject.

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.

D8 — 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.

D9 — A connection’s type carries the schema it was opened for.

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.

D10 — A statement handed a connection of another schema is refused while the program is built.

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.

D11 — The same schema is the same build-time value.

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.

D12 — A column is a field of its table’s group, and a flat field where its name is unique.

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.

D13 — A name two fields would share is no flat field, and the message names the ways.

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.

D14 — A column whose name cannot be a field is refused at the query, asking for 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.

D15 — A block may name the struct its rows are: 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 — Columns match fields by name, and only by name.

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.

D17 — Every column has a field and every field a column, unless the block says otherwise.

A column without a field is NK1219; a field without a column is NK1220. Two build-time arguments relax it, each false by default:

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.

D18 — A column that may be 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.

D19 — A target struct’s field is a type a column can be, or a group.

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.

D20 — A block names a grammar from a package as 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.

D21 — A block names the entry it runs where the grammar has more than one, unless the grammar marks one 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).

5. Consequences

6. What this does not decide

This site is open source. Improve this page.