From 7053a787428c273a7497c63bbc6d92484c09a01f Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:10:37 +0100 Subject: [PATCH] =?UTF-8?q?feat(stdlib):=20Sqlite=20prepared=20statements?= =?UTF-8?q?=20=E2=80=94=20db-theory=20#1b=20(Stmt=20+=2010=20typed=20exter?= =?UTF-8?q?ns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers the prepared-statement surface on top of the #1a 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 extern surface (`stdlib/Sqlite.affine`) - `pub 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 conv). - `db_bind_text(s, idx, v) -> Int` - `db_bind_null(s, idx) -> Int` - `db_step(s) -> Int` — 1 if a row is available (`SQLITE_ROW`), 0 if iteration is complete (`SQLITE_DONE`). - `db_column_count(s) -> Int` — width of the current row. - `db_column_int(s, idx) -> Int` — 0-indexed column read; NULL → 0. - `db_column_text(s, idx) -> String` — 0-indexed column read; NULL → "". - `db_reset(s) -> Int` — re-step from row 0 without recompiling. - `db_finalize(s) -> Int` — release; handle invalid after. Lifecycle + return-value conventions documented inline at the call site. Bind-index is 1-based, column-index is 0-based — matches both `jsr:@db/sqlite` and `better-sqlite3` (the two reference adapters the `__as_sqlite` contract supports). ## Codegen lowerings (`lib/codegen_deno.ml`) 10 new entries in `deno_builtins`, each mapping the AffineScript extern to a `__as_db*` JS helper. Each helper delegates to one `globalThis.__as_sqlite.` call, keeping the adapter contract flat — the host adapter's `prepare`/`bindInt`/`bindText`/`bindNull`/ `step`/`columnCount`/`columnInt`/`columnText`/`reset`/`finalize` methods are direct one-line passes through to the underlying sqlite library. NULL semantics handled at the JS-helper layer: `columnInt` / `columnText` on a NULL column return `null` from the adapter, then coerce to `0` / `""` respectively in the helper — matches the AffineScript-side type signature (`Int` / `String`, no `Option`). Callers that need to distinguish NULL from 0 / "" use the convenience `db_query_one` path and inspect the JSON null in the result. ## Smoke harness (`tests/codegen-deno/sqlite_prepared.{affine,harness.mjs}`) Six new smoke functions exercise the full prepared-statement lifecycle: | 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 `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 + bind/step → 2 rows | | `smoke_column_count_basic` | SELECT a, b, c → column_count = 3 | The harness extends the #1a in-memory mock with the 10 statement-side methods. Mock SQL parser stays narrow (CREATE / INSERT-with-binds / SELECT / WHERE / ORDER BY / COUNT(*) / single-expression `SELECT a + b`); harness commentary directs production deployments to the real `__as_sqlite` adapter. ## 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`: **all 32** harnesses pass (was 31) ## Stacked on #522 (db-theory #1a) #522 merged 12:43Z; this branch forks from the resulting main HEAD. ## db-theory #1c scope (next PR, separate) - Schema introspection: `db_schema_tables() -> [String]`, `db_schema_columns(table: String) -> [String]` - Bulk I/O: `db_import_csv(path) -> Int`, `db_export_csv(path) -> Int` - Typed error: `extern type DbError` + `Result`-shaped variants of the convenience surface Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/codegen_deno.ml | 38 ++- stdlib/Sqlite.affine | 57 +++- tests/codegen-deno/sqlite_prepared.affine | 106 +++++++ .../codegen-deno/sqlite_prepared.harness.mjs | 288 ++++++++++++++++++ 4 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 tests/codegen-deno/sqlite_prepared.affine create mode 100644 tests/codegen-deno/sqlite_prepared.harness.mjs diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index 54c8892d..6c2cbb54 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -516,6 +516,31 @@ const __as_dbQueryInt = (h, sql, paramsJson) => { const v = globalThis.__as_sqlite.queryInt(h, sql, params); return Number(v) | 0; }; +// ---- Sqlite prepared statements (db-theory #1b) ---- +// Layered on top of the convenience surface above. The host adapter +// gains nine extra methods (`prepare`, `bindInt`, `bindText`, `bindNull`, +// `step`, `columnCount`, `columnInt`, `columnText`, `reset`, `finalize`); +// the smoke harness's mock implements them, and both `jsr:@db/sqlite` +// and `better-sqlite3` provide direct one-line wrappers (each library +// already exposes a `prepare()` + iterator-style step + typed column +// accessors). Bind-index convention is sqlite3's 1-indexed; column-index +// convention is 0-indexed (matches both adapter libraries). +const __as_dbPrepare = (h, sql) => globalThis.__as_sqlite.prepare(h, sql); +const __as_dbBindInt = (s, idx, v) => { globalThis.__as_sqlite.bindInt(s, idx, v); return 0; }; +const __as_dbBindText = (s, idx, v) => { globalThis.__as_sqlite.bindText(s, idx, v); return 0; }; +const __as_dbBindNull = (s, idx) => { globalThis.__as_sqlite.bindNull(s, idx); return 0; }; +const __as_dbStep = (s) => (globalThis.__as_sqlite.step(s) ? 1 : 0); +const __as_dbColumnCount = (s) => Number(globalThis.__as_sqlite.columnCount(s)) | 0; +const __as_dbColumnInt = (s, idx) => { + const v = globalThis.__as_sqlite.columnInt(s, idx); + return v == null ? 0 : (Number(v) | 0); +}; +const __as_dbColumnText = (s, idx) => { + const v = globalThis.__as_sqlite.columnText(s, idx); + return v == null ? "" : String(v); +}; +const __as_dbReset = (s) => { globalThis.__as_sqlite.reset(s); return 0; }; +const __as_dbFinalize = (s) => { globalThis.__as_sqlite.finalize(s); return 0; }; const __as_httpFetch = async (url, method, headers, bodyOpt) => { const init = { method, headers: __as_httpHeadersToObject(headers) }; if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; @@ -804,7 +829,18 @@ let () = b "db_execute" (fun a -> Printf.sprintf "__as_dbExecute(%s, %s)" (arg 0 a) (arg 1 a)); b "db_query" (fun a -> Printf.sprintf "__as_dbQuery(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); b "db_query_one" (fun a -> Printf.sprintf "__as_dbQueryOne(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); - b "db_query_int" (fun a -> Printf.sprintf "__as_dbQueryInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)) + b "db_query_int" (fun a -> Printf.sprintf "__as_dbQueryInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + (* ---- Sqlite prepared statements (db-theory #1b) ---- *) + b "db_prepare" (fun a -> Printf.sprintf "__as_dbPrepare(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_bind_int" (fun a -> Printf.sprintf "__as_dbBindInt(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + b "db_bind_text" (fun a -> Printf.sprintf "__as_dbBindText(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a)); + b "db_bind_null" (fun a -> Printf.sprintf "__as_dbBindNull(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_step" (fun a -> Printf.sprintf "__as_dbStep(%s)" (arg 0 a)); + b "db_column_count" (fun a -> Printf.sprintf "__as_dbColumnCount(%s)" (arg 0 a)); + b "db_column_int" (fun a -> Printf.sprintf "__as_dbColumnInt(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_column_text" (fun a -> Printf.sprintf "__as_dbColumnText(%s, %s)" (arg 0 a) (arg 1 a)); + b "db_reset" (fun a -> Printf.sprintf "__as_dbReset(%s)" (arg 0 a)); + b "db_finalize" (fun a -> Printf.sprintf "__as_dbFinalize(%s)" (arg 0 a)) (* ============================================================================ Identifier sanitisation (JS reserved words -> trailing underscore) diff --git a/stdlib/Sqlite.affine b/stdlib/Sqlite.affine index 980dac88..c8b87684 100644 --- a/stdlib/Sqlite.affine +++ b/stdlib/Sqlite.affine @@ -3,13 +3,30 @@ // // Sqlite.affine — extern bindings for an SQLite backend. // -// All database handles are opaque Int. Query parameters are JSON-encoded -// strings; each result row is returned as a JSON-encoded string for -// caller-side decoding. The Node-CJS shim wraps Deno sqlite or node:sqlite. +// Two layers: +// 1. **Convenience surface** (`db_open`, `db_execute`, `db_query*`): +// JSON-encoded parameters in, JSON-encoded rows out. Sufficient +// for one-shot queries and prototyping; *not* sufficient for +// injection-safe parametrised queries or cursor-style iteration +// over large result sets. +// 2. **Prepared-statement surface** (`Stmt` type + `db_prepare` + +// `db_bind_*` + `db_step` + `db_column_*` + `db_finalize`): +// sqlite3-style bind-step-column-finalize loop. 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). +// +// All database + statement handles are opaque from the AffineScript +// side. The Deno-ESM backend's `__as_sqlite` adapter contract +// (documented in `lib/codegen_deno.ml`) maps each extern to a host +// SQL library (`jsr:@db/sqlite` for Deno; `better-sqlite3` for Node). module Sqlite; pub extern type Db; +pub extern type Stmt; + +// ── Convenience surface (db-theory #1a) ──────────────────────────── pub extern fn db_open(path: String) -> Db; pub extern fn db_close(d: Db) -> Int; @@ -17,3 +34,37 @@ pub extern fn db_execute(d: Db, sql: String) -> Int; pub extern fn db_query(d: Db, sql: String, params_json: String) -> String; pub extern fn db_query_one(d: Db, sql: String, params_json: String) -> String; pub extern fn db_query_int(d: Db, sql: String, params_json: String) -> Int; + +// ── Prepared statements (db-theory #1b) ──────────────────────────── +// +// Lifecycle: `db_prepare` returns a `Stmt`; bind each `?` placeholder +// (1-indexed, sqlite3 convention) via the typed `db_bind_*` calls; +// iterate rows by calling `db_step` until it returns 0 (`SQLITE_DONE`); +// read column values from the current row via `db_column_*`; release +// the statement with `db_finalize`. A statement may be reset via +// `db_reset` and re-stepped if reused. +// +// Return value conventions: +// - `db_step(s) -> Int`: 1 if a row is available (`SQLITE_ROW`), +// 0 if iteration is complete (`SQLITE_DONE`). Adapter raises on +// other sqlite3 codes (error / busy / misuse). +// - `db_bind_*(s, idx, v) -> Int`: returns 0 on success. +// - `db_column_count(s) -> Int`: number of columns in the current row. +// - `db_column_int(s, idx) -> Int`: column value as integer +// (0-indexed). NULL columns coerce to 0; use `db_query_one` + JSON +// null-check if NULL must be distinguished. +// - `db_column_text(s, idx) -> String`: column value as text. +// NULL columns coerce to "". +// - `db_finalize(s) -> Int`: returns 0; the `Stmt` handle is invalid +// after this call. + +pub extern fn db_prepare(d: Db, sql: String) -> Stmt; +pub extern fn db_bind_int(s: Stmt, idx: Int, v: Int) -> Int; +pub extern fn db_bind_text(s: Stmt, idx: Int, v: String) -> Int; +pub extern fn db_bind_null(s: Stmt, idx: Int) -> Int; +pub extern fn db_step(s: Stmt) -> Int; +pub extern fn db_column_count(s: Stmt) -> Int; +pub extern fn db_column_int(s: Stmt, idx: Int) -> Int; +pub extern fn db_column_text(s: Stmt, idx: Int) -> String; +pub extern fn db_reset(s: Stmt) -> Int; +pub extern fn db_finalize(s: Stmt) -> Int; diff --git a/tests/codegen-deno/sqlite_prepared.affine b/tests/codegen-deno/sqlite_prepared.affine new file mode 100644 index 00000000..1960e939 --- /dev/null +++ b/tests/codegen-deno/sqlite_prepared.affine @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MPL-2.0 +// db-theory #1b — Sqlite prepared-statement codegen smoke +// +// Exercises the 10 prepared-statement externs added in #1b. The +// `.harness.mjs` extends the #1a in-memory mock with prepare / bind* / +// step / column* / reset / finalize methods on `globalThis.__as_sqlite`. +// +// Each smoke function exercises one shape of the lifecycle: +// - smoke_prepare_bind_int_step_finalize: classic `INSERT (?, ?)` +// with two int binds, then re-query via the convenience surface to +// prove the row landed. +// - smoke_step_iteration: SELECT returning N rows; loop `db_step` +// until it returns 0, accumulating an int sum from column 0. +// - smoke_text_bind_and_column: round-trip a string through bind_text +// and column_text. +// - smoke_null_bind: bind NULL; verify column_int coerces to 0. +// - smoke_reset_and_reuse: prepare once, bind+step, reset, re-bind +// with different params, step again. + +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}; + +pub fn smoke_prepare_bind_int_step_finalize(path: String) -> Int { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(a INTEGER, b INTEGER)"); + let s = db_prepare(d, "INSERT INTO t(a, b) VALUES (?, ?)"); + let _id2 = db_bind_int(s, 1, 7); + let _id3 = db_bind_int(s, 2, 35); + let _step1 = db_step(s); + let _id4 = db_finalize(s); + let v = db_query_int(d, "SELECT a + b FROM t LIMIT 1", "[]"); + let _id5 = db_close(d); + v +} + +pub fn smoke_step_iteration(path: String) -> Int { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)"); + let _id2 = db_execute(d, "INSERT INTO t VALUES (1), (2), (3), (4), (5)"); + let s = db_prepare(d, "SELECT n FROM t ORDER BY n"); + let mut sum = 0; + let mut row = db_step(s); + while row == 1 { + sum = sum + db_column_int(s, 0); + row = db_step(s); + } + let _id3 = db_finalize(s); + let _id4 = db_close(d); + sum +} + +pub fn smoke_text_bind_and_column(path: String) -> String { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(name TEXT)"); + let s_ins = db_prepare(d, "INSERT INTO t VALUES (?)"); + let _id2 = db_bind_text(s_ins, 1, "affinescript"); + let _step1 = db_step(s_ins); + let _id3 = db_finalize(s_ins); + let s_sel = db_prepare(d, "SELECT name FROM t LIMIT 1"); + let _step2 = db_step(s_sel); + let out = db_column_text(s_sel, 0); + let _id4 = db_finalize(s_sel); + let _id5 = db_close(d); + out +} + +pub fn smoke_null_bind(path: String) -> Int { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(v INTEGER)"); + let s_ins = db_prepare(d, "INSERT INTO t VALUES (?)"); + let _id2 = db_bind_null(s_ins, 1); + let _step1 = db_step(s_ins); + let _id3 = db_finalize(s_ins); + let s_sel = db_prepare(d, "SELECT v FROM t LIMIT 1"); + let _step2 = db_step(s_sel); + let v = db_column_int(s_sel, 0); + let _id4 = db_finalize(s_sel); + let _id5 = db_close(d); + v +} + +pub fn smoke_reset_and_reuse(path: String) -> Int { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(n INTEGER)"); + let s = db_prepare(d, "INSERT INTO t VALUES (?)"); + let _id2 = db_bind_int(s, 1, 10); + let _step1 = db_step(s); + let _id3 = db_reset(s); + let _id4 = db_bind_int(s, 1, 32); + let _step2 = db_step(s); + let _id5 = db_finalize(s); + let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]"); + let _id6 = db_close(d); + count +} + +pub fn smoke_column_count_basic(path: String) -> Int { + let d = db_open(path); + let _id1 = db_execute(d, "CREATE TABLE t(a INTEGER, b INTEGER, c INTEGER)"); + let _id2 = db_execute(d, "INSERT INTO t VALUES (1, 2, 3)"); + let s = db_prepare(d, "SELECT a, b, c FROM t"); + let _step1 = db_step(s); + let n = db_column_count(s); + let _id3 = db_finalize(s); + let _id4 = db_close(d); + n +} diff --git a/tests/codegen-deno/sqlite_prepared.harness.mjs b/tests/codegen-deno/sqlite_prepared.harness.mjs new file mode 100644 index 00000000..53aab5e1 --- /dev/null +++ b/tests/codegen-deno/sqlite_prepared.harness.mjs @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: MPL-2.0 +// db-theory #1b — Node ESM harness for the Sqlite prepared-statement +// codegen smoke. +// +// Reuses the #1a `__as_sqlite` mock shape (minimal in-memory SQL +// engine for the smoke subset) and extends it with prepared-statement +// methods. Each `Stmt` is an opaque integer handle that the mock +// resolves via an internal map; bind / step / column / reset / +// finalize operate on those handles directly. +// +// Production deployments use a real `__as_sqlite` adapter wrapping +// `jsr:@db/sqlite` or `better-sqlite3`; each library already provides +// near-1:1 wrappers for `prepare`, `bindInt`, `bindText`, `bindNull`, +// `step`, `columnCount`, `columnInt`, `columnText`, `reset`, +// `finalize` (with trivial name adaptations). + +import assert from "node:assert/strict"; + +let nextDbHandle = 1; +let nextStmtHandle = 1; +const dbs = new Map(); +const stmts = new Map(); + +// Same lightweight literal parser as sqlite_smoke.harness.mjs (kept +// inline to keep the harness self-contained — runtime tooling does +// not share helpers across harnesses by design). +const parseRowLiteral = (s) => { + const out = []; + let buf = ""; + let inStr = false; + for (const ch of s) { + if (ch === "'") { inStr = !inStr; buf += ch; } + else if (ch === "," && !inStr) { out.push(buf.trim()); buf = ""; } + else buf += ch; + } + if (buf.trim()) out.push(buf.trim()); + return out.map((t) => ( + t.startsWith("'") && t.endsWith("'") ? t.slice(1, -1) : + /^-?\d+$/.test(t) ? Number(t) : + /^-?\d*\.\d+$/.test(t) ? Number(t) : + t + )); +}; + +// Substitute `?` placeholders left-to-right with the bound values +// (sqlite3 bind-index is 1-based). +const substituteBinds = (sql, binds) => { + let i = 1; + return sql.replace(/\?/g, () => { + const v = binds[i++]; + if (v === null || v === undefined) return "NULL"; + if (typeof v === "number") return String(v); + return "'" + String(v).replace(/'/g, "''") + "'"; + }); +}; + +// Project a row array into a named-column row object using the table schema. +const projectRow = (db, table, row) => { + const cols = db.schema.get(table) || []; + const obj = {}; + for (let i = 0; i < cols.length; i++) obj[cols[i]] = row[i]; + return obj; +}; + +globalThis.__as_sqlite = { + open(path) { + const h = nextDbHandle++; + dbs.set(h, { path, tables: new Map(), schema: new Map() }); + return h; + }, + close(h) { dbs.delete(h); }, + + execute(h, sql) { + const db = dbs.get(h); + if (!db) throw new Error("invalid db handle " + h); + + const create = sql.match(/CREATE TABLE (\w+)\s*\(([^)]+)\)/i); + if (create) { + const cols = create[2].split(",").map((c) => c.trim().split(/\s+/)[0]); + db.tables.set(create[1], []); + db.schema.set(create[1], cols); + return; + } + + const insert = sql.match(/INSERT INTO (\w+)(?:\s*\([^)]*\))?\s+VALUES\s+(.+)/i); + if (insert) { + const tableName = insert[1]; + const valuesPart = insert[2].replace(/;$/, "").trim(); + const tupleRe = /\(([^)]+)\)/g; + let m; + while ((m = tupleRe.exec(valuesPart))) { + const cols = parseRowLiteral(m[1]); + const tbl = db.tables.get(tableName); + if (!tbl) throw new Error("no such table " + tableName); + // Pad to schema width with NULL so single-column inserts work. + const schemaCols = db.schema.get(tableName) || []; + while (cols.length < schemaCols.length) cols.push(null); + tbl.push(cols); + } + } + }, + + query(h, sql, params) { + const db = dbs.get(h); + const tblMatch = sql.match(/FROM\s+(\w+)/i); + if (!tblMatch) return []; + const table = tblMatch[1]; + const rows = db.tables.get(table) || []; + + const where = sql.match(/WHERE\s+(\w+)\s*=\s*\?/i); + let filtered = rows; + if (where) { + const col = where[1]; + const cols = db.schema.get(table) || []; + const idx = cols.indexOf(col); + const v = params[0]; + filtered = rows.filter((r) => r[idx] === v); + } + + const ordered = /ORDER\s+BY/i.test(sql) + ? [...filtered].sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0)) + : filtered; + + return ordered.map((r) => projectRow(db, table, r)); + }, + + queryOne(h, sql, params) { + const all = this.query(h, sql, params); + return all.length > 0 ? all[0] : null; + }, + + queryInt(h, sql, params) { + if (/COUNT\(\*\)/i.test(sql)) { + const tbl = sql.match(/FROM\s+(\w+)/i); + return (dbs.get(h).tables.get(tbl[1]) || []).length; + } + // SUM-of-expression like `SELECT a + b FROM t LIMIT 1`: substitute + // column names with row values from the first matching row. + const db = dbs.get(h); + const tbl = sql.match(/FROM\s+(\w+)/i); + if (!tbl) return 0; + const rows = db.tables.get(tbl[1]) || []; + if (rows.length === 0) return 0; + const cols = db.schema.get(tbl[1]) || []; + const projMatch = sql.match(/SELECT\s+(.+?)\s+FROM/i); + if (!projMatch) return 0; + const expr = projMatch[1].trim(); + if (/^\w+$/.test(expr)) { + const idx = cols.indexOf(expr); + return Number(rows[0][idx] ?? 0); + } + // Tiny `a + b` evaluator: substitute column names with the first + // row's numeric values, then eval. The smoke fixture's only + // expression is `a + b`; this is intentionally narrow. + let e = expr; + for (let i = 0; i < cols.length; i++) { + e = e.replace(new RegExp("\\b" + cols[i] + "\\b", "g"), String(rows[0][i] ?? 0)); + } + // eslint-disable-next-line no-new-func + return Number(new Function("return (" + e + ")")()) | 0; + }, + + // ── Prepared-statement surface (db-theory #1b) ─────────────────── + + prepare(h, sql) { + const sh = nextStmtHandle++; + stmts.set(sh, { + dbHandle: h, + sql, + binds: {}, // 1-indexed + cursor: -1, // -1 = not stepped yet + resultRows: null, // populated on first step for SELECT + resultProjected: null, // matching projected-row form + }); + return sh; + }, + bindInt(s, idx, v) { stmts.get(s).binds[idx] = Number(v) | 0; }, + bindText(s, idx, v) { stmts.get(s).binds[idx] = String(v); }, + bindNull(s, idx) { stmts.get(s).binds[idx] = null; }, + + step(s) { + const st = stmts.get(s); + if (!st) throw new Error("invalid stmt handle " + s); + const db = dbs.get(st.dbHandle); + const sql = st.sql; + + // First step: execute the statement. + if (st.cursor === -1) { + if (/^\s*SELECT/i.test(sql)) { + // Materialise rows; substitute binds into SQL so the existing + // query machinery handles SELECT shape (`WHERE id = ?` etc.). + const substituted = substituteBinds(sql, st.binds); + const projected = this.query(st.dbHandle, substituted, []); + st.resultProjected = projected; + // Also keep the raw row form so column_int by index works + // regardless of projection. + const tblMatch = substituted.match(/FROM\s+(\w+)/i); + const table = tblMatch ? tblMatch[1] : null; + const cols = (table && db.schema.get(table)) || []; + st.resultRows = projected.map((p) => cols.map((c) => p[c])); + st.cursor = 0; + } else { + // INSERT / UPDATE / DELETE / DDL: substitute binds then execute. + const substituted = substituteBinds(sql, st.binds); + this.execute(st.dbHandle, substituted); + st.cursor = 1; // already past the "row available" state + return false; // SQLITE_DONE immediately + } + } else { + st.cursor += 1; + } + + if (st.resultRows && st.cursor < st.resultRows.length) return true; + return false; + }, + + columnCount(s) { + const st = stmts.get(s); + if (!st.resultRows || st.cursor < 0 || st.cursor >= st.resultRows.length) return 0; + return st.resultRows[st.cursor].length; + }, + + columnInt(s, idx) { + const st = stmts.get(s); + if (!st.resultRows || st.cursor < 0 || st.cursor >= st.resultRows.length) return null; + const v = st.resultRows[st.cursor][idx]; + return v == null ? null : (Number(v) | 0); + }, + + columnText(s, idx) { + const st = stmts.get(s); + if (!st.resultRows || st.cursor < 0 || st.cursor >= st.resultRows.length) return null; + const v = st.resultRows[st.cursor][idx]; + return v == null ? null : String(v); + }, + + reset(s) { + const st = stmts.get(s); + st.cursor = -1; + st.resultRows = null; + st.resultProjected = null; + // Per sqlite3 semantics, `sqlite3_reset` does NOT clear binds — + // they persist for the next step. Tests rely on this (and on the + // option to re-bind any subset before stepping again). + }, + + finalize(s) { stmts.delete(s); }, +}; + +const mod = await import("./sqlite_prepared.deno.js"); + +assert.equal( + mod.smoke_prepare_bind_int_step_finalize(":memory:"), + 42, + "smoke_prepare_bind_int_step_finalize: a=7, b=35 -> a+b = 42", +); + +assert.equal( + mod.smoke_step_iteration(":memory:"), + 15, + "smoke_step_iteration: 1+2+3+4+5 = 15", +); + +assert.equal( + mod.smoke_text_bind_and_column(":memory:"), + "affinescript", + "smoke_text_bind_and_column: bound text round-trips through column_text", +); + +assert.equal( + mod.smoke_null_bind(":memory:"), + 0, + "smoke_null_bind: bound NULL coerces to 0 via column_int", +); + +assert.equal( + mod.smoke_reset_and_reuse(":memory:"), + 2, + "smoke_reset_and_reuse: prepare once + bind/step + reset + bind/step = 2 rows", +); + +assert.equal( + mod.smoke_column_count_basic(":memory:"), + 3, + "smoke_column_count_basic: SELECT a, b, c FROM t -> column_count = 3", +); + +console.log("sqlite_prepared OK");