first commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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>();
|
||||
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<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, 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user