Issue #475: After the MVP: a query’s rows without repeated values in memory (Shared fields, zero-copy rows, SQLite specifics)

Open and answer this issue on GitHub.

performance, note · opened 2026-10-06 · updated 2026-10-06 · 1 comment

After the MVP. Nothing here is decided, and nothing here is built for the MVP. This issue keeps the ideas from the decision round on #114 / ADR-299, so that the rules for rows and target structs, decided now, leave these optimisations open.

The problem

Take a chain of many-to-one links: accounts.user → users.address → addresses.country → countries { id, name, code }. A query asks for every account together with its country’s name and code:

struct Land  { name: String, code: String }
struct Konto { id: i64, iban: String, c: Land }

let q = dsl sqlite(schema: APP) -> Konto {
    SELECT a.id, a.iban, c.name, c.code
    FROM accounts AS a
    JOIN users AS u ON a.user = u.id
    JOIN addresses AS d ON u.address = d.id
    JOIN countries AS c ON d.country = c.id
} eod

ORMs solve this with an identity map. Nikaia has no ORM (ADR-299 D5), so the question is whether the language can still let a program avoid the repetition.

What works today, by hand

A program can run two queries and assemble the result itself with Shared[T] (Part I 6.2):

struct Land       { id: i64, name: String, code: String }
struct KontoZeile { id: i64, iban: String, land: i64 }
struct Konto      { id: i64, iban: String, land: Shared[Land] }

let nach_id = laender.execute(db).map(|l| (l.id, Shared(l))).collect()
let alle = konten.execute(db)
    .map(|z| Konto { id: z.id, iban: z.iban, land: nach_id[z.land] })
    .collect()

Ideas

1. Shared[T] on a field or a group of a target struct

struct Konto {
    id:    i64,
    iban:  String,
    c:     Shared[Land],     // a group: equal countries once
    stadt: Shared[String],   // one column, no key: equal names once
}

The compiler-written conversion (ADR-299 D8) would merge equal values within one execute.

Why value equality is enough, without a notion of record identity. A Shared[T] cannot be changed, and as far as the specification goes a program cannot ask whether two handles point at the same memory (there is no ptr_eq). So merging is invisible: it saves memory and means nothing.

A primary or unique key from the schema would then only speed up the comparison (compare the id instead of every field), not define it. The DDL grammar reads CREATE TABLE and so sees PRIMARY KEY and UNIQUE, but meta::column(name, type) does not pass them on. A call such as meta::key(table, columns) would.

Costs:

Today a group field of type Shared[T] is refused because it is not a struct. Turning that refusal into a permission later changes no existing program, so the target-struct rules do not block this.

2. Rows as views into the driver’s buffer

Where merging happens matters less than when:

The second needs the Connection trait to hand back a row as a view into the driver’s buffer, valid until the next row. That is what SQLite (sqlite3_column_text, valid until the next sqlite3_step) and libpq hand back anyway. Without Shared it also saves one copy per cell: today it would be into a db::Value and then into the field.

Open:

3. What the server delivers

Database How a row arrives "Deutschland" for 100 000 accounts
SQLite (in process) a pointer into SQLite’s buffer, valid until the next row rewritten 100 000 times into the same buffer
PostgreSQL one DataRow message per row, each column as length and bytes; no dictionary in the standard protocol, as far as known 100 000 times over the wire
MySQL as PostgreSQL; the wire can be compressed (zlib/zstd) fewer bytes on the wire, 100 000 times after unpacking
Columnar (Arrow Flight SQL, DuckDB, ClickHouse) blocks per column, text often as a dictionary plus indices once, plus 100 000 small numbers

With row protocols a driver can find repeats only by hashing, as the conversion can. Only columnar protocols deliver the merge for free, and using that needs a trait that hands back blocks rather than rows. That is a separate question about the shape of std::db.

The normalisation lives only in the database file. The JOIN undoes it on the server.

4. SQLite through extern "C" (ADR-302)

examples/sqlite/main.nika binds SQLite directly; crates/nikaia/tests/foreign_pointers.rs builds and runs it. What being in process offers:

What has to stay open now

Comments

keywan-ghadami, 2026-10-06

Why SQLite may not check a statement while the program is built (asked by the owner).

The idea would be: the driver’s grammar opens an in-memory database from schema.sql, calls sqlite3_prepare_v2 on the statement, and reads sqlite3_column_origin_name / _table_name / _decltype. Four things stand in the way, in order:

  1. Build-time code may touch nothing but the build’s parameters (ADR-287 D14), and that is read from the ledger’s touches.
  2. A C declaration touches everything (ADR-302 D4, fail-closed), because a C signature says nothing about what the function does. So sqlite3_prepare_v2 is refused at build time by D14 already (NK1152).
  3. A package may not claim otherwise (ADR-321 D9). If a driver could write touches nothing over a C function, any package could write it over system() or unlink(). The compiler cannot tell sqlite3_prepare_v2 from system(): both are names in an extern "C" block. That is Rust’s build.rs / proc-macro hole, a supply-chain path onto the developer’s machine and CI.
  4. Reproducibility. examples/sqlite links the machine’s libsqlite3, and versions accept different SQL (RETURNING since 3.35, STRICT since 3.37). One project would build on one machine and not on another, and the build cache key (ADR-321 D4) does not contain the system library.

What it costs: the grammar has to replicate SQLite’s name and type resolution.

Neither gives a wrong value.

The one way it could be allowed, not decided: the compiler ships and vouches for one fixed SQLite, as it does for std’s Rust half (ADR-020 D5). The price:


Generated by Claude Code

Back to Open Issues