@syncular/typegen 0.6.0 → 0.8.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.
@@ -1,5 +1,5 @@
1
1
  /** Schema-aware revision-1 SYQL validation (§§5, 8–13). */
2
- import type { IrColumnType, IrDocument, IrTable } from './ir';
2
+ import type { IrColumnType, IrDocument } from './ir';
3
3
  import {
4
4
  type AnalyzedQuery,
5
5
  analyzeStatement,
@@ -28,20 +28,20 @@ import type {
28
28
  } from './syql-parser';
29
29
  import type {
30
30
  SyqlLogicalQuery,
31
- SyqlLogicalReactiveNode,
32
31
  SyqlLogicalTemplateNode,
33
32
  SyqlLogicalWhenNode,
34
33
  SyqlSemanticProgram,
35
34
  } from './syql-semantics';
35
+ import { syqlRangeEndBind, syqlRangeStartBind } from './syql-semantics';
36
36
 
37
37
  export type SyqlValidationErrorCode =
38
38
  | 'SYQL6001_INVALID_PLACEMENT'
39
39
  | 'SYQL6002_INVALID_SQL'
40
40
  | 'SYQL6003_NONDETERMINISTIC_SQL'
41
41
  | 'SYQL6004_TYPE_CONFLICT'
42
- | 'SYQL6005_INVALID_REACTIVE_DIRECTIVE'
42
+ | 'SYQL6005_INVALID_SYNC_QUERY'
43
43
  | 'SYQL6006_INVALID_SORT'
44
- | 'SYQL6007_INVALID_PAGE'
44
+ | 'SYQL6007_INVALID_LIMIT'
45
45
  | 'SYQL6008_INVALID_IDENTITY';
46
46
 
47
47
  export interface SyqlValidatedSort {
@@ -59,7 +59,7 @@ export interface SyqlValidatedQuery {
59
59
  readonly reactive: QueryReactiveMetadata;
60
60
  readonly identity?: readonly string[];
61
61
  readonly sort?: SyqlValidatedSort;
62
- readonly page?: {
62
+ readonly limit?: {
63
63
  readonly control: string;
64
64
  readonly defaultSize: number;
65
65
  readonly maxSize: number;
@@ -73,7 +73,7 @@ export interface SyqlValidatedProgram {
73
73
 
74
74
  interface Marker {
75
75
  readonly name: string;
76
- readonly node: SyqlLogicalWhenNode | SyqlLogicalReactiveNode;
76
+ readonly node: SyqlLogicalWhenNode;
77
77
  }
78
78
 
79
79
  interface StructuralToken {
@@ -101,14 +101,6 @@ interface BindSymbol {
101
101
  readonly authoredType?: SyqlValueType;
102
102
  }
103
103
 
104
- interface ResolvedDirective {
105
- readonly node: SyqlLogicalReactiveNode;
106
- readonly ref: TableRef;
107
- readonly table: IrTable;
108
- readonly scopes: readonly QueryScopeBinding[];
109
- readonly coverage?: QueryCoverageBinding;
110
- }
111
-
112
104
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
113
105
  const NONDETERMINISTIC_FUNCTIONS = new Set([
114
106
  'random',
@@ -383,22 +375,19 @@ class Validator {
383
375
  logical.declaration,
384
376
  location,
385
377
  );
386
- this.#validateDeterminism(activeSql, logical.declaration.sql.span);
387
- this.#validatePortableProfile(activeSql, logical.declaration.sql.span);
378
+ this.#validateDeterminism(activeSql, logical.declaration.statement.span);
379
+ this.#validatePortableProfile(
380
+ activeSql,
381
+ logical.declaration.statement.span,
382
+ );
388
383
 
389
384
  const refs = scanTableRefs(activeSql, this.#ir);
390
385
  const bindSymbols = this.#bindSymbols(logical.declaration);
391
- const resolvedDirectives = this.#resolveDirectives(
392
- logical,
393
- refs,
394
- bindSymbols,
395
- );
396
386
  const bindTypes = this.#resolveBindTypes(
397
387
  logical,
398
388
  activeSql,
399
389
  refs,
400
390
  bindSymbols,
401
- resolvedDirectives,
402
391
  );
403
392
 
404
393
  const sort = this.#validateSort(
@@ -412,30 +401,48 @@ class Validator {
412
401
  sort?.profiles.find((profile) => profile.name === sort.defaultProfile)
413
402
  ?.sql,
414
403
  );
415
- if (logical.declaration.page !== undefined) {
416
- referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
404
+ if (logical.declaration.limit !== undefined) {
405
+ referenceSql = `${referenceSql.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
417
406
  }
418
407
 
408
+ const usedBindNames = new Set(
409
+ lexSyqlSqlSource(location, activeSql)
410
+ .filter((token) => token.kind === 'bind')
411
+ .map((token) => token.text.slice(1)),
412
+ );
419
413
  const headers = [...bindTypes]
414
+ .filter(([name]) => usedBindNames.has(name))
420
415
  .map(([name, type]) => `-- param :${name} ${type.base}`)
421
416
  .join('\n');
422
417
  const statement =
423
418
  headers.length === 0 ? referenceSql : `${headers}\n${referenceSql}`;
424
419
  let analysis: AnalyzedQuery;
425
420
  try {
421
+ const analysisNaming =
422
+ this.#naming === undefined
423
+ ? undefined
424
+ : {
425
+ ...this.#naming,
426
+ internalParams: [
427
+ ...(this.#naming.internalParams ?? []),
428
+ ...[...usedBindNames].filter((name) =>
429
+ name.startsWith('__syql'),
430
+ ),
431
+ ],
432
+ };
426
433
  analysis = analyzeStatement(
427
434
  logical.declaration.name,
428
435
  location,
429
436
  statement,
430
437
  this.#ir,
431
438
  this.#db,
432
- this.#naming,
439
+ analysisNaming,
433
440
  );
434
441
  } catch (error) {
435
442
  const message = error instanceof Error ? error.message : String(error);
436
443
  this.#fail(
437
444
  'SYQL6002_INVALID_SQL',
438
- logical.declaration.sql.span,
445
+ logical.declaration.statement.span,
439
446
  `reference SQL rejected: ${message}`,
440
447
  );
441
448
  }
@@ -447,22 +454,22 @@ class Validator {
447
454
  profile.sql,
448
455
  );
449
456
  const paged =
450
- logical.declaration.page === undefined
457
+ logical.declaration.limit === undefined
451
458
  ? candidate
452
- : `${candidate.trimEnd()} limit ${logical.declaration.page.defaultSize}`;
459
+ : `${candidate.trimEnd()} limit ${logical.declaration.limit.defaultSize}`;
453
460
  try {
454
461
  this.#db.analyze(paged);
455
462
  } catch (error) {
456
463
  const message = error instanceof Error ? error.message : String(error);
457
464
  this.#fail(
458
465
  'SYQL6006_INVALID_SORT',
459
- logical.declaration.sort?.span ?? logical.declaration.sql.span,
466
+ logical.declaration.sort?.span ?? logical.declaration.statement.span,
460
467
  `sort profile ${profile.name} rejected by SQLite: ${message}`,
461
468
  );
462
469
  }
463
470
  }
464
471
 
465
- const reactive = this.#buildReactive(refs, resolvedDirectives);
472
+ const reactive = this.#inferReactive(logical, markerSql, refs, bindSymbols);
466
473
  const identity = this.#proveIdentity(
467
474
  logical.declaration,
468
475
  activeSql,
@@ -491,13 +498,13 @@ class Validator {
491
498
  reactive: reactiveWithIdentity,
492
499
  ...(identity === undefined ? {} : { identity }),
493
500
  ...(sort === undefined ? {} : { sort }),
494
- ...(logical.declaration.page === undefined
501
+ ...(logical.declaration.limit === undefined
495
502
  ? {}
496
503
  : {
497
- page: {
498
- control: logical.declaration.page.control,
499
- defaultSize: logical.declaration.page.defaultSize,
500
- maxSize: logical.declaration.page.maxSize,
504
+ limit: {
505
+ control: logical.declaration.limit.control,
506
+ defaultSize: logical.declaration.limit.defaultSize,
507
+ maxSize: logical.declaration.limit.maxSize,
501
508
  },
502
509
  }),
503
510
  };
@@ -526,22 +533,11 @@ class Validator {
526
533
  if (node.kind === 'when') {
527
534
  return `(${this.#render(node.body, mode, markers)})`;
528
535
  }
529
- return this.#renderDirective(node);
536
+ throw new Error('unknown SYQL logical template node');
530
537
  })
531
538
  .join('');
532
539
  }
533
540
 
534
- #renderDirective(node: SyqlLogicalReactiveNode): string {
535
- const predicates = node.directive.bindings.map((binding) => {
536
- const column = `${binding.column.qualifier}.${binding.column.name}`;
537
- const values = binding.values.map((value) => `:${value.name}`);
538
- return binding.operator === 'equal'
539
- ? `${column} = ${values[0]}`
540
- : `${column} in (${values.join(', ')})`;
541
- });
542
- return `(${predicates.join(' and ')})`;
543
- }
544
-
545
541
  #inspect(sql: string, file: string): SqlStructure {
546
542
  const raw = significant(lexSyqlSqlSource(file, sql));
547
543
  const tokens: StructuralToken[] = [];
@@ -655,22 +651,14 @@ class Validator {
655
651
  this.#fail(
656
652
  'SYQL6001_INVALID_PLACEMENT',
657
653
  marker.node.span,
658
- `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} must be an entire outer conjunct`,
654
+ 'when must be an entire outer conjunct',
659
655
  );
660
656
  }
661
- if (marker.node.kind === 'when') {
662
- if (clause !== 'where' && clause !== 'having') {
663
- this.#fail(
664
- 'SYQL6001_INVALID_PLACEMENT',
665
- marker.node.span,
666
- 'when is allowed only in the outer WHERE or HAVING clause',
667
- );
668
- }
669
- } else if (clause !== 'where') {
657
+ if (clause !== 'where' && clause !== 'having') {
670
658
  this.#fail(
671
659
  'SYQL6001_INVALID_PLACEMENT',
672
660
  marker.node.span,
673
- `@${marker.node.kind} is allowed only in the outer WHERE clause`,
661
+ 'when is allowed only in the outer WHERE or HAVING clause',
674
662
  );
675
663
  }
676
664
  }
@@ -679,14 +667,14 @@ class Validator {
679
667
  this.#fail(
680
668
  'SYQL6001_INVALID_PLACEMENT',
681
669
  marker.node.span,
682
- `${marker.node.kind === 'when' ? 'when' : `@${marker.node.kind}`} cannot appear in a nested SQL expression or statement`,
670
+ 'when cannot appear in a nested SQL expression or statement',
683
671
  );
684
672
  }
685
673
  }
686
674
  if (structure.hasCompound && markers.length > 0) {
687
675
  this.#fail(
688
676
  'SYQL6001_INVALID_PLACEMENT',
689
- query.sql.span,
677
+ query.statement.span,
690
678
  'embedded SYQL conjuncts are ambiguous in a compound outer statement',
691
679
  );
692
680
  }
@@ -706,13 +694,13 @@ class Validator {
706
694
  );
707
695
  }
708
696
  if (
709
- query.page !== undefined &&
697
+ query.limit !== undefined &&
710
698
  (structure.hasOuterLimit || structure.hasOuterOffset)
711
699
  ) {
712
700
  this.#fail(
713
- 'SYQL6007_INVALID_PAGE',
714
- query.page.span,
715
- 'page declaration conflicts with an authored outer LIMIT or OFFSET',
701
+ 'SYQL6007_INVALID_LIMIT',
702
+ query.limit.span,
703
+ 'dynamic LIMIT conflicts with an authored outer LIMIT or OFFSET',
716
704
  );
717
705
  }
718
706
  const first = structure.outer
@@ -721,12 +709,16 @@ class Validator {
721
709
  if (first !== 'select' && first !== 'with') {
722
710
  this.#fail(
723
711
  'SYQL6002_INVALID_SQL',
724
- query.sql.span,
712
+ query.statement.span,
725
713
  `${location} must contain one SELECT or WITH ... SELECT statement`,
726
714
  );
727
715
  }
728
716
  if (sql.trim().length === 0) {
729
- this.#fail('SYQL6002_INVALID_SQL', query.sql.span, 'empty SQL statement');
717
+ this.#fail(
718
+ 'SYQL6002_INVALID_SQL',
719
+ query.statement.span,
720
+ 'empty SQL statement',
721
+ );
730
722
  }
731
723
  }
732
724
 
@@ -879,8 +871,21 @@ class Validator {
879
871
  #bindSymbols(query: SyqlQueryDeclaration): ReadonlyMap<string, BindSymbol> {
880
872
  const symbols = new Map<string, BindSymbol>();
881
873
  for (const parameter of query.parameters) {
882
- if (parameter.kind === 'switch') continue;
883
- if (parameter.kind === 'group') {
874
+ if (parameter.kind === 'range') {
875
+ for (const name of [
876
+ syqlRangeStartBind(parameter.name),
877
+ syqlRangeEndBind(parameter.name),
878
+ ]) {
879
+ symbols.set(name, {
880
+ name,
881
+ parameter,
882
+ span: parameter.nameSpan,
883
+ ...(parameter.type === undefined
884
+ ? {}
885
+ : { authoredType: parameter.type }),
886
+ });
887
+ }
888
+ } else if (parameter.kind === 'group') {
884
889
  for (const member of parameter.members) {
885
890
  symbols.set(member.name, {
886
891
  name: member.name,
@@ -908,29 +913,11 @@ class Validator {
908
913
  sql: string,
909
914
  refs: readonly TableRef[],
910
915
  symbols: ReadonlyMap<string, BindSymbol>,
911
- directives: readonly ResolvedDirective[],
912
916
  ): ReadonlyMap<string, SyqlValueType> {
913
917
  const resolved = new Map(logical.bindTypes);
914
- const directiveTypes = new Map<string, IrColumnType[]>();
915
- for (const directive of directives) {
916
- for (const binding of directive.node.directive.bindings) {
917
- const column = directive.table.columns.find(
918
- (item) => item.name === binding.column.name,
919
- );
920
- if (column === undefined) continue;
921
- for (const bind of binding.values) {
922
- const list = directiveTypes.get(bind.name) ?? [];
923
- list.push(column.type);
924
- directiveTypes.set(bind.name, list);
925
- }
926
- }
927
- }
928
918
 
929
919
  for (const symbol of symbols.values()) {
930
- const evidence = [
931
- ...inferParamTypeEvidence(symbol.name, sql, refs, this.#ir),
932
- ...(directiveTypes.get(symbol.name) ?? []),
933
- ];
920
+ const evidence = inferParamTypeEvidence(symbol.name, sql, refs, this.#ir);
934
921
  const unique = [...new Set(evidence)];
935
922
  if (unique.length > 1) {
936
923
  this.#fail(
@@ -963,224 +950,279 @@ class Validator {
963
950
  );
964
951
  }
965
952
  }
966
- for (const directive of directives) {
967
- for (const binding of directive.node.directive.bindings) {
968
- const column = directive.table.columns.find(
969
- (item) => item.name === binding.column.name,
970
- );
971
- if (column === undefined) continue;
972
- for (const bind of binding.values) {
973
- const type = resolved.get(bind.name);
974
- if (
975
- type === undefined ||
976
- type.base !== column.type ||
977
- type.nullable !== column.nullable
978
- ) {
979
- this.#fail(
980
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
981
- bind.span,
982
- `scope bind :${bind.name} must exactly match ${column.type}${column.nullable ? ' | null' : ''}`,
983
- );
984
- }
985
- }
986
- }
987
- }
988
953
  return resolved;
989
954
  }
990
955
 
991
- #resolveDirectives(
956
+ #inferReactive(
992
957
  logical: SyqlLogicalQuery,
958
+ markerSql: string,
993
959
  refs: readonly TableRef[],
994
960
  symbols: ReadonlyMap<string, BindSymbol>,
995
- ): readonly ResolvedDirective[] {
996
- const nodes: SyqlLogicalReactiveNode[] = [];
997
- const collect = (template: readonly SyqlLogicalTemplateNode[]): void => {
998
- for (const node of template) {
999
- if (node.kind === 'scope' || node.kind === 'cover') nodes.push(node);
1000
- else if (node.kind === 'predicate') collect(node.body);
1001
- }
961
+ ): QueryReactiveMetadata {
962
+ type InferredScope = {
963
+ readonly binding: QueryScopeBinding;
964
+ readonly operator: 'equal' | 'in';
1002
965
  };
1003
- collect(logical.template);
1004
- return nodes.map((node) => {
1005
- const first = node.directive.bindings[0];
1006
- if (first === undefined) {
1007
- this.#fail(
1008
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1009
- node.span,
1010
- `@${node.kind} requires a binding`,
1011
- );
966
+ type Candidate = {
967
+ readonly ref: TableRef;
968
+ readonly scopes: readonly InferredScope[];
969
+ };
970
+ const cleaned = stripCommentsAndStrings(markerSql);
971
+ const structure = this.#inspect(
972
+ cleaned,
973
+ logical.declaration.statement.span.file,
974
+ );
975
+ const escapeRegex = (value: string): string =>
976
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
977
+ const requiredAt = (index: number): boolean => {
978
+ let whereStart: number | undefined;
979
+ for (const item of structure.outer) {
980
+ if (item.token.span.start.offset >= index) break;
981
+ const lower = tokenLower(item.token);
982
+ if (lower === 'where') whereStart = item.token.span.end.offset;
983
+ else if (OUTER_CLAUSE_ENDERS.has(lower ?? '')) whereStart = undefined;
1012
984
  }
1013
- const qualifier = first.column.qualifier;
1014
- const matchingRefs = refs.filter((ref) => ref.alias === qualifier);
1015
- if (matchingRefs.length !== 1) {
1016
- this.#fail(
1017
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1018
- first.column.span,
1019
- `scope qualifier ${qualifier} must resolve to exactly one read table instance`,
1020
- );
985
+ if (whereStart === undefined) return false;
986
+
987
+ const clauseEnd =
988
+ structure.outer.find(
989
+ (item) =>
990
+ item.token.span.start.offset > index &&
991
+ OUTER_CLAUSE_ENDERS.has(tokenLower(item.token) ?? ''),
992
+ )?.token.span.start.offset ?? cleaned.length;
993
+ const matchStack: number[] = [];
994
+ for (let cursor = whereStart; cursor < index; cursor += 1) {
995
+ if (cleaned[cursor] === '(') matchStack.push(cursor);
996
+ else if (cleaned[cursor] === ')') matchStack.pop();
1021
997
  }
1022
- const ref = matchingRefs[0] as TableRef;
1023
- const table = this.#ir.tables.find((item) => item.name === ref.table);
1024
- if (table === undefined) {
1025
- this.#fail(
1026
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1027
- first.column.span,
1028
- `scope qualifier ${qualifier} does not resolve to a synced table`,
1029
- );
998
+
999
+ // Parenthesized boolean expressions are valid, but predicates inside a
1000
+ // CTE or subquery are not outer proof obligations for sync coverage.
1001
+ if (
1002
+ matchStack.some((open) =>
1003
+ /\b(?:SELECT|WITH)\b/i.test(cleaned.slice(open + 1, index)),
1004
+ )
1005
+ ) {
1006
+ return false;
1030
1007
  }
1031
- const seenColumns = new Set<string>();
1032
- const scopes: QueryScopeBinding[] = [];
1033
- for (const binding of node.directive.bindings) {
1034
- if (binding.column.qualifier !== qualifier) {
1035
- this.#fail(
1036
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1037
- binding.column.span,
1038
- `all @${node.kind} bindings must name the same table instance`,
1039
- );
1008
+
1009
+ // Negated equality does not constrain the result to that scope.
1010
+ const prefix = cleaned.slice(whereStart, index);
1011
+ const boundaries = [...prefix.matchAll(/\b(?:AND|OR)\b/gi)].map(
1012
+ (match) => (match.index ?? -1) + match[0].length,
1013
+ );
1014
+ const lastBoundary = Math.max(prefix.lastIndexOf('('), ...boundaries);
1015
+ if (/\bNOT\b/i.test(prefix.slice(lastBoundary + 1))) return false;
1016
+ if (
1017
+ matchStack.some((open) =>
1018
+ /\bNOT\s*$/i.test(cleaned.slice(whereStart, open)),
1019
+ )
1020
+ ) {
1021
+ return false;
1022
+ }
1023
+
1024
+ const stack: number[] = [];
1025
+ for (let cursor = whereStart; cursor < clauseEnd; cursor += 1) {
1026
+ const char = cleaned[cursor];
1027
+ if (char === '(') stack.push(cursor);
1028
+ else if (char === ')') stack.pop();
1029
+ if (
1030
+ /^OR\b/i.test(cleaned.slice(cursor)) &&
1031
+ stack.length <= matchStack.length &&
1032
+ stack.every((open, stackIndex) => matchStack[stackIndex] === open)
1033
+ ) {
1034
+ return false;
1040
1035
  }
1041
- if (seenColumns.has(binding.column.name)) {
1042
- this.#fail(
1043
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1044
- binding.column.span,
1045
- `scope column ${binding.column.name} is listed twice`,
1036
+ }
1037
+ return true;
1038
+ };
1039
+ const required = new Set(
1040
+ [...symbols.values()].flatMap((symbol) =>
1041
+ symbol.parameter.kind === 'value' &&
1042
+ !symbol.parameter.optional &&
1043
+ symbol.authoredType?.nullable !== true
1044
+ ? [symbol.name]
1045
+ : [],
1046
+ ),
1047
+ );
1048
+ const candidates: Candidate[] = [];
1049
+ for (const ref of refs) {
1050
+ const table = this.#ir.tables.find((item) => item.name === ref.table);
1051
+ if (table === undefined) continue;
1052
+ const inferred: InferredScope[] = [];
1053
+ for (const scope of table.scopes) {
1054
+ const alias = escapeRegex(ref.alias);
1055
+ const column = escapeRegex(scope.column);
1056
+ const unqualifiedMatches = refs.filter((candidate) => {
1057
+ const other = this.#ir.tables.find(
1058
+ (item) => item.name === candidate.table,
1046
1059
  );
1047
- }
1048
- seenColumns.add(binding.column.name);
1049
- const scope = table.scopes.find(
1050
- (candidate) => candidate.column === binding.column.name,
1060
+ return other?.columns.some((item) => item.name === scope.column);
1061
+ }).length;
1062
+ const subject =
1063
+ unqualifiedMatches === 1
1064
+ ? `(?:${alias}\\.)?${column}`
1065
+ : `${alias}\\.${column}`;
1066
+ const found: Array<{ name: string; operator: 'equal' | 'in' }> = [];
1067
+ const equality = new RegExp(
1068
+ `${subject}\\s*(?:=|==|\\bIS\\b)\\s*:([A-Za-z_][A-Za-z0-9_]*)\\b|:([A-Za-z_][A-Za-z0-9_]*)\\b\\s*(?:=|==)\\s*${subject}`,
1069
+ 'gi',
1051
1070
  );
1052
- if (scope === undefined) {
1053
- this.#fail(
1054
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1055
- binding.column.span,
1056
- `${table.name}.${binding.column.name} is not a declared scope column`,
1057
- );
1071
+ for (const match of cleaned.matchAll(equality)) {
1072
+ if (!requiredAt(match.index)) continue;
1073
+ const name = match[1] ?? match[2];
1074
+ if (name !== undefined && required.has(name)) {
1075
+ found.push({ name, operator: 'equal' });
1076
+ }
1058
1077
  }
1059
- for (const bind of binding.values) {
1060
- const symbol = symbols.get(bind.name);
1078
+ const inList = new RegExp(`${subject}\\s+IN\\s*\\(([^)]*)\\)`, 'gi');
1079
+ for (const match of cleaned.matchAll(inList)) {
1080
+ if (!requiredAt(match.index)) continue;
1081
+ const body = match[1] ?? '';
1082
+ const bodyStart = match.index + match[0].length - body.length - 1;
1083
+ const bodyTokens = significant(
1084
+ lexSyqlSqlSource(
1085
+ logical.declaration.statement.span.file,
1086
+ markerSql.slice(bodyStart, bodyStart + body.length),
1087
+ ),
1088
+ );
1089
+ const validBindList = bodyTokens.every((token, index) =>
1090
+ index % 2 === 0
1091
+ ? token.kind === 'bind'
1092
+ : token.kind === 'punctuation' && token.text === ',',
1093
+ );
1094
+ const params = bodyTokens
1095
+ .filter((_, index) => index % 2 === 0)
1096
+ .map((token) => token.text.slice(1));
1061
1097
  if (
1062
- symbol === undefined ||
1063
- symbol.parameter.kind !== 'value' ||
1064
- symbol.parameter.optional
1098
+ validBindList &&
1099
+ params.length > 0 &&
1100
+ params.every((name) => required.has(name))
1065
1101
  ) {
1066
- this.#fail(
1067
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1068
- bind.span,
1069
- `scope value :${bind.name} must be a required scalar input`,
1070
- );
1102
+ for (const name of params) found.push({ name, operator: 'in' });
1071
1103
  }
1072
1104
  }
1073
- scopes.push({
1074
- table: table.name,
1075
- variable: scope.variable,
1076
- pattern: scope.pattern,
1077
- params: binding.values.map((value) => value.name),
1078
- });
1105
+ const params = [...new Set(found.map((item) => item.name))];
1106
+ if (params.length > 0) {
1107
+ inferred.push({
1108
+ binding: {
1109
+ table: table.name,
1110
+ variable: scope.variable,
1111
+ pattern: scope.pattern,
1112
+ params,
1113
+ },
1114
+ operator: found.some((item) => item.operator === 'in')
1115
+ ? 'in'
1116
+ : 'equal',
1117
+ });
1118
+ }
1079
1119
  }
1080
- let coverage: QueryCoverageBinding | undefined;
1081
- if (node.kind === 'cover') {
1082
- const required = new Set(table.scopes.map((scope) => scope.column));
1083
- if (
1084
- seenColumns.size !== required.size ||
1085
- [...required].some((column) => !seenColumns.has(column))
1086
- ) {
1087
- this.#fail(
1088
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1089
- node.span,
1090
- `@cover for ${table.name} must bind every declared scope exactly once`,
1091
- );
1120
+ candidates.push({ ref, scopes: inferred });
1121
+ }
1122
+
1123
+ const dependencies = [...new Set(refs.map((ref) => ref.table))]
1124
+ .sort()
1125
+ .map((table) => {
1126
+ const instances = candidates.filter((item) => item.ref.table === table);
1127
+ if (instances.some((item) => item.scopes.length === 0)) {
1128
+ return { table, scopes: [] };
1092
1129
  }
1093
- const firstScope = scopes[0] as QueryScopeBinding;
1094
- for (
1095
- let index = 1;
1096
- index < node.directive.bindings.length;
1097
- index += 1
1098
- ) {
1099
- const binding = node.directive.bindings[index];
1100
- if (binding?.operator !== 'equal' || binding.values.length !== 1) {
1101
- this.#fail(
1102
- 'SYQL6005_INVALID_REACTIVE_DIRECTIVE',
1103
- binding?.span ?? node.span,
1104
- 'fixed @cover scopes after the first binding must use one equality bind',
1130
+ const scopes = instances
1131
+ .flatMap((item) => item.scopes.map((scope) => scope.binding))
1132
+ .reduce<QueryScopeBinding[]>((out, scope) => {
1133
+ const existing = out.find(
1134
+ (item) => item.variable === scope.variable,
1105
1135
  );
1106
- }
1107
- }
1108
- const byVariable = new Map(
1109
- scopes.map((scope) => [scope.variable, scope]),
1110
- );
1111
- coverage = {
1112
- table: table.name,
1113
- variable: firstScope.variable,
1114
- units: firstScope.params,
1115
- fixedScopes: table.scopes
1116
- .filter((scope) => scope.variable !== firstScope.variable)
1117
- .map((scope) => {
1118
- const fixed = byVariable.get(scope.variable) as QueryScopeBinding;
1119
- return { variable: fixed.variable, params: fixed.params };
1120
- }),
1121
- };
1122
- }
1123
- return {
1124
- node,
1125
- ref,
1126
- table,
1127
- scopes,
1128
- ...(coverage === undefined ? {} : { coverage }),
1129
- };
1130
- });
1131
- }
1136
+ if (existing === undefined) out.push(scope);
1137
+ else {
1138
+ out[out.indexOf(existing)] = {
1139
+ ...existing,
1140
+ params: [...new Set([...existing.params, ...scope.params])],
1141
+ };
1142
+ }
1143
+ return out;
1144
+ }, []);
1145
+ return { table, scopes };
1146
+ });
1132
1147
 
1133
- #buildReactive(
1134
- refs: readonly TableRef[],
1135
- directives: readonly ResolvedDirective[],
1136
- ): QueryReactiveMetadata {
1137
- const dependencies: Array<{
1138
- table: string;
1139
- scopes: QueryScopeBinding[];
1140
- }> = [];
1141
- const coverage: QueryCoverageBinding[] = [];
1142
- for (const tableName of [...new Set(refs.map((ref) => ref.table))].sort()) {
1143
- const instances = refs.filter((ref) => ref.table === tableName);
1144
- const byAlias = new Map<string, ResolvedDirective[]>();
1145
- for (const directive of directives.filter(
1146
- (item) => item.table.name === tableName,
1147
- )) {
1148
- const list = byAlias.get(directive.ref.alias) ?? [];
1149
- list.push(directive);
1150
- byAlias.set(directive.ref.alias, list);
1151
- }
1152
- const fallback = instances.some(
1153
- (instance) => !byAlias.has(instance.alias),
1148
+ if (!logical.declaration.sync) return { dependencies, coverage: [] };
1149
+ let eligible = candidates.filter((candidate) => {
1150
+ const table = this.#ir.tables.find(
1151
+ (item) => item.name === candidate.ref.table,
1152
+ );
1153
+ return (
1154
+ table !== undefined &&
1155
+ candidates.filter((item) => item.ref.table === candidate.ref.table)
1156
+ .length === 1 &&
1157
+ table.scopes.length > 0 &&
1158
+ candidate.scopes.length === table.scopes.length
1159
+ );
1160
+ });
1161
+ const syncBy = logical.declaration.syncBy;
1162
+ if (syncBy !== undefined) {
1163
+ eligible = eligible.filter(
1164
+ (candidate) => candidate.ref.alias === syncBy.qualifier,
1165
+ );
1166
+ }
1167
+ if (eligible.length !== 1) {
1168
+ this.#fail(
1169
+ 'SYQL6005_INVALID_SYNC_QUERY',
1170
+ logical.declaration.syncBy?.span ?? logical.declaration.nameSpan,
1171
+ eligible.length === 0
1172
+ ? 'sync query coverage cannot be proven from required equality/IN predicates over every declared scope'
1173
+ : 'sync query resolves to multiple coverable table instances; split the query or select one with `by alias.scope`',
1174
+ );
1175
+ }
1176
+ const candidate = eligible[0] as Candidate;
1177
+ const table = this.#ir.tables.find(
1178
+ (item) => item.name === candidate.ref.table,
1179
+ );
1180
+ if (table === undefined) throw new Error('eligible table disappeared');
1181
+ if (table.scopes.length > 1 && syncBy === undefined) {
1182
+ this.#fail(
1183
+ 'SYQL6005_INVALID_SYNC_QUERY',
1184
+ logical.declaration.nameSpan,
1185
+ `sync query for multi-scope table ${table.name} must select its unit dimension with \`by ${candidate.ref.alias}.scope_column\``,
1186
+ );
1187
+ }
1188
+ const dimensionColumn = syncBy?.column ?? table.scopes[0]?.column;
1189
+ const dimension = table.scopes.find(
1190
+ (scope) => scope.column === dimensionColumn,
1191
+ );
1192
+ if (dimension === undefined) {
1193
+ this.#fail(
1194
+ 'SYQL6005_INVALID_SYNC_QUERY',
1195
+ syncBy?.span ?? logical.declaration.nameSpan,
1196
+ `${candidate.ref.alias}.${dimensionColumn ?? ''} is not a declared scope`,
1154
1197
  );
1155
- const scopes = fallback
1156
- ? []
1157
- : [...byAlias.values()]
1158
- .flatMap((items) => items.flatMap((item) => item.scopes))
1159
- .reduce<QueryScopeBinding[]>((out, scope) => {
1160
- const existing = out.find(
1161
- (item) => item.variable === scope.variable,
1162
- );
1163
- if (existing === undefined) out.push(scope);
1164
- else {
1165
- const merged = [
1166
- ...new Set([...existing.params, ...scope.params]),
1167
- ];
1168
- out[out.indexOf(existing)] = { ...existing, params: merged };
1169
- }
1170
- return out;
1171
- }, []);
1172
- dependencies.push({ table: tableName, scopes });
1173
- if (!fallback) {
1174
- coverage.push(
1175
- ...[...byAlias.values()].flatMap((items) =>
1176
- items.flatMap((item) =>
1177
- item.coverage === undefined ? [] : [item.coverage],
1178
- ),
1179
- ),
1180
- );
1181
- }
1182
1198
  }
1183
- return { dependencies, coverage };
1199
+ const byVariable = new Map(
1200
+ candidate.scopes.map((scope) => [scope.binding.variable, scope]),
1201
+ );
1202
+ const unit = byVariable.get(dimension.variable) as InferredScope;
1203
+ const fixedScopes = table.scopes
1204
+ .filter((scope) => scope.variable !== dimension.variable)
1205
+ .map((scope) => {
1206
+ const fixed = byVariable.get(scope.variable) as InferredScope;
1207
+ if (fixed.operator !== 'equal' || fixed.binding.params.length !== 1) {
1208
+ this.#fail(
1209
+ 'SYQL6005_INVALID_SYNC_QUERY',
1210
+ syncBy?.span ?? logical.declaration.nameSpan,
1211
+ `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`,
1212
+ );
1213
+ }
1214
+ return {
1215
+ variable: fixed.binding.variable,
1216
+ params: fixed.binding.params,
1217
+ };
1218
+ });
1219
+ const coverage: QueryCoverageBinding = {
1220
+ table: table.name,
1221
+ variable: unit.binding.variable,
1222
+ units: unit.binding.params,
1223
+ fixedScopes,
1224
+ };
1225
+ return { dependencies, coverage: [coverage] };
1184
1226
  }
1185
1227
 
1186
1228
  #validateSort(
@@ -1275,68 +1317,20 @@ class Validator {
1275
1317
  if (!IDENT_RE.test(column.name)) {
1276
1318
  this.#fail(
1277
1319
  'SYQL6008_INVALID_IDENTITY',
1278
- query.identity?.span ?? query.sql.span,
1320
+ query.statement.span,
1279
1321
  `result name ${JSON.stringify(column.name)} must be an unquoted IDENT value`,
1280
1322
  );
1281
1323
  }
1282
1324
  if (resultNames.has(column.name)) {
1283
1325
  this.#fail(
1284
1326
  'SYQL6008_INVALID_IDENTITY',
1285
- query.identity?.span ?? query.sql.span,
1327
+ query.statement.span,
1286
1328
  `result name ${JSON.stringify(column.name)} is duplicated`,
1287
1329
  );
1288
1330
  }
1289
1331
  resultNames.add(column.name);
1290
1332
  }
1291
1333
 
1292
- if (query.identity !== undefined) {
1293
- if (nonSimple) {
1294
- this.#fail(
1295
- 'SYQL6008_INVALID_IDENTITY',
1296
- query.identity.span,
1297
- 'identity cannot be proven for this grouped, compound, nested, outer-join, aggregate, or self-join shape',
1298
- );
1299
- }
1300
- const selected = query.identity.fields.map((field) => {
1301
- const matches = analysis.columns.filter(
1302
- (column) => column.name === field,
1303
- );
1304
- const column = matches[0];
1305
- if (
1306
- matches.length !== 1 ||
1307
- column === undefined ||
1308
- column.fidelity !== 'exact' ||
1309
- column.nullable ||
1310
- column.origin === undefined
1311
- ) {
1312
- this.#fail(
1313
- 'SYQL6008_INVALID_IDENTITY',
1314
- query.identity?.span ?? query.sql.span,
1315
- `identity field ${field} must be one exact, non-null physical result column`,
1316
- );
1317
- }
1318
- return column;
1319
- });
1320
- for (const ref of refs) {
1321
- const table = this.#ir.tables.find((item) => item.name === ref.table);
1322
- if (
1323
- table !== undefined &&
1324
- !selected.some(
1325
- (column) =>
1326
- column.origin?.table === table.name &&
1327
- column.origin.column === table.primaryKey,
1328
- )
1329
- ) {
1330
- this.#fail(
1331
- 'SYQL6008_INVALID_IDENTITY',
1332
- query.identity.span,
1333
- `identity must include projected primary key ${table.name}.${table.primaryKey}`,
1334
- );
1335
- }
1336
- }
1337
- return selected.map((column) => column.langName);
1338
- }
1339
-
1340
1334
  if (nonSimple || refs.length === 0) return undefined;
1341
1335
  const inferred: string[] = [];
1342
1336
  for (const ref of refs) {
@@ -1364,14 +1358,14 @@ class Validator {
1364
1358
  refs: readonly TableRef[],
1365
1359
  ): void {
1366
1360
  const bounded =
1367
- query.page !== undefined ||
1361
+ query.limit !== undefined ||
1368
1362
  structure.hasOuterLimit ||
1369
1363
  structure.hasOuterOffset;
1370
1364
  if (!bounded) return;
1371
1365
  if (identity === undefined || identity.length === 0) {
1372
1366
  this.#fail(
1373
1367
  'SYQL6006_INVALID_SORT',
1374
- query.sort?.span ?? query.page?.span ?? query.sql.span,
1368
+ query.sort?.span ?? query.limit?.span ?? query.statement.span,
1375
1369
  'a bounded query requires a proven identity and total outer order',
1376
1370
  );
1377
1371
  }
@@ -1379,7 +1373,7 @@ class Validator {
1379
1373
  sort?.profiles.map((profile) => ({
1380
1374
  name: profile.name,
1381
1375
  sql: profile.sql,
1382
- span: query.sort?.span ?? query.sql.span,
1376
+ span: query.sort?.span ?? query.statement.span,
1383
1377
  })) ??
1384
1378
  (structure.orderBodyStart !== undefined &&
1385
1379
  structure.orderEnd !== undefined
@@ -1389,14 +1383,14 @@ class Validator {
1389
1383
  sql: sql
1390
1384
  .slice(structure.orderBodyStart, structure.orderEnd)
1391
1385
  .trim(),
1392
- span: query.sql.span,
1386
+ span: query.statement.span,
1393
1387
  },
1394
1388
  ]
1395
1389
  : []);
1396
1390
  if (orders.length === 0) {
1397
1391
  this.#fail(
1398
1392
  'SYQL6006_INVALID_SORT',
1399
- query.page?.span ?? query.sql.span,
1393
+ query.limit?.span ?? query.statement.span,
1400
1394
  'a bounded query requires an outer ORDER BY or sort section',
1401
1395
  );
1402
1396
  }