@syncular/typegen 0.15.27 → 0.15.29

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,6 +1,7 @@
1
1
  import { analyzeStatement, inferParamTypeEvidence, scanTableRefs, stripCommentsAndStrings, } from './query.js';
2
2
  import { isSyqlTrivia, lexSyqlSqlSource, SyqlFrontendError, } from './syql-lexer.js';
3
3
  import { syqlRangeEndBind, syqlRangeStartBind } from './syql-semantics.js';
4
+ const IDENT_SOURCE = '[A-Za-z_][A-Za-z0-9_]*';
4
5
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
6
  const NONDETERMINISTIC_FUNCTIONS = new Set([
6
7
  'random',
@@ -694,12 +695,67 @@ class Validator {
694
695
  }
695
696
  return true;
696
697
  };
698
+ const requiredJoinEqualityAt = (index) => {
699
+ const prefix = cleaned.slice(0, index);
700
+ const clauses = [
701
+ ...prefix.matchAll(/\b(ON|JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/gi),
702
+ ];
703
+ const last = clauses.at(-1);
704
+ if (last?.[1]?.toUpperCase() !== 'ON')
705
+ return false;
706
+ const start = (last.index ?? 0) + last[0].length;
707
+ const next = /\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/i.exec(cleaned.slice(index));
708
+ const end = next === null ? cleaned.length : index + next.index;
709
+ const clause = cleaned.slice(start, end);
710
+ return !/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause);
711
+ };
712
+ const refByAlias = new Map(refs.map((ref) => [ref.alias.toLowerCase(), ref]));
713
+ const scopeNode = (alias, column) => `${alias.toLowerCase()}\0${column.toLowerCase()}`;
714
+ const parents = new Map();
715
+ const find = (node) => {
716
+ const parent = parents.get(node);
717
+ if (parent === undefined) {
718
+ parents.set(node, node);
719
+ return node;
720
+ }
721
+ if (parent === node)
722
+ return node;
723
+ const root = find(parent);
724
+ parents.set(node, root);
725
+ return root;
726
+ };
727
+ const union = (left, right) => {
728
+ const leftRoot = find(left);
729
+ const rightRoot = find(right);
730
+ if (leftRoot !== rightRoot)
731
+ parents.set(rightRoot, leftRoot);
732
+ };
733
+ const scopeColumn = (alias, column) => {
734
+ const ref = refByAlias.get(alias.toLowerCase());
735
+ const table = this.#ir.tables.find((item) => item.name === ref?.table);
736
+ return (table?.scopes.some((scope) => scope.column.toLowerCase() === column.toLowerCase()) === true);
737
+ };
738
+ const qualifiedEquality = new RegExp(`(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==|\\bIS\\b)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`, 'gi');
739
+ for (const match of cleaned.matchAll(qualifiedEquality)) {
740
+ const leftAlias = match[1];
741
+ const leftColumn = match[2];
742
+ const rightAlias = match[3];
743
+ const rightColumn = match[4];
744
+ if (!scopeColumn(leftAlias, leftColumn) ||
745
+ !scopeColumn(rightAlias, rightColumn) ||
746
+ (!requiredAt(match.index ?? 0) &&
747
+ !requiredJoinEqualityAt(match.index ?? 0))) {
748
+ continue;
749
+ }
750
+ union(scopeNode(leftAlias, leftColumn), scopeNode(rightAlias, rightColumn));
751
+ }
697
752
  const required = new Set([...symbols.values()].flatMap((symbol) => symbol.parameter.kind === 'value' &&
698
753
  !symbol.parameter.optional &&
699
754
  symbol.authoredType?.nullable !== true
700
755
  ? [symbol.name]
701
756
  : []));
702
757
  const candidates = [];
758
+ const directScopeEvidence = new Map();
703
759
  for (const ref of refs) {
704
760
  const table = this.#ir.tables.find((item) => item.name === ref.table);
705
761
  if (table === undefined)
@@ -747,7 +803,7 @@ class Validator {
747
803
  }
748
804
  const params = [...new Set(found.map((item) => item.name))];
749
805
  if (params.length > 0) {
750
- inferred.push({
806
+ const evidence = {
751
807
  binding: {
752
808
  table: table.name,
753
809
  variable: scope.variable,
@@ -757,19 +813,56 @@ class Validator {
757
813
  operator: found.some((item) => item.operator === 'in')
758
814
  ? 'in'
759
815
  : 'equal',
760
- });
816
+ };
817
+ inferred.push(evidence);
818
+ directScopeEvidence.set(scopeNode(ref.alias, scope.column), evidence);
761
819
  }
762
820
  }
763
821
  candidates.push({ ref, scopes: inferred });
764
822
  }
823
+ const resolvedCandidates = candidates.map((candidate) => {
824
+ const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
825
+ if (table === undefined)
826
+ return candidate;
827
+ const directByVariable = new Map(candidate.scopes.map((scope) => [scope.binding.variable, scope]));
828
+ const scopes = table.scopes.flatMap((scope) => {
829
+ const direct = directByVariable.get(scope.variable);
830
+ if (direct !== undefined)
831
+ return [direct];
832
+ const root = find(scopeNode(candidate.ref.alias, scope.column));
833
+ const inherited = [...directScopeEvidence.entries()]
834
+ .filter(([node]) => find(node) === root)
835
+ .map(([, evidence]) => evidence);
836
+ const params = [
837
+ ...new Set(inherited.flatMap((evidence) => evidence.binding.params)),
838
+ ];
839
+ if (params.length === 0)
840
+ return [];
841
+ return [
842
+ {
843
+ binding: {
844
+ table: table.name,
845
+ variable: scope.variable,
846
+ pattern: scope.pattern,
847
+ params,
848
+ },
849
+ operator: inherited.some((evidence) => evidence.operator === 'in')
850
+ ? 'in'
851
+ : 'equal',
852
+ },
853
+ ];
854
+ });
855
+ return { ...candidate, scopes };
856
+ });
765
857
  const ftsOwners = new Map(this.#ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
766
858
  const dependencies = [
767
859
  ...new Set(refs.map((ref) => ftsOwners.get(ref.table) ?? ref.table)),
768
860
  ]
769
861
  .sort()
770
862
  .map((table) => {
771
- const instances = candidates.filter((item) => item.ref.table === table);
772
- if (instances.some((item) => item.scopes.length === 0)) {
863
+ const instances = resolvedCandidates.filter((item) => item.ref.table === table);
864
+ if (instances.length !== 1 ||
865
+ instances.some((item) => item.scopes.length === 0)) {
773
866
  return { table, scopes: [] };
774
867
  }
775
868
  const scopes = instances
@@ -790,56 +883,85 @@ class Validator {
790
883
  });
791
884
  if (!logical.declaration.sync)
792
885
  return { dependencies, coverage: [] };
793
- let eligible = candidates.filter((candidate) => {
886
+ const syncedCandidates = resolvedCandidates.filter((candidate) => this.#ir.tables.some((table) => table.name === candidate.ref.table));
887
+ const eligible = syncedCandidates.filter((candidate) => {
794
888
  const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
795
889
  return (table !== undefined &&
796
- candidates.filter((item) => item.ref.table === candidate.ref.table)
797
- .length === 1 &&
890
+ syncedCandidates.filter((item) => item.ref.table === candidate.ref.table).length === 1 &&
798
891
  table.scopes.length > 0 &&
799
892
  candidate.scopes.length === table.scopes.length);
800
893
  });
801
894
  const syncBy = logical.declaration.syncBy;
802
- if (syncBy !== undefined) {
803
- eligible = eligible.filter((candidate) => candidate.ref.alias === syncBy.qualifier);
804
- }
805
- if (eligible.length !== 1) {
806
- this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.syncBy?.span ?? logical.declaration.nameSpan, eligible.length === 0
807
- ? 'sync query coverage cannot be proven from required equality/IN predicates over every declared scope'
808
- : 'sync query resolves to multiple coverable table instances; split the query or select one with `by alias.scope`');
809
- }
810
- const candidate = eligible[0];
811
- const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
812
- if (table === undefined)
813
- throw new Error('eligible table disappeared');
814
- if (table.scopes.length > 1 && syncBy === undefined) {
815
- this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.nameSpan, `sync query for multi-scope table ${table.name} must select its unit dimension with \`by ${candidate.ref.alias}.scope_column\``);
816
- }
817
- const dimensionColumn = syncBy?.column ?? table.scopes[0]?.column;
818
- const dimension = table.scopes.find((scope) => scope.column === dimensionColumn);
819
- if (dimension === undefined) {
820
- this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `${candidate.ref.alias}.${dimensionColumn ?? ''} is not a declared scope`);
821
- }
822
- const byVariable = new Map(candidate.scopes.map((scope) => [scope.binding.variable, scope]));
823
- const unit = byVariable.get(dimension.variable);
824
- const fixedScopes = table.scopes
825
- .filter((scope) => scope.variable !== dimension.variable)
826
- .map((scope) => {
827
- const fixed = byVariable.get(scope.variable);
828
- if (fixed.operator !== 'equal' || fixed.binding.params.length !== 1) {
829
- this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`);
895
+ if (eligible.length === 0 || eligible.length !== syncedCandidates.length) {
896
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.syncBy?.span ?? logical.declaration.nameSpan, 'sync query coverage cannot be proven for every read table from required equality/IN predicates over every declared scope; split the query or add an exact compatible scope proof');
897
+ }
898
+ const byCandidate = syncBy === undefined
899
+ ? undefined
900
+ : eligible.find((candidate) => candidate.ref.alias === syncBy.qualifier);
901
+ if (syncBy !== undefined && byCandidate === undefined) {
902
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy.span, `sync query \`by ${syncBy.qualifier}.${syncBy.column}\` does not name one fully coverable table instance`);
903
+ }
904
+ if (syncBy === undefined &&
905
+ eligible.some((candidate) => {
906
+ const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
907
+ return (table?.scopes.length ?? 0) > 1;
908
+ })) {
909
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', logical.declaration.nameSpan, 'a sync query reading any multi-scope table must select an anchor unit dimension with `by alias.scope_column`');
910
+ }
911
+ let anchorUnit;
912
+ if (byCandidate !== undefined && syncBy !== undefined) {
913
+ const table = this.#ir.tables.find((item) => item.name === byCandidate.ref.table);
914
+ const dimension = table?.scopes.find((scope) => scope.column === syncBy.column);
915
+ anchorUnit = byCandidate.scopes.find((scope) => scope.binding.variable === dimension?.variable);
916
+ if (dimension === undefined || anchorUnit === undefined) {
917
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy.span, `${byCandidate.ref.alias}.${syncBy.column} is not a proven declared scope`);
830
918
  }
919
+ }
920
+ const sameParams = (left, right) => left.length === right.length &&
921
+ left.every((value) => right.includes(value));
922
+ const coverage = eligible.map((candidate) => {
923
+ const table = this.#ir.tables.find((item) => item.name === candidate.ref.table);
924
+ if (table === undefined)
925
+ throw new Error('eligible table disappeared');
926
+ const byVariable = new Map(candidate.scopes.map((scope) => [scope.binding.variable, scope]));
927
+ let unit;
928
+ if (candidate === byCandidate) {
929
+ unit = anchorUnit;
930
+ }
931
+ else if (table.scopes.length === 1) {
932
+ unit = candidate.scopes[0];
933
+ }
934
+ else if (anchorUnit !== undefined) {
935
+ const matching = candidate.scopes.filter((scope) => sameParams(scope.binding.params, anchorUnit.binding.params));
936
+ if (matching.length === 1)
937
+ unit = matching[0];
938
+ }
939
+ if (unit === undefined) {
940
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `joined multi-scope table ${table.name} has no unambiguous unit dimension compatible with the selected anchor; split the query`);
941
+ }
942
+ const fixedScopes = table.scopes
943
+ .filter((scope) => scope.variable !== unit.binding.variable)
944
+ .map((scope) => {
945
+ const fixed = byVariable.get(scope.variable);
946
+ if (fixed === undefined ||
947
+ fixed.operator !== 'equal' ||
948
+ fixed.binding.params.length !== 1) {
949
+ this.#fail('SYQL6005_INVALID_SYNC_QUERY', syncBy?.span ?? logical.declaration.nameSpan, `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`);
950
+ }
951
+ return {
952
+ variable: fixed.binding.variable,
953
+ params: fixed.binding.params,
954
+ };
955
+ });
831
956
  return {
832
- variable: fixed.binding.variable,
833
- params: fixed.binding.params,
957
+ table: table.name,
958
+ variable: unit.binding.variable,
959
+ units: unit.binding.params,
960
+ fixedScopes,
834
961
  };
835
962
  });
836
- const coverage = {
837
- table: table.name,
838
- variable: unit.binding.variable,
839
- units: unit.binding.params,
840
- fixedScopes,
841
- };
842
- return { dependencies, coverage: [coverage] };
963
+ coverage.sort((left, right) => left.table.localeCompare(right.table));
964
+ return { dependencies, coverage };
843
965
  }
844
966
  #validateSort(query, structure, location) {
845
967
  if (query.sort === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.15.27",
3
+ "version": "0.15.29",
4
4
  "description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -48,7 +48,7 @@
48
48
  "!dist/**/*.test.d.ts"
49
49
  ],
50
50
  "devDependencies": {
51
- "@syncular/core": "0.15.27",
52
- "@syncular/server": "0.15.27"
51
+ "@syncular/core": "0.15.29",
52
+ "@syncular/server": "0.15.29"
53
53
  }
54
54
  }
@@ -401,7 +401,10 @@ function emitSelect(query: AnalyzedQuery): string[] {
401
401
  ' }',
402
402
  );
403
403
  }
404
- if (metadata.plan.backend === 'variants') {
404
+ const usesActivationMask =
405
+ metadata.plan.backend === 'variants' &&
406
+ metadata.plan.activationControls.length > 0;
407
+ if (usesActivationMask) {
405
408
  lines.push(' let mut activation_mask = 0usize;');
406
409
  metadata.plan.activationControls.forEach((control, index) => {
407
410
  lines.push(
@@ -425,12 +428,11 @@ function emitSelect(query: AnalyzedQuery): string[] {
425
428
  }
426
429
  const sortIndex = sort?.kind === 'sort' ? 'sort_index' : '0usize';
427
430
  const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
428
- const index =
429
- metadata.plan.backend === 'variants'
430
- ? profileCount === 1
431
- ? 'activation_mask'
432
- : `activation_mask * ${profileCount} + ${sortIndex}`
433
- : sortIndex;
431
+ const index = usesActivationMask
432
+ ? profileCount === 1
433
+ ? 'activation_mask'
434
+ : `activation_mask * ${profileCount} + ${sortIndex}`
435
+ : sortIndex;
434
436
  lines.push(
435
437
  ` let statement_index = ${index};`,
436
438
  ' match statement_index {',