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>
242 lines
7.9 KiB
TypeScript
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`
|
|
);
|
|
}
|