Skip to content

Commit fffae8b

Browse files
feat(stdlib): Sqlite.affine codegen foundation — db-theory #1a (6 externs wired Deno-ESM end-to-end) (#522)
## Summary First step of the **db-theory programme**. SQLite's surface looked partial in the roadmap but was actually vapourware: 6 externs declared in `stdlib/Sqlite.affine`, **zero codegen / zero adapter / zero tests / zero consumers**. This PR lands the foundation so the extension PR (#1b — prepared statements, schema introspection, bulk I/O) has a real pathway. ## Changes - **`lib/codegen_deno.ml` `deno_builtins`** — 6 new entries lowering each extern to its `__as_db*` JS helper. - **`lib/codegen_deno.ml` prelude** — `// ---- Sqlite ----` block with 6 `const __as_db*` helpers delegating to `globalThis.__as_sqlite`. Same shape as Motion / Pixi / PixiSound (only Tier-2 binding without it). Inline comment gives the Deno (`jsr:@db/sqlite`) and Node (`better-sqlite3`) adapter recipes. - **`tests/codegen-deno/sqlite_smoke.{affine,harness.mjs}`** — 3 smoke functions exercising full lifecycle, `WHERE id = ?` param-bound projection, and ordered multi-row retrieval. Harness installs an in-memory SQL mock (CREATE / INSERT / SELECT / WHERE / ORDER BY / COUNT(*) subset) explicitly marked as smoke-only. ## Verification - `dune build bin/main.exe`: clean - `dune runtest`: **357 / 357** green - `tools/run_codegen_deno_tests.sh`: **all 31** harnesses pass (was 30) ## Next (db-theory #1b — separate PR) - `Stmt` opaque type + `db_prepare` / `db_bind_*` / `db_step` / `db_column_*` / `db_finalize` - `db_schema_tables()` + `db_schema_columns(table)` introspection - `db_import_csv` / `db_export_csv` bulk I/O - Typed `DbError` + `Result<T, DbError>` returns ## Test plan - [x] `dune build` clean - [x] `dune runtest` 357 / 357 green (codegen ⊆ stdlib consistency picks up the 6 new builtins) - [x] `tools/run_codegen_deno_tests.sh` 31 / 31 harnesses pass - [x] `sqlite_smoke` mock validates all three smoke functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2c2e37e commit fffae8b

3 files changed

Lines changed: 236 additions & 1 deletion

File tree

lib/codegen_deno.ml

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,46 @@ const __as_hpmJsonEscapeString = (s) => {
476476
}
477477
return out;
478478
};
479+
// ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ----
480+
// Host JS environment must expose globalThis.__as_sqlite, a namespace
481+
// implementing the small adapter contract below. Consumers init once
482+
// (Deno):
483+
// import * as s from "jsr:@db/sqlite";
484+
// globalThis.__as_sqlite = {
485+
// open: (p) => new s.Database(p),
486+
// close: (db) => db.close(),
487+
// execute: (db, sql) => db.exec(sql),
488+
// query: (db, sql, params) => db.prepare(sql).all(...params),
489+
// queryOne: (db, sql, params) => db.prepare(sql).get(...params),
490+
// queryInt: (db, sql, params) => db.prepare(sql).value(...params),
491+
// };
492+
// or (Node + better-sqlite3): adapt the same shape. The smoke harness
493+
// installs an in-memory mock that implements the same contract.
494+
//
495+
// Parameter marshalling is intentionally simple: the AffineScript side
496+
// hands the adapter a JSON-encoded `params` string (`"[]"` for none);
497+
// rows + single-row results come back as JSON strings for caller-side
498+
// decoding via `json::parse`. This matches the existing 6-extern
499+
// stdlib/Sqlite.affine surface; richer typed bindings (prepared
500+
// statements, schema introspection, bulk I/O) land in db-theory #1b.
501+
const __as_dbOpen = (path) => globalThis.__as_sqlite.open(path);
502+
const __as_dbClose = (h) => { globalThis.__as_sqlite.close(h); return 0; };
503+
const __as_dbExecute = (h, sql) => { globalThis.__as_sqlite.execute(h, sql); return 0; };
504+
const __as_dbQuery = (h, sql, paramsJson) => {
505+
const params = paramsJson === "" || paramsJson === "[]" ? [] : JSON.parse(paramsJson);
506+
const rows = globalThis.__as_sqlite.query(h, sql, params);
507+
return JSON.stringify(rows);
508+
};
509+
const __as_dbQueryOne = (h, sql, paramsJson) => {
510+
const params = paramsJson === "" || paramsJson === "[]" ? [] : JSON.parse(paramsJson);
511+
const row = globalThis.__as_sqlite.queryOne(h, sql, params);
512+
return JSON.stringify(row);
513+
};
514+
const __as_dbQueryInt = (h, sql, paramsJson) => {
515+
const params = paramsJson === "" || paramsJson === "[]" ? [] : JSON.parse(paramsJson);
516+
const v = globalThis.__as_sqlite.queryInt(h, sql, params);
517+
return Number(v) | 0;
518+
};
479519
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
480520
const init = { method, headers: __as_httpHeadersToObject(headers) };
481521
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
@@ -757,7 +797,14 @@ let () =
757797
b "hpm_json_object_get" (fun a -> Printf.sprintf "__as_hpmJsonObjectGet(%s, %s)" (arg 0 a) (arg 1 a));
758798
b "hpm_json_array_len" (fun a -> Printf.sprintf "__as_hpmJsonArrayLen(%s)" (arg 0 a));
759799
b "hpm_json_array_get" (fun a -> Printf.sprintf "__as_hpmJsonArrayGet(%s, %s)" (arg 0 a) (arg 1 a));
760-
b "hpm_json_escape_string" (fun a -> Printf.sprintf "__as_hpmJsonEscapeString(%s)" (arg 0 a))
800+
b "hpm_json_escape_string" (fun a -> Printf.sprintf "__as_hpmJsonEscapeString(%s)" (arg 0 a));
801+
(* ---- Sqlite (db-theory #1a / stdlib/Sqlite.affine): SQL via host adapter ---- *)
802+
b "db_open" (fun a -> Printf.sprintf "__as_dbOpen(%s)" (arg 0 a));
803+
b "db_close" (fun a -> Printf.sprintf "__as_dbClose(%s)" (arg 0 a));
804+
b "db_execute" (fun a -> Printf.sprintf "__as_dbExecute(%s, %s)" (arg 0 a) (arg 1 a));
805+
b "db_query" (fun a -> Printf.sprintf "__as_dbQuery(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
806+
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))
761808

762809
(* ============================================================================
763810
Identifier sanitisation (JS reserved words -> trailing underscore)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// db-theory #1a — Sqlite codegen smoke
3+
//
4+
// Exercises the 6 stdlib/Sqlite.affine externs through the Deno-ESM
5+
// codegen path. The .harness.mjs installs an in-memory `globalThis
6+
// .__as_sqlite` mock implementing the adapter contract documented in
7+
// `lib/codegen_deno.ml :: __as_dbOpen` so this runs under plain Node
8+
// without requiring a real sqlite library.
9+
//
10+
// db-theory #1b (prepared statements, schema introspection, bulk I/O,
11+
// typed `DbError`) layers on top of this foundation; landing the
12+
// foundation first keeps each PR independently reviewable.
13+
14+
use Sqlite::{ db_open, db_close, db_execute, db_query, db_query_one, db_query_int };
15+
16+
pub fn smoke_full_lifecycle(path: String) -> Int {
17+
let d = db_open(path);
18+
let _id1 = db_execute(d, "CREATE TABLE t(id INTEGER, name TEXT)");
19+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')");
20+
let count = db_query_int(d, "SELECT COUNT(*) FROM t", "[]");
21+
let _id3 = db_close(d);
22+
count
23+
}
24+
25+
pub fn smoke_query_one(path: String) -> String {
26+
let d = db_open(path);
27+
let _id1 = db_execute(d, "CREATE TABLE t(id INTEGER, name TEXT)");
28+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1, 'first')");
29+
let row = db_query_one(d, "SELECT * FROM t WHERE id = ?", "[1]");
30+
let _id3 = db_close(d);
31+
row
32+
}
33+
34+
pub fn smoke_query_all(path: String) -> String {
35+
let d = db_open(path);
36+
let _id1 = db_execute(d, "CREATE TABLE t(id INTEGER)");
37+
let _id2 = db_execute(d, "INSERT INTO t VALUES (1), (2), (3)");
38+
let rows = db_query(d, "SELECT id FROM t ORDER BY id", "[]");
39+
let _id3 = db_close(d);
40+
rows
41+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// db-theory #1a — Node ESM harness for the Sqlite codegen smoke.
3+
//
4+
// Installs a minimal in-memory mock at `globalThis.__as_sqlite` BEFORE
5+
// importing the compiled module, then asserts the three smoke
6+
// functions return what the SQL-subset semantics dictate.
7+
//
8+
// The mock is intentionally narrow — just enough SQL parsing to drive
9+
// the 6-extern codegen path. Production deployments swap it for a
10+
// real adapter (Deno: `jsr:@db/sqlite`; Node: `better-sqlite3`)
11+
// satisfying the same `__as_sqlite` contract documented in
12+
// `lib/codegen_deno.ml :: __as_dbOpen`.
13+
14+
import assert from "node:assert/strict";
15+
16+
let nextHandle = 1;
17+
const dbs = new Map();
18+
19+
const parseRowLiteral = (s) => {
20+
// Split on commas at top-level (no nested parens, no embedded commas
21+
// inside quoted strings beyond a single value). Sufficient for the
22+
// smoke's CREATE / INSERT / SELECT subset.
23+
const out = [];
24+
let buf = "";
25+
let inStr = false;
26+
for (const ch of s) {
27+
if (ch === "'") { inStr = !inStr; buf += ch; }
28+
else if (ch === "," && !inStr) { out.push(buf.trim()); buf = ""; }
29+
else buf += ch;
30+
}
31+
if (buf.trim()) out.push(buf.trim());
32+
return out.map((t) => (
33+
t.startsWith("'") && t.endsWith("'") ? t.slice(1, -1) :
34+
/^-?\d+$/.test(t) ? Number(t) :
35+
/^-?\d*\.\d+$/.test(t) ? Number(t) :
36+
t
37+
));
38+
};
39+
40+
globalThis.__as_sqlite = {
41+
open(path) {
42+
const h = nextHandle++;
43+
dbs.set(h, { path, tables: new Map(), schema: new Map() });
44+
return h;
45+
},
46+
close(h) { dbs.delete(h); },
47+
execute(h, sql) {
48+
const db = dbs.get(h);
49+
if (!db) throw new Error("invalid handle " + h);
50+
51+
const create = sql.match(/CREATE TABLE (\w+)\s*\(([^)]+)\)/i);
52+
if (create) {
53+
const cols = create[2].split(",").map((c) => c.trim().split(/\s+/)[0]);
54+
db.tables.set(create[1], []);
55+
db.schema.set(create[1], cols);
56+
return;
57+
}
58+
59+
const insert = sql.match(/INSERT INTO (\w+)\s+VALUES\s+(.+)/i);
60+
if (insert) {
61+
const tableName = insert[1];
62+
const valuesPart = insert[2].replace(/;$/, "").trim();
63+
const tupleRe = /\(([^)]+)\)/g;
64+
let m;
65+
while ((m = tupleRe.exec(valuesPart))) {
66+
const cols = parseRowLiteral(m[1]);
67+
const tbl = db.tables.get(tableName);
68+
if (!tbl) throw new Error("no such table " + tableName);
69+
tbl.push(cols);
70+
}
71+
return;
72+
}
73+
74+
// Unknown DDL — accept silently for smoke purposes.
75+
},
76+
77+
// Project a row (array) into a row object keyed by column name.
78+
_project(db, tableName, row) {
79+
const cols = db.schema.get(tableName) || [];
80+
const obj = {};
81+
for (let i = 0; i < cols.length; i++) obj[cols[i]] = row[i];
82+
return obj;
83+
},
84+
85+
query(h, sql, params) {
86+
const db = dbs.get(h);
87+
const tbl = sql.match(/FROM\s+(\w+)/i);
88+
if (!tbl) return [];
89+
const rows = db.tables.get(tbl[1]) || [];
90+
91+
// WHERE id = ? — single-column equality filter on a positional bind.
92+
const where = sql.match(/WHERE\s+(\w+)\s*=\s*\?/i);
93+
let filtered = rows;
94+
if (where) {
95+
const col = where[1];
96+
const cols = db.schema.get(tbl[1]) || [];
97+
const idx = cols.indexOf(col);
98+
const v = params[0];
99+
filtered = rows.filter((r) => r[idx] === v);
100+
}
101+
102+
const ordered = /ORDER\s+BY/i.test(sql)
103+
? [...filtered].sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0))
104+
: filtered;
105+
106+
return ordered.map((r) => this._project(db, tbl[1], r));
107+
},
108+
109+
queryOne(h, sql, params) {
110+
const all = this.query(h, sql, params);
111+
return all.length > 0 ? all[0] : null;
112+
},
113+
114+
queryInt(h, sql, params) {
115+
if (/COUNT\(\*\)/i.test(sql)) {
116+
const tbl = sql.match(/FROM\s+(\w+)/i);
117+
const db = dbs.get(h);
118+
return (db.tables.get(tbl[1]) || []).length;
119+
}
120+
const all = this.query(h, sql, params);
121+
if (all.length === 0) return 0;
122+
const first = all[0];
123+
const k = Object.keys(first)[0];
124+
return Number(first[k]);
125+
},
126+
};
127+
128+
const mod = await import("./sqlite_smoke.deno.js");
129+
130+
assert.equal(
131+
mod.smoke_full_lifecycle(":memory:"),
132+
3,
133+
"smoke_full_lifecycle: CREATE + 3-row INSERT + COUNT(*) returns 3",
134+
);
135+
136+
const oneStr = mod.smoke_query_one(":memory:");
137+
const one = JSON.parse(oneStr);
138+
assert.equal(one.id, 1, "smoke_query_one: param-bound WHERE id=1 returns row with id=1");
139+
assert.equal(one.name, "first", "smoke_query_one: row['name'] field round-trips");
140+
141+
const allStr = mod.smoke_query_all(":memory:");
142+
const all = JSON.parse(allStr);
143+
assert.equal(all.length, 3, "smoke_query_all: ORDER BY id returns 3 rows");
144+
assert.equal(all[0].id, 1, "smoke_query_all: row[0].id = 1");
145+
assert.equal(all[2].id, 3, "smoke_query_all: row[2].id = 3");
146+
147+
console.log("sqlite_smoke OK");

0 commit comments

Comments
 (0)