@syncular/typegen 0.5.1 → 0.6.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.
Files changed (47) hide show
  1. package/README.md +75 -33
  2. package/dist/cli.js +43 -17
  3. package/dist/emit-queries-dart.js +167 -55
  4. package/dist/emit-queries-kotlin.js +166 -55
  5. package/dist/emit-queries-swift.js +182 -57
  6. package/dist/emit-queries.js +289 -123
  7. package/dist/fmt.d.ts +2 -2
  8. package/dist/fmt.js +323 -316
  9. package/dist/generate.d.ts +1 -1
  10. package/dist/generate.js +28 -9
  11. package/dist/index.d.ts +3 -1
  12. package/dist/index.js +3 -1
  13. package/dist/lsp.d.ts +1 -3
  14. package/dist/lsp.js +359 -185
  15. package/dist/manifest.d.ts +1 -3
  16. package/dist/manifest.js +1 -1
  17. package/dist/query-ir.js +53 -42
  18. package/dist/query.d.ts +115 -63
  19. package/dist/query.js +60 -11
  20. package/dist/syql-lexer.d.ts +2 -0
  21. package/dist/syql-lexer.js +9 -2
  22. package/dist/syql-lowering.d.ts +22 -0
  23. package/dist/syql-lowering.js +411 -0
  24. package/dist/syql-semantics.d.ts +76 -0
  25. package/dist/syql-semantics.js +551 -0
  26. package/dist/syql-validator.d.ts +35 -0
  27. package/dist/syql-validator.js +966 -0
  28. package/package.json +3 -3
  29. package/src/cli.ts +51 -21
  30. package/src/emit-queries-dart.ts +195 -69
  31. package/src/emit-queries-kotlin.ts +197 -69
  32. package/src/emit-queries-swift.ts +224 -68
  33. package/src/emit-queries.ts +413 -165
  34. package/src/fmt.ts +377 -303
  35. package/src/generate.ts +43 -9
  36. package/src/index.ts +3 -1
  37. package/src/lsp.ts +425 -215
  38. package/src/manifest.ts +2 -4
  39. package/src/query-ir.ts +52 -42
  40. package/src/query.ts +199 -79
  41. package/src/syql-lexer.ts +12 -1
  42. package/src/syql-lowering.ts +638 -0
  43. package/src/syql-semantics.ts +859 -0
  44. package/src/syql-validator.ts +1492 -0
  45. package/dist/syql.d.ts +0 -102
  46. package/dist/syql.js +0 -1141
  47. package/src/syql.ts +0 -1470
package/src/syql.ts DELETED
@@ -1,1470 +0,0 @@
1
- /**
2
- * The `.syql` frontend (DESIGN-queries.md §3–§7): a functional CONTAINER —
3
- * GraphQL-style signatures + SQLDelight-style inference — around SQL
4
- * expressions. A file holds any number of `query` and `fragment`
5
- * declarations:
6
- *
7
- * ```text
8
- * fragment visibleIn(listId) {
9
- * list_id = :listId and archived_at is null
10
- * }
11
- *
12
- * query listTodos(listId, status?, from+to?, unassigned?: flag)
13
- * orderBy position | created_at | title default position
14
- * limit max 200 default 50
15
- * {
16
- * select id, title, done, created_at
17
- * from todos
18
- * where @visibleIn(:listId)
19
- * and status = :status
20
- * and created_at between :from and :to
21
- * and if (:unassigned) { assignee_id is null }
22
- * }
23
- * ```
24
- *
25
- * Everything composes at GENERATE time: fragments splice textually (values
26
- * only — args are `:param` refs), optional params lower to §7 neutralization
27
- * guards (`:p IS NULL OR (…)`), knobs lower to a checked allowlist
28
- * (orderBy) and a bound+clamped param (limit), and the result is ONE plain
29
- * SQL statement that rides the exact same SQLite-check + naming-lowering
30
- * pipeline as the `.sql` tier. Nothing below the produced `AnalyzedQuery`
31
- * knows which frontend a query came from (§1).
32
- *
33
- * Conditional rules (§4, recommendation B with A as primitive):
34
- * - AUTO-GUARD: a top-level WHERE conjunct that mentions optional params
35
- * applies only when ALL of them are provided.
36
- * - B1: an optional param outside a top-level conjunct (projection, FROM,
37
- * subquery, or under an OR inside its conjunct) is a loud error telling
38
- * the author to write an explicit `if` guard or make it required.
39
- * - B2: `if (:p, …) { predicate }` is the explicit primitive — required for
40
- * flag params (which never appear in a predicate as written).
41
- */
42
- import { TypegenError } from './errors';
43
- import type { IrDocument } from './ir';
44
- import { snakeToCamel } from './naming';
45
- import {
46
- type AnalyzedQuery,
47
- analyzeStatement,
48
- type QueryDb,
49
- type QueryNamingOptions,
50
- type QueryParam,
51
- type QueryReactiveMetadata,
52
- toPositionalSql,
53
- validateOverrideName,
54
- } from './query';
55
-
56
- // ---------------------------------------------------------------------------
57
- // Declarations
58
- // ---------------------------------------------------------------------------
59
-
60
- export interface SyqlParamDecl {
61
- readonly name: string;
62
- readonly optional: boolean;
63
- /** `from+to?` pairing: both params share the group key. */
64
- readonly group?: string;
65
- /** `name?: flag` — a boolean guard param (§3). */
66
- readonly flag: boolean;
67
- }
68
-
69
- export interface SyqlOrderBy {
70
- readonly allowed: readonly string[];
71
- readonly defaultColumn: string;
72
- readonly defaultDir: 'asc' | 'desc';
73
- }
74
-
75
- export interface SyqlLimit {
76
- readonly max?: number;
77
- readonly default?: number;
78
- }
79
-
80
- export interface SyqlDepends {
81
- readonly table: string;
82
- readonly variable: string;
83
- readonly params: readonly string[];
84
- }
85
-
86
- export interface SyqlWindow {
87
- readonly table: string;
88
- readonly variable: string;
89
- readonly units: readonly string[];
90
- readonly fixedScopes: readonly {
91
- readonly variable: string;
92
- readonly param: string;
93
- }[];
94
- }
95
-
96
- export interface SyqlFragmentDecl {
97
- readonly name: string;
98
- readonly params: readonly SyqlParamDecl[];
99
- /** The predicate body (raw SQL expression text). */
100
- readonly body: string;
101
- /** Source offset of the `fragment` keyword (editor tooling). */
102
- readonly offset: number;
103
- }
104
-
105
- export interface SyqlQueryDecl {
106
- readonly name: string;
107
- readonly params: readonly SyqlParamDecl[];
108
- readonly orderBy?: SyqlOrderBy;
109
- readonly limit?: SyqlLimit;
110
- /** §7 opt-in: lower to 2^N enumerated statements (perfect index use)
111
- * instead of one neutralized statement. API-invisible. */
112
- readonly variants?: boolean;
113
- /** Checked escape hatches for SQL shapes the conservative analyzer cannot
114
- * prove. They live with the query, never in React application code. */
115
- readonly depends?: readonly SyqlDepends[];
116
- readonly windows?: readonly SyqlWindow[];
117
- readonly keyBy?: readonly string[];
118
- /** The SQL-shaped body (may contain @fragment refs and if-guards). */
119
- readonly body: string;
120
- /** Source offset of the `query` keyword (editor tooling). */
121
- readonly offset: number;
122
- }
123
-
124
- export interface SyqlFile {
125
- readonly fragments: readonly SyqlFragmentDecl[];
126
- readonly queries: readonly SyqlQueryDecl[];
127
- }
128
-
129
- const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
130
-
131
- // ---------------------------------------------------------------------------
132
- // File parser
133
- // ---------------------------------------------------------------------------
134
-
135
- /** Cursor over the file with comment/whitespace skipping. */
136
- class Cursor {
137
- i = 0;
138
- constructor(
139
- readonly src: string,
140
- readonly file: string,
141
- ) {}
142
-
143
- fail(message: string): never {
144
- const line = this.src.slice(0, this.i).split('\n').length;
145
- throw new TypegenError(`${this.file}:${line}`, message);
146
- }
147
-
148
- /** Skip whitespace and `--`/`/* *​/` comments. */
149
- skip(): void {
150
- for (;;) {
151
- while (this.i < this.src.length && /\s/.test(this.src[this.i] as string))
152
- this.i += 1;
153
- if (this.src.startsWith('--', this.i)) {
154
- const nl = this.src.indexOf('\n', this.i);
155
- this.i = nl === -1 ? this.src.length : nl + 1;
156
- } else if (this.src.startsWith('/*', this.i)) {
157
- const end = this.src.indexOf('*/', this.i + 2);
158
- this.i = end === -1 ? this.src.length : end + 2;
159
- } else {
160
- return;
161
- }
162
- }
163
- }
164
-
165
- atEnd(): boolean {
166
- this.skip();
167
- return this.i >= this.src.length;
168
- }
169
-
170
- /** Read one identifier-shaped word. */
171
- word(what: string): string {
172
- this.skip();
173
- const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i));
174
- if (m === null) this.fail(`expected ${what}`);
175
- this.i += m[0].length;
176
- return m[0];
177
- }
178
-
179
- /** Peek the next identifier-shaped word without consuming. */
180
- peekWord(): string | null {
181
- this.skip();
182
- const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(this.src.slice(this.i));
183
- return m === null ? null : m[0];
184
- }
185
-
186
- /** Consume one exact character. */
187
- expect(ch: string): void {
188
- this.skip();
189
- if (this.src[this.i] !== ch) {
190
- this.fail(`expected ${JSON.stringify(ch)}`);
191
- }
192
- this.i += 1;
193
- }
194
-
195
- peekChar(): string | undefined {
196
- this.skip();
197
- return this.src[this.i];
198
- }
199
-
200
- /** Read a non-negative integer literal. */
201
- int(what: string): number {
202
- this.skip();
203
- const m = /^\d+/.exec(this.src.slice(this.i));
204
- if (m === null) this.fail(`expected ${what} (an integer)`);
205
- this.i += m[0].length;
206
- return Number.parseInt(m[0], 10);
207
- }
208
-
209
- /** Read a balanced `{ … }` block (string/comment aware); returns the
210
- * INNER text. */
211
- braceBlock(): string {
212
- this.expect('{');
213
- const start = this.i;
214
- let depth = 1;
215
- while (this.i < this.src.length) {
216
- const ch = this.src[this.i] as string;
217
- if (ch === "'") {
218
- this.i += 1;
219
- while (this.i < this.src.length) {
220
- if (this.src[this.i] === "'") {
221
- if (this.src[this.i + 1] === "'") this.i += 2;
222
- else {
223
- this.i += 1;
224
- break;
225
- }
226
- } else this.i += 1;
227
- }
228
- } else if (this.src.startsWith('--', this.i)) {
229
- const nl = this.src.indexOf('\n', this.i);
230
- this.i = nl === -1 ? this.src.length : nl + 1;
231
- } else if (this.src.startsWith('/*', this.i)) {
232
- const end = this.src.indexOf('*/', this.i + 2);
233
- this.i = end === -1 ? this.src.length : end + 2;
234
- } else if (ch === '{') {
235
- depth += 1;
236
- this.i += 1;
237
- } else if (ch === '}') {
238
- depth -= 1;
239
- this.i += 1;
240
- if (depth === 0) return this.src.slice(start, this.i - 1);
241
- } else {
242
- this.i += 1;
243
- }
244
- }
245
- this.fail('unterminated { … } block');
246
- }
247
- }
248
-
249
- function parseSignature(cur: Cursor, owner: string): SyqlParamDecl[] {
250
- cur.expect('(');
251
- const params: SyqlParamDecl[] = [];
252
- const seen = new Set<string>();
253
- const push = (p: SyqlParamDecl) => {
254
- if (!IDENT_RE.test(p.name)) {
255
- cur.fail(`${owner}: invalid param name ${JSON.stringify(p.name)}`);
256
- }
257
- if (seen.has(p.name)) {
258
- cur.fail(`${owner}: duplicate param ${JSON.stringify(p.name)}`);
259
- }
260
- seen.add(p.name);
261
- params.push(p);
262
- };
263
- cur.skip();
264
- if (cur.peekChar() === ')') {
265
- cur.expect(')');
266
- return params;
267
- }
268
- for (;;) {
269
- const first = cur.word(`${owner}: param name`);
270
- cur.skip();
271
- if (cur.peekChar() === '+') {
272
- // `from+to?` — an optional GROUP: both provided or both omitted.
273
- cur.expect('+');
274
- const second = cur.word(`${owner}: second param of the ${first}+ group`);
275
- cur.skip();
276
- if (cur.peekChar() !== '?') {
277
- cur.fail(
278
- `${owner}: a ${first}+${second} group must be optional — write ${first}+${second}?`,
279
- );
280
- }
281
- cur.expect('?');
282
- const group = first;
283
- push({ name: first, optional: true, group, flag: false });
284
- push({ name: second, optional: true, group, flag: false });
285
- } else {
286
- let optional = false;
287
- if (cur.peekChar() === '?') {
288
- cur.expect('?');
289
- optional = true;
290
- }
291
- let flag = false;
292
- cur.skip();
293
- if (cur.peekChar() === ':') {
294
- cur.expect(':');
295
- const anno = cur.word(`${owner}: param annotation`);
296
- if (anno !== 'flag') {
297
- cur.fail(
298
- `${owner}: unknown annotation ${JSON.stringify(anno)} — \`: flag\` is the only param annotation (§3); every other type is inferred`,
299
- );
300
- }
301
- if (!optional) {
302
- cur.fail(`${owner}: a flag param is a guard — write ${first}?: flag`);
303
- }
304
- flag = true;
305
- }
306
- push({ name: first, optional, flag });
307
- }
308
- cur.skip();
309
- if (cur.peekChar() === ',') {
310
- cur.expect(',');
311
- continue;
312
- }
313
- cur.expect(')');
314
- return params;
315
- }
316
- }
317
-
318
- function parseKnobs(
319
- cur: Cursor,
320
- owner: string,
321
- ): {
322
- orderBy?: SyqlOrderBy;
323
- limit?: SyqlLimit;
324
- variants?: boolean;
325
- depends?: readonly SyqlDepends[];
326
- windows?: readonly SyqlWindow[];
327
- keyBy?: readonly string[];
328
- } {
329
- let orderBy: SyqlOrderBy | undefined;
330
- let limit: SyqlLimit | undefined;
331
- let variants: boolean | undefined;
332
- const depends: SyqlDepends[] = [];
333
- const windows: SyqlWindow[] = [];
334
- let keyBy: string[] | undefined;
335
- const paramList = (label: string): string[] => {
336
- const out = [cur.word(`${owner}: ${label} param`)];
337
- while (cur.peekChar() === '|') {
338
- cur.expect('|');
339
- out.push(cur.word(`${owner}: ${label} param`));
340
- }
341
- return out;
342
- };
343
- for (;;) {
344
- const word = cur.peekWord();
345
- if (word === 'variants') {
346
- if (variants === true) cur.fail(`${owner}: duplicate variants knob`);
347
- cur.word('variants');
348
- variants = true;
349
- } else if (word === 'depends') {
350
- cur.word('depends');
351
- const table = cur.word(`${owner}: dependency table`);
352
- const on = cur.word(`${owner}: dependency \`on\``);
353
- if (on !== 'on')
354
- cur.fail(
355
- `${owner}: depends syntax is \`depends <table> on <scope> = <param>\``,
356
- );
357
- const variable = cur.word(`${owner}: dependency scope variable`);
358
- cur.expect('=');
359
- depends.push({ table, variable, params: paramList('dependency') });
360
- } else if (word === 'window') {
361
- cur.word('window');
362
- const table = cur.word(`${owner}: window table`);
363
- const by = cur.word(`${owner}: window \`by\``);
364
- if (by !== 'by')
365
- cur.fail(
366
- `${owner}: window syntax is \`window <table> by <scope> = <param>\``,
367
- );
368
- const variable = cur.word(`${owner}: window scope variable`);
369
- cur.expect('=');
370
- const units = paramList('window unit');
371
- const fixedScopes: Array<{ variable: string; param: string }> = [];
372
- if (cur.peekWord() === 'fixed') {
373
- cur.word('fixed');
374
- for (;;) {
375
- const fixedVariable = cur.word(`${owner}: fixed scope variable`);
376
- cur.expect('=');
377
- const param = cur.word(`${owner}: fixed scope param`);
378
- fixedScopes.push({ variable: fixedVariable, param });
379
- if (cur.peekChar() !== ',') break;
380
- cur.expect(',');
381
- }
382
- }
383
- windows.push({ table, variable, units, fixedScopes });
384
- } else if (word === 'key') {
385
- if (keyBy !== undefined)
386
- cur.fail(`${owner}: duplicate key by declaration`);
387
- cur.word('key');
388
- const by = cur.word(`${owner}: key \`by\``);
389
- if (by !== 'by')
390
- cur.fail(`${owner}: key syntax is \`key by <result-column>\``);
391
- keyBy = paramList('key column');
392
- } else if (word === 'orderBy') {
393
- if (orderBy !== undefined) cur.fail(`${owner}: duplicate orderBy knob`);
394
- cur.word('orderBy');
395
- const allowed: string[] = [];
396
- allowed.push(cur.word(`${owner}: orderBy column`));
397
- cur.skip();
398
- while (cur.peekChar() === '|') {
399
- cur.expect('|');
400
- allowed.push(cur.word(`${owner}: orderBy column`));
401
- cur.skip();
402
- }
403
- const kw = cur.word(`${owner}: orderBy default`);
404
- if (kw !== 'default') {
405
- cur.fail(
406
- `${owner}: orderBy needs \`default <column>\` after the allowlist`,
407
- );
408
- }
409
- const defaultColumn = cur.word(`${owner}: orderBy default column`);
410
- if (!allowed.includes(defaultColumn)) {
411
- cur.fail(
412
- `${owner}: orderBy default ${JSON.stringify(defaultColumn)} is not in the allowlist (${allowed.join(' | ')})`,
413
- );
414
- }
415
- let defaultDir: 'asc' | 'desc' = 'asc';
416
- const dir = cur.peekWord();
417
- if (dir === 'asc' || dir === 'desc') {
418
- cur.word('direction');
419
- defaultDir = dir;
420
- }
421
- const dup = new Set<string>();
422
- for (const col of allowed) {
423
- if (dup.has(col)) {
424
- cur.fail(`${owner}: orderBy lists ${JSON.stringify(col)} twice`);
425
- }
426
- dup.add(col);
427
- }
428
- orderBy = { allowed, defaultColumn, defaultDir };
429
- } else if (word === 'limit') {
430
- if (limit !== undefined) cur.fail(`${owner}: duplicate limit knob`);
431
- cur.word('limit');
432
- let max: number | undefined;
433
- let def: number | undefined;
434
- for (;;) {
435
- const part = cur.peekWord();
436
- if (part === 'max' && max === undefined) {
437
- cur.word('max');
438
- max = cur.int(`${owner}: limit max`);
439
- } else if (part === 'default' && def === undefined) {
440
- cur.word('default');
441
- def = cur.int(`${owner}: limit default`);
442
- } else {
443
- break;
444
- }
445
- }
446
- if (max === undefined && def === undefined) {
447
- cur.fail(`${owner}: limit needs \`max <n>\` and/or \`default <n>\``);
448
- }
449
- if (max !== undefined && def !== undefined && def > max) {
450
- cur.fail(`${owner}: limit default ${def} exceeds max ${max}`);
451
- }
452
- limit = {
453
- ...(max !== undefined ? { max } : {}),
454
- ...(def !== undefined ? { default: def } : {}),
455
- };
456
- } else {
457
- return {
458
- ...(orderBy !== undefined ? { orderBy } : {}),
459
- ...(limit !== undefined ? { limit } : {}),
460
- ...(variants !== undefined ? { variants } : {}),
461
- ...(depends.length > 0 ? { depends } : {}),
462
- ...(windows.length > 0 ? { windows } : {}),
463
- ...(keyBy !== undefined ? { keyBy } : {}),
464
- };
465
- }
466
- }
467
- }
468
-
469
- /** Parse one `.syql` file into its declarations (no lowering yet). */
470
- export function parseSyqlFile(file: string, content: string): SyqlFile {
471
- const cur = new Cursor(content, file);
472
- const fragments: SyqlFragmentDecl[] = [];
473
- const queries: SyqlQueryDecl[] = [];
474
- const names = new Set<string>();
475
- while (!cur.atEnd()) {
476
- cur.skip();
477
- const offset = cur.i;
478
- const kw = cur.word('`query` or `fragment`');
479
- if (kw !== 'query' && kw !== 'fragment') {
480
- cur.fail(
481
- `expected \`query\` or \`fragment\`, found ${JSON.stringify(kw)}`,
482
- );
483
- }
484
- const name = cur.word(`${kw} name`);
485
- validateOverrideName(name, file);
486
- if (names.has(name)) {
487
- cur.fail(`duplicate declaration ${JSON.stringify(name)} in ${file}`);
488
- }
489
- names.add(name);
490
- const owner = `${kw} ${name}`;
491
- const params = parseSignature(cur, owner);
492
- if (kw === 'fragment') {
493
- cur.skip();
494
- const body = cur.braceBlock().trim();
495
- if (body.length === 0) cur.fail(`${owner}: empty fragment body`);
496
- fragments.push({ name, params, body, offset });
497
- } else {
498
- const knobs = parseKnobs(cur, owner);
499
- cur.skip();
500
- const body = cur.braceBlock().trim();
501
- if (body.length === 0) cur.fail(`${owner}: empty query body`);
502
- queries.push({ name, params, ...knobs, body, offset });
503
- }
504
- }
505
- return { fragments, queries };
506
- }
507
-
508
- // ---------------------------------------------------------------------------
509
- // SQL-shape scanning helpers (string/comment aware)
510
- // ---------------------------------------------------------------------------
511
-
512
- /** Blank out strings and comments (preserving length) so scans are safe. */
513
- function blank(sql: string): string {
514
- let out = '';
515
- let i = 0;
516
- while (i < sql.length) {
517
- const ch = sql[i] as string;
518
- if (sql.startsWith('--', i)) {
519
- const nl = sql.indexOf('\n', i);
520
- const end = nl === -1 ? sql.length : nl;
521
- out += ' '.repeat(end - i);
522
- i = end;
523
- } else if (sql.startsWith('/*', i)) {
524
- const close = sql.indexOf('*/', i + 2);
525
- const end = close === -1 ? sql.length : close + 2;
526
- out += ' '.repeat(end - i);
527
- i = end;
528
- } else if (ch === "'") {
529
- let j = i + 1;
530
- for (;;) {
531
- if (j >= sql.length) break;
532
- if (sql[j] === "'") {
533
- if (sql[j + 1] === "'") j += 2;
534
- else {
535
- j += 1;
536
- break;
537
- }
538
- } else j += 1;
539
- }
540
- out += ' '.repeat(j - i);
541
- i = j;
542
- } else {
543
- out += ch;
544
- i += 1;
545
- }
546
- }
547
- return out;
548
- }
549
-
550
- /** All `:param` names in `text` (strings/comments already blanked). */
551
- function scanParams(text: string): string[] {
552
- const out: string[] = [];
553
- for (const m of blank(text).matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)) {
554
- const name = m[1] as string;
555
- if (!out.includes(name)) out.push(name);
556
- }
557
- return out;
558
- }
559
-
560
- interface WhereSpan {
561
- /** Offset of the `where` keyword itself. */
562
- readonly kwStart: number;
563
- /** Offset just after the `where` keyword. */
564
- readonly start: number;
565
- /** Offset of the clause end (next top-level clause keyword or EOS). */
566
- readonly end: number;
567
- }
568
-
569
- const CLAUSE_ENDERS = new Set(['group', 'having', 'order', 'limit', 'window']);
570
-
571
- /** Locate the MAIN statement's WHERE clause at paren depth 0. */
572
- function findWhere(body: string): WhereSpan | null {
573
- const b = blank(body);
574
- let depth = 0;
575
- let i = 0;
576
- let kwStart = -1;
577
- let start = -1;
578
- while (i < b.length) {
579
- const ch = b[i] as string;
580
- if (ch === '(') depth += 1;
581
- else if (ch === ')') depth -= 1;
582
- else if (/[A-Za-z_]/.test(ch)) {
583
- let j = i + 1;
584
- while (j < b.length && /[A-Za-z0-9_]/.test(b[j] as string)) j += 1;
585
- const word = b.slice(i, j).toLowerCase();
586
- if (depth === 0) {
587
- if (start === -1 && word === 'where') {
588
- kwStart = i;
589
- start = j;
590
- } else if (start !== -1 && CLAUSE_ENDERS.has(word)) {
591
- return { kwStart, start, end: i };
592
- }
593
- }
594
- i = j;
595
- continue;
596
- }
597
- i += 1;
598
- }
599
- return start === -1 ? null : { kwStart, start, end: body.length };
600
- }
601
-
602
- /**
603
- * Split a WHERE expression into top-level conjuncts on `AND` — paren-aware,
604
- * and BETWEEN-aware (the `AND` closing a `BETWEEN` is not a conjunction).
605
- */
606
- function splitConjuncts(expr: string): string[] {
607
- const b = blank(expr);
608
- const parts: string[] = [];
609
- let depth = 0;
610
- let braceDepth = 0;
611
- let pendingBetween = 0;
612
- let start = 0;
613
- let i = 0;
614
- while (i < b.length) {
615
- const ch = b[i] as string;
616
- if (ch === '(') depth += 1;
617
- else if (ch === ')') depth -= 1;
618
- else if (ch === '{') braceDepth += 1;
619
- else if (ch === '}') braceDepth -= 1;
620
- else if (/[A-Za-z_]/.test(ch)) {
621
- let j = i + 1;
622
- while (j < b.length && /[A-Za-z0-9_]/.test(b[j] as string)) j += 1;
623
- const word = b.slice(i, j).toLowerCase();
624
- if (depth === 0 && braceDepth === 0) {
625
- if (word === 'between') pendingBetween += 1;
626
- else if (word === 'and') {
627
- if (pendingBetween > 0) pendingBetween -= 1;
628
- else {
629
- parts.push(expr.slice(start, i));
630
- start = j;
631
- }
632
- }
633
- }
634
- i = j;
635
- continue;
636
- }
637
- i += 1;
638
- }
639
- parts.push(expr.slice(start));
640
- return parts.map((p) => p.trim()).filter((p) => p.length > 0);
641
- }
642
-
643
- // ---------------------------------------------------------------------------
644
- // Lowering
645
- // ---------------------------------------------------------------------------
646
-
647
- const FRAGMENT_REF_RE = /@([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/;
648
-
649
- interface ParamInfo {
650
- optional: boolean;
651
- group?: string;
652
- flag: boolean;
653
- declared: boolean;
654
- }
655
-
656
- /**
657
- * Splice `@fragment(:arg, …)` refs (iteratively — fragments may use other
658
- * fragments; cycles are caught by a depth cap). Fragment params rename to
659
- * the caller's arg names; a fragment's OPTIONAL params propagate their
660
- * optionality into `paramInfo` (GraphQL-fragment variable propagation, §3)
661
- * unless the query declares the arg itself (the declaration wins).
662
- */
663
- function spliceFragments(
664
- body: string,
665
- fragments: ReadonlyMap<string, SyqlFragmentDecl>,
666
- paramInfo: Map<string, ParamInfo>,
667
- location: string,
668
- ): string {
669
- let out = body;
670
- for (let round = 0; ; round += 1) {
671
- if (round > 10) {
672
- throw new TypegenError(
673
- location,
674
- 'fragment expansion exceeded depth 10 — fragments must not reference each other cyclically',
675
- );
676
- }
677
- const blanked = blank(out);
678
- const m = FRAGMENT_REF_RE.exec(blanked);
679
- if (m === null) return out;
680
- const [, name, argText] = m as unknown as [string, string, string];
681
- const fragment = fragments.get(name);
682
- if (fragment === undefined) {
683
- throw new TypegenError(
684
- location,
685
- `unknown fragment @${name} — fragments are declared in the same .syql file`,
686
- );
687
- }
688
- const args = argText
689
- .split(',')
690
- .map((a) => a.trim())
691
- .filter((a) => a.length > 0);
692
- if (args.length !== fragment.params.length) {
693
- throw new TypegenError(
694
- location,
695
- `@${name} takes ${fragment.params.length} arg(s) (${fragment.params.map((p) => p.name).join(', ')}), got ${args.length}`,
696
- );
697
- }
698
- const rename = new Map<string, string>();
699
- fragment.params.forEach((param, index) => {
700
- const arg = args[index] as string;
701
- const argMatch = /^:([A-Za-z_][A-Za-z0-9_]*)$/.exec(arg);
702
- if (argMatch === null) {
703
- throw new TypegenError(
704
- location,
705
- `@${name}: arg ${index + 1} must be a \`:param\` reference (values only, I1), got ${JSON.stringify(arg)}`,
706
- );
707
- }
708
- const argName = argMatch[1] as string;
709
- rename.set(param.name, argName);
710
- // Propagate the fragment param's optionality unless the query
711
- // declared this param itself.
712
- const existing = paramInfo.get(argName);
713
- if (existing === undefined) {
714
- paramInfo.set(argName, {
715
- optional: param.optional,
716
- flag: param.flag,
717
- declared: false,
718
- ...(param.group !== undefined
719
- ? { group: `${name}.${param.group}` }
720
- : {}),
721
- });
722
- } else if (!existing.declared && param.optional && !existing.optional) {
723
- existing.optional = true;
724
- }
725
- });
726
- // Substitute the fragment body's params, then splice parenthesized.
727
- let pred = fragment.body;
728
- const fragmentParams = new Set(fragment.params.map((p) => p.name));
729
- for (const used of scanParams(pred)) {
730
- if (!fragmentParams.has(used)) {
731
- throw new TypegenError(
732
- location,
733
- `fragment ${name} uses :${used}, which is not among its params (${[...fragmentParams].join(', ')}) — fragments are closed over their signature`,
734
- );
735
- }
736
- }
737
- pred = pred.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (whole, p: string) => {
738
- const to = rename.get(p);
739
- return to === undefined ? whole : `:${to}`;
740
- });
741
- out = `${out.slice(0, m.index)}(${pred})${out.slice(m.index + m[0].length)}`;
742
- }
743
- }
744
-
745
- const IF_GUARD_RE = /^if\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/;
746
-
747
- /** Guard SQL for one param: value params test provision; flags test truth. */
748
- function guardTerm(name: string, info: ParamInfo | undefined): string {
749
- if (info?.flag === true) return `coalesce(:${name}, 0) = 0`;
750
- return `:${name} is null`;
751
- }
752
-
753
- /** One top-level WHERE conjunct after guard analysis: `governing` is the
754
- * set of OPTIONAL param names gating it (empty = required conjunct). */
755
- export interface LoweredConjunct {
756
- readonly pred: string;
757
- readonly governing: readonly string[];
758
- }
759
-
760
- export interface LoweredSyqlQuery {
761
- /** The lowered single SQL statement (named params, no knob tails). */
762
- readonly sql: string;
763
- /** Params discovered/declared, in signature-then-first-use order. */
764
- readonly paramInfo: ReadonlyMap<string, ParamInfo>;
765
- /** Statement text before the `where` keyword (whole statement when there
766
- * is no WHERE). */
767
- readonly prefix: string;
768
- /** Statement text after the WHERE clause (may be empty). */
769
- readonly suffix: string;
770
- /** The structured conjuncts (empty when there is no WHERE) — the §7
771
- * variant backend re-assembles per provided-combination from these. */
772
- readonly conjuncts: readonly LoweredConjunct[];
773
- }
774
-
775
- /**
776
- * Lower one query body: splice fragments, auto-guard optional conjuncts,
777
- * expand `if` guards, enforce B1. Returns plain SQL (knob tails are the
778
- * caller's job).
779
- */
780
- export function lowerSyqlBody(
781
- decl: SyqlQueryDecl,
782
- fragments: ReadonlyMap<string, SyqlFragmentDecl>,
783
- location: string,
784
- ): LoweredSyqlQuery {
785
- const paramInfo = new Map<string, ParamInfo>();
786
- for (const p of decl.params) {
787
- paramInfo.set(p.name, {
788
- optional: p.optional,
789
- flag: p.flag,
790
- declared: true,
791
- ...(p.group !== undefined ? { group: p.group } : {}),
792
- });
793
- }
794
-
795
- if (blank(decl.body).includes(';')) {
796
- throw new TypegenError(
797
- location,
798
- 'a query body is exactly one SELECT statement — remove the `;`',
799
- );
800
- }
801
-
802
- const spliced = spliceFragments(decl.body, fragments, paramInfo, location);
803
-
804
- const isOptional = (name: string): boolean =>
805
- paramInfo.get(name)?.optional === true;
806
-
807
- const where = findWhere(spliced);
808
- const conjuncts: string[] = [];
809
- const structured: LoweredConjunct[] = [];
810
- let loweredWhere = '';
811
- if (where !== null) {
812
- const expr = spliced.slice(where.start, where.end);
813
- for (const conjunct of splitConjuncts(expr)) {
814
- const ifMatch = IF_GUARD_RE.exec(conjunct);
815
- if (ifMatch !== null) {
816
- // B2 — the explicit primitive: if (:a, :b) { predicate }.
817
- const guardParams = (ifMatch[1] as string)
818
- .split(',')
819
- .map((a) => a.trim())
820
- .filter((a) => a.length > 0)
821
- .map((a) => {
822
- const pm = /^:([A-Za-z_][A-Za-z0-9_]*)$/.exec(a);
823
- if (pm === null) {
824
- throw new TypegenError(
825
- location,
826
- `if (…) guards take \`:param\` refs, got ${JSON.stringify(a)}`,
827
- );
828
- }
829
- return pm[1] as string;
830
- });
831
- if (guardParams.length === 0) {
832
- throw new TypegenError(location, 'if () needs at least one :param');
833
- }
834
- const pred = (ifMatch[2] as string).trim();
835
- if (pred.length === 0) {
836
- throw new TypegenError(location, 'if (…) { } has an empty predicate');
837
- }
838
- for (const g of guardParams) {
839
- const info = paramInfo.get(g);
840
- if (info === undefined) {
841
- throw new TypegenError(
842
- location,
843
- `if (:${g}): param ${g} is not declared in the signature`,
844
- );
845
- }
846
- if (!info.optional) {
847
- throw new TypegenError(
848
- location,
849
- `if (:${g}): ${g} is required — an if-guard on a required param never varies; drop the guard or mark the param optional (${g}?)`,
850
- );
851
- }
852
- }
853
- const guards = guardParams
854
- .map((g) => guardTerm(g, paramInfo.get(g)))
855
- .join(' or ');
856
- conjuncts.push(`(${guards} or (${pred}))`);
857
- structured.push({ pred, governing: guardParams });
858
- continue;
859
- }
860
-
861
- const used = scanParams(conjunct);
862
- const optionals = used.filter(isOptional);
863
- const flags = used.filter((p) => paramInfo.get(p)?.flag === true);
864
- if (flags.length > 0) {
865
- throw new TypegenError(
866
- location,
867
- `flag param :${flags[0]} cannot appear in a predicate (§3 — it never binds as written). Use \`if (:${flags[0]}) { … }\`.`,
868
- );
869
- }
870
- if (optionals.length === 0) {
871
- conjuncts.push(conjunct);
872
- structured.push({ pred: conjunct, governing: [] });
873
- continue;
874
- }
875
- // B1 placement validator: inside its conjunct, an optional param must
876
- // not sit under an OR or inside a subquery — semantics get murky;
877
- // demand the explicit primitive.
878
- const blankedConjunct = blank(conjunct).toLowerCase();
879
- if (/\bor\b/.test(blankedConjunct)) {
880
- throw new TypegenError(
881
- location,
882
- `optional param :${optionals[0]} sits under an OR — auto-guarding a disjunction is ambiguous (B1). Write \`if (:${optionals[0]}) { … }\` or make the param required.`,
883
- );
884
- }
885
- if (/\bselect\b/.test(blankedConjunct)) {
886
- throw new TypegenError(
887
- location,
888
- `optional param :${optionals[0]} sits inside a subquery — auto-guarding cannot see through it (B1). Write \`if (:${optionals[0]}) { … }\` or make the param required.`,
889
- );
890
- }
891
- // Auto-guard (§4 B): the conjunct applies iff ALL its optional params
892
- // (including every member of a touched group) are provided.
893
- const governing = new Set<string>(optionals);
894
- for (const p of optionals) {
895
- const group = paramInfo.get(p)?.group;
896
- if (group !== undefined) {
897
- for (const [other, info] of paramInfo) {
898
- if (info.group === group) governing.add(other);
899
- }
900
- }
901
- }
902
- const guards = [...governing]
903
- .map((g) => guardTerm(g, paramInfo.get(g)))
904
- .join(' or ');
905
- conjuncts.push(`(${guards} or (${conjunct}))`);
906
- structured.push({ pred: conjunct, governing: [...governing] });
907
- }
908
- loweredWhere = conjuncts.join(' and ');
909
- }
910
-
911
- // B1 (rest of statement): optional params may ONLY live in the WHERE.
912
- const outsideWhere =
913
- where === null
914
- ? spliced
915
- : spliced.slice(0, where.start) + spliced.slice(where.end);
916
- for (const used of scanParams(outsideWhere)) {
917
- if (isOptional(used)) {
918
- throw new TypegenError(
919
- location,
920
- `optional param :${used} appears outside the WHERE clause (B1) — optional params only guard top-level WHERE conjuncts; make it required or restructure`,
921
- );
922
- }
923
- }
924
-
925
- // Declared params must actually be used (flags are used by their guards —
926
- // which splice into the WHERE — so this covers them too).
927
- const usedAnywhere = new Set(scanParams(spliced));
928
- for (const p of decl.params) {
929
- if (!usedAnywhere.has(p.name)) {
930
- throw new TypegenError(
931
- location,
932
- `param ${p.name} is declared but never used in the body`,
933
- );
934
- }
935
- }
936
-
937
- const sql =
938
- where === null
939
- ? spliced
940
- : `${spliced.slice(0, where.start)} ${loweredWhere}${spliced.slice(where.end).length > 0 ? ` ${spliced.slice(where.end).trimStart()}` : ''}`;
941
-
942
- const prefix = (
943
- where === null ? spliced : spliced.slice(0, where.kwStart)
944
- ).replace(/\s+/g, ' ');
945
- const suffix = (where === null ? '' : spliced.slice(where.end)).replace(
946
- /\s+/g,
947
- ' ',
948
- );
949
- return {
950
- sql: sql.replace(/\s+/g, ' ').trim(),
951
- paramInfo,
952
- prefix: prefix.trim(),
953
- suffix: suffix.trim(),
954
- conjuncts: structured.map((c) => ({
955
- pred: c.pred.replace(/\s+/g, ' ').trim(),
956
- governing: c.governing,
957
- })),
958
- };
959
- }
960
-
961
- // ---------------------------------------------------------------------------
962
- // Analysis (parse → lower → the shared SQLite-check pipeline)
963
- // ---------------------------------------------------------------------------
964
-
965
- function checkedReactiveMetadata(
966
- decl: SyqlQueryDecl,
967
- base: AnalyzedQuery,
968
- params: readonly QueryParam[],
969
- ir: IrDocument,
970
- location: string,
971
- ): QueryReactiveMetadata {
972
- if (
973
- decl.depends === undefined &&
974
- decl.windows === undefined &&
975
- decl.keyBy === undefined
976
- ) {
977
- return base.reactive;
978
- }
979
- const paramByName = new Map(params.map((param) => [param.name, param]));
980
- const explicitScopes = new Map<
981
- string,
982
- Map<string, { pattern: string; params: Set<string> }>
983
- >();
984
- const explicitTables = new Set<string>();
985
-
986
- const tableAndScope = (tableName: string, variable: string) => {
987
- if (!base.tables.includes(tableName)) {
988
- throw new TypegenError(
989
- location,
990
- `reactive declaration names table ${JSON.stringify(tableName)}, but the query does not read it`,
991
- );
992
- }
993
- const table = ir.tables.find((candidate) => candidate.name === tableName);
994
- if (table === undefined) {
995
- throw new TypegenError(
996
- location,
997
- `unknown reactive table ${JSON.stringify(tableName)}`,
998
- );
999
- }
1000
- const scope = table.scopes.find(
1001
- (candidate) => candidate.variable === variable,
1002
- );
1003
- if (scope === undefined) {
1004
- throw new TypegenError(
1005
- location,
1006
- `table ${tableName} has no scope variable ${JSON.stringify(variable)}`,
1007
- );
1008
- }
1009
- return { table, scope };
1010
- };
1011
- const checkedParam = (
1012
- tableName: string,
1013
- variable: string,
1014
- name: string,
1015
- ): QueryParam => {
1016
- const param = paramByName.get(name);
1017
- if (param === undefined) {
1018
- throw new TypegenError(
1019
- location,
1020
- `reactive declaration references undeclared param :${name}`,
1021
- );
1022
- }
1023
- if (param.optional === true) {
1024
- throw new TypegenError(
1025
- location,
1026
- `reactive scope ${tableName}.${variable} cannot use optional param :${name}; an absent value is not a window unit`,
1027
- );
1028
- }
1029
- const { table, scope } = tableAndScope(tableName, variable);
1030
- const column = table.columns.find(
1031
- (candidate) => candidate.name === scope.column,
1032
- );
1033
- if (column === undefined || column.type !== param.type) {
1034
- throw new TypegenError(
1035
- location,
1036
- `reactive param :${name} has type ${param.type}, incompatible with ${tableName}.${scope.column} (${column?.type ?? 'unknown'})`,
1037
- );
1038
- }
1039
- return param;
1040
- };
1041
- const addScope = (
1042
- table: string,
1043
- variable: string,
1044
- names: readonly string[],
1045
- ) => {
1046
- const { scope } = tableAndScope(table, variable);
1047
- explicitTables.add(table);
1048
- let byVariable = explicitScopes.get(table);
1049
- if (byVariable === undefined) {
1050
- byVariable = new Map();
1051
- explicitScopes.set(table, byVariable);
1052
- }
1053
- let binding = byVariable.get(variable);
1054
- if (binding === undefined) {
1055
- binding = { pattern: scope.pattern, params: new Set() };
1056
- byVariable.set(variable, binding);
1057
- }
1058
- for (const name of names) {
1059
- checkedParam(table, variable, name);
1060
- binding.params.add(name);
1061
- }
1062
- };
1063
-
1064
- for (const dependency of decl.depends ?? []) {
1065
- addScope(dependency.table, dependency.variable, dependency.params);
1066
- }
1067
-
1068
- const coverage: Array<QueryReactiveMetadata['coverage'][number]> = [];
1069
- const seenWindows = new Set<string>();
1070
- for (const window of decl.windows ?? []) {
1071
- const key = `${window.table}\0${window.variable}`;
1072
- if (seenWindows.has(key)) {
1073
- throw new TypegenError(
1074
- location,
1075
- `duplicate window declaration for ${window.table}.${window.variable}`,
1076
- );
1077
- }
1078
- seenWindows.add(key);
1079
- const { table } = tableAndScope(window.table, window.variable);
1080
- addScope(window.table, window.variable, window.units);
1081
- const fixedByVariable = new Map<string, string>();
1082
- for (const fixed of window.fixedScopes) {
1083
- if (fixed.variable === window.variable) {
1084
- throw new TypegenError(
1085
- location,
1086
- `window ${window.table}.${window.variable} repeats its unit scope as fixed`,
1087
- );
1088
- }
1089
- if (fixedByVariable.has(fixed.variable)) {
1090
- throw new TypegenError(
1091
- location,
1092
- `window ${window.table} repeats fixed scope ${fixed.variable}`,
1093
- );
1094
- }
1095
- checkedParam(window.table, fixed.variable, fixed.param);
1096
- fixedByVariable.set(fixed.variable, fixed.param);
1097
- addScope(window.table, fixed.variable, [fixed.param]);
1098
- }
1099
- const missing = table.scopes
1100
- .map((scope) => scope.variable)
1101
- .filter(
1102
- (variable) =>
1103
- variable !== window.variable && !fixedByVariable.has(variable),
1104
- );
1105
- if (missing.length > 0) {
1106
- throw new TypegenError(
1107
- location,
1108
- `window ${window.table}.${window.variable} must fix the remaining scope variables: ${missing.join(', ')}`,
1109
- );
1110
- }
1111
- coverage.push({
1112
- table: window.table,
1113
- variable: window.variable,
1114
- units: window.units,
1115
- fixedScopes: table.scopes
1116
- .filter((scope) => scope.variable !== window.variable)
1117
- .map((scope) => ({
1118
- variable: scope.variable,
1119
- params: [fixedByVariable.get(scope.variable) as string],
1120
- })),
1121
- });
1122
- }
1123
-
1124
- const dependencies = base.reactive.dependencies.map((dependency) => {
1125
- if (!explicitTables.has(dependency.table)) return dependency;
1126
- const scopes = [...(explicitScopes.get(dependency.table)?.entries() ?? [])]
1127
- .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1128
- .map(([variable, binding]) => ({
1129
- table: dependency.table,
1130
- variable,
1131
- pattern: binding.pattern,
1132
- params: [...binding.params],
1133
- }));
1134
- return { table: dependency.table, scopes };
1135
- });
1136
-
1137
- let rowKey = base.reactive.rowKey;
1138
- if (decl.keyBy !== undefined) {
1139
- const fields = decl.keyBy.map((name) => {
1140
- const column = base.columns.find(
1141
- (candidate) => candidate.name === name || candidate.langName === name,
1142
- );
1143
- if (column === undefined) {
1144
- throw new TypegenError(
1145
- location,
1146
- `key by column ${JSON.stringify(name)} is not projected by the query`,
1147
- );
1148
- }
1149
- return column.langName;
1150
- });
1151
- if (new Set(fields).size !== fields.length) {
1152
- throw new TypegenError(
1153
- location,
1154
- 'key by contains a duplicate result column',
1155
- );
1156
- }
1157
- rowKey = fields;
1158
- }
1159
-
1160
- return {
1161
- dependencies,
1162
- coverage:
1163
- decl.windows === undefined
1164
- ? base.reactive.coverage
1165
- : [
1166
- ...base.reactive.coverage.filter(
1167
- (item) => !seenWindows.has(`${item.table}\0${item.variable}`),
1168
- ),
1169
- ...coverage,
1170
- ],
1171
- ...(rowKey !== undefined ? { rowKey } : {}),
1172
- };
1173
- }
1174
-
1175
- /** Analyze every query of one `.syql` file into `AnalyzedQuery` units —
1176
- * byte-compatible with the `.sql` frontend's output (§1: nothing below the
1177
- * IR knows the frontend). */
1178
- export function analyzeSyqlFile(
1179
- relPath: string,
1180
- content: string,
1181
- ir: IrDocument,
1182
- db: QueryDb,
1183
- naming?: QueryNamingOptions,
1184
- ): AnalyzedQuery[] {
1185
- const parsed = parseSyqlFile(relPath, content);
1186
- const fragments = new Map(parsed.fragments.map((f) => [f.name, f] as const));
1187
- if (parsed.queries.length === 0 && parsed.fragments.length > 0) {
1188
- throw new TypegenError(
1189
- relPath,
1190
- 'this .syql file declares only fragments — fragments are file-scoped, so at least one query must use them here',
1191
- );
1192
- }
1193
- return parsed.queries.map((decl) => {
1194
- const location = `${relPath} (query ${decl.name})`;
1195
- const lowered = lowerSyqlBody(decl, fragments, location);
1196
- const blanked = blank(lowered.sql).toLowerCase();
1197
-
1198
- // Knob preconditions on the BODY (the knob owns the tail).
1199
- if (decl.orderBy !== undefined && /\border\s+by\b/.test(blanked)) {
1200
- throw new TypegenError(
1201
- location,
1202
- 'the body has an ORDER BY and the query declares an orderBy knob — one or the other',
1203
- );
1204
- }
1205
- if (decl.limit !== undefined && /\blimit\b/.test(blanked)) {
1206
- throw new TypegenError(
1207
- location,
1208
- 'the body has a LIMIT and the query declares a limit knob — one or the other',
1209
- );
1210
- }
1211
- if (
1212
- (decl.limit !== undefined || decl.orderBy !== undefined) &&
1213
- lowered.paramInfo.has('limit')
1214
- ) {
1215
- throw new TypegenError(
1216
- location,
1217
- 'a query with a limit/orderBy knob cannot also declare a param named `limit`',
1218
- );
1219
- }
1220
-
1221
- // Check every orderBy allowlist column against the schema FIRST (each
1222
- // variant is a real prepared statement — §6) so a bad column errors as
1223
- // "orderBy column …", not as the default-tail check.
1224
- if (decl.orderBy !== undefined) {
1225
- for (const column of decl.orderBy.allowed) {
1226
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)) {
1227
- throw new TypegenError(
1228
- location,
1229
- `orderBy column ${JSON.stringify(column)} is not a plain identifier`,
1230
- );
1231
- }
1232
- try {
1233
- db.analyze(`${lowered.sql} order by ${column} asc`);
1234
- } catch (error) {
1235
- const message =
1236
- error instanceof Error ? error.message : String(error);
1237
- throw new TypegenError(
1238
- location,
1239
- `orderBy column ${JSON.stringify(column)} rejected by SQLite: ${message}`,
1240
- );
1241
- }
1242
- }
1243
- }
1244
-
1245
- // Assemble the checked statement: default knob tails baked in, `-- param`
1246
- // headers typing what inference cannot see (flags, the limit bind). The
1247
- // limit's default + clamp live IN the SQL (`min(coalesce(:limit, d), m)`)
1248
- // so an absent runtime value is a no-op — the same neutralization idea
1249
- // as the §7 guards — and every emitter just binds `limit ?? null`.
1250
- const headers: string[] = [];
1251
- for (const [name, info] of lowered.paramInfo) {
1252
- if (info.flag) headers.push(`-- param :${name} boolean`);
1253
- }
1254
- let limitTail = '';
1255
- if (decl.limit !== undefined) {
1256
- const fallback = decl.limit.default ?? decl.limit.max;
1257
- const inner = `coalesce(:limit, ${fallback})`;
1258
- limitTail = ` limit ${
1259
- decl.limit.max !== undefined
1260
- ? `min(${inner}, ${decl.limit.max})`
1261
- : inner
1262
- }`;
1263
- headers.push('-- param :limit integer');
1264
- }
1265
- let sqlWithTails = lowered.sql;
1266
- if (decl.orderBy !== undefined) {
1267
- sqlWithTails += ` order by ${decl.orderBy.defaultColumn} ${decl.orderBy.defaultDir}`;
1268
- }
1269
- sqlWithTails += limitTail;
1270
- const statementText = [...headers, sqlWithTails].join('\n');
1271
-
1272
- const base = analyzeStatement(
1273
- decl.name,
1274
- location,
1275
- statementText,
1276
- ir,
1277
- db,
1278
- naming,
1279
- );
1280
-
1281
- // Attach optionality/group/flag + knob metadata to the checked unit.
1282
- const params: QueryParam[] = base.params.map((param) => {
1283
- if (param.name === 'limit' && decl.limit !== undefined) {
1284
- return { ...param, optional: true };
1285
- }
1286
- const info = lowered.paramInfo.get(param.name);
1287
- if (info === undefined) return param;
1288
- return {
1289
- ...param,
1290
- ...(info.optional ? { optional: true } : {}),
1291
- ...(info.group !== undefined ? { group: info.group } : {}),
1292
- ...(info.flag ? { flag: true } : {}),
1293
- };
1294
- });
1295
-
1296
- const mode = naming?.naming ?? 'camel';
1297
- const mapCol = (name: string): string =>
1298
- mode === 'preserve' ? name : snakeToCamel(name);
1299
-
1300
- const reactive = checkedReactiveMetadata(decl, base, params, ir, location);
1301
-
1302
- const orderBy =
1303
- decl.orderBy === undefined
1304
- ? undefined
1305
- : {
1306
- allowed: decl.orderBy.allowed.map((name) => ({
1307
- name,
1308
- langName: mapCol(name),
1309
- })),
1310
- defaultColumn: decl.orderBy.defaultColumn,
1311
- defaultDir: decl.orderBy.defaultDir,
1312
- };
1313
-
1314
- // The static base (positional) emitters compose dynamic tails onto:
1315
- // positionalSql minus the default tails we appended above. Sliced from
1316
- // the positional form itself so `?` vs `?N` numbering is preserved
1317
- // (the appended tails are by construction the LAST clauses).
1318
- let positionalSqlBase: string | undefined;
1319
- let positionalLimitTail: string | undefined;
1320
- if (decl.orderBy !== undefined) {
1321
- const orderIdx = base.positionalSql.lastIndexOf(' order by ');
1322
- if (orderIdx === -1) {
1323
- throw new TypegenError(
1324
- location,
1325
- 'internal: lowered SQL lost its appended order-by tail',
1326
- );
1327
- }
1328
- positionalSqlBase = base.positionalSql.slice(0, orderIdx);
1329
- if (decl.limit !== undefined) {
1330
- const limitIdx = base.positionalSql.lastIndexOf(' limit ');
1331
- if (limitIdx <= orderIdx) {
1332
- throw new TypegenError(
1333
- location,
1334
- 'internal: lowered SQL lost its appended limit tail',
1335
- );
1336
- }
1337
- positionalLimitTail = base.positionalSql.slice(limitIdx);
1338
- }
1339
- }
1340
-
1341
- // Drop the internal `-- param` typing headers from the exposed
1342
- // (projection-lowered) SQL.
1343
- const exposedSql = base.sql
1344
- .replace(/^(?:\s*-- param :[^\n]*\n)+/, '')
1345
- .trimStart();
1346
-
1347
- // §7 variant-enumeration backend: opt-in per query (`variants`), or
1348
- // selected by the manifest's queryBackend heuristic — `auto` enumerates
1349
- // when the combination count is trivially small (N ≤ 2 groups → ≤ 4
1350
- // statements, perfect index use for free), `variants` enumerates
1351
- // whenever the query is eligible. Assembled by swapping the WHERE
1352
- // clause of the (projection-lowered) neutralization statement, so the
1353
- // two backends differ ONLY in guard mechanics — semantically identical
1354
- // by construction.
1355
- const optionalGroupCount = (() => {
1356
- const keys = new Set<string>();
1357
- for (const [name, info] of lowered.paramInfo) {
1358
- if (info.optional) keys.add(info.group ?? name);
1359
- }
1360
- return keys.size;
1361
- })();
1362
- const backend = naming?.backend ?? 'neutralize';
1363
- const wantVariants =
1364
- decl.variants === true ||
1365
- (decl.orderBy === undefined &&
1366
- optionalGroupCount > 0 &&
1367
- (backend === 'variants' ||
1368
- (backend === 'auto' && optionalGroupCount <= 2)));
1369
- let variants: AnalyzedQuery['variants'];
1370
- let variantGroups: AnalyzedQuery['variantGroups'];
1371
- if (wantVariants) {
1372
- if (decl.orderBy !== undefined) {
1373
- throw new TypegenError(
1374
- location,
1375
- 'the variants backend and the orderBy knob both multiply statements — they cannot combine (yet); drop one',
1376
- );
1377
- }
1378
- const groupList: { key: string; params: string[]; flag: boolean }[] = [];
1379
- for (const [name, info] of lowered.paramInfo) {
1380
- if (!info.optional) continue;
1381
- const key = info.group ?? name;
1382
- const existing = groupList.find((g) => g.key === key);
1383
- if (existing !== undefined) existing.params.push(name);
1384
- else groupList.push({ key, params: [name], flag: info.flag });
1385
- }
1386
- const n = groupList.length;
1387
- if (n === 0) {
1388
- throw new TypegenError(
1389
- location,
1390
- 'the variants backend needs at least one optional param — a fully static query has exactly one variant already',
1391
- );
1392
- }
1393
- if (n > 8) {
1394
- throw new TypegenError(
1395
- location,
1396
- `${n} optional groups would enumerate ${2 ** n} statements — a query with nine independent optional filters is a design smell, not a codegen challenge (§7); split the query or drop \`variants\``,
1397
- );
1398
- }
1399
- if (n > 4) {
1400
- console.warn(
1401
- `${location}: variants enumerates ${2 ** n} statements (${n} optional groups) — consider the default neutralization backend`,
1402
- );
1403
- }
1404
- const w = findWhere(exposedSql);
1405
- if (w === null) throw new Error('unreachable: optional params ⇒ WHERE');
1406
- const before = exposedSql.slice(0, w.kwStart).trimEnd();
1407
- const after = exposedSql.slice(w.end).trim();
1408
- const groupOf = (param: string): string =>
1409
- lowered.paramInfo.get(param)?.group ?? param;
1410
- const out: NonNullable<AnalyzedQuery['variants']>[number][] = [];
1411
- for (let mask = 0; mask < 2 ** n; mask++) {
1412
- const provided = new Set(
1413
- groupList.filter((_, i) => ((mask >> i) & 1) === 1).map((g) => g.key),
1414
- );
1415
- const included = lowered.conjuncts.filter((c) =>
1416
- c.governing.every((p) => provided.has(groupOf(p))),
1417
- );
1418
- const whereSql = included.map((c) => c.pred).join(' and ');
1419
- const sqlV = [
1420
- before,
1421
- ...(whereSql.length > 0 ? [`where ${whereSql}`] : []),
1422
- ...(after.length > 0 ? [after] : []),
1423
- ]
1424
- .join(' ')
1425
- .replace(/\s+/g, ' ')
1426
- .trim();
1427
- try {
1428
- db.analyze(sqlV);
1429
- } catch (error) {
1430
- const message =
1431
- error instanceof Error ? error.message : String(error);
1432
- throw new TypegenError(
1433
- location,
1434
- `variant [${[...provided].join('+') || 'none'}] rejected by SQLite: ${message}`,
1435
- );
1436
- }
1437
- out.push({
1438
- when: groupList
1439
- .filter((_, i) => ((mask >> i) & 1) === 1)
1440
- .map((g) => g.key),
1441
- sql: sqlV,
1442
- positionalSql: toPositionalSql(sqlV),
1443
- params: scanParams(sqlV),
1444
- });
1445
- }
1446
- variants = out;
1447
- variantGroups = groupList.map((g) => ({
1448
- key: g.key,
1449
- params: g.params,
1450
- flag: g.flag,
1451
- }));
1452
- }
1453
-
1454
- return {
1455
- ...base,
1456
- sourceSql: decl.body.trim(),
1457
- sql: exposedSql,
1458
- params,
1459
- reactive,
1460
- ...(orderBy !== undefined ? { orderBy } : {}),
1461
- ...(decl.limit !== undefined ? { limit: decl.limit } : {}),
1462
- ...(positionalSqlBase !== undefined ? { positionalSqlBase } : {}),
1463
- ...(positionalLimitTail !== undefined && decl.orderBy !== undefined
1464
- ? { positionalLimitTail }
1465
- : {}),
1466
- ...(variants !== undefined ? { variants } : {}),
1467
- ...(variantGroups !== undefined ? { variantGroups } : {}),
1468
- };
1469
- });
1470
- }