@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
@@ -0,0 +1,966 @@
1
+ import { analyzeStatement, inferParamTypeEvidence, scanTableRefs, stripCommentsAndStrings, } from './query.js';
2
+ import { isSyqlTrivia, lexSyqlSqlSource, SyqlFrontendError, } from './syql-lexer.js';
3
+ const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ const NONDETERMINISTIC_FUNCTIONS = new Set([
5
+ 'random',
6
+ 'randomblob',
7
+ 'changes',
8
+ 'total_changes',
9
+ 'last_insert_rowid',
10
+ 'sqlite_version',
11
+ 'sqlite_source_id',
12
+ ]);
13
+ const DATE_FUNCTIONS = new Set([
14
+ 'date',
15
+ 'time',
16
+ 'datetime',
17
+ 'julianday',
18
+ 'unixepoch',
19
+ 'strftime',
20
+ 'timediff',
21
+ ]);
22
+ const CURRENT_TIME_KEYWORDS = new Set([
23
+ 'current_date',
24
+ 'current_time',
25
+ 'current_timestamp',
26
+ ]);
27
+ const AGGREGATE_FUNCTIONS = new Set([
28
+ 'avg',
29
+ 'count',
30
+ 'group_concat',
31
+ 'max',
32
+ 'min',
33
+ 'sum',
34
+ 'total',
35
+ ]);
36
+ /**
37
+ * The function surface of the standard SQLite 3.46.0 amalgamation.
38
+ *
39
+ * Keep this explicit: preparing with Bun's host SQLite proves that SQL is
40
+ * valid on that (usually newer) engine, not that it is valid on the revision-1
41
+ * floor. Unknown, extension-provided, and post-floor functions are rejected
42
+ * before the host prepare step.
43
+ */
44
+ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
45
+ // Core scalar and aggregate functions.
46
+ 'abs',
47
+ 'avg',
48
+ 'char',
49
+ 'cast',
50
+ 'coalesce',
51
+ 'concat',
52
+ 'concat_ws',
53
+ 'count',
54
+ 'format',
55
+ 'glob',
56
+ 'group_concat',
57
+ 'hex',
58
+ 'ifnull',
59
+ 'iif',
60
+ 'instr',
61
+ 'length',
62
+ 'like',
63
+ 'likelihood',
64
+ 'likely',
65
+ 'lower',
66
+ 'ltrim',
67
+ 'max',
68
+ 'min',
69
+ 'nullif',
70
+ 'octet_length',
71
+ 'printf',
72
+ 'quote',
73
+ 'replace',
74
+ 'round',
75
+ 'rtrim',
76
+ 'sign',
77
+ 'string_agg',
78
+ 'substr',
79
+ 'substring',
80
+ 'sum',
81
+ 'total',
82
+ 'trim',
83
+ 'typeof',
84
+ 'unhex',
85
+ 'unicode',
86
+ 'unlikely',
87
+ 'upper',
88
+ 'zeroblob',
89
+ // Date/time functions. Determinism applies additional restrictions below.
90
+ ...DATE_FUNCTIONS,
91
+ // JSON1 and JSONB are core in the 3.46.0 amalgamation.
92
+ 'json',
93
+ 'json_array',
94
+ 'json_array_length',
95
+ 'json_error_position',
96
+ 'json_each',
97
+ 'json_extract',
98
+ 'json_group_array',
99
+ 'json_group_object',
100
+ 'json_insert',
101
+ 'json_object',
102
+ 'json_patch',
103
+ 'json_pretty',
104
+ 'json_quote',
105
+ 'json_remove',
106
+ 'json_replace',
107
+ 'json_set',
108
+ 'json_type',
109
+ 'json_tree',
110
+ 'json_valid',
111
+ 'jsonb',
112
+ 'jsonb_array',
113
+ 'jsonb_extract',
114
+ 'jsonb_group_array',
115
+ 'jsonb_group_object',
116
+ 'jsonb_insert',
117
+ 'jsonb_object',
118
+ 'jsonb_patch',
119
+ 'jsonb_remove',
120
+ 'jsonb_replace',
121
+ 'jsonb_set',
122
+ ]);
123
+ const SQL_PAREN_KEYWORDS = new Set([
124
+ 'and',
125
+ 'as',
126
+ 'else',
127
+ 'exists',
128
+ 'filter',
129
+ 'from',
130
+ 'group',
131
+ 'having',
132
+ 'in',
133
+ 'join',
134
+ 'limit',
135
+ 'not',
136
+ 'offset',
137
+ 'on',
138
+ 'or',
139
+ 'order',
140
+ 'over',
141
+ 'select',
142
+ 'then',
143
+ 'values',
144
+ 'when',
145
+ 'where',
146
+ 'with',
147
+ ]);
148
+ const SQLITE_346_PORTABLE_COLLATIONS = new Set(['binary', 'nocase', 'rtrim']);
149
+ const OUTER_CLAUSE_ENDERS = new Set([
150
+ 'group',
151
+ 'having',
152
+ 'order',
153
+ 'limit',
154
+ 'offset',
155
+ 'union',
156
+ 'intersect',
157
+ 'except',
158
+ ]);
159
+ function typeText(type) {
160
+ return typeof type === 'string'
161
+ ? type
162
+ : `${type.base}${type.nullable ? ' | null' : ''}`;
163
+ }
164
+ function valueType(base, nullable, span) {
165
+ return { base, nullable, span };
166
+ }
167
+ function significant(tokens) {
168
+ return tokens.filter((token) => !isSyqlTrivia(token) && token.kind !== 'eof');
169
+ }
170
+ function tokenLower(token) {
171
+ return token?.kind === 'identifier' ? token.text.toLowerCase() : token?.text;
172
+ }
173
+ function decodeSqlString(token) {
174
+ if (token.kind !== 'string' || token.text.length < 2)
175
+ return undefined;
176
+ return token.text.slice(1, -1).replaceAll("''", "'");
177
+ }
178
+ function templateText(template) {
179
+ return template.tokens
180
+ .map((token) => token.text)
181
+ .join('')
182
+ .trim();
183
+ }
184
+ function functionArgumentCount(tokens, openIndex) {
185
+ let depth = 0;
186
+ let commas = 0;
187
+ let hasArgument = false;
188
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
189
+ const token = tokens[index];
190
+ if (token.text === '(') {
191
+ depth += 1;
192
+ hasArgument = true;
193
+ }
194
+ else if (token.text === ')') {
195
+ if (depth === 0)
196
+ return hasArgument ? commas + 1 : 0;
197
+ depth -= 1;
198
+ hasArgument = true;
199
+ }
200
+ else if (token.text === ',' && depth === 0) {
201
+ commas += 1;
202
+ }
203
+ else {
204
+ hasArgument = true;
205
+ }
206
+ }
207
+ return undefined;
208
+ }
209
+ function matchingParenIndex(tokens, openIndex) {
210
+ let depth = 0;
211
+ for (let index = openIndex + 1; index < tokens.length; index += 1) {
212
+ const token = tokens[index];
213
+ if (token.text === '(')
214
+ depth += 1;
215
+ else if (token.text === ')') {
216
+ if (depth === 0)
217
+ return index;
218
+ depth -= 1;
219
+ }
220
+ }
221
+ return undefined;
222
+ }
223
+ class Validator {
224
+ #semantic;
225
+ #ir;
226
+ #db;
227
+ #naming;
228
+ constructor(semantic, ir, db, naming) {
229
+ this.#semantic = semantic;
230
+ this.#ir = ir;
231
+ this.#db = db;
232
+ this.#naming = naming;
233
+ }
234
+ validate() {
235
+ return {
236
+ semantic: this.#semantic,
237
+ queries: this.#semantic.queries.map((query) => this.#validateQuery(query)),
238
+ };
239
+ }
240
+ #validateQuery(logical) {
241
+ const location = `${logical.module.file} (query ${logical.declaration.name})`;
242
+ const markers = [];
243
+ const markerSql = this.#render(logical.template, 'markers', markers);
244
+ const markerStructure = this.#inspect(markerSql, location);
245
+ this.#validatePlacement(markerStructure, markers, logical.declaration);
246
+ const activeSql = this.#render(logical.template, 'active', []);
247
+ const activeStructure = this.#inspect(activeSql, location);
248
+ this.#validateStatementShape(activeSql, activeStructure, logical.declaration, location);
249
+ this.#validateDeterminism(activeSql, logical.declaration.sql.span);
250
+ this.#validatePortableProfile(activeSql, logical.declaration.sql.span);
251
+ const refs = scanTableRefs(activeSql, this.#ir);
252
+ const bindSymbols = this.#bindSymbols(logical.declaration);
253
+ const resolvedDirectives = this.#resolveDirectives(logical, refs, bindSymbols);
254
+ const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols, resolvedDirectives);
255
+ const sort = this.#validateSort(logical.declaration, activeStructure, location);
256
+ let referenceSql = this.#composeSort(activeSql, activeStructure, sort?.profiles.find((profile) => profile.name === sort.defaultProfile)
257
+ ?.sql);
258
+ if (logical.declaration.page !== undefined) {
259
+ referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
260
+ }
261
+ const headers = [...bindTypes]
262
+ .map(([name, type]) => `-- param :${name} ${type.base}`)
263
+ .join('\n');
264
+ const statement = headers.length === 0 ? referenceSql : `${headers}\n${referenceSql}`;
265
+ let analysis;
266
+ try {
267
+ analysis = analyzeStatement(logical.declaration.name, location, statement, this.#ir, this.#db, this.#naming);
268
+ }
269
+ catch (error) {
270
+ const message = error instanceof Error ? error.message : String(error);
271
+ this.#fail('SYQL6002_INVALID_SQL', logical.declaration.sql.span, `reference SQL rejected: ${message}`);
272
+ }
273
+ for (const profile of sort?.profiles ?? []) {
274
+ const candidate = this.#composeSort(activeSql, activeStructure, profile.sql);
275
+ const paged = logical.declaration.page === undefined
276
+ ? candidate
277
+ : `${candidate.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
278
+ try {
279
+ this.#db.analyze(paged);
280
+ }
281
+ catch (error) {
282
+ const message = error instanceof Error ? error.message : String(error);
283
+ this.#fail('SYQL6006_INVALID_SORT', logical.declaration.sort?.span ?? logical.declaration.sql.span, `sort profile ${profile.name} rejected by SQLite: ${message}`);
284
+ }
285
+ }
286
+ const reactive = this.#buildReactive(refs, resolvedDirectives);
287
+ const identity = this.#proveIdentity(logical.declaration, activeSql, refs, analysis);
288
+ this.#validateStableOrder(logical.declaration, activeSql, activeStructure, sort, identity, analysis, refs);
289
+ const reactiveWithIdentity = {
290
+ ...reactive,
291
+ ...(identity === undefined ? {} : { rowKey: identity }),
292
+ };
293
+ return {
294
+ logical,
295
+ referenceSql,
296
+ analysis: { ...analysis, reactive: reactiveWithIdentity },
297
+ bindTypes,
298
+ reactive: reactiveWithIdentity,
299
+ ...(identity === undefined ? {} : { identity }),
300
+ ...(sort === undefined ? {} : { sort }),
301
+ ...(logical.declaration.page === undefined
302
+ ? {}
303
+ : {
304
+ page: {
305
+ control: logical.declaration.page.control,
306
+ defaultSize: logical.declaration.page.defaultSize,
307
+ maxSize: logical.declaration.page.maxSize,
308
+ },
309
+ }),
310
+ };
311
+ }
312
+ #render(nodes, mode, markers) {
313
+ return nodes
314
+ .map((node) => {
315
+ if (node.kind === 'sql') {
316
+ return node.parts
317
+ .map((part) => (part.kind === 'text' ? part.text : `:${part.name}`))
318
+ .join('');
319
+ }
320
+ if (node.kind === 'predicate') {
321
+ return `(${this.#render(node.body, mode, markers)})`;
322
+ }
323
+ if (mode === 'markers') {
324
+ const name = `__syql_node_${markers.length}`;
325
+ markers.push({ name, node });
326
+ return name;
327
+ }
328
+ if (node.kind === 'when') {
329
+ return `(${this.#render(node.body, mode, markers)})`;
330
+ }
331
+ return this.#renderDirective(node);
332
+ })
333
+ .join('');
334
+ }
335
+ #renderDirective(node) {
336
+ const predicates = node.directive.bindings.map((binding) => {
337
+ const column = `${binding.column.qualifier}.${binding.column.name}`;
338
+ const values = binding.values.map((value) => `:${value.name}`);
339
+ return binding.operator === 'equal'
340
+ ? `${column} = ${values[0]}`
341
+ : `${column} in (${values.join(', ')})`;
342
+ });
343
+ return `(${predicates.join(' and ')})`;
344
+ }
345
+ #inspect(sql, file) {
346
+ const raw = significant(lexSyqlSqlSource(file, sql));
347
+ const tokens = [];
348
+ let depth = 0;
349
+ for (const token of raw) {
350
+ if (token.kind === 'punctuation' && token.text === ')')
351
+ depth -= 1;
352
+ tokens.push({ token, depth });
353
+ if (token.kind === 'punctuation' && token.text === '(')
354
+ depth += 1;
355
+ if (depth < 0) {
356
+ this.#fail('SYQL6002_INVALID_SQL', token.span, 'unbalanced SQL parenthesis');
357
+ }
358
+ }
359
+ if (depth !== 0) {
360
+ this.#fail('SYQL6002_INVALID_SQL', raw[raw.length - 1]?.span ?? {
361
+ file,
362
+ start: { offset: 0, line: 1, column: 1 },
363
+ end: { offset: 0, line: 1, column: 1 },
364
+ }, 'unbalanced SQL parenthesis');
365
+ }
366
+ const outer = tokens.filter((item) => item.depth === 0);
367
+ let orderStart;
368
+ let orderBodyStart;
369
+ let orderEnd;
370
+ let tailStart;
371
+ let hasOuterLimit = false;
372
+ let hasOuterOffset = false;
373
+ let hasCompound = false;
374
+ for (let index = 0; index < outer.length; index += 1) {
375
+ const item = outer[index];
376
+ const lower = tokenLower(item.token);
377
+ const next = outer[index + 1]?.token;
378
+ if (lower === 'order' && tokenLower(next) === 'by') {
379
+ orderStart = item.token.span.start.offset;
380
+ orderBodyStart = next?.span.end.offset;
381
+ }
382
+ else if (lower === 'limit') {
383
+ hasOuterLimit = true;
384
+ tailStart = Math.min(tailStart ?? Number.MAX_SAFE_INTEGER, item.token.span.start.offset);
385
+ }
386
+ else if (lower === 'offset') {
387
+ hasOuterOffset = true;
388
+ tailStart = Math.min(tailStart ?? Number.MAX_SAFE_INTEGER, item.token.span.start.offset);
389
+ }
390
+ else if (lower === 'union' ||
391
+ lower === 'intersect' ||
392
+ lower === 'except') {
393
+ hasCompound = true;
394
+ }
395
+ if (orderBodyStart !== undefined &&
396
+ orderEnd === undefined &&
397
+ (lower === 'limit' ||
398
+ lower === 'offset' ||
399
+ lower === 'union' ||
400
+ lower === 'intersect' ||
401
+ lower === 'except')) {
402
+ orderEnd = item.token.span.start.offset;
403
+ }
404
+ }
405
+ if (orderBodyStart !== undefined && orderEnd === undefined)
406
+ orderEnd = sql.length;
407
+ return {
408
+ tokens,
409
+ outer,
410
+ hasOuterOrder: orderStart !== undefined,
411
+ hasOuterLimit,
412
+ hasOuterOffset,
413
+ hasCompound,
414
+ ...(orderStart === undefined ? {} : { orderStart }),
415
+ ...(orderBodyStart === undefined ? {} : { orderBodyStart }),
416
+ ...(orderEnd === undefined ? {} : { orderEnd }),
417
+ ...(tailStart === undefined ? {} : { tailStart }),
418
+ };
419
+ }
420
+ #validatePlacement(structure, markers, query) {
421
+ const byName = new Map(markers.map((marker) => [marker.name, marker]));
422
+ let clause;
423
+ for (let index = 0; index < structure.outer.length; index += 1) {
424
+ const item = structure.outer[index];
425
+ const lower = tokenLower(item.token);
426
+ if (lower === 'where' || lower === 'having')
427
+ clause = lower;
428
+ else if (OUTER_CLAUSE_ENDERS.has(lower ?? ''))
429
+ clause = undefined;
430
+ const marker = byName.get(item.token.text);
431
+ if (marker === undefined)
432
+ continue;
433
+ const previous = tokenLower(structure.outer[index - 1]?.token);
434
+ const next = tokenLower(structure.outer[index + 1]?.token);
435
+ const boundaryBefore = previous === 'where' || previous === 'having' || previous === 'and';
436
+ const boundaryAfter = next === undefined || next === 'and' || OUTER_CLAUSE_ENDERS.has(next);
437
+ if (!boundaryBefore || !boundaryAfter) {
438
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} must be an entire outer conjunct`);
439
+ }
440
+ if (marker.node.kind === 'when') {
441
+ if (clause !== 'where' && clause !== 'having') {
442
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, 'when is allowed only in the outer WHERE or HAVING clause');
443
+ }
444
+ }
445
+ else if (clause !== 'where') {
446
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `@${marker.node.kind} is allowed only in the outer WHERE clause`);
447
+ }
448
+ }
449
+ for (const marker of markers) {
450
+ if (!structure.outer.some((item) => item.token.text === marker.name)) {
451
+ this.#fail('SYQL6001_INVALID_PLACEMENT', marker.node.span, `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} cannot appear in a nested SQL expression or statement`);
452
+ }
453
+ }
454
+ if (structure.hasCompound && markers.length > 0) {
455
+ this.#fail('SYQL6001_INVALID_PLACEMENT', query.sql.span, 'embedded SYQL conjuncts are ambiguous in a compound outer statement');
456
+ }
457
+ }
458
+ #validateStatementShape(sql, structure, query, location) {
459
+ if (query.sort !== undefined && structure.hasOuterOrder) {
460
+ this.#fail('SYQL6006_INVALID_SORT', query.sort.span, 'sort section conflicts with an authored outer ORDER BY');
461
+ }
462
+ if (query.page !== undefined &&
463
+ (structure.hasOuterLimit || structure.hasOuterOffset)) {
464
+ this.#fail('SYQL6007_INVALID_PAGE', query.page.span, 'page declaration conflicts with an authored outer LIMIT or OFFSET');
465
+ }
466
+ const first = structure.outer
467
+ .find((item) => item.token.kind === 'identifier')
468
+ ?.token.text.toLowerCase();
469
+ if (first !== 'select' && first !== 'with') {
470
+ this.#fail('SYQL6002_INVALID_SQL', query.sql.span, `${location} must contain one SELECT or WITH ... SELECT statement`);
471
+ }
472
+ if (sql.trim().length === 0) {
473
+ this.#fail('SYQL6002_INVALID_SQL', query.sql.span, 'empty SQL statement');
474
+ }
475
+ }
476
+ #validateDeterminism(sql, span) {
477
+ const tokens = significant(lexSyqlSqlSource(span.file, sql));
478
+ const functionStack = [];
479
+ let depth = 0;
480
+ for (let index = 0; index < tokens.length; index += 1) {
481
+ const token = tokens[index];
482
+ const lower = tokenLower(token);
483
+ const next = tokens[index + 1];
484
+ if (depth > 0 &&
485
+ token.kind === 'identifier' &&
486
+ (lower === 'limit' || lower === 'offset')) {
487
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', token.span, `nested ${token.text.toUpperCase()} is rejected until its local row identity and total order can be proven`);
488
+ }
489
+ if (token.kind === 'identifier' && lower === 'over') {
490
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', token.span, 'window expressions are rejected until their partition identity and total order can be proven');
491
+ }
492
+ if (CURRENT_TIME_KEYWORDS.has(lower ?? '')) {
493
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', span, `${token.text} depends on wall-clock time`);
494
+ }
495
+ if (token.kind === 'identifier' &&
496
+ next?.text === '(' &&
497
+ NONDETERMINISTIC_FUNCTIONS.has(lower ?? '')) {
498
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', span, `${token.text}() is not deterministic from snapshot and inputs`);
499
+ }
500
+ if (token.text === '(') {
501
+ const previous = tokens[index - 1];
502
+ functionStack.push(previous?.kind === 'identifier'
503
+ ? previous.text.toLowerCase()
504
+ : undefined);
505
+ depth += 1;
506
+ }
507
+ else if (token.text === ')') {
508
+ functionStack.pop();
509
+ depth -= 1;
510
+ }
511
+ else if (decodeSqlString(token)?.toLowerCase() === 'now' &&
512
+ functionStack.some((name) => name !== undefined && DATE_FUNCTIONS.has(name))) {
513
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', span, "SQLite date/time modifier 'now' is not deterministic");
514
+ }
515
+ }
516
+ }
517
+ #validatePortableProfile(sql, span) {
518
+ const tokens = significant(lexSyqlSqlSource(span.file, sql));
519
+ for (let index = 0; index < tokens.length; index += 1) {
520
+ const token = tokens[index];
521
+ const lower = tokenLower(token);
522
+ if (lower === 'collate') {
523
+ const collation = tokens[index + 1];
524
+ const name = tokenLower(collation);
525
+ if (collation === undefined ||
526
+ !SQLITE_346_PORTABLE_COLLATIONS.has(name ?? '')) {
527
+ this.#fail('SYQL6002_INVALID_SQL', collation?.span ?? token.span, `collation ${collation?.text ?? '<missing>'} is outside the portable SQLite 3.46.0 profile`);
528
+ }
529
+ }
530
+ if (token.kind !== 'identifier' ||
531
+ tokens[index + 1]?.text !== '(' ||
532
+ SQL_PAREN_KEYWORDS.has(lower ?? '')) {
533
+ continue;
534
+ }
535
+ const closeIndex = matchingParenIndex(tokens, index + 1);
536
+ // A WITH declaration such as `items(id) AS (...)` is not a function.
537
+ if (closeIndex !== undefined &&
538
+ tokenLower(tokens[closeIndex + 1]) === 'as' &&
539
+ tokens[closeIndex + 2]?.text === '(') {
540
+ continue;
541
+ }
542
+ if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '')) {
543
+ this.#fail('SYQL6002_INVALID_SQL', token.span, `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile`);
544
+ }
545
+ const argumentCount = functionArgumentCount(tokens, index + 1);
546
+ if (lower === 'iif' && argumentCount !== 3) {
547
+ this.#fail('SYQL6002_INVALID_SQL', token.span, 'SQLite 3.46.0 requires exactly three arguments to iif()');
548
+ }
549
+ if ((lower === 'date' ||
550
+ lower === 'time' ||
551
+ lower === 'datetime' ||
552
+ lower === 'julianday' ||
553
+ lower === 'unixepoch') &&
554
+ argumentCount === 0) {
555
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', token.span, `${token.text}() without a time-value implicitly reads the wall clock`);
556
+ }
557
+ if (lower === 'strftime' && (argumentCount ?? 0) < 2) {
558
+ this.#fail('SYQL6003_NONDETERMINISTIC_SQL', token.span, 'strftime() without an explicit time-value implicitly reads the wall clock');
559
+ }
560
+ }
561
+ }
562
+ #bindSymbols(query) {
563
+ const symbols = new Map();
564
+ for (const parameter of query.parameters) {
565
+ if (parameter.kind === 'switch')
566
+ continue;
567
+ if (parameter.kind === 'group') {
568
+ for (const member of parameter.members) {
569
+ symbols.set(member.name, {
570
+ name: member.name,
571
+ parameter,
572
+ span: member.nameSpan,
573
+ ...(member.type === undefined ? {} : { authoredType: member.type }),
574
+ });
575
+ }
576
+ }
577
+ else {
578
+ symbols.set(parameter.name, {
579
+ name: parameter.name,
580
+ parameter,
581
+ span: parameter.nameSpan,
582
+ ...(parameter.type === undefined
583
+ ? {}
584
+ : { authoredType: parameter.type }),
585
+ });
586
+ }
587
+ }
588
+ return symbols;
589
+ }
590
+ #resolveBindTypes(logical, sql, refs, symbols, directives) {
591
+ const resolved = new Map(logical.bindTypes);
592
+ const directiveTypes = new Map();
593
+ for (const directive of directives) {
594
+ for (const binding of directive.node.directive.bindings) {
595
+ const column = directive.table.columns.find((item) => item.name === binding.column.name);
596
+ if (column === undefined)
597
+ continue;
598
+ for (const bind of binding.values) {
599
+ const list = directiveTypes.get(bind.name) ?? [];
600
+ list.push(column.type);
601
+ directiveTypes.set(bind.name, list);
602
+ }
603
+ }
604
+ }
605
+ for (const symbol of symbols.values()) {
606
+ const evidence = [
607
+ ...inferParamTypeEvidence(symbol.name, sql, refs, this.#ir),
608
+ ...(directiveTypes.get(symbol.name) ?? []),
609
+ ];
610
+ const unique = [...new Set(evidence)];
611
+ if (unique.length > 1) {
612
+ this.#fail('SYQL6004_TYPE_CONFLICT', symbol.span, `bind :${symbol.name} has conflicting checked types: ${unique.join(', ')}`);
613
+ }
614
+ const existing = resolved.get(symbol.name) ?? symbol.authoredType;
615
+ const inferred = unique[0];
616
+ if (existing !== undefined &&
617
+ inferred !== undefined &&
618
+ existing.base !== inferred) {
619
+ this.#fail('SYQL6004_TYPE_CONFLICT', symbol.span, `bind :${symbol.name} is declared ${typeText(existing)} but SQL requires ${inferred}`);
620
+ }
621
+ if (existing !== undefined)
622
+ resolved.set(symbol.name, existing);
623
+ else if (inferred !== undefined) {
624
+ resolved.set(symbol.name, valueType(inferred, false, symbol.span));
625
+ }
626
+ else {
627
+ this.#fail('SYQL6004_TYPE_CONFLICT', symbol.span, `cannot infer a revision-1 type for bind :${symbol.name}; add an annotation`);
628
+ }
629
+ }
630
+ for (const directive of directives) {
631
+ for (const binding of directive.node.directive.bindings) {
632
+ const column = directive.table.columns.find((item) => item.name === binding.column.name);
633
+ if (column === undefined)
634
+ continue;
635
+ for (const bind of binding.values) {
636
+ const type = resolved.get(bind.name);
637
+ if (type === undefined ||
638
+ type.base !== column.type ||
639
+ type.nullable !== column.nullable) {
640
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', bind.span, `scope bind :${bind.name} must exactly match ${column.type}${column.nullable ? ' | null' : ''}`);
641
+ }
642
+ }
643
+ }
644
+ }
645
+ return resolved;
646
+ }
647
+ #resolveDirectives(logical, refs, symbols) {
648
+ const nodes = [];
649
+ const collect = (template) => {
650
+ for (const node of template) {
651
+ if (node.kind === 'scope' || node.kind === 'cover')
652
+ nodes.push(node);
653
+ else if (node.kind === 'predicate')
654
+ collect(node.body);
655
+ }
656
+ };
657
+ collect(logical.template);
658
+ return nodes.map((node) => {
659
+ const first = node.directive.bindings[0];
660
+ if (first === undefined) {
661
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', node.span, `@${node.kind} requires a binding`);
662
+ }
663
+ const qualifier = first.column.qualifier;
664
+ const matchingRefs = refs.filter((ref) => ref.alias === qualifier);
665
+ if (matchingRefs.length !== 1) {
666
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', first.column.span, `scope qualifier ${qualifier} must resolve to exactly one read table instance`);
667
+ }
668
+ const ref = matchingRefs[0];
669
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
670
+ if (table === undefined) {
671
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', first.column.span, `scope qualifier ${qualifier} does not resolve to a synced table`);
672
+ }
673
+ const seenColumns = new Set();
674
+ const scopes = [];
675
+ for (const binding of node.directive.bindings) {
676
+ if (binding.column.qualifier !== qualifier) {
677
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `all @${node.kind} bindings must name the same table instance`);
678
+ }
679
+ if (seenColumns.has(binding.column.name)) {
680
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `scope column ${binding.column.name} is listed twice`);
681
+ }
682
+ seenColumns.add(binding.column.name);
683
+ const scope = table.scopes.find((candidate) => candidate.column === binding.column.name);
684
+ if (scope === undefined) {
685
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding.column.span, `${table.name}.${binding.column.name} is not a declared scope column`);
686
+ }
687
+ for (const bind of binding.values) {
688
+ const symbol = symbols.get(bind.name);
689
+ if (symbol === undefined ||
690
+ symbol.parameter.kind !== 'value' ||
691
+ symbol.parameter.optional) {
692
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', bind.span, `scope value :${bind.name} must be a required scalar input`);
693
+ }
694
+ }
695
+ scopes.push({
696
+ table: table.name,
697
+ variable: scope.variable,
698
+ pattern: scope.pattern,
699
+ params: binding.values.map((value) => value.name),
700
+ });
701
+ }
702
+ let coverage;
703
+ if (node.kind === 'cover') {
704
+ const required = new Set(table.scopes.map((scope) => scope.column));
705
+ if (seenColumns.size !== required.size ||
706
+ [...required].some((column) => !seenColumns.has(column))) {
707
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', node.span, `@cover for ${table.name} must bind every declared scope exactly once`);
708
+ }
709
+ const firstScope = scopes[0];
710
+ for (let index = 1; index < node.directive.bindings.length; index += 1) {
711
+ const binding = node.directive.bindings[index];
712
+ if (binding?.operator !== 'equal' || binding.values.length !== 1) {
713
+ this.#fail('SYQL6005_INVALID_REACTIVE_DIRECTIVE', binding?.span ?? node.span, 'fixed @cover scopes after the first binding must use one equality bind');
714
+ }
715
+ }
716
+ const byVariable = new Map(scopes.map((scope) => [scope.variable, scope]));
717
+ coverage = {
718
+ table: table.name,
719
+ variable: firstScope.variable,
720
+ units: firstScope.params,
721
+ fixedScopes: table.scopes
722
+ .filter((scope) => scope.variable !== firstScope.variable)
723
+ .map((scope) => {
724
+ const fixed = byVariable.get(scope.variable);
725
+ return { variable: fixed.variable, params: fixed.params };
726
+ }),
727
+ };
728
+ }
729
+ return {
730
+ node,
731
+ ref,
732
+ table,
733
+ scopes,
734
+ ...(coverage === undefined ? {} : { coverage }),
735
+ };
736
+ });
737
+ }
738
+ #buildReactive(refs, directives) {
739
+ const dependencies = [];
740
+ const coverage = [];
741
+ for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
742
+ const instances = refs.filter((ref) => ref.table === tableName);
743
+ const byAlias = new Map();
744
+ for (const directive of directives.filter((item) => item.table.name === tableName)) {
745
+ const list = byAlias.get(directive.ref.alias) ?? [];
746
+ list.push(directive);
747
+ byAlias.set(directive.ref.alias, list);
748
+ }
749
+ const fallback = instances.some((instance) => !byAlias.has(instance.alias));
750
+ const scopes = fallback
751
+ ? []
752
+ : [...byAlias.values()]
753
+ .flatMap((items) => items.flatMap((item) => item.scopes))
754
+ .reduce((out, scope) => {
755
+ const existing = out.find((item) => item.variable === scope.variable);
756
+ if (existing === undefined)
757
+ out.push(scope);
758
+ else {
759
+ const merged = [
760
+ ...new Set([...existing.params, ...scope.params]),
761
+ ];
762
+ out[out.indexOf(existing)] = { ...existing, params: merged };
763
+ }
764
+ return out;
765
+ }, []);
766
+ dependencies.push({ table: tableName, scopes });
767
+ if (!fallback) {
768
+ coverage.push(...[...byAlias.values()].flatMap((items) => items.flatMap((item) => item.coverage === undefined ? [] : [item.coverage])));
769
+ }
770
+ }
771
+ return { dependencies, coverage };
772
+ }
773
+ #validateSort(query, structure, location) {
774
+ if (query.sort === undefined)
775
+ return undefined;
776
+ if (structure.hasOuterOrder) {
777
+ this.#fail('SYQL6006_INVALID_SORT', query.sort.span, 'sort section conflicts with authored outer ORDER BY');
778
+ }
779
+ const profiles = query.sort.profiles.map((profile) => {
780
+ const sql = templateText(profile.order);
781
+ const tokens = significant(lexSyqlSqlSource(location, sql));
782
+ for (let index = 0; index < tokens.length; index += 1) {
783
+ const token = tokens[index];
784
+ const lower = tokenLower(token);
785
+ if (lower === 'select' ||
786
+ lower === 'limit' ||
787
+ lower === 'offset' ||
788
+ lower === 'window' ||
789
+ lower === 'over' ||
790
+ lower === 'order' ||
791
+ lower === 'group' ||
792
+ lower === 'having') {
793
+ this.#fail('SYQL6006_INVALID_SORT', profile.order.span, `sort profile ${profile.name} contains forbidden ${token.text}`);
794
+ }
795
+ if (token.kind === 'identifier' &&
796
+ tokens[index + 1]?.text === '(' &&
797
+ AGGREGATE_FUNCTIONS.has(lower ?? '')) {
798
+ this.#fail('SYQL6006_INVALID_SORT', profile.order.span, `sort profile ${profile.name} contains aggregate ${token.text}()`);
799
+ }
800
+ }
801
+ this.#validateDeterminism(sql, profile.order.span);
802
+ this.#validatePortableProfile(sql, profile.order.span);
803
+ return { name: profile.name, sql };
804
+ });
805
+ return {
806
+ control: query.sort.control,
807
+ defaultProfile: query.sort.defaultProfile,
808
+ profiles,
809
+ };
810
+ }
811
+ #composeSort(sql, structure, order) {
812
+ if (order === undefined)
813
+ return sql.trim();
814
+ const insertion = structure.tailStart ?? sql.length;
815
+ return `${sql.slice(0, insertion).trimEnd()} order by ${order} ${sql
816
+ .slice(insertion)
817
+ .trimStart()}`.trim();
818
+ }
819
+ #proveIdentity(query, sql, refs, analysis) {
820
+ const cleaned = stripCommentsAndStrings(sql);
821
+ const nonSimple = /\b(?:DISTINCT|GROUP\s+BY|UNION|INTERSECT|EXCEPT|LEFT\s+JOIN|RIGHT\s+JOIN|FULL\s+JOIN)\b/i.test(cleaned) ||
822
+ /\b(?:count|sum|total|avg|min|max|group_concat)\s*\(/i.test(cleaned) ||
823
+ (cleaned.match(/\bSELECT\b/gi)?.length ?? 0) !== 1 ||
824
+ new Set(refs.map((ref) => `${ref.table}\0${ref.alias}`)).size !==
825
+ refs.length ||
826
+ new Set(refs.map((ref) => ref.table)).size !== refs.length;
827
+ const resultNames = new Set();
828
+ for (const column of analysis.columns) {
829
+ if (!IDENT_RE.test(column.name)) {
830
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `result name ${JSON.stringify(column.name)} must be an unquoted IDENT value`);
831
+ }
832
+ if (resultNames.has(column.name)) {
833
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `result name ${JSON.stringify(column.name)} is duplicated`);
834
+ }
835
+ resultNames.add(column.name);
836
+ }
837
+ if (query.identity !== undefined) {
838
+ if (nonSimple) {
839
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.identity.span, 'identity cannot be proven for this grouped, compound, nested, outer-join, aggregate, or self-join shape');
840
+ }
841
+ const selected = query.identity.fields.map((field) => {
842
+ const matches = analysis.columns.filter((column) => column.name === field);
843
+ const column = matches[0];
844
+ if (matches.length !== 1 ||
845
+ column === undefined ||
846
+ column.fidelity !== 'exact' ||
847
+ column.nullable ||
848
+ column.origin === undefined) {
849
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.identity?.span ?? query.sql.span, `identity field ${field} must be one exact, non-null physical result column`);
850
+ }
851
+ return column;
852
+ });
853
+ for (const ref of refs) {
854
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
855
+ if (table !== undefined &&
856
+ !selected.some((column) => column.origin?.table === table.name &&
857
+ column.origin.column === table.primaryKey)) {
858
+ this.#fail('SYQL6008_INVALID_IDENTITY', query.identity.span, `identity must include projected primary key ${table.name}.${table.primaryKey}`);
859
+ }
860
+ }
861
+ return selected.map((column) => column.langName);
862
+ }
863
+ if (nonSimple || refs.length === 0)
864
+ return undefined;
865
+ const inferred = [];
866
+ for (const ref of refs) {
867
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
868
+ const column = analysis.columns.find((candidate) => table !== undefined &&
869
+ candidate.origin?.table === table.name &&
870
+ candidate.origin.column === table.primaryKey &&
871
+ !candidate.nullable);
872
+ if (column === undefined)
873
+ return undefined;
874
+ inferred.push(column.langName);
875
+ }
876
+ return inferred;
877
+ }
878
+ #validateStableOrder(query, sql, structure, sort, identity, analysis, refs) {
879
+ const bounded = query.page !== undefined ||
880
+ structure.hasOuterLimit ||
881
+ structure.hasOuterOffset;
882
+ if (!bounded)
883
+ return;
884
+ if (identity === undefined || identity.length === 0) {
885
+ this.#fail('SYQL6006_INVALID_SORT', query.sort?.span ?? query.page?.span ?? query.sql.span, 'a bounded query requires a proven identity and total outer order');
886
+ }
887
+ const orders = sort?.profiles.map((profile) => ({
888
+ name: profile.name,
889
+ sql: profile.sql,
890
+ span: query.sort?.span ?? query.sql.span,
891
+ })) ??
892
+ (structure.orderBodyStart !== undefined &&
893
+ structure.orderEnd !== undefined
894
+ ? [
895
+ {
896
+ name: 'authored',
897
+ sql: sql
898
+ .slice(structure.orderBodyStart, structure.orderEnd)
899
+ .trim(),
900
+ span: query.sql.span,
901
+ },
902
+ ]
903
+ : []);
904
+ if (orders.length === 0) {
905
+ this.#fail('SYQL6006_INVALID_SORT', query.page?.span ?? query.sql.span, 'a bounded query requires an outer ORDER BY or sort section');
906
+ }
907
+ for (const order of orders) {
908
+ const terms = this.#splitOrderTerms(order.sql, order.span);
909
+ if (terms.length < identity.length) {
910
+ this.#fail('SYQL6006_INVALID_SORT', order.span, `order ${order.name} does not end in the complete identity tie-breaker`);
911
+ }
912
+ const suffix = terms.slice(terms.length - identity.length);
913
+ identity.forEach((field, index) => {
914
+ const result = analysis.columns.find((column) => column.langName === field);
915
+ const origin = result?.origin;
916
+ const match = /^(?:([A-Za-z_][A-Za-z0-9_]*)\.)?([A-Za-z_][A-Za-z0-9_]*)\s+(?:asc|desc)$/i.exec(suffix[index] ?? '');
917
+ if (origin === undefined ||
918
+ match === null ||
919
+ match[2] !== origin.column) {
920
+ this.#fail('SYQL6006_INVALID_SORT', order.span, `order ${order.name} must end with plain ${origin?.column ?? field} ASC/DESC identity term`);
921
+ }
922
+ const qualifier = match[1];
923
+ if (qualifier !== undefined) {
924
+ const ref = refs.find((candidate) => candidate.alias === qualifier);
925
+ if (ref?.table !== origin.table) {
926
+ this.#fail('SYQL6006_INVALID_SORT', order.span, `identity order qualifier ${qualifier} does not resolve to ${origin.table}`);
927
+ }
928
+ }
929
+ });
930
+ }
931
+ }
932
+ #splitOrderTerms(sql, span) {
933
+ const tokens = lexSyqlSqlSource(span.file, sql).filter((token) => token.kind !== 'eof');
934
+ const terms = [];
935
+ let depth = 0;
936
+ let current = '';
937
+ for (const token of tokens) {
938
+ if (token.kind === 'punctuation' && token.text === ')')
939
+ depth -= 1;
940
+ if (token.kind === 'punctuation' && token.text === ',' && depth === 0) {
941
+ if (current.trim().length === 0) {
942
+ this.#fail('SYQL6006_INVALID_SORT', span, 'empty ORDER BY term');
943
+ }
944
+ terms.push(current.trim());
945
+ current = '';
946
+ continue;
947
+ }
948
+ current += token.text;
949
+ if (token.kind === 'punctuation' && token.text === '(')
950
+ depth += 1;
951
+ }
952
+ if (current.trim().length > 0)
953
+ terms.push(current.trim());
954
+ if (terms.length === 0) {
955
+ this.#fail('SYQL6006_INVALID_SORT', span, 'empty ORDER BY list');
956
+ }
957
+ return terms;
958
+ }
959
+ #fail(code, span, message) {
960
+ throw new SyqlFrontendError(code, span, message);
961
+ }
962
+ }
963
+ /** Validate a semantic SYQL program against schema IR and SQLite. */
964
+ export function validateSyqlProgram(semantic, ir, db, naming) {
965
+ return new Validator(semantic, ir, db, naming).validate();
966
+ }