@syncular/typegen 0.15.28 → 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.
package/src/query.ts CHANGED
@@ -28,9 +28,10 @@
28
28
  * origin column's exact declared type. We map it back through the SAME
29
29
  * TYPE_MAP the migration parser uses → the exact IR column type. For
30
30
  * NULLABILITY we resolve the ref against the IR ourselves (parse the SELECT
31
- * list + FROM/JOIN, match `name`/`alias.name` to an IR table column) — an
32
- * IR-exact non-nullable/nullable answer. This is the drift-kill: same type
33
- * AND nullability as the schema, guaranteed by SQLite's own reference check.
31
+ * list + FROM/JOIN, match `name`/`alias.name` to an IR table column), then
32
+ * lift it when an outer join can synthesize NULL for that relation. This is
33
+ * the drift-kill: schema nullability plus SQL join null-extension, with
34
+ * SQLite still proving every reference.
34
35
  * - **Computed expression** (`count(*)`, `done + 1`, `:label AS l`): decltype
35
36
  * is null. We fall back to a documented honest type from the raw column
36
37
  * name/shape: aggregate/arith → nullable number, anything else → nullable
@@ -693,6 +694,8 @@ export interface TableRef {
693
694
  readonly table: string;
694
695
  /** Alias (or the table name when un-aliased). */
695
696
  readonly alias: string;
697
+ /** True when an enclosing flat join chain can null-extend this relation. */
698
+ readonly nullable: boolean;
696
699
  }
697
700
 
698
701
  const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
@@ -707,6 +710,7 @@ const RESERVED_ALIAS = new Set([
707
710
  'left',
708
711
  'right',
709
712
  'outer',
713
+ 'natural',
710
714
  'join',
711
715
  'cross',
712
716
  'using',
@@ -715,27 +719,176 @@ const RESERVED_ALIAS = new Set([
715
719
  ]);
716
720
  const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
717
721
  const TABLE_REF_RE = new RegExp(
718
- `\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`,
722
+ `\\b(FROM|(?:NATURAL\\s+)?(?:(LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`,
719
723
  'gi',
720
724
  );
721
725
 
722
- export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
726
+ function parenthesisDepthAt(sql: string, index: number): number {
727
+ let depth = 0;
728
+ for (let cursor = 0; cursor < index; cursor += 1) {
729
+ if (sql[cursor] === '(') depth += 1;
730
+ else if (sql[cursor] === ')') depth = Math.max(0, depth - 1);
731
+ }
732
+ return depth;
733
+ }
734
+
735
+ function matchingParenthesis(sql: string, open: number): number {
736
+ let depth = 0;
737
+ for (let cursor = open; cursor < sql.length; cursor += 1) {
738
+ if (sql[cursor] === '(') depth += 1;
739
+ else if (sql[cursor] === ')') {
740
+ depth -= 1;
741
+ if (depth === 0) return cursor;
742
+ }
743
+ }
744
+ return sql.length;
745
+ }
746
+
747
+ function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
723
748
  const cleaned = stripCommentsAndStrings(sql);
724
749
  const known = new Set(
725
750
  ir.tables.flatMap((table) => [
726
- table.name,
727
- ...table.ftsIndexes.map((index) => index.name),
751
+ table.name.toLowerCase(),
752
+ ...table.ftsIndexes.map((index) => index.name.toLowerCase()),
728
753
  ]),
729
754
  );
755
+ const activeFromDepths = new Set<number>();
756
+ const clauseEnders = new Set([
757
+ 'WHERE',
758
+ 'GROUP',
759
+ 'HAVING',
760
+ 'ORDER',
761
+ 'LIMIT',
762
+ 'WINDOW',
763
+ 'UNION',
764
+ 'EXCEPT',
765
+ 'INTERSECT',
766
+ 'RETURNING',
767
+ ]);
768
+ let depth = 0;
769
+ let cursor = 0;
770
+ const nextIdentifier = (start: number): string | undefined => {
771
+ let index = start;
772
+ while (
773
+ index < cleaned.length &&
774
+ (/\s/.test(cleaned[index] as string) || cleaned[index] === '(')
775
+ ) {
776
+ index += 1;
777
+ }
778
+ const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
779
+ return match?.[0];
780
+ };
781
+
782
+ while (cursor < cleaned.length) {
783
+ const char = cleaned[cursor] as string;
784
+ if (char === '(') {
785
+ const next = nextIdentifier(cursor + 1);
786
+ if (
787
+ activeFromDepths.has(depth) &&
788
+ next !== undefined &&
789
+ known.has(next.toLowerCase())
790
+ ) {
791
+ activeFromDepths.add(depth + 1);
792
+ }
793
+ depth += 1;
794
+ cursor += 1;
795
+ continue;
796
+ }
797
+ if (char === ')') {
798
+ activeFromDepths.delete(depth);
799
+ depth = Math.max(0, depth - 1);
800
+ cursor += 1;
801
+ continue;
802
+ }
803
+ if (char === ',' && activeFromDepths.has(depth)) {
804
+ const next = nextIdentifier(cursor + 1);
805
+ if (next !== undefined && known.has(next.toLowerCase())) return true;
806
+ cursor += 1;
807
+ continue;
808
+ }
809
+ if (/[A-Za-z_]/.test(char)) {
810
+ let end = cursor + 1;
811
+ while (
812
+ end < cleaned.length &&
813
+ /[A-Za-z0-9_]/.test(cleaned[end] as string)
814
+ ) {
815
+ end += 1;
816
+ }
817
+ const word = cleaned.slice(cursor, end).toUpperCase();
818
+ if (word === 'FROM') activeFromDepths.add(depth);
819
+ else if (clauseEnders.has(word)) activeFromDepths.delete(depth);
820
+ cursor = end;
821
+ continue;
822
+ }
823
+ cursor += 1;
824
+ }
825
+ return false;
826
+ }
827
+
828
+ export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
829
+ const cleaned = stripCommentsAndStrings(sql);
830
+ const known = new Map(
831
+ ir.tables.flatMap((table) => [
832
+ [table.name.toLowerCase(), table.name] as const,
833
+ ...table.ftsIndexes.map(
834
+ (index) => [index.name.toLowerCase(), index.name] as const,
835
+ ),
836
+ ]),
837
+ );
838
+ const nullableGroups = [
839
+ ...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
840
+ ].map((match) => {
841
+ const open = (match.index ?? 0) + match[0].lastIndexOf('(');
842
+ return { open, close: matchingParenthesis(cleaned, open) };
843
+ });
730
844
  const refs: TableRef[] = [];
845
+ const relationStartByDepth = new Map<number, number>();
731
846
  for (const m of cleaned.matchAll(TABLE_REF_RE)) {
732
- const table = m[1] as string;
733
- if (!known.has(table)) continue; // e.g. FROM (subquery) — table is `(`; skip
734
- let alias = m[2];
847
+ const operator = (m[1] as string).toUpperCase();
848
+ const outerKind = m[2]?.toUpperCase();
849
+ const rawTable = m[4] as string;
850
+ const operatorDepth = parenthesisDepthAt(cleaned, m.index ?? 0);
851
+ const relativeTableIndex = m[0]
852
+ .toLowerCase()
853
+ .indexOf(rawTable.toLowerCase(), (m[1] as string).length);
854
+ const tableIndex =
855
+ (m.index ?? 0) + Math.max((m[1] as string).length, relativeTableIndex);
856
+ const tableDepth = parenthesisDepthAt(cleaned, tableIndex);
857
+ if (operator === 'FROM') {
858
+ relationStartByDepth.set(operatorDepth, refs.length);
859
+ relationStartByDepth.set(tableDepth, refs.length);
860
+ } else if (
861
+ tableDepth > operatorDepth &&
862
+ !relationStartByDepth.has(tableDepth)
863
+ ) {
864
+ relationStartByDepth.set(tableDepth, refs.length);
865
+ }
866
+ if (outerKind === 'RIGHT' || outerKind === 'FULL') {
867
+ const relationStart =
868
+ relationStartByDepth.get(operatorDepth) ?? refs.length;
869
+ for (let index = relationStart; index < refs.length; index += 1) {
870
+ const prior = refs[index];
871
+ if (prior !== undefined && !prior.nullable) {
872
+ refs[index] = { ...prior, nullable: true };
873
+ }
874
+ }
875
+ }
876
+ const table = known.get(rawTable.toLowerCase());
877
+ if (table === undefined) continue; // e.g. a derived SELECT/CTE name
878
+ let alias = m[5];
735
879
  if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
736
880
  alias = undefined;
737
881
  }
738
- refs.push({ table, alias: alias ?? table });
882
+ refs.push({
883
+ table,
884
+ alias: alias ?? table,
885
+ nullable:
886
+ outerKind === 'LEFT' ||
887
+ outerKind === 'FULL' ||
888
+ nullableGroups.some(
889
+ (group) => tableIndex > group.open && tableIndex < group.close,
890
+ ),
891
+ });
739
892
  }
740
893
  return refs;
741
894
  }
@@ -793,6 +946,7 @@ const PLAIN_REF_RE = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})$`);
793
946
  interface ResolvedSource {
794
947
  readonly table: string;
795
948
  readonly column: IrTable['columns'][number];
949
+ readonly nullableByJoin: boolean;
796
950
  }
797
951
 
798
952
  function declaredFtsProjection(ir: IrDocument, tableName: string): boolean {
@@ -819,47 +973,80 @@ function resolveSource(
819
973
  const columnName = m[2] as string;
820
974
  const byName = new Map(ir.tables.map((t) => [t.name, t] as const));
821
975
  if (qualifier !== undefined) {
822
- const ref = refs.find((r) => r.alias === qualifier);
976
+ const ref = refs.find(
977
+ (candidate) => candidate.alias.toLowerCase() === qualifier.toLowerCase(),
978
+ );
823
979
  if (ref === undefined) return null;
824
980
  const table = byName.get(ref.table);
825
- const col = table?.columns.find((c) => c.name === columnName);
826
- if (col !== undefined) return { table: ref.table, column: col };
981
+ const col = table?.columns.find(
982
+ (candidate) => candidate.name.toLowerCase() === columnName.toLowerCase(),
983
+ );
984
+ if (col !== undefined) {
985
+ return {
986
+ table: ref.table,
987
+ column: col,
988
+ nullableByJoin: ref.nullable,
989
+ };
990
+ }
827
991
  if (
828
992
  columnName === FTS_SOURCE_ID_COLUMN &&
829
993
  declaredFtsProjection(ir, ref.table)
830
994
  ) {
831
- return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
995
+ return {
996
+ table: ref.table,
997
+ column: FTS_SOURCE_ID_IR_COLUMN,
998
+ nullableByJoin: ref.nullable,
999
+ };
832
1000
  }
833
1001
  return null;
834
1002
  }
835
1003
  // Unqualified: search every FROM/JOIN table (SQLite already resolved
836
- // ambiguity; a single-table query is the common case).
1004
+ // ambiguity; a single-table query is the common case). A USING/NATURAL
1005
+ // coalesced name can have more than one physical origin, so keep it on the
1006
+ // honest nullable fallback path instead of guessing one side.
1007
+ const matches: ResolvedSource[] = [];
837
1008
  for (const ref of refs) {
838
1009
  const table = byName.get(ref.table);
839
- const col = table?.columns.find((c) => c.name === columnName);
840
- if (col !== undefined) return { table: ref.table, column: col };
1010
+ const col = table?.columns.find(
1011
+ (candidate) => candidate.name.toLowerCase() === columnName.toLowerCase(),
1012
+ );
1013
+ if (col !== undefined) {
1014
+ matches.push({
1015
+ table: ref.table,
1016
+ column: col,
1017
+ nullableByJoin: ref.nullable,
1018
+ });
1019
+ }
841
1020
  if (
842
1021
  columnName === FTS_SOURCE_ID_COLUMN &&
843
1022
  declaredFtsProjection(ir, ref.table)
844
1023
  ) {
845
- return { table: ref.table, column: FTS_SOURCE_ID_IR_COLUMN };
1024
+ matches.push({
1025
+ table: ref.table,
1026
+ column: FTS_SOURCE_ID_IR_COLUMN,
1027
+ nullableByJoin: ref.nullable,
1028
+ });
846
1029
  }
847
1030
  }
848
- return null;
1031
+ return matches.length === 1 ? (matches[0] as ResolvedSource) : null;
849
1032
  }
850
1033
 
851
1034
  /** Resolve the one supported query-only protocol column. SQLite still proves
852
1035
  * qualifier/ambiguity against the synthesized local tables. */
853
- function isSyncVersionSource(
1036
+ function syncVersionSourceNullability(
854
1037
  item: SelectItem,
855
1038
  refs: readonly TableRef[],
856
- ): boolean {
1039
+ ): boolean | null {
857
1040
  const match = PLAIN_REF_RE.exec(item.expr);
858
- if (match === null || match[2] !== SYNC_VERSION_COLUMN) return false;
1041
+ if (match === null || match[2] !== SYNC_VERSION_COLUMN) return null;
859
1042
  const qualifier = match[1];
860
- return qualifier === undefined
861
- ? refs.length === 1
862
- : refs.some((ref) => ref.alias === qualifier);
1043
+ if (qualifier === undefined) {
1044
+ return refs.length === 1 ? (refs[0]?.nullable ?? null) : null;
1045
+ }
1046
+ const matches = refs.filter(
1047
+ (ref) => ref.alias.toLowerCase() === qualifier.toLowerCase(),
1048
+ );
1049
+ return matches.length === 1 ? (matches[0]?.nullable ?? null) : null;
863
1050
  }
864
1051
 
865
1052
  // -- param type inference -----------------------------------------------------
@@ -1279,6 +1466,12 @@ export function analyzeStatement(
1279
1466
  if (sourceSql.length === 0) {
1280
1467
  throw new TypegenError(file, 'query file is empty');
1281
1468
  }
1469
+ if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
1470
+ throw new TypegenError(
1471
+ file,
1472
+ 'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause',
1473
+ );
1474
+ }
1282
1475
 
1283
1476
  // SELECT-only (the read tier). A `WITH` is allowed when its main statement
1284
1477
  // is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
@@ -1375,7 +1568,9 @@ export function analyzeStatement(
1375
1568
  const source = item !== undefined ? resolveSource(item, refs, ir) : null;
1376
1569
  const sqlName = sqlNames[index] ?? colName;
1377
1570
  const langName = langNames[index] ?? colName;
1378
- if (item !== undefined && isSyncVersionSource(item, refs)) {
1571
+ const syncVersionNullable =
1572
+ item !== undefined ? syncVersionSourceNullability(item, refs) : null;
1573
+ if (syncVersionNullable !== null) {
1379
1574
  if (sqlName === SYNC_VERSION_COLUMN) {
1380
1575
  throw new TypegenError(
1381
1576
  file,
@@ -1386,7 +1581,7 @@ export function analyzeStatement(
1386
1581
  name: sqlName,
1387
1582
  langName,
1388
1583
  type: 'integer',
1389
- nullable: false,
1584
+ nullable: syncVersionNullable,
1390
1585
  fidelity: 'exact',
1391
1586
  };
1392
1587
  }
@@ -1403,7 +1598,7 @@ export function analyzeStatement(
1403
1598
  name: sqlName,
1404
1599
  langName,
1405
1600
  type: sourceType,
1406
- nullable: source.column.nullable,
1601
+ nullable: source.column.nullable || source.nullableByJoin,
1407
1602
  fidelity: 'exact',
1408
1603
  origin: { table: source.table, column: source.column.name },
1409
1604
  };