/** * 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(); 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"; 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(); 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") { blocks.push( `export async function ${q.name}(${sig}): Promise {\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, ExecResult } from "@wrnexus/db";`]; if (usedModels.size > 0) { imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`); } return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`; }