@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 +1 -1
- package/dist/syql-browser.d.ts +18 -0
- package/dist/syql-browser.js +46 -0
- package/dist/syql-lexer.d.ts +2 -0
- package/dist/syql-lexer.js +3 -0
- package/dist/syql-validator.js +173 -0
- package/package.json +11 -3
- package/src/lsp.ts +1 -1
- package/src/syql-browser.ts +83 -0
- package/src/syql-lexer.ts +3 -0
- package/src/syql-validator.ts +248 -0
package/dist/lsp.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/syql-lexer.d.ts
CHANGED
|
@@ -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);
|
package/dist/syql-lexer.js
CHANGED
|
@@ -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
|
}
|
package/dist/syql-validator.js
CHANGED
|
@@ -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.
|
|
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.
|
|
52
|
-
"@syncular/server": "0.15.
|
|
59
|
+
"@syncular/core": "0.15.32",
|
|
60
|
+
"@syncular/server": "0.15.32"
|
|
53
61
|
}
|
|
54
62
|
}
|
package/src/lsp.ts
CHANGED
|
@@ -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
|
}
|
package/src/syql-validator.ts
CHANGED
|
@@ -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) {
|