Skip to content

Commit 7b8e1cc

Browse files
ryanrasticlaude
andcommitted
feat(sqlite): json_each/json_tree as table-valued Srf methods
SQLite's SRFs (eponymous virtual tables) ride the same emitter path PG's set-returning functions already use: isSrf + outColumns facts → runtime.Srf methods with the fixed row shape. json_each and json_tree land on Text and Blob (per-receiver overloads, optional jsonpath arg — omitted optionals filter out before arg splicing), usable as db.from(text.jsonEach()). Verifier grows SRF-aware checks: existence probes in FROM position (they're not in pragma_function_list), exact projected-column-list claims, and per-row storage-class checks for concretely-typed columns across seeded args. Extraction prompt documents the isSrf/outColumns rule. generate_series stays out — the series extension isn't compiled into better-sqlite3's build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5182647 commit 7b8e1cc

7 files changed

Lines changed: 228 additions & 6 deletions

File tree

src/types/emission/emit.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,12 +378,18 @@ export const emitMethods = (host: TypeEntry, allFns: EmitFn[], cfg: EmitConfig):
378378
const params = paramsOf(o0);
379379
const argNames = params.map((_, i) => `arg${i}`);
380380
const allArgs = argNames.length > 0 ? `this, ${argNames.join(", ")}` : "this";
381+
// Omitted optionals arrive as explicit undefined — filter before
382+
// the Srf splices args into SQL.
383+
const argsExpr = params.some((a) => a.optional)
384+
? `[${allArgs}].filter((a) => a !== undefined)`
385+
: `[${allArgs}]`;
381386
const colEntries = fn.outColumns.map((c) => { const t = table.get(c.type)!; return `${c.name}: types.${t.className}<1>`; });
382387
const colRuntime = fn.outColumns.map((c) => { const t = table.get(c.type)!; return `["${c.name}", types.${t.className}]`; });
383388
const sig = buildSig(fn, o0, true, camelcase(fn.sql));
384389
const ret = `runtime.Srf<{ ${colEntries.join("; ")} }, "${fn.sql}">`;
390+
lines.push(...(fn.doc ? [` /** \`${fn.sql}\` — ${fn.doc} */`] : []));
385391
lines.push(` @expose.unchecked()`);
386-
lines.push(` ${sig}: ${ret} { return new runtime.Srf("${fn.sql}", [${allArgs}], [${colRuntime.join(", ")}]) as any; }`);
392+
lines.push(` ${sig}: ${ret} { return new runtime.Srf("${fn.sql}", ${argsExpr}, [${colRuntime.join(", ")}]) as any; }`);
387393
continue;
388394
}
389395

src/types/sqlite/docs/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,9 @@ of the official SQLite documentation into a JSON facts file. Read the
108108
file docs/{PAGE} completely, then output a single JSON document — no
109109
prose, no markdown fences — conforming to the DocPage schema in
110110
docs/index.ts (fields: page, functions[]; each function: sql, kind,
111-
doc, deterministic?, overloads[]; each overload: args[],
112-
variadic?, returns, nullability, nullPositions?; each arg: type,
113-
hint?, optional?).
111+
doc, deterministic?, isSrf?, outColumns?, overloads[]; each overload:
112+
args[], variadic?, returns, nullability, nullPositions?; each arg:
113+
type, hint?, optional?).
114114
115115
Rules:
116116
@@ -185,5 +185,12 @@ Rules:
185185
8. doc: one sentence, present tense, from the page's description —
186186
include version notes the page states (e.g. "(3.44+)").
187187
188+
9. Table-valued (set-returning) functions the page documents
189+
(json_each, json_tree): kind "scalar" with "isSrf": true and
190+
"outColumns" listing the projected columns in order with their
191+
storage classes ("any" where the class depends on the data). Their
192+
overloads describe the CALL arguments as usual, with
193+
"returns": null — the row shape lives in outColumns.
194+
188195
Output only the JSON document.
189196
`.trim();

src/types/sqlite/docs/json1.json

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -966,6 +966,152 @@
966966
"nullability": "never"
967967
}
968968
]
969+
},
970+
{
971+
"sql": "json_each",
972+
"kind": "scalar",
973+
"isSrf": true,
974+
"doc": "Table-valued: one row per element of the top-level array/object (or of the value at PATH).",
975+
"deterministic": true,
976+
"outColumns": [
977+
{
978+
"name": "key",
979+
"type": "any"
980+
},
981+
{
982+
"name": "value",
983+
"type": "any"
984+
},
985+
{
986+
"name": "type",
987+
"type": "text"
988+
},
989+
{
990+
"name": "atom",
991+
"type": "any"
992+
},
993+
{
994+
"name": "id",
995+
"type": "integer"
996+
},
997+
{
998+
"name": "parent",
999+
"type": "integer"
1000+
},
1001+
{
1002+
"name": "fullkey",
1003+
"type": "text"
1004+
},
1005+
{
1006+
"name": "path",
1007+
"type": "text"
1008+
}
1009+
],
1010+
"overloads": [
1011+
{
1012+
"args": [
1013+
{
1014+
"type": "text",
1015+
"hint": "json"
1016+
},
1017+
{
1018+
"type": "text",
1019+
"hint": "jsonpath",
1020+
"optional": true
1021+
}
1022+
],
1023+
"returns": null,
1024+
"nullability": "never"
1025+
},
1026+
{
1027+
"args": [
1028+
{
1029+
"type": "blob",
1030+
"hint": "json"
1031+
},
1032+
{
1033+
"type": "text",
1034+
"hint": "jsonpath",
1035+
"optional": true
1036+
}
1037+
],
1038+
"returns": null,
1039+
"nullability": "never"
1040+
}
1041+
]
1042+
},
1043+
{
1044+
"sql": "json_tree",
1045+
"kind": "scalar",
1046+
"isSrf": true,
1047+
"doc": "Table-valued: recursive walk of the whole JSON tree, one row per container and leaf.",
1048+
"deterministic": true,
1049+
"outColumns": [
1050+
{
1051+
"name": "key",
1052+
"type": "any"
1053+
},
1054+
{
1055+
"name": "value",
1056+
"type": "any"
1057+
},
1058+
{
1059+
"name": "type",
1060+
"type": "text"
1061+
},
1062+
{
1063+
"name": "atom",
1064+
"type": "any"
1065+
},
1066+
{
1067+
"name": "id",
1068+
"type": "integer"
1069+
},
1070+
{
1071+
"name": "parent",
1072+
"type": "integer"
1073+
},
1074+
{
1075+
"name": "fullkey",
1076+
"type": "text"
1077+
},
1078+
{
1079+
"name": "path",
1080+
"type": "text"
1081+
}
1082+
],
1083+
"overloads": [
1084+
{
1085+
"args": [
1086+
{
1087+
"type": "text",
1088+
"hint": "json"
1089+
},
1090+
{
1091+
"type": "text",
1092+
"hint": "jsonpath",
1093+
"optional": true
1094+
}
1095+
],
1096+
"returns": null,
1097+
"nullability": "never"
1098+
},
1099+
{
1100+
"args": [
1101+
{
1102+
"type": "blob",
1103+
"hint": "json"
1104+
},
1105+
{
1106+
"type": "text",
1107+
"hint": "jsonpath",
1108+
"optional": true
1109+
}
1110+
],
1111+
"returns": null,
1112+
"nullability": "never"
1113+
}
1114+
]
9691115
}
9701116
]
9711117
}

src/types/sqlite/generated/blob.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ export class Blob<in out N extends number> extends Any<N> {
111111
jsonType<M0 extends types.Text<any> | string>(arg0?: M0): types.Text<0 | 1>;
112112
@expose.unchecked()
113113
jsonType(arg0?: unknown): any { const [__rt, ...__rest] = runtime.match([arg0], [[[], types.Text], [[{ type: types.Text, allowPrimitive: true }], types.Text]]); return runtime.funcCall("json_type", [this, ...__rest], __rt) as any; }
114+
/** `json_each` — Table-valued: one row per element of the top-level array/object (or of the value at PATH). */
115+
@expose.unchecked()
116+
jsonEach<M0 extends types.Text<any> | string>(arg0?: M0): runtime.Srf<{ key: types.Any<1>; value: types.Any<1>; type: types.Text<1>; atom: types.Any<1>; id: types.Integer<1>; parent: types.Integer<1>; fullkey: types.Text<1>; path: types.Text<1> }, "json_each"> { return new runtime.Srf("json_each", [this, arg0].filter((a) => a !== undefined), [["key", types.Any], ["value", types.Any], ["type", types.Text], ["atom", types.Any], ["id", types.Integer], ["parent", types.Integer], ["fullkey", types.Text], ["path", types.Text]]) as any; }
117+
/** `json_tree` — Table-valued: recursive walk of the whole JSON tree, one row per container and leaf. */
118+
@expose.unchecked()
119+
jsonTree<M0 extends types.Text<any> | string>(arg0?: M0): runtime.Srf<{ key: types.Any<1>; value: types.Any<1>; type: types.Text<1>; atom: types.Any<1>; id: types.Integer<1>; parent: types.Integer<1>; fullkey: types.Text<1>; path: types.Text<1> }, "json_tree"> { return new runtime.Srf("json_tree", [this, arg0].filter((a) => a !== undefined), [["key", types.Any], ["value", types.Any], ["type", types.Text], ["atom", types.Any], ["id", types.Integer], ["parent", types.Integer], ["fullkey", types.Text], ["path", types.Text]]) as any; }
114120
/** `->` — JSON subcomponent at path, as JSON text; NULL if absent. */
115121
@expose.unchecked()
116122
['->']<M0 extends types.Text<any> | string>(arg0: M0): types.Text<0 | 1> { const [__rt, ...__rest] = runtime.match([arg0], [[[{ type: types.Text, allowPrimitive: true }], types.Text]]); return runtime.opCall(runtime.sql`->`, [this, ...__rest] as [unknown, unknown], __rt) as any; }

src/types/sqlite/generated/text.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ export class Text<in out N extends number> extends Any<N> {
179179
/** `jsonb_group_object` — Aggregate key/value pairs into a JSONB object. */
180180
@expose.unchecked()
181181
jsonbGroupObject<M0 extends types.Any<any> | number | string | boolean | Uint8Array>(arg0: M0): types.Blob<1> { const [__rt, ...__rest] = runtime.match([arg0], [[[{ type: types.Any, allowPrimitive: true }], types.Blob]]); return runtime.funcCall("jsonb_group_object", [this, ...__rest], __rt) as any; }
182+
/** `json_each` — Table-valued: one row per element of the top-level array/object (or of the value at PATH). */
183+
@expose.unchecked()
184+
jsonEach<M0 extends types.Text<any> | string>(arg0?: M0): runtime.Srf<{ key: types.Any<1>; value: types.Any<1>; type: types.Text<1>; atom: types.Any<1>; id: types.Integer<1>; parent: types.Integer<1>; fullkey: types.Text<1>; path: types.Text<1> }, "json_each"> { return new runtime.Srf("json_each", [this, arg0].filter((a) => a !== undefined), [["key", types.Any], ["value", types.Any], ["type", types.Text], ["atom", types.Any], ["id", types.Integer], ["parent", types.Integer], ["fullkey", types.Text], ["path", types.Text]]) as any; }
185+
/** `json_tree` — Table-valued: recursive walk of the whole JSON tree, one row per container and leaf. */
186+
@expose.unchecked()
187+
jsonTree<M0 extends types.Text<any> | string>(arg0?: M0): runtime.Srf<{ key: types.Any<1>; value: types.Any<1>; type: types.Text<1>; atom: types.Any<1>; id: types.Integer<1>; parent: types.Integer<1>; fullkey: types.Text<1>; path: types.Text<1> }, "json_tree"> { return new runtime.Srf("json_tree", [this, arg0].filter((a) => a !== undefined), [["key", types.Any], ["value", types.Any], ["type", types.Text], ["atom", types.Any], ["id", types.Integer], ["parent", types.Integer], ["fullkey", types.Text], ["path", types.Text]]) as any; }
182188
/** `->` — JSON subcomponent at path, as JSON text; NULL if absent. */
183189
@expose.unchecked()
184190
['->']<M0 extends types.Text<any> | string>(arg0: M0): types.Text<0 | 1> { const [__rt, ...__rest] = runtime.match([arg0], [[[{ type: types.Text, allowPrimitive: true }], types.Text]]); return runtime.opCall(runtime.sql`->`, [this, ...__rest] as [unknown, unknown], __rt) as any; }

src/types/sqlite/signatures.verify.test.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,15 @@ describe("completeness gate", () => {
128128
// Operators aren't listed in pragma_function_list (except -> and
129129
// ->>, which appear as both) — the typo guard applies to
130130
// functions only.
131-
const phantom = SIGNATURES.filter((f) => (f.kind === "scalar" || f.kind === "aggregate") && !pragmaNames.has(f.sql)).map((f) => f.sql);
131+
// Table-valued functions are eponymous virtual tables, not
132+
// pragma_function_list entries — their typo guard is that they
133+
// prepare in FROM position.
134+
const phantom = SIGNATURES.filter((f) => !f.isSrf && (f.kind === "scalar" || f.kind === "aggregate") && !pragmaNames.has(f.sql)).map((f) => f.sql);
132135
expect(phantom, `defined but not in pragma_function_list (typo?)`).toEqual([]);
136+
const srfPhantom = SIGNATURES.filter((f) => f.isSrf).filter((f) => {
137+
try { db.prepare(`SELECT * FROM ${f.sql}('{}')`); return false; } catch { return true; }
138+
}).map((f) => f.sql);
139+
expect(srfPhantom, `SRF defined but not a table-valued function (typo?)`).toEqual([]);
133140

134141
const doubleBooked = SIGNATURES.filter((f) => excluded.has(f.sql)).map((f) => f.sql);
135142
expect(doubleBooked, `both defined and excluded`).toEqual([]);
@@ -146,7 +153,11 @@ describe("determinism facts", () => {
146153
const engine = new Map<string, boolean>();
147154
for (const r of rows) { engine.set(r.name, engine.get(r.name) || !!(r.flags & 0x800)); }
148155
for (const f of SIGNATURES) {
149-
if (f.kind === "scalar") {
156+
if (f.isSrf) {
157+
// Virtual tables carry no SQLITE_DETERMINISTIC flag; the
158+
// schema convention is isSrf ⇒ deterministic: true.
159+
expect(f.deterministic, `${f.sql} (SRF) must claim deterministic: true`).toBe(true);
160+
} else if (f.kind === "scalar") {
150161
expect(f.deterministic, `${f.sql} deterministic fact vs engine flag`).toBe(engine.get(f.sql));
151162
} else if (f.kind === "aggregate") {
152163
expect(f.deterministic, `${f.sql} (aggregate) must claim deterministic: true`).toBe(true);
@@ -171,10 +182,41 @@ describe("determinism facts", () => {
171182
});
172183
});
173184

185+
// Table-valued claims: exact projected column list (names, in order)
186+
// and per-row storage class for concretely-claimed columns, across
187+
// seeded args in FROM position.
188+
const verifySrf = (fn: FnDef, r: () => number): void => {
189+
const cols = fn.outColumns!;
190+
const stmt = db.prepare(`SELECT * FROM ${fn.sql}('{"a":1}')`);
191+
expect(stmt.columns().map((c) => c.name), `${fn.sql} projected columns`).toEqual(cols.map((c) => c.name));
192+
for (const [oi, o] of fn.overloads.entries()) {
193+
for (const shape of argTuples(o, oi, 4)) {
194+
const args = shape.map((a) => sample(r, a));
195+
const sel = cols.map((c) => `typeof("${c.name}") AS "${c.name}"`).join(", ");
196+
let rows: Record<string, string>[];
197+
try {
198+
rows = db.prepare(`SELECT ${sel} FROM ${fn.sql}(${shape.map(() => "?").join(", ")})`).all(...args) as Record<string, string>[];
199+
} catch (e) {
200+
throw new Error(`${fn.sql} threw on claimed-valid args ${fmt(args)}: ${(e as Error).message}`, { cause: e });
201+
}
202+
for (const row of rows) {
203+
for (const c of cols) {
204+
if (c.type === "any") { continue; }
205+
expect([c.type, "null"], `${fn.sql}${fmt(args)} column ${c.name} storage class`).toContain(row[c.name]);
206+
}
207+
}
208+
}
209+
}
210+
};
211+
174212
describe("signature claims vs engine behavior", () => {
175213
for (const fn of SIGNATURES) {
176214
test(`${fn.kind} ${fn.sql}`, () => {
177215
const r = rng(0xC0FFEE ^ [...fn.sql].reduce((h, c) => h * 31 + c.charCodeAt(0), 7));
216+
if (fn.isSrf) {
217+
verifySrf(fn, r);
218+
return;
219+
}
178220
for (const [oi, o] of fn.overloads.entries()) {
179221
for (const shape of argTuples(o, oi, 6)) {
180222
const args = shape.map((a) => sample(r, a));

src/types/sqlite/smoke.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ test("same-return integer/real twins route primitives to Real (no truncation)",
111111
expect(parseFloat(r.rows[0]!["v"]!)).toBeCloseTo(Math.atan2(1, 0.5), 5);
112112
});
113113

114+
test("json_each: table-valued function via db.from", async () => {
115+
const each = Text.from('{"a":1,"b":2}').jsonEach();
116+
const rows = await conn.execute(db.from(each).orderBy(({ json_each }) => json_each.key));
117+
expect(rows).toMatchObject([
118+
{ key: "a", value: "1", type: "integer" },
119+
{ key: "b", value: "2", type: "integer" },
120+
]);
121+
});
122+
114123
test("optional-arity dispatch: omitted and supplied optional args both match", async () => {
115124
// Regression: match had no arity gate, so a zero-matcher case
116125
// ([[ ]], e.g. round()'s no-arg overload) vacuously matched ANY

0 commit comments

Comments
 (0)