Files
ELY-Browser/cloudflare/tests/sqlite_d1_test_support.ts
T

152 lines
4.9 KiB
TypeScript

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<T>(statements: ElyD1PreparedStatement[]): Promise<T[]> {
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<unknown> {
execute(this.databasePath, sql);
return {};
}
withSession(constraint: "first-primary"): SqliteD1Database {
this.sessionConstraints.push(constraint);
return this;
}
rows<T>(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<T>(): Promise<T | null> {
return this.database.rows<T>(this.sql())[0] ?? null;
}
async all<T>(): Promise<ElyD1Result<T>> {
return { results: this.database.rows<T>(this.sql()) };
}
async run(): Promise<unknown> {
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<string, unknown>[] {
const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true);
return output.trim() === "" ? [] : JSON.parse(output) as Record<string, unknown>[];
}
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");
}