Files
WRNexusJS/packages/db/src/generate.ts
T
ClintchizandClaude Opus 5 b3b65dddd8
Quality / quality (ubuntu-latest) (push) Failing after 12m46s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(db): stamp the dialect into generated query files
The same generate command emitted ? one run and $1 the next, which looked
like non-determinism. It is not: postgres uses $1 placeholders where
sqlite and mysql use ?, and the driver comes from the active profile, so
building under a different profile rewrites this committed file.

The header now records the dialect it was generated for, making the flip
visible in the diff and explaining check:generated-types failures instead
of leaving them looking like random churn.

Worth deciding separately: a committed artifact whose contents depend on
the active profile will keep drifting. Either generate per dialect, or
stop committing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:44:36 +05:30

242 lines
7.9 KiB
TypeScript

/**
* sqlc-style query generator. Annotated SQL in `app/db/queries/*.sql` becomes
* typed TS functions whose params + results are inferred from the TS models and
* whose rows are mapped back through `model.parse`.
*
* -- name: GetUserByEmail :one
* SELECT * FROM users WHERE email = :email;
*
* → GetUserByEmail(db, { email: string }): Promise<{…} | null>
*
* Type inference is best-effort (comparisons + INSERT column lists + SELECT list
* vs the model); anything it can't resolve becomes `unknown`.
*/
import type { Column, Model } from "./schema.ts";
import type { Dialect } from "./sql.ts";
export type QueryKind = "one" | "many" | "exec";
export interface QueryDef {
name: string;
kind: QueryKind;
sql: string;
}
/** A model plus the variable name it is exported under (for imports). */
export interface ModelRef {
varName: string;
model: Model;
}
/** Parse annotated queries from one `.sql` file's contents. */
export function parseQueries(content: string): QueryDef[] {
const out: QueryDef[] = [];
const re = /--\s*name:\s*(\w+)\s*:(one|many|exec)\b[^\n]*\n([\s\S]*?)(?=--\s*name:|$)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content))) {
out.push({
name: m[1]!,
kind: m[2]!.toLowerCase() as QueryKind,
sql: m[3]!.trim().replace(/;\s*$/, ""),
});
}
return out;
}
/** Rewrite `:name` placeholders to positional params, keeping their order. */
function toPositional(sql: string, dialect: Dialect): { sql: string; order: string[] } {
const order: string[] = [];
const rewritten = sql.replace(/:([A-Za-z_]\w*)/g, (_m, name: string) => {
order.push(name);
return dialect === "postgres" ? `$${order.length}` : "?";
});
return { sql: rewritten, order };
}
function uniqueInOrder(names: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const n of names) {
if (!seen.has(n)) {
seen.add(n);
out.push(n);
}
}
return out;
}
function tableOf(sql: string): string | undefined {
const from = /\bFROM\s+["`]?(\w+)["`]?/i.exec(sql);
if (from) return from[1];
const into = /\bINTO\s+["`]?(\w+)["`]?/i.exec(sql);
if (into) return into[1];
const upd = /\bUPDATE\s+["`]?(\w+)["`]?/i.exec(sql);
return upd?.[1];
}
function tsOutput(col: Column): string {
switch (col.def.type) {
case "id":
case "int":
case "real":
return "number";
case "bool":
return "boolean";
case "timestamp":
return "Date";
case "json":
return "unknown";
default:
return "string";
}
}
function tsInput(col: Column): string {
return col.def.type === "timestamp" ? "string | Date" : tsOutput(col);
}
interface SelectCol {
name: string;
/** A type forced by an aggregate (e.g. COUNT → number), overriding the model. */
forced?: string;
}
/** Split a SELECT list on top-level commas (respecting `fn(a, b)`). */
function splitTopLevel(list: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of list) {
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur);
cur = "";
} else cur += ch;
}
if (cur.trim()) out.push(cur);
return out;
}
/** Parse the SELECT list into columns, or null for `SELECT *`. */
function selectColumns(sql: string): SelectCol[] | null {
const m = /SELECT\s+([\s\S]*?)\s+FROM\b/i.exec(sql);
if (!m) return null;
const list = m[1]!.trim();
if (list === "*") return null;
return splitTopLevel(list).map((seg): SelectCol => {
const s = seg.trim();
const alias = /\s+AS\s+["`]?(\w+)["`]?$/i.exec(s);
const name = alias ? alias[1]! : s.split(".").pop()!.replace(/["`]/g, "");
const forced = /\b(count|sum|avg|min|max|total)\s*\(/i.test(s) ? "number" : undefined;
return { name, forced };
});
}
/** True when every selected column is a plain model column (so `model.parse` fits). */
function columnsMatchModel(cols: SelectCol[] | null, model: Model | undefined): boolean {
if (!model) return false;
if (cols === null) return true; // SELECT * → full model row
return cols.every((c) => !c.forced && !!model.columns[c.name]);
}
function resultType(cols: SelectCol[] | null, model: Model | undefined): string {
if (cols === null) {
if (!model) return "Record<string, unknown>";
return `{ ${Object.entries(model.columns)
.map(([k, col]) => `${k}: ${tsOutput(col)}`)
.join("; ")} }`;
}
return `{ ${cols
.map(
(c) =>
`${c.name}: ${c.forced ?? (model && model.columns[c.name] ? tsOutput(model.columns[c.name]!) : "unknown")}`,
)
.join("; ")} }`;
}
/** Find the column a `:param` is compared to / inserted into, if any. */
function paramColumn(param: string, sql: string): string | undefined {
const op = "(?:=|!=|<>|<=|>=|<|>|LIKE)";
const cmp1 = new RegExp(`(\\w+)\\s*${op}\\s*:${param}\\b`, "i").exec(sql);
if (cmp1) return cmp1[1];
const cmp2 = new RegExp(`:${param}\\b\\s*${op}\\s*(\\w+)`, "i").exec(sql);
if (cmp2) return cmp2[1];
const ins = /INSERT\s+INTO\s+\w+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i.exec(sql);
if (ins) {
const cols = ins[1]!.split(",").map((s) => s.trim().replace(/["`]/g, ""));
const vals = ins[2]!.split(",").map((s) => s.trim());
const idx = vals.indexOf(`:${param}`);
if (idx >= 0 && cols[idx]) return cols[idx];
}
return undefined;
}
function inferParamType(param: string, sql: string, model: Model | undefined): string {
if (!model) return "unknown";
const col = paramColumn(param, sql);
const column = col ? model.columns[col] : undefined;
return column ? tsInput(column) : "unknown";
}
/** Generate the full `queries.gen.ts` source. */
export function generateQueriesFile(
queries: QueryDef[],
models: ModelRef[],
dialect: Dialect,
): string {
const byTable = new Map(models.map((m) => [m.model.name, m]));
const usedModels = new Set<string>();
let usesExecResult = false;
const blocks: string[] = [];
for (const q of queries) {
const { sql, order } = toPositional(q.sql, dialect);
const sqlLit = JSON.stringify(sql);
const positional = `[${order.map((n) => `args.${n}`).join(", ")}]`;
const params = uniqueInOrder(order);
const table = tableOf(q.sql);
const ref = table ? byTable.get(table) : undefined;
const argFields = params.map((p) => `${p}: ${inferParamType(p, q.sql, ref?.model)}`);
const sig = argFields.length ? `db: Db, args: { ${argFields.join("; ")} }` : "db: Db";
if (q.kind === "exec") {
usesExecResult = true;
blocks.push(
`export async function ${q.name}(${sig}): Promise<ExecResult> {\n return db.exec(${sqlLit}, ${positional});\n}`,
);
continue;
}
const cols = selectColumns(q.sql);
const row = resultType(cols, ref?.model);
const ret = q.kind === "one" ? `${row} | null` : `${row}[]`;
const method = q.kind === "one" ? "one" : "all";
// Only map through the model when the selected columns are model columns.
const passModel = !!ref && columnsMatchModel(cols, ref.model);
let modelArg = "";
if (passModel && ref) {
usedModels.add(ref.varName);
modelArg = `, ${ref.varName}`;
}
blocks.push(
`export async function ${q.name}(${sig}): Promise<${ret}> {\n return (await db.${method}(${sqlLit}, ${positional}${modelArg})) as ${ret};\n}`,
);
}
const imports = [`import type { Db${usesExecResult ? ", ExecResult" : ""} } from "@wrnexus/db";`];
if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
}
// The dialect is stamped into the header because it changes the emitted SQL:
// postgres uses $1 placeholders where sqlite and mysql use ?. Regenerating
// under a different profile therefore rewrites this committed file, and
// without the stamp the diff looks like unexplained churn.
return (
`// AUTO-GENERATED by \`wrnexus db generate\` (dialect: ${dialect}) — do not edit.\n` +
`${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`
);
}