@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.
- package/README.md +31 -10
- package/dist/emit-queries-rust.js +4 -2
- package/dist/query.d.ts +2 -0
- package/dist/query.js +196 -31
- package/dist/syql-validator.js +166 -44
- package/package.json +3 -3
- package/src/emit-queries-rust.ts +9 -7
- package/src/query.ts +224 -29
- package/src/syql-validator.ts +233 -58
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)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
|
733
|
-
|
|
734
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
826
|
-
|
|
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 {
|
|
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(
|
|
840
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1041
|
+
if (match === null || match[2] !== SYNC_VERSION_COLUMN) return null;
|
|
859
1042
|
const qualifier = match[1];
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
};
|