import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import type { ElyD1PreparedStatement, ElyD1Result } from "../src/bindings.js"; import type { RecordedD1Database } from "./devices_test_support.js"; export class SqliteD1Database implements RecordedD1Database { readonly authBinds: unknown[][] = []; readonly authQueries: string[] = []; readonly batches: number[] = []; readonly binds: unknown[][] = []; readonly queries: string[] = []; readonly sessionConstraints: string[] = []; private beforeBatchSql: string | undefined; constructor( private readonly databasePath: string, beforeBatchSql?: string, ) { this.beforeBatchSql = beforeBatchSql; } prepare(sql: string): ElyD1PreparedStatement { this.queries.push(sql); return new SqliteD1Statement(this, sql); } async batch(statements: ElyD1PreparedStatement[]): Promise { this.batches.push(statements.length); if (this.beforeBatchSql !== undefined) { execute(this.databasePath, this.beforeBatchSql); this.beforeBatchSql = undefined; } const prepared = statements.map((statement) => { assert.ok(statement instanceof SqliteD1Statement); return statement.sql(); }); const script = [ ".bail on", "PRAGMA foreign_keys = ON;", "BEGIN IMMEDIATE;", ...prepared.flatMap((sql, index) => [ `.print __ELY_BEGIN_${index}`, sql, `.print __ELY_CHANGES_${index}`, "SELECT changes() AS __ely_changes;", `.print __ELY_END_${index}`, ]), "COMMIT;", ].join("\n"); const output = sqlite(this.databasePath, script, true); const lines = output.trim().split(/\r?\n/).filter(Boolean); const results = prepared.map((_, index) => { const begin = lines.indexOf(`__ELY_BEGIN_${index}`); const changesMarker = lines.indexOf(`__ELY_CHANGES_${index}`); const end = lines.indexOf(`__ELY_END_${index}`); assert.ok(begin >= 0 && changesMarker > begin && end > changesMarker); const rows = lines .slice(begin + 1, changesMarker) .flatMap((line) => JSON.parse(line) as unknown[]); const changeRows = JSON.parse(lines[changesMarker + 1] ?? "[]") as { __ely_changes?: unknown; }[]; const value = changeRows[0]?.__ely_changes; assert.equal(typeof value, "number"); return { results: rows, meta: { changes: value } }; }); return results as T[]; } async exec(sql: string): Promise { execute(this.databasePath, sql); return {}; } withSession(constraint: "first-primary"): SqliteD1Database { this.sessionConstraints.push(constraint); return this; } rows(sql: string): T[] { return query(this.databasePath, sql) as T[]; } } class SqliteD1Statement implements ElyD1PreparedStatement { private values: unknown[] = []; constructor( private readonly database: SqliteD1Database, private readonly queryText: string, ) {} bind(...values: unknown[]): ElyD1PreparedStatement { this.values = values; this.database.binds.push(values); return this; } async first(): Promise { return this.database.rows(this.sql())[0] ?? null; } async all(): Promise> { return { results: this.database.rows(this.sql()) }; } async run(): Promise { const rows = this.database.rows<{ changes: number }>( `${this.sql()}\nSELECT changes() AS changes;`, ); return { results: [], meta: { changes: rows[0]?.changes ?? 0 } }; } sql(): string { let index = 0; const sql = this.queryText.replace(/\?/g, () => sqlLiteral(this.values[index++])); assert.equal(index, this.values.length, "D1 bind count must match SQL placeholders"); return `${sql.trim().replace(/;$/, "")};`; } } export function execute(databasePath: string, sql: string): void { sqlite(databasePath, `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`); } export function query(databasePath: string, sql: string): Record[] { const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true); return output.trim() === "" ? [] : JSON.parse(output) as Record[]; } function sqlite(databasePath: string, sql: string, json = false): string { try { return execFileSync("sqlite3", [...(json ? ["-json"] : []), databasePath], { input: sql, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], }); } catch (error) { const stderr = typeof error === "object" && error !== null && "stderr" in error ? String(error.stderr) : ""; throw new Error(`${error instanceof Error ? error.message : String(error)}\n${stderr}`); } } function sqlLiteral(value: unknown): string { if (value === null) return "NULL"; if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`; if (typeof value === "number" && Number.isFinite(value)) return value.toString(); throw new TypeError("Unsupported SQLite test binding"); }