246 lines
8.3 KiB
TypeScript
246 lines
8.3 KiB
TypeScript
import type {
|
|
AdminDatabaseLike,
|
|
DbParams,
|
|
DbTransaction,
|
|
PreparedStatement,
|
|
} from "@suaveplan/api-types/db";
|
|
|
|
interface Row {
|
|
[k: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* Tiny SQL-shape-aware in-memory admin adapter for unit tests in
|
|
* `@suaveplan/api-db-adapter`. It does NOT implement real SQL; it parses
|
|
* the small set of statements emitted by the tenant-scope wrapper and the
|
|
* migration runner.
|
|
*
|
|
* Supported:
|
|
* - CREATE TABLE IF NOT EXISTS <name> (...columns...)
|
|
* - INSERT INTO <name> (cols) VALUES (?,?,?)
|
|
* - INSERT INTO <name> (cols) VALUES (?,?,?), (?,?,?), ... (multi-row not required)
|
|
* - SELECT * FROM <name> [WHERE col = ? [AND col = ?]*]
|
|
* - SELECT <col> FROM <name> WHERE plugin = ?
|
|
* - DELETE FROM <name> [WHERE ...]
|
|
* - UPDATE <name> SET col = ? [, ...] [WHERE ...]
|
|
* - ALTER TABLE <name> ADD COLUMN <col> <type>
|
|
* - arbitrary "fail-me" sentinel SQL via the `failOn` predicate
|
|
*/
|
|
export interface MemoryAdminOptions {
|
|
/** When called with the SQL, return `true` to throw. Used for failure tests. */
|
|
failOn?: (sql: string) => boolean;
|
|
/** Disable transaction support to exercise the no-tx code path. */
|
|
noTransaction?: boolean;
|
|
}
|
|
|
|
export function createMemoryAdmin(
|
|
options: MemoryAdminOptions = {}
|
|
): AdminDatabaseLike & {
|
|
readonly tables: ReadonlyMap<string, Row[]>;
|
|
lastSql(): string | undefined;
|
|
sqlLog(): ReadonlyArray<{ sql: string; params: ReadonlyArray<unknown> }>;
|
|
} {
|
|
const tables = new Map<string, Row[]>();
|
|
let isClosed = false;
|
|
const log: { sql: string; params: ReadonlyArray<unknown> }[] = [];
|
|
|
|
const trip = (sql: string): void => {
|
|
if (options.failOn?.(sql) === true) {
|
|
throw new Error(`memory-admin: failOn triggered for: ${sql}`);
|
|
}
|
|
};
|
|
const normParams = (p?: DbParams): unknown[] => {
|
|
if (p === undefined) return [];
|
|
if (Array.isArray(p)) return [...p];
|
|
return [];
|
|
};
|
|
|
|
const runStatement = (
|
|
sql: string,
|
|
params: unknown[]
|
|
): { rows?: Row[]; rowsAffected: number } => {
|
|
log.push({ sql, params });
|
|
trip(sql);
|
|
const trimmed = sql.trim();
|
|
|
|
// CREATE TABLE IF NOT EXISTS <name> ...
|
|
const createM = trimmed.match(
|
|
/^create\s+table\s+if\s+not\s+exists\s+(\w+)/i
|
|
);
|
|
if (createM !== null) {
|
|
const name = createM[1] ?? "";
|
|
if (!tables.has(name)) tables.set(name, []);
|
|
return { rowsAffected: 0 };
|
|
}
|
|
|
|
// ALTER TABLE <name> ADD COLUMN <col> <type>
|
|
const alterM = trimmed.match(
|
|
/^alter\s+table\s+(\w+)\s+add\s+column\s+(\w+)/i
|
|
);
|
|
if (alterM !== null) {
|
|
return { rowsAffected: 0 };
|
|
}
|
|
|
|
// INSERT INTO <name> (cols) VALUES (?, ?, ...)
|
|
const insertM = trimmed.match(
|
|
/^insert\s+into\s+(\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i
|
|
);
|
|
if (insertM !== null) {
|
|
const name = insertM[1] ?? "";
|
|
const cols = (insertM[2] ?? "").split(",").map((c) => c.trim());
|
|
const vals = (insertM[3] ?? "").split(",").map((v) => v.trim());
|
|
const row: Row = {};
|
|
let pi = 0;
|
|
for (let i = 0; i < cols.length; i++) {
|
|
const v = vals[i] ?? "?";
|
|
if (v === "?") {
|
|
row[cols[i] ?? ""] = params[pi++];
|
|
} else if (/^\$\d+$/.test(v)) {
|
|
const idx = Number(v.slice(1)) - 1;
|
|
row[cols[i] ?? ""] = params[idx];
|
|
} else {
|
|
row[cols[i] ?? ""] = v.replace(/^['"]|['"]$/g, "");
|
|
}
|
|
}
|
|
if (!tables.has(name)) tables.set(name, []);
|
|
tables.get(name)?.push(row);
|
|
return { rowsAffected: 1 };
|
|
}
|
|
|
|
// SELECT ... FROM <name> [WHERE col = ? [AND col = ?]*]
|
|
const selectM = trimmed.match(
|
|
/^select\s+(.+?)\s+from\s+(\w+)(?:\s+where\s+(.+?))?(?:\s+order\s+by\s+.+)?(?:\s+limit\s+\d+)?;?\s*$/i
|
|
);
|
|
if (selectM !== null) {
|
|
const select = selectM[1] ?? "*";
|
|
const name = selectM[2] ?? "";
|
|
const whereClause = selectM[3];
|
|
const rows = tables.get(name) ?? [];
|
|
let filtered = rows;
|
|
if (whereClause !== undefined) {
|
|
const preds = whereClause.split(/\s+and\s+/i).map((p) => {
|
|
const m = p.match(
|
|
/(\w+)\s*=\s*(\?|\$\d+|'[^']*'|"[^"]*"|\d+)/
|
|
);
|
|
return m === null
|
|
? null
|
|
: { col: m[1] ?? "", val: m[2] ?? "" };
|
|
});
|
|
let pi = 0;
|
|
const resolved = preds.map((pr) => {
|
|
if (pr === null) return null;
|
|
let v: unknown;
|
|
if (pr.val === "?") {
|
|
v = params[pi++];
|
|
} else if (/^\$\d+$/.test(pr.val)) {
|
|
v = params[Number(pr.val.slice(1)) - 1];
|
|
} else {
|
|
v = pr.val.replace(/^['"]|['"]$/g, "");
|
|
}
|
|
return { col: pr.col, v };
|
|
});
|
|
filtered = rows.filter((r) =>
|
|
resolved.every(
|
|
(pr) =>
|
|
pr === null || String(r[pr.col]) === String(pr.v)
|
|
)
|
|
);
|
|
}
|
|
if (select.trim() === "*") {
|
|
return { rows: filtered, rowsAffected: filtered.length };
|
|
}
|
|
const cols = select.split(",").map((c) => c.trim());
|
|
return {
|
|
rows: filtered.map((r) => {
|
|
const out: Row = {};
|
|
for (const c of cols) out[c] = r[c];
|
|
return out;
|
|
}),
|
|
rowsAffected: filtered.length,
|
|
};
|
|
}
|
|
|
|
// UPDATE — used by some tests
|
|
const updateM = trimmed.match(
|
|
/^update\s+(\w+)\s+set\s+(.+?)(?:\s+where\s+(.+))?$/i
|
|
);
|
|
if (updateM !== null) {
|
|
// For tests we only need to know that the rewriter appended a
|
|
// WHERE clause; we don't have to actually mutate rows.
|
|
return { rowsAffected: 0 };
|
|
}
|
|
|
|
// DELETE
|
|
const deleteM = trimmed.match(/^delete\s+from\s+(\w+)/i);
|
|
if (deleteM !== null) {
|
|
return { rowsAffected: 0 };
|
|
}
|
|
|
|
throw new Error(`memory-admin: unsupported SQL: ${sql}`);
|
|
};
|
|
|
|
const query = async <R = unknown>(
|
|
sql: string,
|
|
params?: DbParams
|
|
): Promise<R> => {
|
|
if (isClosed) throw new Error("memory-admin: closed");
|
|
const r = runStatement(sql, normParams(params));
|
|
return (r.rows ?? []) as unknown as R;
|
|
};
|
|
|
|
const execute = async (
|
|
sql: string,
|
|
params?: DbParams
|
|
): Promise<{ rowsAffected: number }> => {
|
|
if (isClosed) throw new Error("memory-admin: closed");
|
|
const r = runStatement(sql, normParams(params));
|
|
return { rowsAffected: r.rowsAffected };
|
|
};
|
|
|
|
const prepare = async <R = unknown>(
|
|
sql: string
|
|
): Promise<PreparedStatement<R>> => ({
|
|
id: `stmt:${sql}`,
|
|
execute: async (params) => (await query<R>(sql, params)) as R,
|
|
finalize: async () => undefined,
|
|
});
|
|
|
|
const transaction = async <R>(
|
|
fn: (tx: DbTransaction) => Promise<R>
|
|
): Promise<R> => {
|
|
const snapshot = new Map<string, Row[]>();
|
|
for (const [k, v] of tables) snapshot.set(k, [...v]);
|
|
const tx: DbTransaction = {
|
|
query,
|
|
execute,
|
|
commit: async () => undefined,
|
|
rollback: async () => undefined,
|
|
};
|
|
try {
|
|
return await fn(tx);
|
|
} catch (e) {
|
|
tables.clear();
|
|
for (const [k, v] of snapshot) tables.set(k, v);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const noTx = options.noTransaction === true;
|
|
|
|
return {
|
|
engine: "sqlite",
|
|
query,
|
|
execute,
|
|
prepare,
|
|
...(noTx ? {} : { transaction }),
|
|
close: async () => {
|
|
isClosed = true;
|
|
},
|
|
get tables() {
|
|
return tables;
|
|
},
|
|
lastSql: () => log.at(-1)?.sql,
|
|
sqlLog: () => log,
|
|
};
|
|
}
|