@syncular/typegen 0.15.30 → 0.15.32

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/dist/lsp.js CHANGED
@@ -289,7 +289,7 @@ export class SyqlLanguageServer {
289
289
  severity: 1,
290
290
  source: 'syncular',
291
291
  code: error.code,
292
- message,
292
+ message: error.detail,
293
293
  };
294
294
  }
295
295
  return {
@@ -0,0 +1,18 @@
1
+ import type { IrDocument } from './ir.js';
2
+ import { type QueryBackend, type QueryDb, type QueryNamingOptions, synthesizeDdl } from './query.js';
3
+ import { type SyqlLoweredQuery } from './syql-lowering.js';
4
+ export declare const SYQL_BROWSER_DEFAULT_FILE = "/playground.syql";
5
+ export type { IrDocument, QueryDb };
6
+ export { synthesizeDdl };
7
+ export interface CompileSyqlSourceOptions {
8
+ readonly file?: string;
9
+ readonly naming?: QueryNamingOptions;
10
+ readonly backend?: QueryBackend;
11
+ }
12
+ export interface CompiledSyqlSource {
13
+ readonly queries: readonly SyqlLoweredQuery[];
14
+ }
15
+ /** Compile every query declaration in one virtual source file. */
16
+ export declare function compileSyqlSource(source: string, ir: IrDocument, db: QueryDb, options?: CompileSyqlSourceOptions): CompiledSyqlSource;
17
+ /** Format one virtual source file with the canonical SYQL formatter. */
18
+ export declare function formatSyqlSource(source: string, file?: string): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Browser-safe composition boundary for one virtual SYQL source file.
3
+ *
4
+ * The parser, semantic analyzer, validator, formatter, and lowerer are all
5
+ * portable. Filesystem traversal and SQLite initialization are deliberately
6
+ * left to the host: browsers provide an in-memory QueryDb, while Bun/Node can
7
+ * keep using the normal project generator.
8
+ */
9
+ import { formatSyql } from './fmt.js';
10
+ import { synthesizeDdl, } from './query.js';
11
+ import { SyqlFrontendError } from './syql-lexer.js';
12
+ import { lowerSyqlQuery } from './syql-lowering.js';
13
+ import { parseSyqlSyntaxFile } from './syql-parser.js';
14
+ import { analyzeSyqlSemantics } from './syql-semantics.js';
15
+ import { validateSyqlProgram } from './syql-validator.js';
16
+ export const SYQL_BROWSER_DEFAULT_FILE = '/playground.syql';
17
+ export { synthesizeDdl };
18
+ function singleFileGraph(file, source) {
19
+ const module = parseSyqlSyntaxFile(file, source);
20
+ const unsupportedImport = module.imports[0];
21
+ if (unsupportedImport !== undefined) {
22
+ throw new SyqlFrontendError('PLAYGROUND_IMPORTS_UNAVAILABLE', unsupportedImport.span, 'the browser playground supports one virtual file; declare reusable predicates in the same editor');
23
+ }
24
+ return {
25
+ root: '/',
26
+ entries: [file],
27
+ modules: [module],
28
+ moduleByPath: new Map([[file, module]]),
29
+ edges: [],
30
+ };
31
+ }
32
+ /** Compile every query declaration in one virtual source file. */
33
+ export function compileSyqlSource(source, ir, db, options = {}) {
34
+ const file = options.file ?? SYQL_BROWSER_DEFAULT_FILE;
35
+ const semantic = analyzeSyqlSemantics(singleFileGraph(file, source));
36
+ const validated = validateSyqlProgram(semantic, ir, db, options.naming);
37
+ return {
38
+ queries: validated.queries.map((query) => lowerSyqlQuery(query, ir, db, options.naming, {
39
+ ...(options.backend === undefined ? {} : { backend: options.backend }),
40
+ })),
41
+ };
42
+ }
43
+ /** Format one virtual source file with the canonical SYQL formatter. */
44
+ export function formatSyqlSource(source, file = SYQL_BROWSER_DEFAULT_FILE) {
45
+ return formatSyql(file, source);
46
+ }
@@ -31,6 +31,8 @@ export type SyqlLexErrorCode = 'SYQL1001_UNTERMINATED_STRING' | 'SYQL1002_UNTERM
31
31
  /** A source-spanned, stable-code error shared by the lexer and parser. */
32
32
  export declare class SyqlFrontendError extends TypegenError {
33
33
  readonly code: string;
34
+ /** Human-readable diagnostic without the location/code prefix in `message`. */
35
+ readonly detail: string;
34
36
  readonly span: SyqlSourceSpan;
35
37
  readonly sourceFile: string;
36
38
  constructor(code: string, span: SyqlSourceSpan, message: string);
@@ -9,12 +9,15 @@ import { TypegenError } from './errors.js';
9
9
  /** A source-spanned, stable-code error shared by the lexer and parser. */
10
10
  export class SyqlFrontendError extends TypegenError {
11
11
  code;
12
+ /** Human-readable diagnostic without the location/code prefix in `message`. */
13
+ detail;
12
14
  span;
13
15
  sourceFile;
14
16
  constructor(code, span, message) {
15
17
  super(`${span.file}:${span.start.line}:${span.start.column}`, `${code}: ${message}`);
16
18
  this.name = 'SyqlFrontendError';
17
19
  this.code = code;
20
+ this.detail = message;
18
21
  this.span = span;
19
22
  this.sourceFile = span.file;
20
23
  }
@@ -3,6 +3,7 @@ import { isSyqlTrivia, lexSyqlSqlSource, SyqlFrontendError, } from './syql-lexer
3
3
  import { syqlRangeEndBind, syqlRangeStartBind } from './syql-semantics.js';
4
4
  const IDENT_SOURCE = '[A-Za-z_][A-Za-z0-9_]*';
5
5
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
6
+ const SQLITE_SUBJECT_RE = '[A-Za-z_][A-Za-z0-9_$]*';
6
7
  const NONDETERMINISTIC_FUNCTIONS = new Set([
7
8
  'random',
8
9
  'randomblob',
@@ -26,6 +27,49 @@ const CURRENT_TIME_KEYWORDS = new Set([
26
27
  'current_time',
27
28
  'current_timestamp',
28
29
  ]);
30
+ function editDistance(left, right) {
31
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
32
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
33
+ const current = [leftIndex];
34
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
35
+ current[rightIndex] = Math.min(current[rightIndex - 1] + 1, previous[rightIndex] + 1, previous[rightIndex - 1] +
36
+ (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1));
37
+ }
38
+ previous.splice(0, previous.length, ...current);
39
+ }
40
+ return previous[right.length];
41
+ }
42
+ function nearestName(authored, candidates) {
43
+ const normalized = authored.toLowerCase();
44
+ const ranked = [...new Set(candidates)]
45
+ .filter((candidate) => candidate.toLowerCase() !== normalized)
46
+ .map((candidate) => ({
47
+ candidate,
48
+ distance: editDistance(normalized, candidate.toLowerCase()),
49
+ }))
50
+ .sort((left, right) => left.distance - right.distance ||
51
+ left.candidate.localeCompare(right.candidate));
52
+ const nearest = ranked[0];
53
+ if (nearest === undefined)
54
+ return undefined;
55
+ const threshold = Math.max(1, Math.floor(normalized.length / 3));
56
+ return nearest.distance <= threshold ? nearest.candidate : undefined;
57
+ }
58
+ function suggestion(authored, candidates) {
59
+ const nearest = nearestName(authored, candidates);
60
+ return nearest === undefined ? '' : `; did you mean \`${nearest}\`?`;
61
+ }
62
+ function identifierText(token) {
63
+ if (token?.kind === 'identifier')
64
+ return token.text;
65
+ if (token?.kind !== 'quoted-identifier')
66
+ return undefined;
67
+ if (token.text.startsWith('[') && token.text.endsWith(']')) {
68
+ return token.text.slice(1, -1).replaceAll(']]', ']');
69
+ }
70
+ const quote = token.text[0];
71
+ return token.text.slice(1, -1).replaceAll(`${quote}${quote}`, quote ?? '');
72
+ }
29
73
  const AGGREGATE_FUNCTIONS = new Set([
30
74
  'avg',
31
75
  'count',
@@ -261,6 +305,7 @@ class Validator {
261
305
  this.#validateDeterminism(activeSql, logical.declaration.statement.span);
262
306
  const refs = scanTableRefs(activeSql, this.#ir);
263
307
  this.#validatePortableProfile(activeSql, logical.declaration.statement.span, refs);
308
+ this.#validateSqlite(activeSql, logical, refs);
264
309
  const bindSymbols = this.#bindSymbols(logical.declaration);
265
310
  const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols);
266
311
  const sort = this.#validateSort(logical.declaration, activeStructure, location);
@@ -573,6 +618,134 @@ class Validator {
573
618
  }
574
619
  }
575
620
  }
621
+ #validateSqlite(sql, logical, refs) {
622
+ try {
623
+ this.#db.analyze(sql);
624
+ }
625
+ catch (error) {
626
+ const sqliteMessage = error instanceof Error ? error.message : String(error);
627
+ const diagnostic = this.#describeSqliteError(sqliteMessage, logical, refs);
628
+ this.#fail('SYQL6002_INVALID_SQL', diagnostic.span, diagnostic.message);
629
+ }
630
+ }
631
+ #describeSqliteError(sqliteMessage, logical, refs) {
632
+ const fallback = logical.declaration.statement.span;
633
+ const tableNames = this.#ir.tables.flatMap((table) => [
634
+ table.name,
635
+ ...table.ftsIndexes.map((index) => index.name),
636
+ ]);
637
+ const noTable = new RegExp(`no such table:\\s*(?:main\\.)?(${SQLITE_SUBJECT_RE})`, 'i').exec(sqliteMessage);
638
+ if (noTable !== null) {
639
+ const table = noTable[1];
640
+ return {
641
+ span: this.#sqlSubjectSpan(logical, table, 'table') ?? fallback,
642
+ message: `unknown table \`${table}\`${suggestion(table, tableNames)}`,
643
+ };
644
+ }
645
+ const noColumn = new RegExp(`no such column:\\s*(${SQLITE_SUBJECT_RE})(?:\\.(${SQLITE_SUBJECT_RE}))?`, 'i').exec(sqliteMessage);
646
+ if (noColumn !== null) {
647
+ const first = noColumn[1];
648
+ const second = noColumn[2];
649
+ if (second !== undefined) {
650
+ const qualifier = first;
651
+ const ref = refs.find((candidate) => candidate.alias.toLowerCase() === qualifier.toLowerCase());
652
+ if (ref === undefined) {
653
+ const qualifiers = refs.map((candidate) => candidate.alias);
654
+ return {
655
+ span: this.#sqlSubjectSpan(logical, `${qualifier}.${second}`, 'qualifier') ?? fallback,
656
+ message: `unknown table or alias \`${qualifier}\`${suggestion(qualifier, qualifiers)}`,
657
+ };
658
+ }
659
+ const columns = this.#columnsForTable(ref.table);
660
+ return {
661
+ span: this.#sqlSubjectSpan(logical, `${qualifier}.${second}`, 'column') ??
662
+ fallback,
663
+ message: `unknown column \`${second}\` on \`${qualifier}\`${suggestion(second, columns)}`,
664
+ };
665
+ }
666
+ const columns = refs.flatMap((ref) => this.#columnsForTable(ref.table));
667
+ return {
668
+ span: this.#sqlSubjectSpan(logical, first, 'column') ?? fallback,
669
+ message: `unknown column \`${first}\`${suggestion(first, columns)}`,
670
+ };
671
+ }
672
+ const ambiguous = new RegExp(`ambiguous column name:\\s*(?:${SQLITE_SUBJECT_RE}\\.)?(${SQLITE_SUBJECT_RE})`, 'i').exec(sqliteMessage);
673
+ if (ambiguous !== null) {
674
+ const column = ambiguous[1];
675
+ const choices = refs
676
+ .filter((ref) => this.#columnsForTable(ref.table).some((candidate) => candidate.toLowerCase() === column.toLowerCase()))
677
+ .map((ref) => `\`${ref.alias}.${column}\``);
678
+ return {
679
+ span: this.#sqlSubjectSpan(logical, column, 'column') ?? fallback,
680
+ message: `column \`${column}\` is ambiguous${choices.length === 0
681
+ ? '; add a table or alias qualifier'
682
+ : `; use ${choices.join(' or ')}`}`,
683
+ };
684
+ }
685
+ const syntax = /near\s+["']([^"']+)["']:\s*syntax error/i.exec(sqliteMessage);
686
+ if (syntax !== null) {
687
+ const token = syntax[1];
688
+ return {
689
+ span: this.#sqlSubjectSpan(logical, token, 'token') ?? fallback,
690
+ message: `SQL syntax error near \`${token}\``,
691
+ };
692
+ }
693
+ if (/incomplete input/i.test(sqliteMessage)) {
694
+ return {
695
+ span: {
696
+ file: fallback.file,
697
+ start: fallback.end,
698
+ end: fallback.end,
699
+ },
700
+ message: 'incomplete SQL statement',
701
+ };
702
+ }
703
+ return {
704
+ span: fallback,
705
+ message: `SQL rejected by SQLite: ${sqliteMessage}`,
706
+ };
707
+ }
708
+ #columnsForTable(tableName) {
709
+ const table = this.#ir.tables.find((candidate) => candidate.name === tableName);
710
+ if (table !== undefined)
711
+ return table.columns.map((column) => column.name);
712
+ for (const owner of this.#ir.tables) {
713
+ const fts = owner.ftsIndexes.find((candidate) => candidate.name === tableName);
714
+ if (fts !== undefined)
715
+ return ['_syncular_source_id', ...fts.columns];
716
+ }
717
+ return [];
718
+ }
719
+ #sqlSubjectSpan(logical, subject, focus) {
720
+ const tokens = significant(logical.declaration.statement.tokens);
721
+ const [qualifier, column] = subject.split('.', 2);
722
+ const matches = (token, expected) => identifierText(token)?.toLowerCase() === expected.toLowerCase();
723
+ if (focus === 'table') {
724
+ for (let index = 0; index < tokens.length - 1; index += 1) {
725
+ const keyword = tokenLower(tokens[index]);
726
+ if ((keyword === 'from' || keyword === 'join') &&
727
+ matches(tokens[index + 1], subject)) {
728
+ return tokens[index + 1]?.span;
729
+ }
730
+ }
731
+ }
732
+ if (column !== undefined) {
733
+ for (let index = 0; index < tokens.length - 2; index += 1) {
734
+ if (matches(tokens[index], qualifier) &&
735
+ tokens[index + 1]?.text === '.' &&
736
+ matches(tokens[index + 2], column)) {
737
+ return focus === 'qualifier'
738
+ ? tokens[index]?.span
739
+ : tokens[index + 2]?.span;
740
+ }
741
+ }
742
+ }
743
+ for (const token of tokens) {
744
+ if (matches(token, subject))
745
+ return token.span;
746
+ }
747
+ return undefined;
748
+ }
576
749
  #bindSymbols(query) {
577
750
  const symbols = new Map();
578
751
  for (const parameter of query.parameters) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/typegen",
3
- "version": "0.15.30",
3
+ "version": "0.15.32",
4
4
  "description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -36,6 +36,14 @@
36
36
  "types": "./dist/index.d.ts",
37
37
  "default": "./dist/index.js"
38
38
  }
39
+ },
40
+ "./syql-browser": {
41
+ "bun": "./src/syql-browser.ts",
42
+ "browser": "./dist/syql-browser.js",
43
+ "import": {
44
+ "types": "./dist/syql-browser.d.ts",
45
+ "default": "./dist/syql-browser.js"
46
+ }
39
47
  }
40
48
  },
41
49
  "files": [
@@ -48,7 +56,7 @@
48
56
  "!dist/**/*.test.d.ts"
49
57
  ],
50
58
  "devDependencies": {
51
- "@syncular/core": "0.15.30",
52
- "@syncular/server": "0.15.30"
59
+ "@syncular/core": "0.15.32",
60
+ "@syncular/server": "0.15.32"
53
61
  }
54
62
  }
package/src/lsp.ts CHANGED
@@ -379,7 +379,7 @@ export class SyqlLanguageServer {
379
379
  severity: 1,
380
380
  source: 'syncular',
381
381
  code: error.code,
382
- message,
382
+ message: error.detail,
383
383
  };
384
384
  }
385
385
  return {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Browser-safe composition boundary for one virtual SYQL source file.
3
+ *
4
+ * The parser, semantic analyzer, validator, formatter, and lowerer are all
5
+ * portable. Filesystem traversal and SQLite initialization are deliberately
6
+ * left to the host: browsers provide an in-memory QueryDb, while Bun/Node can
7
+ * keep using the normal project generator.
8
+ */
9
+ import { formatSyql } from './fmt';
10
+ import type { IrDocument } from './ir';
11
+ import {
12
+ type QueryBackend,
13
+ type QueryDb,
14
+ type QueryNamingOptions,
15
+ synthesizeDdl,
16
+ } from './query';
17
+ import { SyqlFrontendError } from './syql-lexer';
18
+ import { lowerSyqlQuery, type SyqlLoweredQuery } from './syql-lowering';
19
+ import type { SyqlModuleGraph } from './syql-modules';
20
+ import { parseSyqlSyntaxFile } from './syql-parser';
21
+ import { analyzeSyqlSemantics } from './syql-semantics';
22
+ import { validateSyqlProgram } from './syql-validator';
23
+
24
+ export const SYQL_BROWSER_DEFAULT_FILE = '/playground.syql';
25
+
26
+ export type { IrDocument, QueryDb };
27
+ export { synthesizeDdl };
28
+
29
+ export interface CompileSyqlSourceOptions {
30
+ readonly file?: string;
31
+ readonly naming?: QueryNamingOptions;
32
+ readonly backend?: QueryBackend;
33
+ }
34
+
35
+ export interface CompiledSyqlSource {
36
+ readonly queries: readonly SyqlLoweredQuery[];
37
+ }
38
+
39
+ function singleFileGraph(file: string, source: string): SyqlModuleGraph {
40
+ const module = parseSyqlSyntaxFile(file, source);
41
+ const unsupportedImport = module.imports[0];
42
+ if (unsupportedImport !== undefined) {
43
+ throw new SyqlFrontendError(
44
+ 'PLAYGROUND_IMPORTS_UNAVAILABLE',
45
+ unsupportedImport.span,
46
+ 'the browser playground supports one virtual file; declare reusable predicates in the same editor',
47
+ );
48
+ }
49
+ return {
50
+ root: '/',
51
+ entries: [file],
52
+ modules: [module],
53
+ moduleByPath: new Map([[file, module]]),
54
+ edges: [],
55
+ };
56
+ }
57
+
58
+ /** Compile every query declaration in one virtual source file. */
59
+ export function compileSyqlSource(
60
+ source: string,
61
+ ir: IrDocument,
62
+ db: QueryDb,
63
+ options: CompileSyqlSourceOptions = {},
64
+ ): CompiledSyqlSource {
65
+ const file = options.file ?? SYQL_BROWSER_DEFAULT_FILE;
66
+ const semantic = analyzeSyqlSemantics(singleFileGraph(file, source));
67
+ const validated = validateSyqlProgram(semantic, ir, db, options.naming);
68
+ return {
69
+ queries: validated.queries.map((query) =>
70
+ lowerSyqlQuery(query, ir, db, options.naming, {
71
+ ...(options.backend === undefined ? {} : { backend: options.backend }),
72
+ }),
73
+ ),
74
+ };
75
+ }
76
+
77
+ /** Format one virtual source file with the canonical SYQL formatter. */
78
+ export function formatSyqlSource(
79
+ source: string,
80
+ file = SYQL_BROWSER_DEFAULT_FILE,
81
+ ): string {
82
+ return formatSyql(file, source);
83
+ }
package/src/syql-lexer.ts CHANGED
@@ -55,6 +55,8 @@ export type SyqlLexErrorCode =
55
55
  /** A source-spanned, stable-code error shared by the lexer and parser. */
56
56
  export class SyqlFrontendError extends TypegenError {
57
57
  readonly code: string;
58
+ /** Human-readable diagnostic without the location/code prefix in `message`. */
59
+ readonly detail: string;
58
60
  readonly span: SyqlSourceSpan;
59
61
  readonly sourceFile: string;
60
62
 
@@ -65,6 +67,7 @@ export class SyqlFrontendError extends TypegenError {
65
67
  );
66
68
  this.name = 'SyqlFrontendError';
67
69
  this.code = code;
70
+ this.detail = message;
68
71
  this.span = span;
69
72
  this.sourceFile = span.file;
70
73
  }
@@ -101,8 +101,14 @@ interface BindSymbol {
101
101
  readonly authoredType?: SyqlValueType;
102
102
  }
103
103
 
104
+ interface SqliteDiagnostic {
105
+ readonly span: SyqlSourceSpan;
106
+ readonly message: string;
107
+ }
108
+
104
109
  const IDENT_SOURCE = '[A-Za-z_][A-Za-z0-9_]*';
105
110
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
111
+ const SQLITE_SUBJECT_RE = '[A-Za-z_][A-Za-z0-9_$]*';
106
112
  const NONDETERMINISTIC_FUNCTIONS = new Set([
107
113
  'random',
108
114
  'randomblob',
@@ -126,6 +132,63 @@ const CURRENT_TIME_KEYWORDS = new Set([
126
132
  'current_time',
127
133
  'current_timestamp',
128
134
  ]);
135
+
136
+ function editDistance(left: string, right: string): number {
137
+ const previous = Array.from(
138
+ { length: right.length + 1 },
139
+ (_, index) => index,
140
+ );
141
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
142
+ const current = [leftIndex];
143
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
144
+ current[rightIndex] = Math.min(
145
+ (current[rightIndex - 1] as number) + 1,
146
+ (previous[rightIndex] as number) + 1,
147
+ (previous[rightIndex - 1] as number) +
148
+ (left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1),
149
+ );
150
+ }
151
+ previous.splice(0, previous.length, ...current);
152
+ }
153
+ return previous[right.length] as number;
154
+ }
155
+
156
+ function nearestName(
157
+ authored: string,
158
+ candidates: readonly string[],
159
+ ): string | undefined {
160
+ const normalized = authored.toLowerCase();
161
+ const ranked = [...new Set(candidates)]
162
+ .filter((candidate) => candidate.toLowerCase() !== normalized)
163
+ .map((candidate) => ({
164
+ candidate,
165
+ distance: editDistance(normalized, candidate.toLowerCase()),
166
+ }))
167
+ .sort(
168
+ (left, right) =>
169
+ left.distance - right.distance ||
170
+ left.candidate.localeCompare(right.candidate),
171
+ );
172
+ const nearest = ranked[0];
173
+ if (nearest === undefined) return undefined;
174
+ const threshold = Math.max(1, Math.floor(normalized.length / 3));
175
+ return nearest.distance <= threshold ? nearest.candidate : undefined;
176
+ }
177
+
178
+ function suggestion(authored: string, candidates: readonly string[]): string {
179
+ const nearest = nearestName(authored, candidates);
180
+ return nearest === undefined ? '' : `; did you mean \`${nearest}\`?`;
181
+ }
182
+
183
+ function identifierText(token: SyqlToken | undefined): string | undefined {
184
+ if (token?.kind === 'identifier') return token.text;
185
+ if (token?.kind !== 'quoted-identifier') return undefined;
186
+ if (token.text.startsWith('[') && token.text.endsWith(']')) {
187
+ return token.text.slice(1, -1).replaceAll(']]', ']');
188
+ }
189
+ const quote = token.text[0];
190
+ return token.text.slice(1, -1).replaceAll(`${quote}${quote}`, quote ?? '');
191
+ }
129
192
  const AGGREGATE_FUNCTIONS = new Set([
130
193
  'avg',
131
194
  'count',
@@ -393,6 +456,7 @@ class Validator {
393
456
  logical.declaration.statement.span,
394
457
  refs,
395
458
  );
459
+ this.#validateSqlite(activeSql, logical, refs);
396
460
 
397
461
  const bindSymbols = this.#bindSymbols(logical.declaration);
398
462
  const bindTypes = this.#resolveBindTypes(
@@ -898,6 +962,190 @@ class Validator {
898
962
  }
899
963
  }
900
964
 
965
+ #validateSqlite(
966
+ sql: string,
967
+ logical: SyqlLogicalQuery,
968
+ refs: readonly TableRef[],
969
+ ): void {
970
+ try {
971
+ this.#db.analyze(sql);
972
+ } catch (error) {
973
+ const sqliteMessage =
974
+ error instanceof Error ? error.message : String(error);
975
+ const diagnostic = this.#describeSqliteError(
976
+ sqliteMessage,
977
+ logical,
978
+ refs,
979
+ );
980
+ this.#fail('SYQL6002_INVALID_SQL', diagnostic.span, diagnostic.message);
981
+ }
982
+ }
983
+
984
+ #describeSqliteError(
985
+ sqliteMessage: string,
986
+ logical: SyqlLogicalQuery,
987
+ refs: readonly TableRef[],
988
+ ): SqliteDiagnostic {
989
+ const fallback = logical.declaration.statement.span;
990
+ const tableNames = this.#ir.tables.flatMap((table) => [
991
+ table.name,
992
+ ...table.ftsIndexes.map((index) => index.name),
993
+ ]);
994
+ const noTable = new RegExp(
995
+ `no such table:\\s*(?:main\\.)?(${SQLITE_SUBJECT_RE})`,
996
+ 'i',
997
+ ).exec(sqliteMessage);
998
+ if (noTable !== null) {
999
+ const table = noTable[1] as string;
1000
+ return {
1001
+ span: this.#sqlSubjectSpan(logical, table, 'table') ?? fallback,
1002
+ message: `unknown table \`${table}\`${suggestion(table, tableNames)}`,
1003
+ };
1004
+ }
1005
+
1006
+ const noColumn = new RegExp(
1007
+ `no such column:\\s*(${SQLITE_SUBJECT_RE})(?:\\.(${SQLITE_SUBJECT_RE}))?`,
1008
+ 'i',
1009
+ ).exec(sqliteMessage);
1010
+ if (noColumn !== null) {
1011
+ const first = noColumn[1] as string;
1012
+ const second = noColumn[2];
1013
+ if (second !== undefined) {
1014
+ const qualifier = first;
1015
+ const ref = refs.find(
1016
+ (candidate) =>
1017
+ candidate.alias.toLowerCase() === qualifier.toLowerCase(),
1018
+ );
1019
+ if (ref === undefined) {
1020
+ const qualifiers = refs.map((candidate) => candidate.alias);
1021
+ return {
1022
+ span:
1023
+ this.#sqlSubjectSpan(
1024
+ logical,
1025
+ `${qualifier}.${second}`,
1026
+ 'qualifier',
1027
+ ) ?? fallback,
1028
+ message: `unknown table or alias \`${qualifier}\`${suggestion(qualifier, qualifiers)}`,
1029
+ };
1030
+ }
1031
+ const columns = this.#columnsForTable(ref.table);
1032
+ return {
1033
+ span:
1034
+ this.#sqlSubjectSpan(logical, `${qualifier}.${second}`, 'column') ??
1035
+ fallback,
1036
+ message: `unknown column \`${second}\` on \`${qualifier}\`${suggestion(second, columns)}`,
1037
+ };
1038
+ }
1039
+ const columns = refs.flatMap((ref) => this.#columnsForTable(ref.table));
1040
+ return {
1041
+ span: this.#sqlSubjectSpan(logical, first, 'column') ?? fallback,
1042
+ message: `unknown column \`${first}\`${suggestion(first, columns)}`,
1043
+ };
1044
+ }
1045
+
1046
+ const ambiguous = new RegExp(
1047
+ `ambiguous column name:\\s*(?:${SQLITE_SUBJECT_RE}\\.)?(${SQLITE_SUBJECT_RE})`,
1048
+ 'i',
1049
+ ).exec(sqliteMessage);
1050
+ if (ambiguous !== null) {
1051
+ const column = ambiguous[1] as string;
1052
+ const choices = refs
1053
+ .filter((ref) =>
1054
+ this.#columnsForTable(ref.table).some(
1055
+ (candidate) => candidate.toLowerCase() === column.toLowerCase(),
1056
+ ),
1057
+ )
1058
+ .map((ref) => `\`${ref.alias}.${column}\``);
1059
+ return {
1060
+ span: this.#sqlSubjectSpan(logical, column, 'column') ?? fallback,
1061
+ message: `column \`${column}\` is ambiguous${
1062
+ choices.length === 0
1063
+ ? '; add a table or alias qualifier'
1064
+ : `; use ${choices.join(' or ')}`
1065
+ }`,
1066
+ };
1067
+ }
1068
+
1069
+ const syntax = /near\s+["']([^"']+)["']:\s*syntax error/i.exec(
1070
+ sqliteMessage,
1071
+ );
1072
+ if (syntax !== null) {
1073
+ const token = syntax[1] as string;
1074
+ return {
1075
+ span: this.#sqlSubjectSpan(logical, token, 'token') ?? fallback,
1076
+ message: `SQL syntax error near \`${token}\``,
1077
+ };
1078
+ }
1079
+ if (/incomplete input/i.test(sqliteMessage)) {
1080
+ return {
1081
+ span: {
1082
+ file: fallback.file,
1083
+ start: fallback.end,
1084
+ end: fallback.end,
1085
+ },
1086
+ message: 'incomplete SQL statement',
1087
+ };
1088
+ }
1089
+ return {
1090
+ span: fallback,
1091
+ message: `SQL rejected by SQLite: ${sqliteMessage}`,
1092
+ };
1093
+ }
1094
+
1095
+ #columnsForTable(tableName: string): readonly string[] {
1096
+ const table = this.#ir.tables.find(
1097
+ (candidate) => candidate.name === tableName,
1098
+ );
1099
+ if (table !== undefined) return table.columns.map((column) => column.name);
1100
+ for (const owner of this.#ir.tables) {
1101
+ const fts = owner.ftsIndexes.find(
1102
+ (candidate) => candidate.name === tableName,
1103
+ );
1104
+ if (fts !== undefined) return ['_syncular_source_id', ...fts.columns];
1105
+ }
1106
+ return [];
1107
+ }
1108
+
1109
+ #sqlSubjectSpan(
1110
+ logical: SyqlLogicalQuery,
1111
+ subject: string,
1112
+ focus: 'table' | 'qualifier' | 'column' | 'token',
1113
+ ): SyqlSourceSpan | undefined {
1114
+ const tokens = significant(logical.declaration.statement.tokens);
1115
+ const [qualifier, column] = subject.split('.', 2);
1116
+ const matches = (token: SyqlToken | undefined, expected: string): boolean =>
1117
+ identifierText(token)?.toLowerCase() === expected.toLowerCase();
1118
+
1119
+ if (focus === 'table') {
1120
+ for (let index = 0; index < tokens.length - 1; index += 1) {
1121
+ const keyword = tokenLower(tokens[index]);
1122
+ if (
1123
+ (keyword === 'from' || keyword === 'join') &&
1124
+ matches(tokens[index + 1], subject)
1125
+ ) {
1126
+ return tokens[index + 1]?.span;
1127
+ }
1128
+ }
1129
+ }
1130
+ if (column !== undefined) {
1131
+ for (let index = 0; index < tokens.length - 2; index += 1) {
1132
+ if (
1133
+ matches(tokens[index], qualifier as string) &&
1134
+ tokens[index + 1]?.text === '.' &&
1135
+ matches(tokens[index + 2], column)
1136
+ ) {
1137
+ return focus === 'qualifier'
1138
+ ? tokens[index]?.span
1139
+ : tokens[index + 2]?.span;
1140
+ }
1141
+ }
1142
+ }
1143
+ for (const token of tokens) {
1144
+ if (matches(token, subject)) return token.span;
1145
+ }
1146
+ return undefined;
1147
+ }
1148
+
901
1149
  #bindSymbols(query: SyqlQueryDeclaration): ReadonlyMap<string, BindSymbol> {
902
1150
  const symbols = new Map<string, BindSymbol>();
903
1151
  for (const parameter of query.parameters) {