Skip to content

Commit 0f88028

Browse files
feat(stdlib): Sqlite prepared statements — db-theory #1b (Stmt + 10 typed externs) (#524)
## Summary Layers the prepared-statement surface on top of the #522 convenience surface. Use this for anything carrying **user input**, anything iterating over **more than a handful of rows**, and anything where **typed value marshalling** matters (Int / Text / NULL). ## New stdlib surface (`stdlib/Sqlite.affine`) | extern | purpose | |---|---| | `extern type Stmt` | opaque statement handle | | `db_prepare(d, sql) -> Stmt` | compile SQL with `?` placeholders | | `db_bind_int(s, idx, v) -> Int` | bind 1-indexed param (sqlite3 convention) | | `db_bind_text(s, idx, v) -> Int` | bind text | | `db_bind_null(s, idx) -> Int` | bind NULL | | `db_step(s) -> Int` | `1` = `SQLITE_ROW`, `0` = `SQLITE_DONE` | | `db_column_count(s) -> Int` | current-row width | | `db_column_int(s, idx) -> Int` | 0-indexed read; NULL → 0 | | `db_column_text(s, idx) -> String` | 0-indexed read; NULL → "" | | `db_reset(s) -> Int` | re-step from row 0 without recompiling | | `db_finalize(s) -> Int` | release | Bind-index is 1-based, column-index is 0-based — matches both `jsr:@db/sqlite` and `better-sqlite3` (the two reference `__as_sqlite` adapters). NULL coercion happens at the JS-helper layer; callers needing NULL/value-zero discrimination use the convenience `db_query_one` path. ## Smoke harness (`tests/codegen-deno/sqlite_prepared.{affine,harness.mjs}`) | smoke fn | exercises | |---|---| | `smoke_prepare_bind_int_step_finalize` | `INSERT(?, ?)` with two int binds + finalize + query-back | | `smoke_step_iteration` | `SELECT` N rows, loop `db_step` until 0, accumulate via `db_column_int` | | `smoke_text_bind_and_column` | bind_text → step → column_text round-trip | | `smoke_null_bind` | bind_null → column_int coerces to 0 | | `smoke_reset_and_reuse` | prepare once + bind/step + reset + re-bind/step → 2 rows | | `smoke_column_count_basic` | `SELECT a, b, c FROM t` → column_count = 3 | Mock extends #522's in-memory adapter with 10 statement-side methods; production adapters (`jsr:@db/sqlite` / `better-sqlite3`) provide near-1:1 wrappers. ## Verification - `dune build bin/main.exe`: clean - `dune runtest`: **363 / 363** green (codegen ⊆ stdlib consistency picks up the 10 new builtins; all match `stdlib/Sqlite.affine`) - `tools/run_codegen_deno_tests.sh`: **32 / 32** harnesses pass (was 31) ## Stacked on #522 #522 merged 12:43Z; this branch forks from the resulting main HEAD. Clean diff. ## db-theory #1c (next PR, separate) - Schema introspection (`db_schema_tables`, `db_schema_columns`) - Bulk I/O (`db_import_csv`, `db_export_csv`) - Typed `DbError` + `Result<T, DbError>` variants ## Test plan - [x] dune build clean - [x] dune runtest 363/363 green - [x] tools/run_codegen_deno_tests.sh 32/32 pass - [x] 6 prepared-statement smoke assertions pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5ebdba2 commit 0f88028

4 files changed

Lines changed: 485 additions & 4 deletions

File tree

lib/codegen_deno.ml

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,31 @@ const __as_dbQueryInt = (h, sql, paramsJson) => {
516516
const v = globalThis.__as_sqlite.queryInt(h, sql, params);
517517
return Number(v) | 0;
518518
};
519+
// ---- Sqlite prepared statements (db-theory #1b) ----
520+
// Layered on top of the convenience surface above. The host adapter
521+
// gains nine extra methods (`prepare`, `bindInt`, `bindText`, `bindNull`,
522+
// `step`, `columnCount`, `columnInt`, `columnText`, `reset`, `finalize`);
523+
// the smoke harness's mock implements them, and both `jsr:@db/sqlite`
524+
// and `better-sqlite3` provide direct one-line wrappers (each library
525+
// already exposes a `prepare()` + iterator-style step + typed column
526+
// accessors). Bind-index convention is sqlite3's 1-indexed; column-index
527+
// convention is 0-indexed (matches both adapter libraries).
528+
const __as_dbPrepare = (h, sql) => globalThis.__as_sqlite.prepare(h, sql);
529+
const __as_dbBindInt = (s, idx, v) => { globalThis.__as_sqlite.bindInt(s, idx, v); return 0; };
530+
const __as_dbBindText = (s, idx, v) => { globalThis.__as_sqlite.bindText(s, idx, v); return 0; };
531+
const __as_dbBindNull = (s, idx) => { globalThis.__as_sqlite.bindNull(s, idx); return 0; };
532+
const __as_dbStep = (s) => (globalThis.__as_sqlite.step(s) ? 1 : 0);
533+
const __as_dbColumnCount = (s) => Number(globalThis.__as_sqlite.columnCount(s)) | 0;
534+
const __as_dbColumnInt = (s, idx) => {
535+
const v = globalThis.__as_sqlite.columnInt(s, idx);
536+
return v == null ? 0 : (Number(v) | 0);
537+
};
538+
const __as_dbColumnText = (s, idx) => {
539+
const v = globalThis.__as_sqlite.columnText(s, idx);
540+
return v == null ? "" : String(v);
541+
};
542+
const __as_dbReset = (s) => { globalThis.__as_sqlite.reset(s); return 0; };
543+
const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return 0; };
519544
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
520545
const init = { method, headers: __as_httpHeadersToObject(headers) };
521546
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
@@ -804,7 +829,18 @@ let () =
804829
b "db_execute" (fun a -> Printf.sprintf "__as_dbExecute(%s, %s)" (arg 0 a) (arg 1 a));
805830
b "db_query" (fun a -> Printf.sprintf "__as_dbQuery(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
806831
b "db_query_one" (fun a -> Printf.sprintf "__as_dbQueryOne(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
807-
b "db_query_int" (fun a -> Printf.sprintf "__as_dbQueryInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a))
832+
b "db_query_int" (fun a -> Printf.sprintf "__as_dbQueryInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
833+
(* ---- Sqlite prepared statements (db-theory #1b) ---- *)
834+
b "db_prepare" (fun a -> Printf.sprintf "__as_dbPrepare(%s, %s)" (arg 0 a) (arg 1 a));
835+
b "db_bind_int" (fun a -> Printf.sprintf "__as_dbBindInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
836+
b "db_bind_text" (fun a -> Printf.sprintf "__as_dbBindText(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
837+
b "db_bind_null" (fun a -> Printf.sprintf "__as_dbBindNull(%s, %s)" (arg 0 a) (arg 1 a));
838+
b "db_step" (fun a -> Printf.sprintf "__as_dbStep(%s)" (arg 0 a));
839+
b "db_column_count" (fun a -> Printf.sprintf "__as_dbColumnCount(%s)" (arg 0 a));
840+
b "db_column_int" (fun a -> Printf.sprintf "__as_dbColumnInt(%s, %s)" (arg 0 a) (arg 1 a));
841+
b "db_column_text" (fun a -> Printf.sprintf "__as_dbColumnText(%s, %s)" (arg 0 a) (arg 1 a));
842+
b "db_reset" (fun a -> Printf.sprintf "__as_dbReset(%s)" (arg 0 a));
843+
b "db_finalize" (fun a -> Printf.sprintf "__as_dbFinalize(%s)" (arg 0 a))
808844

809845
(* ============================================================================
810846
Identifier sanitisation (JS reserved words -> trailing underscore)

stdlib/Sqlite.affine

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,68 @@
33
//
44
// Sqlite.affine — extern bindings for an SQLite backend.
55
//
6-
// All database handles are opaque Int. Query parameters are JSON-encoded
7-
// strings; each result row is returned as a JSON-encoded string for
8-
// caller-side decoding. The Node-CJS shim wraps Deno sqlite or node:sqlite.
6+
// Two layers:
7+
// 1. **Convenience surface** (`db_open`, `db_execute`, `db_query*`):
8+
// JSON-encoded parameters in, JSON-encoded rows out. Sufficient
9+
// for one-shot queries and prototyping; *not* sufficient for
10+
// injection-safe parametrised queries or cursor-style iteration
11+
// over large result sets.
12+
// 2. **Prepared-statement surface** (`Stmt` type + `db_prepare` +
13+
// `db_bind_*` + `db_step` + `db_column_*` + `db_finalize`):
14+
// sqlite3-style bind-step-column-finalize loop. Use this for
15+
// anything carrying user input, anything iterating over more
16+
// than a handful of rows, and anything where typed value
17+
// marshalling matters (Int / Text / null).
18+
//
19+
// All database + statement handles are opaque from the AffineScript
20+
// side. The Deno-ESM backend's `__as_sqlite` adapter contract
21+
// (documented in `lib/codegen_deno.ml`) maps each extern to a host
22+
// SQL library (`jsr:@db/sqlite` for Deno; `better-sqlite3` for Node).
923

1024
module Sqlite;
1125

1226
pub extern type Db;
27+
pub extern type Stmt;
28+
29+
// ── Convenience surface (db-theory #1a) ────────────────────────────
1330

1431
pub extern fn db_open(path: String) -> Db;
1532
pub extern fn db_close(d: Db) -> Int;
1633
pub extern fn db_execute(d: Db, sql: String) -> Int;
1734
pub extern fn db_query(d: Db, sql: String, params_json: String) -> String;
1835
pub extern fn db_query_one(d: Db, sql: String, params_json: String) -> String;
1936
pub extern fn db_query_int(d: Db, sql: String, params_json: String) -> Int;
37+
38+
// ── Prepared statements (db-theory #1b) ────────────────────────────
39+
//
40+
// Lifecycle: `db_prepare` returns a `Stmt`; bind each `?` placeholder
41+
// (1-indexed, sqlite3 convention) via the typed `db_bind_*` calls;
42+
// iterate rows by calling `db_step` until it returns 0 (`SQLITE_DONE`);
43+
// read column values from the current row via `db_column_*`; release
44+
// the statement with `db_finalize`. A statement may be reset via
45+
// `db_reset` and re-stepped if reused.
46+
//
47+
// Return value conventions:
48+
// - `db_step(s) -> Int`: 1 if a row is available (`SQLITE_ROW`),
49+
// 0 if iteration is complete (`SQLITE_DONE`). Adapter raises on
50+
// other sqlite3 codes (error / busy / misuse).
51+
// - `db_bind_*(s, idx, v) -> Int`: returns 0 on success.
52+
// - `db_column_count(s) -> Int`: number of columns in the current row.
53+
// - `db_column_int(s, idx) -> Int`: column value as integer
54+
// (0-indexed). NULL columns coerce to 0; use `db_query_one` + JSON
55+
// null-check if NULL must be distinguished.
56+
// - `db_column_text(s, idx) -> String`: column value as text.
57+
// NULL columns coerce to "".
58+
// - `db_finalize(s) -> Int`: returns 0; the `Stmt` handle is invalid
59+
// after this call.
60+
61+
pub extern fn db_prepare(d: Db, sql: String) -> Stmt;
62+
pub extern fn db_bind_int(s: Stmt, idx: Int, v: Int) -> Int;
63+
pub extern fn db_bind_text(s: Stmt, idx: Int, v: String) -> Int;
64+
pub extern fn db_bind_null(s: Stmt, idx: Int) -> Int;
65+
pub extern fn db_step(s: Stmt) -> Int;
66+
pub extern fn db_column_count(s: Stmt) -> Int;
67+
pub extern fn db_column_int(s: Stmt, idx: Int) -> Int;
68+
pub extern fn db_column_text(s: Stmt, idx: Int) -> String;
69+
pub extern fn db_reset(s: Stmt) -> Int;
70+
pub extern fn db_finalize(s: Stmt) -> Int;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// db-theory #1b — Sqlite prepared-statement codegen smoke
3+
//
4+
// Exercises the 10 prepared-statement externs added in #1b. The
5+
// `.harness.mjs` extends the #1a in-memory mock with prepare / bind* /
6+
// step / column* / reset / finalize methods on `globalThis.__as_sqlite`.
7+
//
8+
// Each smoke function exercises one shape of the lifecycle:
9+
// - smoke_prepare_bind_int_step_finalize: classic `INSERT (?, ?)`
10+
// with two int binds, then re-query via the convenience surface to
11+
// prove the row landed.
12+
// - smoke_step_iteration: SELECT returning N rows; loop `db_step`
13+
// until it returns 0, accumulating an int sum from column 0.
14+
// - smoke_text_bind_and_column: round-trip a string through bind_text
15+
// and column_text.
16+
// - smoke_null_bind: bind NULL; verify column_int coerces to 0.
17+
// - smoke_reset_and_reuse: prepare once, bind+step, reset, re-bind
18+
// with different params, step again.
19+
20+
use Sqlite::{db_open, db_close, db_execute, db_prepare, db_bind_int, db_bind_text, db_bind_null, db_step, db_column_count, db_column_int, db_column_text, db_reset, db_finalize, db_query_int, db_query_one};
21+
22+
pub fn smoke_prepare_bind_int_step_finalize(path: String) -> Int {
23+
let d = db_open(path);
24+
let _id1 = db_execute(d, "CREATE TABLE t(a INTEGER, b INTEGER)");
25+
let s = db_prepare(d, "INSERT INTO t(a, b) VALUES (?, ?)");
26+
let _id2 = db_bind_int(s, 1, 7);
27+
let _id3 = db_bind_int(s, 2, 35);
28+
let _step1 = db_step(s);
29+
let _id4 = db_finalize(s);
30+
let v = db_query_int(d, "SELECT a + b FROM t LIMIT 1", "[]");
31+
let _id5 = db_close(d);
32+
v
33+
}
34+
35+
pub fn smoke_step_iteration(path: String) -> Int {
36+
let d = db_open(path);
37+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
38+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1), (2), (3), (4), (5)");
39+
let s = db_prepare(d, "SELECT n FROM t ORDER BY n");
40+
let mut sum = 0;
41+
let mut row = db_step(s);
42+
while row == 1 {
43+
sum = sum + db_column_int(s, 0);
44+
row = db_step(s);
45+
}
46+
let _id3 = db_finalize(s);
47+
let _id4 = db_close(d);
48+
sum
49+
}
50+
51+
pub fn smoke_text_bind_and_column(path: String) -> String {
52+
let d = db_open(path);
53+
let _id1 = db_execute(d, "CREATE TABLE t(name TEXT)");
54+
let s_ins = db_prepare(d, "INSERT INTO t VALUES (?)");
55+
let _id2 = db_bind_text(s_ins, 1, "affinescript");
56+
let _step1 = db_step(s_ins);
57+
let _id3 = db_finalize(s_ins);
58+
let s_sel = db_prepare(d, "SELECT name FROM t LIMIT 1");
59+
let _step2 = db_step(s_sel);
60+
let out = db_column_text(s_sel, 0);
61+
let _id4 = db_finalize(s_sel);
62+
let _id5 = db_close(d);
63+
out
64+
}
65+
66+
pub fn smoke_null_bind(path: String) -> Int {
67+
let d = db_open(path);
68+
let _id1 = db_execute(d, "CREATE TABLE t(v INTEGER)");
69+
let s_ins = db_prepare(d, "INSERT INTO t VALUES (?)");
70+
let _id2 = db_bind_null(s_ins, 1);
71+
let _step1 = db_step(s_ins);
72+
let _id3 = db_finalize(s_ins);
73+
let s_sel = db_prepare(d, "SELECT v FROM t LIMIT 1");
74+
let _step2 = db_step(s_sel);
75+
let v = db_column_int(s_sel, 0);
76+
let _id4 = db_finalize(s_sel);
77+
let _id5 = db_close(d);
78+
v
79+
}
80+
81+
pub fn smoke_reset_and_reuse(path: String) -> Int {
82+
let d = db_open(path);
83+
let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)");
84+
let s = db_prepare(d, "INSERT INTO t VALUES (?)");
85+
let _id2 = db_bind_int(s, 1, 10);
86+
let _step1 = db_step(s);
87+
let _id3 = db_reset(s);
88+
let _id4 = db_bind_int(s, 1, 32);
89+
let _step2 = db_step(s);
90+
let _id5 = db_finalize(s);
91+
let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]");
92+
let _id6 = db_close(d);
93+
count
94+
}
95+
96+
pub fn smoke_column_count_basic(path: String) -> Int {
97+
let d = db_open(path);
98+
let _id1 = db_execute(d, "CREATE TABLE t(a INTEGER, b INTEGER, c INTEGER)");
99+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1, 2, 3)");
100+
let s = db_prepare(d, "SELECT a, b, c FROM t");
101+
let _step1 = db_step(s);
102+
let n = db_column_count(s);
103+
let _id3 = db_finalize(s);
104+
let _id4 = db_close(d);
105+
n
106+
}

0 commit comments

Comments
 (0)