@syncular/typegen 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +436 -94
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +159 -73
- package/dist/emit-dart.d.ts +17 -0
- package/dist/emit-dart.js +201 -0
- package/dist/emit-kotlin.d.ts +16 -0
- package/dist/emit-kotlin.js +201 -0
- package/dist/emit-queries-dart.d.ts +2 -0
- package/dist/emit-queries-dart.js +160 -0
- package/dist/emit-queries-kotlin.d.ts +2 -0
- package/dist/emit-queries-kotlin.js +162 -0
- package/dist/emit-queries-swift.d.ts +2 -0
- package/dist/emit-queries-swift.js +146 -0
- package/dist/emit-queries.d.ts +2 -0
- package/dist/emit-queries.js +129 -0
- package/dist/emit-swift.d.ts +18 -0
- package/dist/emit-swift.js +235 -0
- package/dist/emit.d.ts +16 -0
- package/dist/emit.js +191 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +10 -0
- package/dist/generate.d.ts +53 -18
- package/dist/generate.js +391 -77
- package/dist/index.d.ts +17 -14
- package/dist/index.js +16 -13
- package/dist/init.d.ts +12 -0
- package/dist/init.js +85 -0
- package/dist/ir.d.ts +90 -0
- package/dist/ir.js +94 -0
- package/dist/manifest.d.ts +76 -0
- package/dist/manifest.js +303 -0
- package/dist/query.d.ts +96 -0
- package/dist/query.js +719 -0
- package/dist/sql.d.ts +13 -0
- package/dist/sql.js +440 -0
- package/package.json +14 -34
- package/src/cli.ts +161 -82
- package/src/emit-dart.ts +245 -0
- package/src/emit-kotlin.ts +257 -0
- package/src/emit-queries-dart.ts +203 -0
- package/src/emit-queries-kotlin.ts +209 -0
- package/src/emit-queries-swift.ts +198 -0
- package/src/emit-queries.ts +183 -0
- package/src/emit-swift.ts +295 -0
- package/src/emit.ts +239 -0
- package/src/errors.ts +11 -0
- package/src/generate.ts +574 -105
- package/src/index.ts +16 -13
- package/src/init.ts +100 -0
- package/src/ir.ts +197 -0
- package/src/manifest.ts +445 -0
- package/src/query.ts +918 -0
- package/src/sql.ts +533 -0
- package/dist/app-contract.d.ts +0 -154
- package/dist/app-contract.d.ts.map +0 -1
- package/dist/app-contract.js +0 -250
- package/dist/app-contract.js.map +0 -1
- package/dist/checksums.d.ts +0 -6
- package/dist/checksums.d.ts.map +0 -1
- package/dist/checksums.js +0 -173
- package/dist/checksums.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/generate.d.ts.map +0 -1
- package/dist/generate.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/introspect-postgres.d.ts +0 -8
- package/dist/introspect-postgres.d.ts.map +0 -1
- package/dist/introspect-postgres.js +0 -94
- package/dist/introspect-postgres.js.map +0 -1
- package/dist/introspect-sqlite.d.ts +0 -10
- package/dist/introspect-sqlite.d.ts.map +0 -1
- package/dist/introspect-sqlite.js +0 -97
- package/dist/introspect-sqlite.js.map +0 -1
- package/dist/introspect.d.ts +0 -8
- package/dist/introspect.d.ts.map +0 -1
- package/dist/introspect.js +0 -18
- package/dist/introspect.js.map +0 -1
- package/dist/map-types.d.ts +0 -20
- package/dist/map-types.d.ts.map +0 -1
- package/dist/map-types.js +0 -151
- package/dist/map-types.js.map +0 -1
- package/dist/render.d.ts +0 -23
- package/dist/render.d.ts.map +0 -1
- package/dist/render.js +0 -140
- package/dist/render.js.map +0 -1
- package/dist/types.d.ts +0 -122
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/app-contract.ts +0 -531
- package/src/checksums.ts +0 -257
- package/src/introspect-postgres.ts +0 -149
- package/src/introspect-sqlite.ts +0 -156
- package/src/introspect.ts +0 -36
- package/src/map-types.ts +0 -189
- package/src/render.ts +0 -196
- package/src/types.ts +0 -137
package/src/query.ts
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-query analysis (the sqlc/SQLDelight tier).
|
|
3
|
+
*
|
|
4
|
+
* A `queries/` dir next to migrations is walked RECURSIVELY for `.sql` files;
|
|
5
|
+
* folders are pure organization. A statement's default name is its path
|
|
6
|
+
* relative to the queries root — segments + filename, joined and camelCased
|
|
7
|
+
* (`billing/invoices/list.sql` → `billingInvoicesList`; a flat `list-todos.sql`
|
|
8
|
+
* → `listTodos`, unchanged). A `-- name: ident` marker in a statement's leading
|
|
9
|
+
* comment block overrides the FULL name verbatim. A file may hold MULTIPLE
|
|
10
|
+
* statements (split on top-level `;`); a single-statement file may omit
|
|
11
|
+
* `-- name:` (path-derived), a multi-statement file requires it on EVERY
|
|
12
|
+
* statement. SELECT-only — writes stay `mutate()` (SPEC §7.1). Each query is
|
|
13
|
+
* type-checked BY SQLITE
|
|
14
|
+
* ITSELF: we synthesize the schema's DDL from the IR (the reverse of the SQL
|
|
15
|
+
* parser), build an in-memory `bun:sqlite` DB, and `prepare()` the query. That
|
|
16
|
+
* validates syntax + every table/column reference for free, and yields the
|
|
17
|
+
* result column names + their declared SQLite types (`declaredTypes` =
|
|
18
|
+
* `sqlite3_column_decltype`).
|
|
19
|
+
*
|
|
20
|
+
* ## Typing fidelity (the honest boundary — what bun:sqlite actually exposes)
|
|
21
|
+
*
|
|
22
|
+
* bun:sqlite exposes, per prepared statement: `columnNames`, `declaredTypes`
|
|
23
|
+
* (decltype, once executed once — we `.all()` against the empty DB), and
|
|
24
|
+
* `paramsCount`. It does NOT expose column ORIGIN (table/column) nor param
|
|
25
|
+
* NAMES nor NOT-NULL flags. So:
|
|
26
|
+
*
|
|
27
|
+
* - **Plain column ref** (`title`, `t.title`, `title AS x`): decltype is the
|
|
28
|
+
* origin column's exact declared type. We map it back through the SAME
|
|
29
|
+
* TYPE_MAP the migration parser uses → the exact IR column type. For
|
|
30
|
+
* NULLABILITY we resolve the ref against the IR ourselves (parse the SELECT
|
|
31
|
+
* list + FROM/JOIN, match `name`/`alias.name` to an IR table column) — an
|
|
32
|
+
* IR-exact non-nullable/nullable answer. This is the drift-kill: same type
|
|
33
|
+
* AND nullability as the schema, guaranteed by SQLite's own reference check.
|
|
34
|
+
* - **Computed expression** (`count(*)`, `done + 1`, `:label AS l`): decltype
|
|
35
|
+
* is null. We fall back to a documented honest type from the raw column
|
|
36
|
+
* name/shape: aggregate/arith → nullable number, anything else → nullable
|
|
37
|
+
* string. Marked `nullable: true` always (an expression's nullability is not
|
|
38
|
+
* knowable from decltype). Callers who want an exact type give the column an
|
|
39
|
+
* alias whose ref is plain, or accept the honest fallback.
|
|
40
|
+
*
|
|
41
|
+
* ## Parameters
|
|
42
|
+
*
|
|
43
|
+
* Named `:name` placeholders. Types are inferred where the param compares
|
|
44
|
+
* against a plain column ref (`WHERE list_id = :listId` / `col IN (:a, :b)`) —
|
|
45
|
+
* the param takes that column's IR type. Ambiguous params (compared to an
|
|
46
|
+
* expression, used only in a projection, etc.) require a
|
|
47
|
+
* `-- param :name <type>` header comment; missing both is a generate-time
|
|
48
|
+
* error listing the fix. bun:sqlite can't name params, so we parse `:name`
|
|
49
|
+
* tokens from the SQL ourselves (first-occurrence order = positional order).
|
|
50
|
+
*
|
|
51
|
+
* ## The tables set (for useSyncQuery `{tables}` — exact invalidation)
|
|
52
|
+
*
|
|
53
|
+
* bun:sqlite exposes no authorizer and no statement table-list; EXPLAIN
|
|
54
|
+
* opcodes are fragile. The honest mechanism: the FROM/JOIN table set we
|
|
55
|
+
* already resolve for column typing, VALIDATED against the IR by the
|
|
56
|
+
* SQLite prepare() (an unknown table would have thrown). Boundary: tables
|
|
57
|
+
* referenced ONLY inside a subquery/`WHERE EXISTS (...)` are still captured
|
|
58
|
+
* because we scan every `FROM`/`JOIN` identifier in the whole statement and
|
|
59
|
+
* keep those that name an IR table; a table that appears under neither keyword
|
|
60
|
+
* (impossible for a real reference) is not captured. Conservative + correct
|
|
61
|
+
* for the query shapes this tier supports.
|
|
62
|
+
*/
|
|
63
|
+
import { TypegenError } from './errors';
|
|
64
|
+
import type { IrColumnType, IrDocument, IrTable } from './ir';
|
|
65
|
+
|
|
66
|
+
/** The SQL decltype keyword → §2.4 type map (mirrors sql.ts TYPE_MAP so a
|
|
67
|
+
* plain column ref's decltype resolves to the exact IR type). */
|
|
68
|
+
const DECLTYPE_MAP: Readonly<Record<string, IrColumnType>> = {
|
|
69
|
+
TEXT: 'string',
|
|
70
|
+
INTEGER: 'integer',
|
|
71
|
+
INT: 'integer',
|
|
72
|
+
BIGINT: 'integer',
|
|
73
|
+
SMALLINT: 'integer',
|
|
74
|
+
REAL: 'float',
|
|
75
|
+
FLOAT: 'float',
|
|
76
|
+
DOUBLE: 'float',
|
|
77
|
+
BOOLEAN: 'boolean',
|
|
78
|
+
BOOL: 'boolean',
|
|
79
|
+
JSON: 'json',
|
|
80
|
+
JSONB: 'json',
|
|
81
|
+
BLOB: 'bytes',
|
|
82
|
+
BYTEA: 'bytes',
|
|
83
|
+
BLOB_REF: 'blob_ref',
|
|
84
|
+
BLOBREF: 'blob_ref',
|
|
85
|
+
CRDT: 'crdt',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** A param type is one of the §2.4 types (the columns params compare to). */
|
|
89
|
+
export type QueryParamType = IrColumnType;
|
|
90
|
+
|
|
91
|
+
export interface QueryParam {
|
|
92
|
+
readonly name: string;
|
|
93
|
+
readonly type: QueryParamType;
|
|
94
|
+
/** How the type was resolved — for the docs/tests, not emitted. */
|
|
95
|
+
readonly source: 'inferred' | 'comment';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface QueryColumn {
|
|
99
|
+
readonly name: string;
|
|
100
|
+
readonly type: IrColumnType;
|
|
101
|
+
readonly nullable: boolean;
|
|
102
|
+
/** `exact` = resolved to an IR column (plain ref); `fallback` = a computed
|
|
103
|
+
* expression typed by the documented honest fallback. */
|
|
104
|
+
readonly fidelity: 'exact' | 'fallback';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface AnalyzedQuery {
|
|
108
|
+
/** camelCase function name (path-derived, or a `-- name:` override). */
|
|
109
|
+
readonly name: string;
|
|
110
|
+
/** Source location for errors, e.g. `billing/list.sql` (or `list.sql#2`
|
|
111
|
+
* for the 2nd statement of a multi-statement file). */
|
|
112
|
+
readonly file: string;
|
|
113
|
+
/** The SQL as written (named `:params`), trimmed. */
|
|
114
|
+
readonly sql: string;
|
|
115
|
+
/** The SQL with `:name` rewritten to positional `?` (wrapper surface). */
|
|
116
|
+
readonly positionalSql: string;
|
|
117
|
+
/** Params in first-occurrence (positional) order. */
|
|
118
|
+
readonly params: readonly QueryParam[];
|
|
119
|
+
/** Result columns in SELECT order. */
|
|
120
|
+
readonly columns: readonly QueryColumn[];
|
|
121
|
+
/** IR tables this query reads (the useSyncQuery `{tables}` set), sorted. */
|
|
122
|
+
readonly tables: readonly string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- path → name --------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** A single kebab-case path segment (folder) or filename stem. */
|
|
128
|
+
const KEBAB_SEGMENT_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
129
|
+
/** A valid camelCase identifier — what a `-- name:` override must be. */
|
|
130
|
+
const CAMEL_IDENT_RE = /^[a-z][A-Za-z0-9]*$/;
|
|
131
|
+
|
|
132
|
+
/** Split a kebab word list into a camelCase suffix (used per segment). */
|
|
133
|
+
function kebabWords(segment: string): string[] {
|
|
134
|
+
return segment.split('-');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Path-relative default name. `billing/invoices/list.sql` → `billingInvoicesList`;
|
|
139
|
+
* a flat `list-todos.sql` → `listTodos` (unchanged). Every folder segment AND
|
|
140
|
+
* the filename stem must be lowercase kebab-case — rejects loudly, naming the
|
|
141
|
+
* offending segment, so a stray `List.sql` or `Billing/` fails at generate time.
|
|
142
|
+
*/
|
|
143
|
+
export function queryNameFromPath(relPath: string): string {
|
|
144
|
+
const norm = relPath.replace(/\\/g, '/');
|
|
145
|
+
if (!norm.endsWith('.sql')) {
|
|
146
|
+
throw new TypegenError(
|
|
147
|
+
relPath,
|
|
148
|
+
`query file must end in .sql, got ${JSON.stringify(relPath)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const withoutExt = norm.slice(0, -'.sql'.length);
|
|
152
|
+
const segments = withoutExt.split('/').filter((s) => s.length > 0);
|
|
153
|
+
if (segments.length === 0) {
|
|
154
|
+
throw new TypegenError(
|
|
155
|
+
relPath,
|
|
156
|
+
`invalid query path ${JSON.stringify(relPath)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const words: string[] = [];
|
|
160
|
+
for (const segment of segments) {
|
|
161
|
+
if (!KEBAB_SEGMENT_RE.test(segment)) {
|
|
162
|
+
throw new TypegenError(
|
|
163
|
+
relPath,
|
|
164
|
+
`path segment ${JSON.stringify(segment)} in ${JSON.stringify(norm)} must be lowercase kebab-case (e.g. billing/invoices/list-open.sql → billingInvoicesListOpen)`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
words.push(...kebabWords(segment));
|
|
168
|
+
}
|
|
169
|
+
const first = words[0] as string;
|
|
170
|
+
return (
|
|
171
|
+
first +
|
|
172
|
+
words
|
|
173
|
+
.slice(1)
|
|
174
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
175
|
+
.join('')
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Back-compat flat-file helper: `list-todos.sql` → `listTodos`. Rejects a
|
|
180
|
+
* path with folders (use {@link queryNameFromPath} for nested queries). */
|
|
181
|
+
export function queryNameFromFile(file: string): string {
|
|
182
|
+
if (file.includes('/') || file.includes('\\')) {
|
|
183
|
+
throw new TypegenError(
|
|
184
|
+
file,
|
|
185
|
+
`queryNameFromFile takes a flat filename; got a path ${JSON.stringify(file)} — use queryNameFromPath`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return queryNameFromPath(file);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Validate a `-- name:` override is a camelCase identifier (loud otherwise). */
|
|
192
|
+
export function validateOverrideName(name: string, location: string): string {
|
|
193
|
+
if (!CAMEL_IDENT_RE.test(name)) {
|
|
194
|
+
throw new TypegenError(
|
|
195
|
+
location,
|
|
196
|
+
`-- name: ${JSON.stringify(name)} must be a camelCase identifier (start lowercase, letters/digits only, e.g. billingInvoicesList)`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return name;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -- statement splitting ------------------------------------------------------
|
|
203
|
+
//
|
|
204
|
+
// A file may hold multiple statements separated by top-level `;`. We split on
|
|
205
|
+
// semicolons that are NOT inside a single-quoted string, a `--` line comment,
|
|
206
|
+
// or a `/* */` block comment. The SELECT-only subset has no BEGIN/END blocks,
|
|
207
|
+
// so top-level semicolons are unambiguous statement terminators. A trailing
|
|
208
|
+
// semicolon on the last statement is optional (an empty tail is dropped).
|
|
209
|
+
|
|
210
|
+
export interface SplitStatement {
|
|
211
|
+
/** The raw statement text (leading comment block + SQL), as written. */
|
|
212
|
+
readonly text: string;
|
|
213
|
+
/** 1-based line number of the statement's first character in the file. */
|
|
214
|
+
readonly startLine: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Split file content into statements on top-level `;` (string/comment-aware).
|
|
218
|
+
* Blank/comment-only tails are dropped; each statement keeps its own leading
|
|
219
|
+
* comment block (everything between the previous `;` and this SQL). */
|
|
220
|
+
export function splitStatements(content: string): SplitStatement[] {
|
|
221
|
+
const parts: { text: string; startOffset: number }[] = [];
|
|
222
|
+
let i = 0;
|
|
223
|
+
let start = 0;
|
|
224
|
+
while (i < content.length) {
|
|
225
|
+
const ch = content[i] as string;
|
|
226
|
+
const next = content[i + 1];
|
|
227
|
+
if (ch === '-' && next === '-') {
|
|
228
|
+
const end = content.indexOf('\n', i);
|
|
229
|
+
i = end === -1 ? content.length : end;
|
|
230
|
+
} else if (ch === '/' && next === '*') {
|
|
231
|
+
const end = content.indexOf('*/', i + 2);
|
|
232
|
+
i = end === -1 ? content.length : end + 2;
|
|
233
|
+
} else if (ch === "'") {
|
|
234
|
+
let j = i + 1;
|
|
235
|
+
for (;;) {
|
|
236
|
+
if (j >= content.length) break;
|
|
237
|
+
if (content[j] === "'") {
|
|
238
|
+
if (content[j + 1] === "'") j += 2;
|
|
239
|
+
else {
|
|
240
|
+
j += 1;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
} else j += 1;
|
|
244
|
+
}
|
|
245
|
+
i = j;
|
|
246
|
+
} else if (ch === ';') {
|
|
247
|
+
parts.push({ text: content.slice(start, i), startOffset: start });
|
|
248
|
+
i += 1;
|
|
249
|
+
start = i;
|
|
250
|
+
} else {
|
|
251
|
+
i += 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (start < content.length) {
|
|
255
|
+
parts.push({ text: content.slice(start), startOffset: start });
|
|
256
|
+
}
|
|
257
|
+
const out: SplitStatement[] = [];
|
|
258
|
+
for (const part of parts) {
|
|
259
|
+
// Drop parts whose SQL body (comments/whitespace stripped) is empty — a
|
|
260
|
+
// trailing `;` or a comment-only tail is not a statement.
|
|
261
|
+
if (stripCommentsAndStrings(part.text).trim().length === 0) continue;
|
|
262
|
+
// Report the line of the statement's first NON-blank character (skip the
|
|
263
|
+
// blank/whitespace run after the previous `;`), so the location points at
|
|
264
|
+
// real content, not the terminator's trailing newline.
|
|
265
|
+
const lead = part.text.length - part.text.replace(/^\s+/, '').length;
|
|
266
|
+
const before = content.slice(0, part.startOffset + lead);
|
|
267
|
+
const startLine = before.split('\n').length;
|
|
268
|
+
out.push({ text: part.text.replace(/^\s+/, ''), startLine });
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The leading comment block of a statement: the run of blank + line/block
|
|
274
|
+
* comment lines before the first SQL token. `-- name:` / `-- param` markers are
|
|
275
|
+
* scoped HERE (to the statement they directly precede), not file-wide. Returns
|
|
276
|
+
* the `--` comment lines (block comments carry no markers we parse). */
|
|
277
|
+
function leadingCommentBlock(statementText: string): string[] {
|
|
278
|
+
const lines: string[] = [];
|
|
279
|
+
let i = 0;
|
|
280
|
+
while (i < statementText.length) {
|
|
281
|
+
// Skip leading whitespace on the line.
|
|
282
|
+
while (
|
|
283
|
+
i < statementText.length &&
|
|
284
|
+
/[ \t\r]/.test(statementText[i] as string)
|
|
285
|
+
) {
|
|
286
|
+
i += 1;
|
|
287
|
+
}
|
|
288
|
+
const ch = statementText[i];
|
|
289
|
+
const next = statementText[i + 1];
|
|
290
|
+
if (ch === '\n') {
|
|
291
|
+
i += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === '-' && next === '-') {
|
|
295
|
+
const end = statementText.indexOf('\n', i);
|
|
296
|
+
const line = statementText.slice(i, end === -1 ? undefined : end);
|
|
297
|
+
lines.push(line);
|
|
298
|
+
i = end === -1 ? statementText.length : end + 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (ch === '/' && next === '*') {
|
|
302
|
+
const end = statementText.indexOf('*/', i + 2);
|
|
303
|
+
// Block comments carry no markers we parse; skip and keep scanning for
|
|
304
|
+
// more leading comments (but they don't contribute -- name:/-- param).
|
|
305
|
+
i = end === -1 ? statementText.length : end + 2;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
break; // first SQL token — the comment block ends here
|
|
309
|
+
}
|
|
310
|
+
return lines;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const NAME_MARKER_RE = /^\s*--\s*name:\s*(\S+)\s*$/;
|
|
314
|
+
|
|
315
|
+
/** The `-- name:` override in a statement's leading comment block, or null. */
|
|
316
|
+
export function parseStatementName(
|
|
317
|
+
statementText: string,
|
|
318
|
+
location: string,
|
|
319
|
+
): string | null {
|
|
320
|
+
const lines = leadingCommentBlock(statementText);
|
|
321
|
+
let found: string | null = null;
|
|
322
|
+
for (const line of lines) {
|
|
323
|
+
const m = NAME_MARKER_RE.exec(line);
|
|
324
|
+
if (m === null) continue;
|
|
325
|
+
if (found !== null) {
|
|
326
|
+
throw new TypegenError(
|
|
327
|
+
location,
|
|
328
|
+
`two \`-- name:\` markers on one statement (${JSON.stringify(found)} and ${JSON.stringify(m[1])}) — a statement is named once`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
found = validateOverrideName(m[1] as string, location);
|
|
332
|
+
}
|
|
333
|
+
return found;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// -- lightweight SQL scanning -------------------------------------------------
|
|
337
|
+
//
|
|
338
|
+
// We do NOT re-implement SQLite's parser (that is what prepare() is for). We
|
|
339
|
+
// scan the text only for: header `-- param` comments, `:name` placeholders,
|
|
340
|
+
// FROM/JOIN table identifiers, and SELECT-column → source resolution. SQLite
|
|
341
|
+
// remains the authority on correctness.
|
|
342
|
+
|
|
343
|
+
interface CommentParam {
|
|
344
|
+
readonly name: string;
|
|
345
|
+
readonly type: IrColumnType;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const PARAM_COMMENT_RE =
|
|
349
|
+
/^\s*--\s*param\s+:([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_]+)\s*$/;
|
|
350
|
+
|
|
351
|
+
const PARAM_TYPES = new Set<string>([
|
|
352
|
+
'string',
|
|
353
|
+
'integer',
|
|
354
|
+
'float',
|
|
355
|
+
'boolean',
|
|
356
|
+
'json',
|
|
357
|
+
'bytes',
|
|
358
|
+
'blob_ref',
|
|
359
|
+
'crdt',
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
/** Parse `-- param :name <type>` header lines. They must precede the SQL. */
|
|
363
|
+
function parseCommentParams(file: string, raw: string): CommentParam[] {
|
|
364
|
+
const out: CommentParam[] = [];
|
|
365
|
+
for (const line of raw.split('\n')) {
|
|
366
|
+
const trimmed = line.trim();
|
|
367
|
+
if (trimmed.length === 0) continue;
|
|
368
|
+
if (!trimmed.startsWith('--')) break; // first non-comment line ends the header
|
|
369
|
+
const match = PARAM_COMMENT_RE.exec(line);
|
|
370
|
+
if (match === null) continue; // an ordinary comment, ignore
|
|
371
|
+
const name = match[1] as string;
|
|
372
|
+
const type = (match[2] as string).toLowerCase();
|
|
373
|
+
if (!PARAM_TYPES.has(type)) {
|
|
374
|
+
throw new TypegenError(
|
|
375
|
+
file,
|
|
376
|
+
`-- param :${name}: unknown type ${JSON.stringify(match[2])} (use one of ${[...PARAM_TYPES].join(', ')})`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
out.push({ name, type: type as IrColumnType });
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Strip `--` and block comments so identifier scans don't hit commented SQL,
|
|
385
|
+
* and string literals so `':x'` inside a string isn't read as a param. */
|
|
386
|
+
function stripCommentsAndStrings(sql: string): string {
|
|
387
|
+
let out = '';
|
|
388
|
+
let i = 0;
|
|
389
|
+
while (i < sql.length) {
|
|
390
|
+
const ch = sql[i] as string;
|
|
391
|
+
const next = sql[i + 1];
|
|
392
|
+
if (ch === '-' && next === '-') {
|
|
393
|
+
const end = sql.indexOf('\n', i);
|
|
394
|
+
i = end === -1 ? sql.length : end;
|
|
395
|
+
} else if (ch === '/' && next === '*') {
|
|
396
|
+
const end = sql.indexOf('*/', i + 2);
|
|
397
|
+
i = end === -1 ? sql.length : end + 2;
|
|
398
|
+
out += ' ';
|
|
399
|
+
} else if (ch === "'") {
|
|
400
|
+
const end = sql.indexOf("'", i + 1);
|
|
401
|
+
i = end === -1 ? sql.length : end + 1;
|
|
402
|
+
out += ' ';
|
|
403
|
+
} else {
|
|
404
|
+
out += ch;
|
|
405
|
+
i += 1;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return out;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Distinct `:name` placeholders in first-occurrence order (dedup by name,
|
|
412
|
+
* matching SQLite's paramsCount which counts distinct names). */
|
|
413
|
+
function scanParamNames(sql: string): string[] {
|
|
414
|
+
const cleaned = stripCommentsAndStrings(sql);
|
|
415
|
+
// `:name` but not `::` (Postgres cast) — our subset never has `::`.
|
|
416
|
+
const re = /:([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
417
|
+
const seen: string[] = [];
|
|
418
|
+
for (const m of cleaned.matchAll(re)) {
|
|
419
|
+
const name = m[1] as string;
|
|
420
|
+
if (!seen.includes(name)) seen.push(name);
|
|
421
|
+
}
|
|
422
|
+
return seen;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Rewrite `:name` → positional `?` (repeats included) AND strip comments +
|
|
426
|
+
* collapse whitespace, producing a clean single-line SQL string suitable for
|
|
427
|
+
* embedding in a generated string literal. String literals are preserved
|
|
428
|
+
* verbatim (their inner whitespace is not collapsed). */
|
|
429
|
+
function toPositionalSql(sql: string): string {
|
|
430
|
+
const tokens: string[] = [];
|
|
431
|
+
let i = 0;
|
|
432
|
+
while (i < sql.length) {
|
|
433
|
+
const ch = sql[i] as string;
|
|
434
|
+
const next = sql[i + 1];
|
|
435
|
+
if (ch === '-' && next === '-') {
|
|
436
|
+
const end = sql.indexOf('\n', i);
|
|
437
|
+
i = end === -1 ? sql.length : end + 1;
|
|
438
|
+
tokens.push(' ');
|
|
439
|
+
} else if (ch === '/' && next === '*') {
|
|
440
|
+
const end = sql.indexOf('*/', i + 2);
|
|
441
|
+
i = end === -1 ? sql.length : end + 2;
|
|
442
|
+
tokens.push(' ');
|
|
443
|
+
} else if (ch === "'") {
|
|
444
|
+
// Preserve string literals verbatim (including any '' escapes).
|
|
445
|
+
let j = i + 1;
|
|
446
|
+
let lit = "'";
|
|
447
|
+
for (;;) {
|
|
448
|
+
if (j >= sql.length) break;
|
|
449
|
+
if (sql[j] === "'") {
|
|
450
|
+
if (sql[j + 1] === "'") {
|
|
451
|
+
lit += "''";
|
|
452
|
+
j += 2;
|
|
453
|
+
} else {
|
|
454
|
+
lit += "'";
|
|
455
|
+
j += 1;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
lit += sql[j];
|
|
460
|
+
j += 1;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
tokens.push(lit);
|
|
464
|
+
i = j;
|
|
465
|
+
} else if (ch === ':' && next !== undefined && /[A-Za-z_]/.test(next)) {
|
|
466
|
+
let end = i + 1;
|
|
467
|
+
while (end < sql.length && /[A-Za-z0-9_]/.test(sql[end] as string)) {
|
|
468
|
+
end += 1;
|
|
469
|
+
}
|
|
470
|
+
tokens.push('?');
|
|
471
|
+
i = end;
|
|
472
|
+
} else if (/\s/.test(ch)) {
|
|
473
|
+
tokens.push(' ');
|
|
474
|
+
i += 1;
|
|
475
|
+
} else {
|
|
476
|
+
tokens.push(ch);
|
|
477
|
+
i += 1;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Collapse runs of the whitespace placeholders; trim.
|
|
481
|
+
return tokens.join('').replace(/\s+/g, ' ').trim();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// -- FROM/JOIN table resolution ----------------------------------------------
|
|
485
|
+
|
|
486
|
+
interface TableRef {
|
|
487
|
+
readonly table: string;
|
|
488
|
+
/** Alias (or the table name when un-aliased). */
|
|
489
|
+
readonly alias: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
|
|
493
|
+
// `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
|
|
494
|
+
// tables that exist in the IR; SQLite has already proven every reference.
|
|
495
|
+
const TABLE_REF_RE = new RegExp(
|
|
496
|
+
`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`,
|
|
497
|
+
'gi',
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const RESERVED_ALIAS = new Set([
|
|
501
|
+
'on',
|
|
502
|
+
'where',
|
|
503
|
+
'group',
|
|
504
|
+
'order',
|
|
505
|
+
'inner',
|
|
506
|
+
'left',
|
|
507
|
+
'right',
|
|
508
|
+
'outer',
|
|
509
|
+
'join',
|
|
510
|
+
'cross',
|
|
511
|
+
'using',
|
|
512
|
+
'limit',
|
|
513
|
+
'having',
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
517
|
+
const cleaned = stripCommentsAndStrings(sql);
|
|
518
|
+
const known = new Map(ir.tables.map((t) => [t.name, t] as const));
|
|
519
|
+
const refs: TableRef[] = [];
|
|
520
|
+
for (const m of cleaned.matchAll(TABLE_REF_RE)) {
|
|
521
|
+
const table = m[1] as string;
|
|
522
|
+
if (!known.has(table)) continue; // e.g. FROM (subquery) — table is `(`; skip
|
|
523
|
+
let alias = m[2];
|
|
524
|
+
if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
|
|
525
|
+
alias = undefined;
|
|
526
|
+
}
|
|
527
|
+
refs.push({ table, alias: alias ?? table });
|
|
528
|
+
}
|
|
529
|
+
return refs;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// -- SELECT column → source resolution ---------------------------------------
|
|
533
|
+
//
|
|
534
|
+
// For each result column bun gives us (name + decltype), we resolve its origin
|
|
535
|
+
// IR column so we can attach exact NULLABILITY (decltype carries type but not
|
|
536
|
+
// NOT NULL). We parse the SELECT list into items and, per item, try to find a
|
|
537
|
+
// plain column ref (`col`, `alias.col`) that names an IR column of one of the
|
|
538
|
+
// FROM/JOIN tables. When found, that IR column is the source (exact fidelity);
|
|
539
|
+
// otherwise it is a computed expression (fallback fidelity).
|
|
540
|
+
|
|
541
|
+
interface SelectItem {
|
|
542
|
+
/** The raw expression text (before any `AS alias`). */
|
|
543
|
+
readonly expr: string;
|
|
544
|
+
/** The explicit `AS alias`, if any. */
|
|
545
|
+
readonly alias: string | undefined;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Split a SELECT list on top-level commas (parens-aware). Returns null when
|
|
549
|
+
* the query isn't a plain `SELECT … FROM …` we can split (e.g. `SELECT *`). */
|
|
550
|
+
function splitSelectList(sql: string): SelectItem[] | null {
|
|
551
|
+
const cleaned = stripCommentsAndStrings(sql).replace(/\s+/g, ' ').trim();
|
|
552
|
+
const m = /^SELECT\s+(?:DISTINCT\s+)?(.*?)\sFROM\s/i.exec(cleaned);
|
|
553
|
+
if (m === null || m[1] === undefined) return null;
|
|
554
|
+
const list = m[1];
|
|
555
|
+
if (list.trim() === '*' || /(^|,|\.)\s*\*/.test(list)) return null; // SELECT * / t.*
|
|
556
|
+
const items: SelectItem[] = [];
|
|
557
|
+
let depth = 0;
|
|
558
|
+
let start = 0;
|
|
559
|
+
for (let i = 0; i <= list.length; i++) {
|
|
560
|
+
const ch = list[i];
|
|
561
|
+
if (ch === '(') depth += 1;
|
|
562
|
+
else if (ch === ')') depth -= 1;
|
|
563
|
+
if ((ch === ',' && depth === 0) || i === list.length) {
|
|
564
|
+
const raw = list.slice(start, i).trim();
|
|
565
|
+
start = i + 1;
|
|
566
|
+
if (raw.length === 0) continue;
|
|
567
|
+
// `expr AS alias` or `expr alias` (trailing bare identifier). Only treat
|
|
568
|
+
// a trailing word as an alias when the expr has more than that word.
|
|
569
|
+
const asMatch = /^(.*?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i.exec(raw);
|
|
570
|
+
if (asMatch !== null) {
|
|
571
|
+
items.push({ expr: (asMatch[1] as string).trim(), alias: asMatch[2] });
|
|
572
|
+
} else {
|
|
573
|
+
items.push({ expr: raw, alias: undefined });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return items;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const PLAIN_REF_RE = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})$`);
|
|
581
|
+
|
|
582
|
+
interface ResolvedSource {
|
|
583
|
+
readonly column: IrTable['columns'][number];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Resolve a SELECT item's expression to an IR column, if it is a plain ref. */
|
|
587
|
+
function resolveSource(
|
|
588
|
+
item: SelectItem,
|
|
589
|
+
refs: readonly TableRef[],
|
|
590
|
+
ir: IrDocument,
|
|
591
|
+
): ResolvedSource | null {
|
|
592
|
+
const m = PLAIN_REF_RE.exec(item.expr);
|
|
593
|
+
if (m === null) return null;
|
|
594
|
+
const qualifier = m[1];
|
|
595
|
+
const columnName = m[2] as string;
|
|
596
|
+
const byName = new Map(ir.tables.map((t) => [t.name, t] as const));
|
|
597
|
+
if (qualifier !== undefined) {
|
|
598
|
+
const ref = refs.find((r) => r.alias === qualifier);
|
|
599
|
+
if (ref === undefined) return null;
|
|
600
|
+
const table = byName.get(ref.table);
|
|
601
|
+
const col = table?.columns.find((c) => c.name === columnName);
|
|
602
|
+
return col === undefined ? null : { column: col };
|
|
603
|
+
}
|
|
604
|
+
// Unqualified: search every FROM/JOIN table (SQLite already resolved
|
|
605
|
+
// ambiguity; a single-table query is the common case).
|
|
606
|
+
for (const ref of refs) {
|
|
607
|
+
const table = byName.get(ref.table);
|
|
608
|
+
const col = table?.columns.find((c) => c.name === columnName);
|
|
609
|
+
if (col !== undefined) return { column: col };
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// -- param type inference -----------------------------------------------------
|
|
615
|
+
//
|
|
616
|
+
// Infer a param's type from `col <op> :name` or `col IN (:name, …)` where `col`
|
|
617
|
+
// is a plain ref to an IR column. Equality/comparison/IN only — anything else
|
|
618
|
+
// needs the `-- param` comment (kept deliberately simple and honest).
|
|
619
|
+
|
|
620
|
+
function inferParamType(
|
|
621
|
+
paramName: string,
|
|
622
|
+
sql: string,
|
|
623
|
+
refs: readonly TableRef[],
|
|
624
|
+
ir: IrDocument,
|
|
625
|
+
): IrColumnType | null {
|
|
626
|
+
const cleaned = stripCommentsAndStrings(sql);
|
|
627
|
+
const byName = new Map(ir.tables.map((t) => [t.name, t] as const));
|
|
628
|
+
const lookup = (
|
|
629
|
+
qualifier: string | undefined,
|
|
630
|
+
column: string,
|
|
631
|
+
): IrColumnType | null => {
|
|
632
|
+
if (qualifier !== undefined) {
|
|
633
|
+
const ref = refs.find((r) => r.alias === qualifier);
|
|
634
|
+
const table = ref && byName.get(ref.table);
|
|
635
|
+
return table?.columns.find((c) => c.name === column)?.type ?? null;
|
|
636
|
+
}
|
|
637
|
+
for (const ref of refs) {
|
|
638
|
+
const table = byName.get(ref.table);
|
|
639
|
+
const col = table?.columns.find((c) => c.name === column);
|
|
640
|
+
if (col !== undefined) return col.type;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
};
|
|
644
|
+
// `col = :name`, `col >= :name`, `col <> :name`, `col LIKE :name`, etc.
|
|
645
|
+
const cmp = new RegExp(
|
|
646
|
+
`(?:(${IDENT})\\.)?(${IDENT})\\s*(?:=|==|!=|<>|<=|>=|<|>|\\bLIKE\\b|\\bIS\\b)\\s*:${paramName}\\b`,
|
|
647
|
+
'i',
|
|
648
|
+
);
|
|
649
|
+
const cmpM = cmp.exec(cleaned);
|
|
650
|
+
if (cmpM !== null) {
|
|
651
|
+
const t = lookup(cmpM[1], cmpM[2] as string);
|
|
652
|
+
if (t !== null) return t;
|
|
653
|
+
}
|
|
654
|
+
// reversed: `:name = col`
|
|
655
|
+
const rev = new RegExp(
|
|
656
|
+
`:${paramName}\\b\\s*(?:=|==|!=|<>|<=|>=|<|>)\\s*(?:(${IDENT})\\.)?(${IDENT})`,
|
|
657
|
+
'i',
|
|
658
|
+
);
|
|
659
|
+
const revM = rev.exec(cleaned);
|
|
660
|
+
if (revM !== null) {
|
|
661
|
+
const t = lookup(revM[1], revM[2] as string);
|
|
662
|
+
if (t !== null) return t;
|
|
663
|
+
}
|
|
664
|
+
// `col IN (…, :name, …)` — find the nearest `col IN (` before the param.
|
|
665
|
+
const inRe = new RegExp(
|
|
666
|
+
`(?:(${IDENT})\\.)?(${IDENT})\\s+IN\\s*\\(([^)]*:${paramName}\\b[^)]*)\\)`,
|
|
667
|
+
'i',
|
|
668
|
+
);
|
|
669
|
+
const inM = inRe.exec(cleaned);
|
|
670
|
+
if (inM !== null) {
|
|
671
|
+
const t = lookup(inM[1], inM[2] as string);
|
|
672
|
+
if (t !== null) return t;
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// -- fallback column typing (computed expressions) ----------------------------
|
|
678
|
+
|
|
679
|
+
const AGG_RE = /\b(count|sum|total|avg|min|max)\s*\(/i;
|
|
680
|
+
|
|
681
|
+
/** decltype-null column: the documented honest fallback. Aggregates and
|
|
682
|
+
* arithmetic → nullable number; everything else → nullable string. */
|
|
683
|
+
function fallbackColumnType(expr: string): IrColumnType {
|
|
684
|
+
if (AGG_RE.test(expr) || /[+\-*/]/.test(expr)) return 'float';
|
|
685
|
+
return 'string';
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// -- public API ---------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
export interface QueryDb {
|
|
691
|
+
/** Prepare + return the result columns / declared types / param count.
|
|
692
|
+
* Throws (with SQLite's message) when the SQL is invalid against the DDL. */
|
|
693
|
+
analyze(sql: string): {
|
|
694
|
+
columnNames: readonly string[];
|
|
695
|
+
declaredTypes: readonly (string | null)[];
|
|
696
|
+
paramsCount: number;
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Synthesize the schema DDL from the IR (the reverse of the migration
|
|
701
|
+
* parser): a `CREATE TABLE` per IR table with each column's declared SQLite
|
|
702
|
+
* type + NOT NULL, so `prepare()` validates references and decltype resolves.
|
|
703
|
+
* This is exported so the generator can build one in-memory DB per run. */
|
|
704
|
+
export function synthesizeDdl(ir: IrDocument): string {
|
|
705
|
+
const SQL_TYPE: Record<IrColumnType, string> = {
|
|
706
|
+
string: 'TEXT',
|
|
707
|
+
integer: 'INTEGER',
|
|
708
|
+
float: 'REAL',
|
|
709
|
+
boolean: 'BOOLEAN',
|
|
710
|
+
json: 'JSON',
|
|
711
|
+
bytes: 'BLOB',
|
|
712
|
+
// blob_ref rides as TEXT; keep its distinct decltype so a plain ref types
|
|
713
|
+
// back to blob_ref (DECLTYPE_MAP knows BLOB_REF).
|
|
714
|
+
blob_ref: 'BLOB_REF',
|
|
715
|
+
crdt: 'CRDT',
|
|
716
|
+
};
|
|
717
|
+
const lines: string[] = [];
|
|
718
|
+
for (const table of ir.tables) {
|
|
719
|
+
const cols = table.columns.map((c) => {
|
|
720
|
+
const notNull = c.nullable ? '' : ' NOT NULL';
|
|
721
|
+
const pk = c.name === table.primaryKey ? ' PRIMARY KEY' : '';
|
|
722
|
+
return ` ${c.name} ${SQL_TYPE[c.type]}${pk}${notNull}`;
|
|
723
|
+
});
|
|
724
|
+
lines.push(`CREATE TABLE ${table.name} (\n${cols.join(',\n')}\n);`);
|
|
725
|
+
// Create the declared indexes too — harmless for prepare()/decltype, and
|
|
726
|
+
// keeps the type-check DB's shape honest with what the client materializes.
|
|
727
|
+
for (const index of table.indexes) {
|
|
728
|
+
const unique = index.unique ? 'UNIQUE ' : '';
|
|
729
|
+
lines.push(
|
|
730
|
+
`CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return lines.join('\n');
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Analyze ONE already-split statement against the IR + a prepared-DDL DB. The
|
|
739
|
+
* name is pre-resolved (path-derived or a `-- name:` override) by the caller;
|
|
740
|
+
* `statementText` is a single statement's leading comment block + SQL (no
|
|
741
|
+
* top-level `;`). This is the per-statement core; {@link analyzeQueryFile}
|
|
742
|
+
* splits a file and drives it.
|
|
743
|
+
*/
|
|
744
|
+
export function analyzeStatement(
|
|
745
|
+
name: string,
|
|
746
|
+
location: string,
|
|
747
|
+
statementText: string,
|
|
748
|
+
ir: IrDocument,
|
|
749
|
+
db: QueryDb,
|
|
750
|
+
): AnalyzedQuery {
|
|
751
|
+
const file = location;
|
|
752
|
+
const commentParams = parseCommentParams(file, statementText);
|
|
753
|
+
const sql = statementText.trim();
|
|
754
|
+
if (sql.length === 0) {
|
|
755
|
+
throw new TypegenError(file, 'query file is empty');
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// SELECT-only (the read tier). Reject the first keyword loudly otherwise.
|
|
759
|
+
const firstKeyword = /^\s*([A-Za-z]+)/.exec(
|
|
760
|
+
stripCommentsAndStrings(statementText).trimStart(),
|
|
761
|
+
)?.[1];
|
|
762
|
+
if (firstKeyword === undefined || firstKeyword.toUpperCase() !== 'SELECT') {
|
|
763
|
+
throw new TypegenError(
|
|
764
|
+
file,
|
|
765
|
+
`named queries are SELECT-only (the read tier); found ${JSON.stringify(firstKeyword ?? '')}. Writes go through mutate() (SPEC §7.1).`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Let SQLite validate + describe the query. Any bad reference throws here
|
|
770
|
+
// with SQLite's own message (which names the offending table/column).
|
|
771
|
+
let described: ReturnType<QueryDb['analyze']>;
|
|
772
|
+
try {
|
|
773
|
+
described = db.analyze(sql);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
776
|
+
throw new TypegenError(file, `SQL rejected by SQLite: ${message}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const refs = scanTableRefs(sql, ir);
|
|
780
|
+
const tableSet = new Set(refs.map((r) => r.table));
|
|
781
|
+
const tables = [...tableSet].sort();
|
|
782
|
+
if (tables.length === 0) {
|
|
783
|
+
throw new TypegenError(
|
|
784
|
+
file,
|
|
785
|
+
'query reads no synced table — every named query must read at least one IR table (for exact invalidation)',
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Columns: pair bun's column names + decltypes with our source resolution.
|
|
790
|
+
const items = splitSelectList(sql);
|
|
791
|
+
const columns: QueryColumn[] = described.columnNames.map((colName, index) => {
|
|
792
|
+
const decl = described.declaredTypes[index];
|
|
793
|
+
const item = items?.[index];
|
|
794
|
+
const source = item !== undefined ? resolveSource(item, refs, ir) : null;
|
|
795
|
+
if (source !== null) {
|
|
796
|
+
// Exact: IR column type + IR nullability. (decltype agrees; we prefer
|
|
797
|
+
// the IR type so blob_ref/crdt/json semantic types survive.)
|
|
798
|
+
return {
|
|
799
|
+
name: colName,
|
|
800
|
+
type: source.column.type,
|
|
801
|
+
nullable: source.column.nullable,
|
|
802
|
+
fidelity: 'exact',
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
// Fallback: decltype affinity if present, else the expr-shape fallback.
|
|
806
|
+
const mapped =
|
|
807
|
+
decl !== null && decl !== undefined
|
|
808
|
+
? DECLTYPE_MAP[decl.toUpperCase()]
|
|
809
|
+
: undefined;
|
|
810
|
+
const type =
|
|
811
|
+
mapped ?? (item !== undefined ? fallbackColumnType(item.expr) : 'string');
|
|
812
|
+
return { name: colName, type, nullable: true, fidelity: 'fallback' };
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Params: names from the text (positional order), types from inference or
|
|
816
|
+
// the `-- param` comment; every param must resolve.
|
|
817
|
+
const paramNames = scanParamNames(sql);
|
|
818
|
+
if (paramNames.length !== described.paramsCount) {
|
|
819
|
+
// Defensive: our scan and SQLite disagree (shouldn't happen for the subset).
|
|
820
|
+
throw new TypegenError(
|
|
821
|
+
file,
|
|
822
|
+
`internal: scanned ${paramNames.length} params (${paramNames.join(', ')}) but SQLite reports ${described.paramsCount}`,
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
const commentByName = new Map(commentParams.map((p) => [p.name, p.type]));
|
|
826
|
+
const params: QueryParam[] = paramNames.map((paramName) => {
|
|
827
|
+
const commented = commentByName.get(paramName);
|
|
828
|
+
if (commented !== undefined) {
|
|
829
|
+
return { name: paramName, type: commented, source: 'comment' };
|
|
830
|
+
}
|
|
831
|
+
const inferred = inferParamType(paramName, sql, refs, ir);
|
|
832
|
+
if (inferred !== null) {
|
|
833
|
+
return { name: paramName, type: inferred, source: 'inferred' };
|
|
834
|
+
}
|
|
835
|
+
throw new TypegenError(
|
|
836
|
+
file,
|
|
837
|
+
`cannot infer a type for param :${paramName} (it is not compared to a plain column). Add a header comment: \`-- param :${paramName} <type>\` (one of ${[...PARAM_TYPES].join(', ')}).`,
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
// A `-- param` comment naming a param the query does not use is a mistake.
|
|
841
|
+
for (const cp of commentParams) {
|
|
842
|
+
if (!paramNames.includes(cp.name)) {
|
|
843
|
+
throw new TypegenError(
|
|
844
|
+
file,
|
|
845
|
+
`-- param :${cp.name} names a parameter the query does not use`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
name,
|
|
852
|
+
file,
|
|
853
|
+
sql,
|
|
854
|
+
positionalSql: toPositionalSql(sql),
|
|
855
|
+
params,
|
|
856
|
+
columns,
|
|
857
|
+
tables,
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Analyze a whole query FILE: split into statements, resolve each statement's
|
|
863
|
+
* name (path-derived default, or a `-- name:` override), enforce the
|
|
864
|
+
* multi-statement contract, and analyze each. `relPath` is the file's path
|
|
865
|
+
* relative to the queries root (drives the default name + error locations).
|
|
866
|
+
*
|
|
867
|
+
* Rules (agreed 2026-07-04):
|
|
868
|
+
* - A file with ONE statement may omit `-- name:` → the path-derived default.
|
|
869
|
+
* - A file with MULTIPLE statements requires `-- name:` on EVERY statement; a
|
|
870
|
+
* missing one errors, naming the file + the statement's position/first line.
|
|
871
|
+
*/
|
|
872
|
+
export function analyzeQueryFile(
|
|
873
|
+
relPath: string,
|
|
874
|
+
content: string,
|
|
875
|
+
ir: IrDocument,
|
|
876
|
+
db: QueryDb,
|
|
877
|
+
): AnalyzedQuery[] {
|
|
878
|
+
const statements = splitStatements(content);
|
|
879
|
+
if (statements.length === 0) {
|
|
880
|
+
throw new TypegenError(relPath, 'query file is empty');
|
|
881
|
+
}
|
|
882
|
+
const multi = statements.length > 1;
|
|
883
|
+
const defaultName = queryNameFromPath(relPath);
|
|
884
|
+
return statements.map((stmt, index) => {
|
|
885
|
+
const location = multi ? `${relPath}#${index + 1}` : relPath;
|
|
886
|
+
const override = parseStatementName(stmt.text, location);
|
|
887
|
+
if (multi && override === null) {
|
|
888
|
+
const firstLine =
|
|
889
|
+
stripCommentsAndStrings(stmt.text).trim().split('\n')[0]?.trim() ?? '';
|
|
890
|
+
throw new TypegenError(
|
|
891
|
+
location,
|
|
892
|
+
`${relPath} holds ${statements.length} statements, so each requires a \`-- name: <camelCase>\` marker; statement #${index + 1} (line ${stmt.startLine}: ${JSON.stringify(firstLine.slice(0, 60))}) has none`,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
const name = override ?? defaultName;
|
|
896
|
+
return analyzeStatement(name, location, stmt.text, ir, db);
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** Back-compat single-statement analyzer: resolve the name from the file path
|
|
901
|
+
* (or a `-- name:` override) and analyze. Rejects a `;`-separated file loudly
|
|
902
|
+
* — use {@link analyzeQueryFile} for the multi-statement contract. */
|
|
903
|
+
export function analyzeQuery(
|
|
904
|
+
file: string,
|
|
905
|
+
raw: string,
|
|
906
|
+
ir: IrDocument,
|
|
907
|
+
db: QueryDb,
|
|
908
|
+
): AnalyzedQuery {
|
|
909
|
+
const statements = splitStatements(raw);
|
|
910
|
+
if (statements.length > 1) {
|
|
911
|
+
throw new TypegenError(
|
|
912
|
+
file,
|
|
913
|
+
'a query file holds exactly one SELECT statement here (found a `;` separating statements) — use analyzeQueryFile for multi-statement files',
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
const results = analyzeQueryFile(file, raw, ir, db);
|
|
917
|
+
return results[0] as AnalyzedQuery;
|
|
918
|
+
}
|