@syncular/typegen 0.15.47 → 0.16.1
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 +4 -0
- package/dist/emit-queries-rust.js +1 -1
- package/dist/emit-queries.js +12 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lsp.js +13 -2
- package/dist/query-ir.js +3 -1
- package/dist/query.d.ts +12 -0
- package/dist/query.js +170 -152
- package/dist/syql-diagnostics.d.ts +77 -0
- package/dist/syql-diagnostics.js +72 -0
- package/dist/syql-lowering.js +1 -0
- package/dist/syql-validator.js +9 -1
- package/package.json +3 -3
- package/src/emit-queries-rust.ts +1 -1
- package/src/emit-queries.ts +14 -4
- package/src/index.ts +1 -0
- package/src/lsp.ts +18 -5
- package/src/query-ir.ts +3 -1
- package/src/query.ts +200 -168
- package/src/syql-diagnostics.ts +160 -0
- package/src/syql-lowering.ts +1 -0
- package/src/syql-validator.ts +12 -1
package/src/query.ts
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* for the query shapes this tier supports.
|
|
63
63
|
*/
|
|
64
64
|
import { TypegenError } from './errors';
|
|
65
|
+
import { isSyqlTrivia, lexSyqlSqlSource, type SyqlToken } from './syql-lexer';
|
|
65
66
|
import type { IrColumn, IrColumnType, IrDocument, IrTable } from './ir';
|
|
66
67
|
import { lowerProjection, mainVerbAfterWith } from './lower';
|
|
67
68
|
import { buildNamingMap, type NamingMode, type NamingTarget } from './naming';
|
|
@@ -245,6 +246,7 @@ export interface QuerySyqlStatement {
|
|
|
245
246
|
readonly sql: string;
|
|
246
247
|
readonly positionalSql: string;
|
|
247
248
|
readonly binds: readonly QuerySyqlPlanBind[];
|
|
249
|
+
readonly relations: readonly QueryRelation[];
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
/** The target-neutral physical plan every emitter must implement exactly. */
|
|
@@ -267,6 +269,14 @@ export interface QuerySyqlMetadata {
|
|
|
267
269
|
readonly identity?: readonly string[];
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
/** Physical relation boundaries in the exact positional SQL statement. */
|
|
273
|
+
export interface QueryRelation {
|
|
274
|
+
readonly table: string;
|
|
275
|
+
readonly start: number;
|
|
276
|
+
readonly end: number;
|
|
277
|
+
readonly alias?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
270
280
|
export interface AnalyzedQuery {
|
|
271
281
|
/** camelCase function name (path-derived, or a `-- name:` override). */
|
|
272
282
|
readonly name: string;
|
|
@@ -281,6 +291,7 @@ export interface AnalyzedQuery {
|
|
|
281
291
|
readonly sql: string;
|
|
282
292
|
/** The lowered SQL with `:name` rewritten to positional `?`. */
|
|
283
293
|
readonly positionalSql: string;
|
|
294
|
+
readonly relations: readonly QueryRelation[];
|
|
284
295
|
/** Params in first-occurrence (positional) order. */
|
|
285
296
|
readonly params: readonly QueryParam[];
|
|
286
297
|
/** Result columns in SELECT order. */
|
|
@@ -692,6 +703,9 @@ export function toPositionalSql(sql: string): string {
|
|
|
692
703
|
|
|
693
704
|
export interface TableRef {
|
|
694
705
|
readonly table: string;
|
|
706
|
+
readonly start: number;
|
|
707
|
+
readonly end: number;
|
|
708
|
+
readonly explicitAlias?: string;
|
|
695
709
|
/** Alias (or the table name when un-aliased). */
|
|
696
710
|
readonly alias: string;
|
|
697
711
|
/** True when an enclosing flat join chain can null-extend this relation. */
|
|
@@ -709,6 +723,7 @@ const RESERVED_ALIAS = new Set([
|
|
|
709
723
|
'inner',
|
|
710
724
|
'left',
|
|
711
725
|
'right',
|
|
726
|
+
'full',
|
|
712
727
|
'outer',
|
|
713
728
|
'natural',
|
|
714
729
|
'join',
|
|
@@ -716,197 +731,215 @@ const RESERVED_ALIAS = new Set([
|
|
|
716
731
|
'using',
|
|
717
732
|
'limit',
|
|
718
733
|
'having',
|
|
734
|
+
'union',
|
|
735
|
+
'intersect',
|
|
736
|
+
'except',
|
|
737
|
+
'offset',
|
|
738
|
+
'window',
|
|
739
|
+
'indexed',
|
|
740
|
+
'not',
|
|
719
741
|
]);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
);
|
|
725
|
-
|
|
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;
|
|
742
|
+
function sqlIdentifier(token: SyqlToken | undefined): string | undefined {
|
|
743
|
+
if (token?.kind === 'identifier') return token.text;
|
|
744
|
+
if (token?.kind !== 'quoted-identifier') return undefined;
|
|
745
|
+
const quote = token.text[0];
|
|
746
|
+
const value = token.text.slice(1, -1);
|
|
747
|
+
return quote === '[' ? value : value.split(`${quote}${quote}`).join(quote);
|
|
733
748
|
}
|
|
734
749
|
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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 {
|
|
748
|
-
const cleaned = stripCommentsAndStrings(sql);
|
|
749
|
-
const known = new Set(
|
|
750
|
+
export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
751
|
+
const tokens = lexSyqlSqlSource('query SQL', sql).filter(
|
|
752
|
+
(token) => !isSyqlTrivia(token) && token.kind !== 'eof',
|
|
753
|
+
);
|
|
754
|
+
const known = new Map(
|
|
750
755
|
ir.tables.flatMap((table) => [
|
|
751
|
-
table.name.toLowerCase(),
|
|
752
|
-
...table.ftsIndexes.map(
|
|
756
|
+
[table.name.toLowerCase(), table.name] as const,
|
|
757
|
+
...table.ftsIndexes.map(
|
|
758
|
+
(index) => [index.name.toLowerCase(), index.name] as const,
|
|
759
|
+
),
|
|
753
760
|
]),
|
|
754
761
|
);
|
|
762
|
+
const closes = new Map<number, number>();
|
|
763
|
+
const parents: number[] = [];
|
|
764
|
+
const depths: number[] = [];
|
|
765
|
+
const stack: number[] = [];
|
|
766
|
+
tokens.forEach((token, index) => {
|
|
767
|
+
if (token.text === ')') {
|
|
768
|
+
const open = stack.pop();
|
|
769
|
+
if (open !== undefined) closes.set(open, index);
|
|
770
|
+
}
|
|
771
|
+
parents[index] = stack.at(-1) ?? -1;
|
|
772
|
+
depths[index] = stack.length;
|
|
773
|
+
if (token.text === '(') stack.push(index);
|
|
774
|
+
});
|
|
755
775
|
const activeFromDepths = new Set<number>();
|
|
756
776
|
const clauseEnders = new Set([
|
|
757
|
-
'
|
|
758
|
-
'
|
|
759
|
-
'
|
|
760
|
-
'
|
|
761
|
-
'
|
|
762
|
-
'
|
|
763
|
-
'
|
|
764
|
-
'
|
|
765
|
-
'
|
|
766
|
-
'
|
|
777
|
+
'where',
|
|
778
|
+
'group',
|
|
779
|
+
'having',
|
|
780
|
+
'order',
|
|
781
|
+
'limit',
|
|
782
|
+
'window',
|
|
783
|
+
'union',
|
|
784
|
+
'except',
|
|
785
|
+
'intersect',
|
|
786
|
+
'returning',
|
|
767
787
|
]);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
while (
|
|
778
|
-
index < cleaned.length &&
|
|
779
|
-
(/\s/.test(cleaned[index] as string) || cleaned[index] === '(')
|
|
780
|
-
) {
|
|
781
|
-
index += 1;
|
|
782
|
-
}
|
|
783
|
-
const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
|
|
784
|
-
return match?.[0];
|
|
785
|
-
};
|
|
786
|
-
|
|
787
|
-
while (cursor < cleaned.length) {
|
|
788
|
-
const char = cleaned[cursor] as string;
|
|
789
|
-
if (/\s/.test(char)) {
|
|
790
|
-
cursor += 1;
|
|
791
|
-
continue;
|
|
788
|
+
for (const [index, token] of tokens.entries()) {
|
|
789
|
+
const depth = depths[index] ?? 0;
|
|
790
|
+
const word =
|
|
791
|
+
token.kind === 'identifier' ? token.text.toLowerCase() : undefined;
|
|
792
|
+
if (word === 'in' && sqlIdentifier(tokens[index + 1]) !== undefined) {
|
|
793
|
+
throw new TypegenError(
|
|
794
|
+
'query SQL',
|
|
795
|
+
'table-name IN expressions are unsupported; use an explicit SELECT subquery',
|
|
796
|
+
);
|
|
792
797
|
}
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const next =
|
|
798
|
+
if (word === 'from') activeFromDepths.add(depth);
|
|
799
|
+
else if (word !== undefined && clauseEnders.has(word))
|
|
800
|
+
activeFromDepths.delete(depth);
|
|
801
|
+
else if (token.text === ')') activeFromDepths.delete(depth + 1);
|
|
802
|
+
else if (token.text === '(' && activeFromDepths.has(depth)) {
|
|
803
|
+
const previous = tokens[index - 1]?.text.toLowerCase();
|
|
804
|
+
const next = tokens[index + 1];
|
|
800
805
|
if (
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
806
|
+
previous !== undefined &&
|
|
807
|
+
['from', 'join', ',', '('].includes(previous) &&
|
|
808
|
+
!(
|
|
809
|
+
next?.kind === 'identifier' &&
|
|
810
|
+
['select', 'with', 'values'].includes(next.text.toLowerCase())
|
|
811
|
+
)
|
|
805
812
|
) {
|
|
806
813
|
activeFromDepths.add(depth + 1);
|
|
807
814
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
815
|
+
} else if (token.text === ',' && activeFromDepths.has(depth)) {
|
|
816
|
+
throw new TypegenError(
|
|
817
|
+
'query SQL',
|
|
818
|
+
'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause',
|
|
819
|
+
);
|
|
812
820
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
821
|
+
}
|
|
822
|
+
const ctes: { name: string; start: number; end: number }[] = [];
|
|
823
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
824
|
+
const token = tokens[index];
|
|
825
|
+
if (token?.kind !== 'identifier' || token.text.toLowerCase() !== 'with')
|
|
818
826
|
continue;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
827
|
+
const scopeEnd = closes.get(parents[index] ?? -1) ?? tokens.length;
|
|
828
|
+
let cursor = index + 1;
|
|
829
|
+
if (tokens[cursor]?.text.toLowerCase() === 'recursive') cursor += 1;
|
|
830
|
+
for (;;) {
|
|
831
|
+
const name = sqlIdentifier(tokens[cursor]);
|
|
832
|
+
if (name === undefined) break;
|
|
833
|
+
cursor += 1;
|
|
834
|
+
if (tokens[cursor]?.text === '(')
|
|
835
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
836
|
+
if (tokens[cursor]?.text.toLowerCase() !== 'as') break;
|
|
837
|
+
cursor += 1;
|
|
838
|
+
if (tokens[cursor]?.text.toLowerCase() === 'not') cursor += 1;
|
|
839
|
+
if (tokens[cursor]?.text.toLowerCase() === 'materialized') cursor += 1;
|
|
840
|
+
if (tokens[cursor]?.text !== '(') break;
|
|
841
|
+
ctes.push({ name: name.toLowerCase(), start: index, end: scopeEnd });
|
|
842
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
843
|
+
if (tokens[cursor]?.text !== ',') break;
|
|
824
844
|
cursor += 1;
|
|
825
|
-
continue;
|
|
826
|
-
}
|
|
827
|
-
if (/[A-Za-z_]/.test(char)) {
|
|
828
|
-
let end = cursor + 1;
|
|
829
|
-
while (
|
|
830
|
-
end < cleaned.length &&
|
|
831
|
-
/[A-Za-z0-9_]/.test(cleaned[end] as string)
|
|
832
|
-
) {
|
|
833
|
-
end += 1;
|
|
834
|
-
}
|
|
835
|
-
const word = cleaned.slice(cursor, end).toUpperCase();
|
|
836
|
-
if (word === 'FROM') activeFromDepths.add(depth);
|
|
837
|
-
else if (clauseEnders.has(word)) activeFromDepths.delete(depth);
|
|
838
|
-
previous = word;
|
|
839
|
-
cursor = end;
|
|
840
|
-
continue;
|
|
841
845
|
}
|
|
842
|
-
previous = char;
|
|
843
|
-
cursor += 1;
|
|
844
846
|
}
|
|
845
|
-
return false;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
849
|
-
const cleaned = stripCommentsAndStrings(sql);
|
|
850
|
-
const known = new Map(
|
|
851
|
-
ir.tables.flatMap((table) => [
|
|
852
|
-
[table.name.toLowerCase(), table.name] as const,
|
|
853
|
-
...table.ftsIndexes.map(
|
|
854
|
-
(index) => [index.name.toLowerCase(), index.name] as const,
|
|
855
|
-
),
|
|
856
|
-
]),
|
|
857
|
-
);
|
|
858
|
-
const nullableGroups = [
|
|
859
|
-
...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
|
|
860
|
-
].map((match) => {
|
|
861
|
-
const open = (match.index ?? 0) + match[0].lastIndexOf('(');
|
|
862
|
-
return { open, close: matchingParenthesis(cleaned, open) };
|
|
863
|
-
});
|
|
864
847
|
const refs: TableRef[] = [];
|
|
865
848
|
const relationStartByDepth = new Map<number, number>();
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
(
|
|
876
|
-
|
|
877
|
-
|
|
849
|
+
const nullableGroups: { start: number; end: number }[] = [];
|
|
850
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
851
|
+
const token = tokens[index];
|
|
852
|
+
if (token?.kind !== 'identifier') continue;
|
|
853
|
+
const operator = token.text.toLowerCase();
|
|
854
|
+
if (operator !== 'from' && operator !== 'join') continue;
|
|
855
|
+
let outerKind: string | undefined;
|
|
856
|
+
if (operator === 'join') {
|
|
857
|
+
let previous = index - 1;
|
|
858
|
+
if (tokens[previous]?.text.toLowerCase() === 'outer') previous -= 1;
|
|
859
|
+
if (tokens[previous]?.kind === 'identifier')
|
|
860
|
+
outerKind = tokens[previous]?.text.toLowerCase();
|
|
861
|
+
}
|
|
862
|
+
const operatorDepth = depths[index] ?? 0;
|
|
863
|
+
let cursor = index + 1;
|
|
864
|
+
if (operator === 'from')
|
|
878
865
|
relationStartByDepth.set(operatorDepth, refs.length);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
866
|
+
if (outerKind === 'right' || outerKind === 'full') {
|
|
867
|
+
for (
|
|
868
|
+
let prior = relationStartByDepth.get(operatorDepth) ?? refs.length;
|
|
869
|
+
prior < refs.length;
|
|
870
|
+
prior += 1
|
|
871
|
+
) {
|
|
872
|
+
const ref = refs[prior];
|
|
873
|
+
if (ref !== undefined) refs[prior] = { ...ref, nullable: true };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (
|
|
877
|
+
(outerKind === 'left' || outerKind === 'full') &&
|
|
878
|
+
tokens[cursor]?.text === '('
|
|
883
879
|
) {
|
|
884
|
-
|
|
880
|
+
nullableGroups.push({
|
|
881
|
+
start: cursor,
|
|
882
|
+
end: closes.get(cursor) ?? tokens.length,
|
|
883
|
+
});
|
|
885
884
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
885
|
+
while (tokens[cursor]?.text === '(') cursor += 1;
|
|
886
|
+
const tableToken = tokens[cursor];
|
|
887
|
+
if (
|
|
888
|
+
tableToken?.kind === 'identifier' &&
|
|
889
|
+
['select', 'with', 'values'].includes(tableToken.text.toLowerCase())
|
|
890
|
+
)
|
|
891
|
+
continue;
|
|
892
|
+
const rawTable = sqlIdentifier(tableToken);
|
|
893
|
+
if (rawTable === undefined || tableToken === undefined) {
|
|
894
|
+
throw new TypegenError('query SQL', 'cannot resolve table relation');
|
|
895
895
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
896
|
+
if (tokens[cursor + 1]?.text === '.') {
|
|
897
|
+
throw new TypegenError(
|
|
898
|
+
'query SQL',
|
|
899
|
+
'schema-qualified relations are unsupported; use application table names',
|
|
900
|
+
);
|
|
901
901
|
}
|
|
902
|
+
if (
|
|
903
|
+
ctes.some(
|
|
904
|
+
(cte) =>
|
|
905
|
+
cte.name === rawTable.toLowerCase() &&
|
|
906
|
+
cursor > cte.start &&
|
|
907
|
+
cursor < cte.end,
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
continue;
|
|
911
|
+
const table = known.get(rawTable.toLowerCase());
|
|
912
|
+
if (table === undefined)
|
|
913
|
+
throw new TypegenError(
|
|
914
|
+
'query SQL',
|
|
915
|
+
`unresolved table relation ${JSON.stringify(rawTable)}`,
|
|
916
|
+
);
|
|
917
|
+
const tableDepth = depths[cursor] ?? operatorDepth;
|
|
918
|
+
if (operator === 'from' || !relationStartByDepth.has(tableDepth))
|
|
919
|
+
relationStartByDepth.set(tableDepth, refs.length);
|
|
920
|
+
let aliasToken = tokens[cursor + 1];
|
|
921
|
+
if (
|
|
922
|
+
aliasToken?.kind === 'identifier' &&
|
|
923
|
+
aliasToken.text.toLowerCase() === 'as'
|
|
924
|
+
)
|
|
925
|
+
aliasToken = tokens[cursor + 2];
|
|
926
|
+
const explicitAlias =
|
|
927
|
+
aliasToken?.kind === 'quoted-identifier' ||
|
|
928
|
+
(aliasToken?.kind === 'identifier' &&
|
|
929
|
+
!RESERVED_ALIAS.has(aliasToken.text.toLowerCase()))
|
|
930
|
+
? sqlIdentifier(aliasToken)
|
|
931
|
+
: undefined;
|
|
902
932
|
refs.push({
|
|
903
933
|
table,
|
|
904
|
-
|
|
934
|
+
start: tableToken.span.start.offset,
|
|
935
|
+
end: tableToken.span.end.offset,
|
|
936
|
+
alias: explicitAlias ?? table,
|
|
937
|
+
...(explicitAlias === undefined ? {} : { explicitAlias }),
|
|
905
938
|
nullable:
|
|
906
|
-
outerKind === '
|
|
907
|
-
outerKind === '
|
|
939
|
+
outerKind === 'left' ||
|
|
940
|
+
outerKind === 'full' ||
|
|
908
941
|
nullableGroups.some(
|
|
909
|
-
(group) =>
|
|
942
|
+
(group) => cursor > group.start && cursor < group.end,
|
|
910
943
|
),
|
|
911
944
|
});
|
|
912
945
|
}
|
|
@@ -1486,13 +1519,6 @@ export function analyzeStatement(
|
|
|
1486
1519
|
if (sourceSql.length === 0) {
|
|
1487
1520
|
throw new TypegenError(file, 'query file is empty');
|
|
1488
1521
|
}
|
|
1489
|
-
if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
|
|
1490
|
-
throw new TypegenError(
|
|
1491
|
-
file,
|
|
1492
|
-
'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause',
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
1522
|
// SELECT-only (the read tier). A `WITH` is allowed when its main statement
|
|
1497
1523
|
// is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
|
|
1498
1524
|
const firstKeyword = /^\s*([A-Za-z]+)/.exec(
|
|
@@ -1695,6 +1721,12 @@ export function analyzeStatement(
|
|
|
1695
1721
|
sourceSql,
|
|
1696
1722
|
sql,
|
|
1697
1723
|
positionalSql: toPositionalSql(sql),
|
|
1724
|
+
relations: scanTableRefs(toPositionalSql(sql), ir).map((ref) => ({
|
|
1725
|
+
table: ref.table,
|
|
1726
|
+
start: ref.start,
|
|
1727
|
+
end: ref.end,
|
|
1728
|
+
...(ref.explicitAlias === undefined ? {} : { alias: ref.explicitAlias }),
|
|
1729
|
+
})),
|
|
1698
1730
|
params,
|
|
1699
1731
|
columns,
|
|
1700
1732
|
tables,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { SyqlLexErrorCode } from './syql-lexer';
|
|
2
|
+
import type { SyqlLoweringErrorCode } from './syql-lowering';
|
|
3
|
+
import type { SyqlModuleErrorCode } from './syql-modules';
|
|
4
|
+
import type { SyqlParseErrorCode } from './syql-parser';
|
|
5
|
+
import type { SyqlSemanticErrorCode } from './syql-semantics';
|
|
6
|
+
import type { SyqlTemplateParseErrorCode } from './syql-template-parser';
|
|
7
|
+
import type { SyqlValidationErrorCode } from './syql-validator';
|
|
8
|
+
|
|
9
|
+
export type SyqlRuntimeErrorCode =
|
|
10
|
+
| 'SYQL_RUNTIME_MISSING_REQUIRED_INPUT'
|
|
11
|
+
| 'SYQL_RUNTIME_UNKNOWN_INPUT'
|
|
12
|
+
| 'SYQL_RUNTIME_INVALID_INPUT'
|
|
13
|
+
| 'SYQL_RUNTIME_INVALID_GROUP'
|
|
14
|
+
| 'SYQL_RUNTIME_INVALID_SORT'
|
|
15
|
+
| 'SYQL_RUNTIME_INVALID_LIMIT';
|
|
16
|
+
|
|
17
|
+
export type SyqlDiagnosticCode =
|
|
18
|
+
| SyqlLexErrorCode
|
|
19
|
+
| SyqlParseErrorCode
|
|
20
|
+
| SyqlTemplateParseErrorCode
|
|
21
|
+
| SyqlModuleErrorCode
|
|
22
|
+
| SyqlSemanticErrorCode
|
|
23
|
+
| SyqlValidationErrorCode
|
|
24
|
+
| SyqlLoweringErrorCode
|
|
25
|
+
| 'SYQL9001_PROJECT_CONTEXT'
|
|
26
|
+
| SyqlRuntimeErrorCode;
|
|
27
|
+
|
|
28
|
+
/** Stable compiler and generated-runtime diagnostic remedies keyed by code. */
|
|
29
|
+
export const SYQL_DIAGNOSTIC_REMEDIES = {
|
|
30
|
+
SYQL1001_UNTERMINATED_STRING:
|
|
31
|
+
'Close the string literal with a matching single quote.',
|
|
32
|
+
SYQL1002_UNTERMINATED_IDENTIFIER:
|
|
33
|
+
'Close the quoted identifier with its matching delimiter.',
|
|
34
|
+
SYQL1003_UNTERMINATED_COMMENT: 'Close the block comment with */.',
|
|
35
|
+
SYQL1004_UNTERMINATED_IMPORT_PATH:
|
|
36
|
+
'Close the import path with a matching double quote.',
|
|
37
|
+
SYQL2001_EXPECTED_TOKEN:
|
|
38
|
+
'Insert the token named by the diagnostic at the reported source position.',
|
|
39
|
+
SYQL2002_INVALID_NAME:
|
|
40
|
+
'Rename the declaration or parameter to a valid lower-camel identifier.',
|
|
41
|
+
SYQL2003_RESERVED_NAME:
|
|
42
|
+
'Rename the declaration or parameter so it does not use a reserved SYQL name.',
|
|
43
|
+
SYQL2004_DUPLICATE_NAME:
|
|
44
|
+
'Give each declaration, parameter, group member, and profile a unique name in its scope.',
|
|
45
|
+
SYQL2005_INVALID_IMPORT:
|
|
46
|
+
'Use a relative .syql import with explicit imported predicate names.',
|
|
47
|
+
SYQL2006_EMPTY_TEMPLATE:
|
|
48
|
+
'Add a SQL expression or statement inside the braces.',
|
|
49
|
+
SYQL2007_FORBIDDEN_SEMICOLON:
|
|
50
|
+
'Remove the semicolon from the embedded SQL template.',
|
|
51
|
+
SYQL2008_INVALID_MEMBER:
|
|
52
|
+
'Use a member supported by the surrounding SYQL declaration.',
|
|
53
|
+
SYQL2009_INVALID_INTEGER:
|
|
54
|
+
'Use a decimal integer within the range named by the diagnostic.',
|
|
55
|
+
SYQL2010_INVALID_PAGE_RANGE:
|
|
56
|
+
'Choose default and maximum page sizes that satisfy the declared range.',
|
|
57
|
+
SYQL2011_INVALID_PARAMETER:
|
|
58
|
+
'Rewrite the parameter using a supported value, optional, range, group, sort, or limit form.',
|
|
59
|
+
SYQL2012_INVALID_QUERY_BODY:
|
|
60
|
+
'Rewrite the query body using the clause order and forms defined by the SYQL grammar.',
|
|
61
|
+
SYQL3001_EXPECTED_EMBEDDED_TOKEN:
|
|
62
|
+
'Insert the required token in the embedded SQL construct.',
|
|
63
|
+
SYQL3002_INVALID_BIND:
|
|
64
|
+
'Bind a declared input with the :name form in a supported SQL position.',
|
|
65
|
+
SYQL3003_INVALID_PREDICATE_CALL:
|
|
66
|
+
'Call the predicate with declared bind arguments and matching parentheses.',
|
|
67
|
+
SYQL3004_INVALID_WHEN:
|
|
68
|
+
'Use when with declared controls and a nonempty conditional SQL body.',
|
|
69
|
+
SYQL3005_INVALID_REACTIVE_DIRECTIVE:
|
|
70
|
+
'Rewrite the reactive directive using a supported revision-1 form.',
|
|
71
|
+
SYQL3006_FORBIDDEN_TEMPLATE_NODE:
|
|
72
|
+
'Remove the conditional or predicate construct from this SQL context.',
|
|
73
|
+
SYQL3007_UNEXPECTED_BRACE:
|
|
74
|
+
'Remove the unmatched brace or close the surrounding embedded construct.',
|
|
75
|
+
SYQL3008_FORBIDDEN_PARAMETER_FORM:
|
|
76
|
+
'Use a value bind in this context instead of a range, group, sort, or limit control.',
|
|
77
|
+
SYQL4001_IMPORT_OUTSIDE_ROOT:
|
|
78
|
+
'Move the imported file under the configured query root and use a relative path.',
|
|
79
|
+
SYQL4002_MODULE_NOT_FOUND:
|
|
80
|
+
'Create the imported .syql file or correct the relative import path.',
|
|
81
|
+
SYQL4003_IMPORT_CYCLE:
|
|
82
|
+
'Remove one import edge so predicate modules form an acyclic graph.',
|
|
83
|
+
SYQL4004_UNKNOWN_PREDICATE:
|
|
84
|
+
'Export and import the predicate under the referenced name.',
|
|
85
|
+
SYQL4005_DUPLICATE_IMPORT_TARGET:
|
|
86
|
+
'Import each local predicate name once in the module.',
|
|
87
|
+
SYQL4006_DUPLICATE_QUERY:
|
|
88
|
+
'Give every query in the project a unique public name.',
|
|
89
|
+
SYQL5001_UNKNOWN_PREDICATE:
|
|
90
|
+
'Define the predicate locally or import it under the referenced name.',
|
|
91
|
+
SYQL5002_PREDICATE_CYCLE:
|
|
92
|
+
'Remove the recursive predicate call so expansion terminates.',
|
|
93
|
+
SYQL5003_PREDICATE_ARITY:
|
|
94
|
+
'Pass exactly the number of bind arguments declared by the predicate.',
|
|
95
|
+
SYQL5004_CLOSED_PREDICATE:
|
|
96
|
+
'Declare each external bind as a predicate parameter and pass it at the call site.',
|
|
97
|
+
SYQL5005_UNUSED_PREDICATE_PARAMETER:
|
|
98
|
+
'Use the predicate parameter in its body or remove it from the signature.',
|
|
99
|
+
SYQL5006_UNDECLARED_BIND:
|
|
100
|
+
'Declare the bind as a query input or predicate parameter.',
|
|
101
|
+
SYQL5007_UNUSED_INPUT:
|
|
102
|
+
'Use the query input in SQL or remove it from the public signature.',
|
|
103
|
+
SYQL5008_INVALID_CONTROL:
|
|
104
|
+
'Use an optional input, group, sort, or limit name as the control.',
|
|
105
|
+
SYQL5009_MISSING_DOMINANCE:
|
|
106
|
+
'Guard every optional bind with a when condition that proves its presence.',
|
|
107
|
+
SYQL5010_UNUSED_CONTROL:
|
|
108
|
+
'Use the control in the query body or remove it from the signature.',
|
|
109
|
+
SYQL5011_TYPE_CONFLICT:
|
|
110
|
+
'Make every declaration and SQL use of the bind agree on one SYQL type.',
|
|
111
|
+
SYQL6001_INVALID_PLACEMENT:
|
|
112
|
+
'Move the SYQL construct to the SQL clause named by the diagnostic.',
|
|
113
|
+
SYQL6002_INVALID_SQL:
|
|
114
|
+
'Correct the reported SQLite syntax, table, column, function, or bind error.',
|
|
115
|
+
SYQL6003_NONDETERMINISTIC_SQL:
|
|
116
|
+
'Replace nondeterministic SQL with values supplied through query inputs or stored columns.',
|
|
117
|
+
SYQL6004_TYPE_CONFLICT:
|
|
118
|
+
'Align the declared input type with the schema column and SQL expression types.',
|
|
119
|
+
SYQL6005_INVALID_SYNC_QUERY:
|
|
120
|
+
'Select stable row identity and preserve the scope coverage required for reactive sync.',
|
|
121
|
+
SYQL6006_INVALID_SORT:
|
|
122
|
+
'Use declared sort profiles with deterministic ORDER BY terms and a stable tie-breaker.',
|
|
123
|
+
SYQL6007_INVALID_LIMIT:
|
|
124
|
+
'Use the declared limit control and keep its default and maximum within the supported range.',
|
|
125
|
+
SYQL6008_INVALID_IDENTITY:
|
|
126
|
+
'Declare identity columns that are selected, non-null, and sufficient to identify each result row.',
|
|
127
|
+
SYQL7001_ENUMERATION_LIMIT:
|
|
128
|
+
'Use the auto or neutralize backend, reduce control combinations, or raise the explicit compiler limit.',
|
|
129
|
+
SYQL7002_INTERNAL_LOWERING:
|
|
130
|
+
'Report the query and diagnostic to the Syncular maintainers.',
|
|
131
|
+
SYQL9001_PROJECT_CONTEXT:
|
|
132
|
+
'Open the file under a project with a valid syncular.json, migrations, schema output, and query root.',
|
|
133
|
+
SYQL_RUNTIME_MISSING_REQUIRED_INPUT:
|
|
134
|
+
'Pass the required generated-query input.',
|
|
135
|
+
SYQL_RUNTIME_UNKNOWN_INPUT:
|
|
136
|
+
'Remove the unknown key from the generated-query input object.',
|
|
137
|
+
SYQL_RUNTIME_INVALID_INPUT:
|
|
138
|
+
'Pass a value that matches the generated input type and nullability.',
|
|
139
|
+
SYQL_RUNTIME_INVALID_GROUP:
|
|
140
|
+
'Pass either the complete generated group shape or omit the optional group.',
|
|
141
|
+
SYQL_RUNTIME_INVALID_SORT:
|
|
142
|
+
'Pass one of the sort profile names exported for the generated query.',
|
|
143
|
+
SYQL_RUNTIME_INVALID_LIMIT:
|
|
144
|
+
'Pass an integer within the generated query limit range.',
|
|
145
|
+
} as const satisfies Readonly<Record<SyqlDiagnosticCode, string>>;
|
|
146
|
+
|
|
147
|
+
/** Generate a deterministic JSON-ready code-to-remedy catalog. */
|
|
148
|
+
export function generateSyqlDiagnosticCatalog(): Readonly<
|
|
149
|
+
Array<{ readonly code: SyqlDiagnosticCode; readonly remedy: string }>
|
|
150
|
+
> {
|
|
151
|
+
return (Object.keys(SYQL_DIAGNOSTIC_REMEDIES) as SyqlDiagnosticCode[])
|
|
152
|
+
.sort()
|
|
153
|
+
.map((code) => ({ code, remedy: SYQL_DIAGNOSTIC_REMEDIES[code] }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Resolve a diagnostic remedy without requiring a narrowed code type. */
|
|
157
|
+
export function syqlDiagnosticRemedy(code: string): string | undefined {
|
|
158
|
+
const remedies: Readonly<Record<string, string>> = SYQL_DIAGNOSTIC_REMEDIES;
|
|
159
|
+
return remedies[code];
|
|
160
|
+
}
|
package/src/syql-lowering.ts
CHANGED
package/src/syql-validator.ts
CHANGED
|
@@ -450,7 +450,18 @@ class Validator {
|
|
|
450
450
|
location,
|
|
451
451
|
);
|
|
452
452
|
this.#validateDeterminism(activeSql, logical.declaration.statement.span);
|
|
453
|
-
|
|
453
|
+
let refs: TableRef[];
|
|
454
|
+
try {
|
|
455
|
+
refs = scanTableRefs(activeSql, this.#ir);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// Preserve SQLite's source-spanned diagnostics for invalid relations.
|
|
458
|
+
this.#validateSqlite(activeSql, logical, []);
|
|
459
|
+
this.#fail(
|
|
460
|
+
'SYQL6002_INVALID_SQL',
|
|
461
|
+
logical.declaration.statement.span,
|
|
462
|
+
error instanceof Error ? error.message : String(error),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
454
465
|
this.#validatePortableProfile(
|
|
455
466
|
activeSql,
|
|
456
467
|
logical.declaration.statement.span,
|