@syncular/typegen 0.14.0 → 0.15.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 +29 -1
- package/dist/emit-dart.js +8 -0
- package/dist/emit-kotlin.js +10 -0
- package/dist/emit-swift.js +10 -0
- package/dist/emit.js +8 -0
- package/dist/generate.js +17 -2
- package/dist/ir.d.ts +10 -0
- package/dist/ir.js +9 -0
- package/dist/query.js +22 -4
- package/dist/sql.d.ts +3 -1
- package/dist/sql.js +130 -6
- package/dist/syql-validator.js +19 -4
- package/package.json +3 -3
- package/src/emit-dart.ts +10 -0
- package/src/emit-kotlin.ts +12 -0
- package/src/emit-swift.ts +12 -0
- package/src/emit.ts +10 -0
- package/src/generate.ts +25 -3
- package/src/ir.ts +20 -0
- package/src/query.ts +37 -8
- package/src/sql.ts +167 -7
- package/src/syql-validator.ts +33 -4
package/README.md
CHANGED
|
@@ -185,6 +185,7 @@ duplicate ordinals are errors). The parser accepts exactly:
|
|
|
185
185
|
- `ALTER TABLE name ADD [COLUMN] column-def`
|
|
186
186
|
- `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table ( col [, col…] )`
|
|
187
187
|
- `DROP TABLE [IF EXISTS] name`
|
|
188
|
+
- `CREATE VIRTUAL TABLE [IF NOT EXISTS] name USING fts5( text-col [, text-col…], content = table [, tokenize = 'allowlisted tokenizer'] )`
|
|
188
189
|
- column-def: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL] [DEFAULT literal]`
|
|
189
190
|
- `--` line comments and `/* … */` block comments
|
|
190
191
|
|
|
@@ -226,6 +227,22 @@ reference relational server drops the retired current-row table and its live
|
|
|
226
227
|
scope index. Append-only commit history follows the configured retention
|
|
227
228
|
policy—`DROP TABLE` is schema retirement, not a compliance erasure API.
|
|
228
229
|
|
|
230
|
+
**Local FTS5 projections** (`CREATE VIRTUAL TABLE … USING fts5`). The authored
|
|
231
|
+
`content = table` option declares which already-created synced table owns the
|
|
232
|
+
projection; it is not passed through as SQLite external-content mode. Typegen
|
|
233
|
+
attaches `{ name, columns, tokenize }` to that table's `ftsIndexes` array and
|
|
234
|
+
emits it in TypeScript, Swift, Kotlin, and Dart schema values. The TypeScript
|
|
235
|
+
and Rust clients create a contentful FTS table with a private stable source-ID
|
|
236
|
+
column and deterministic maintenance triggers. FTS is local-only: it is not a
|
|
237
|
+
wire/server table, cannot be subscribed or mutated, and maps back to its owner
|
|
238
|
+
for named-query invalidation. Columns must precede options, be distinct
|
|
239
|
+
non-encrypted strings, and number 1–32. `content` is required. Supported
|
|
240
|
+
tokenizers are `unicode61` (default), its `remove_diacritics 0|1|2` variants,
|
|
241
|
+
`porter unicode61`, and `trigram`. Other modules/options/tokenizers, encrypted
|
|
242
|
+
columns, or schema-object name collisions fail generation. There is no
|
|
243
|
+
automatic `LIKE` fallback when FTS5 is unavailable; local schema creation
|
|
244
|
+
fails loudly.
|
|
245
|
+
|
|
229
246
|
**Hard errors** (each names the construct and source file): any other
|
|
230
247
|
statement (`CREATE TRIGGER/VIEW`, `DROP INDEX`, DML, `ALTER … RENAME`, …); unknown
|
|
231
248
|
or parameterized types (`VARCHAR(36)`); quoted identifiers
|
|
@@ -234,7 +251,9 @@ or parameterized types (`VARCHAR(36)`); quoted identifiers
|
|
|
234
251
|
(`REFERENCES`, `CHECK`, `COLLATE`, …); `DEFAULT (expression)`; composite
|
|
235
252
|
or missing primary keys; `PRIMARY KEY` on `ADD COLUMN`; duplicate
|
|
236
253
|
tables/columns; ASC/DESC, expression, or partial (`WHERE`) index columns; a
|
|
237
|
-
duplicate or unknown-column index;
|
|
254
|
+
duplicate or unknown-column index; unsupported virtual-table modules or FTS5
|
|
255
|
+
options; an FTS projection without an owner or with invalid/encrypted columns;
|
|
256
|
+
trailing clauses (`STRICT`).
|
|
238
257
|
|
|
239
258
|
**`DEFAULT` literals are accepted and ignored**: typegen extracts the
|
|
240
259
|
schema *shape*; executing migrations (where defaults matter) is the
|
|
@@ -447,6 +466,15 @@ a bad table/column reference or a syntax error throws with SQLite's own
|
|
|
447
466
|
message. The prepared statement then yields the result column names +
|
|
448
467
|
`declaredTypes` (SQLite's `sqlite3_column_decltype`).
|
|
449
468
|
|
|
469
|
+
For FTS-backed named queries the synthesized type-check database creates the
|
|
470
|
+
same declared columns plus `_syncular_source_id UNINDEXED`. `MATCH :query`
|
|
471
|
+
infers a string parameter, and the FTS table reference is folded into its
|
|
472
|
+
owning synced table for dependency and scope-coverage metadata. `bm25`,
|
|
473
|
+
`highlight`, and `snippet` are accepted deterministic FTS5 auxiliary
|
|
474
|
+
functions only when the query references a schema-declared FTS projection;
|
|
475
|
+
they use the normal computed-expression fidelity rules. Arbitrary extension
|
|
476
|
+
functions remain outside the portable profile.
|
|
477
|
+
|
|
450
478
|
**Typing fidelity** (what `bun:sqlite` actually exposes, and its honest
|
|
451
479
|
boundary):
|
|
452
480
|
|
package/dist/emit-dart.js
CHANGED
|
@@ -64,6 +64,14 @@ function emitSchemaValue(ir, indent) {
|
|
|
64
64
|
lines.push(`${i} {'pattern': ${quote(scope.pattern)}, 'column': ${quote(scope.column)}},`);
|
|
65
65
|
}
|
|
66
66
|
lines.push(`${i} ],`);
|
|
67
|
+
if (table.ftsIndexes.length > 0) {
|
|
68
|
+
lines.push(`${i} 'ftsIndexes': [`);
|
|
69
|
+
for (const index of table.ftsIndexes) {
|
|
70
|
+
const columns = index.columns.map((column) => quote(column)).join(', ');
|
|
71
|
+
lines.push(`${i} {'name': ${quote(index.name)}, 'columns': [${columns}], 'tokenize': ${quote(index.tokenize)}},`);
|
|
72
|
+
}
|
|
73
|
+
lines.push(`${i} ],`);
|
|
74
|
+
}
|
|
67
75
|
lines.push(`${i} },`);
|
|
68
76
|
}
|
|
69
77
|
lines.push(`${i} ],`);
|
package/dist/emit-kotlin.js
CHANGED
|
@@ -64,6 +64,16 @@ function emitSchemaValue(ir, indent) {
|
|
|
64
64
|
lines.push(`${i} JsonValue.obj("pattern" to JsonValue.of(${quote(scope.pattern)}), "column" to JsonValue.of(${quote(scope.column)})),`);
|
|
65
65
|
}
|
|
66
66
|
lines.push(`${i} )),`);
|
|
67
|
+
if (table.ftsIndexes.length > 0) {
|
|
68
|
+
lines.push(`${i} "ftsIndexes" to JsonValue.arr(listOf(`);
|
|
69
|
+
for (const index of table.ftsIndexes) {
|
|
70
|
+
const columns = index.columns
|
|
71
|
+
.map((column) => `JsonValue.of(${quote(column)})`)
|
|
72
|
+
.join(', ');
|
|
73
|
+
lines.push(`${i} JsonValue.obj("name" to JsonValue.of(${quote(index.name)}), "columns" to JsonValue.arr(listOf(${columns})), "tokenize" to JsonValue.of(${quote(index.tokenize)})),`);
|
|
74
|
+
}
|
|
75
|
+
lines.push(`${i} )),`);
|
|
76
|
+
}
|
|
67
77
|
lines.push(`${i} ),`);
|
|
68
78
|
}
|
|
69
79
|
lines.push(`${i} )),`);
|
package/dist/emit-swift.js
CHANGED
|
@@ -68,6 +68,16 @@ function emitSchemaValue(ir, indent) {
|
|
|
68
68
|
lines.push(`${i} .object(["pattern": .string(${quote(scope.pattern)}), "column": .string(${quote(scope.column)})]),`);
|
|
69
69
|
}
|
|
70
70
|
lines.push(`${i} ]),`);
|
|
71
|
+
if (table.ftsIndexes.length > 0) {
|
|
72
|
+
lines.push(`${i} "ftsIndexes": .array([`);
|
|
73
|
+
for (const index of table.ftsIndexes) {
|
|
74
|
+
const columns = index.columns
|
|
75
|
+
.map((column) => `.string(${quote(column)})`)
|
|
76
|
+
.join(', ');
|
|
77
|
+
lines.push(`${i} .object(["name": .string(${quote(index.name)}), "columns": .array([${columns}]), "tokenize": .string(${quote(index.tokenize)})]),`);
|
|
78
|
+
}
|
|
79
|
+
lines.push(`${i} ]),`);
|
|
80
|
+
}
|
|
71
81
|
lines.push(`${i} ]),`);
|
|
72
82
|
}
|
|
73
83
|
lines.push(`${i} ]),`);
|
package/dist/emit.js
CHANGED
|
@@ -77,6 +77,14 @@ function emitSchema(ir) {
|
|
|
77
77
|
}
|
|
78
78
|
lines.push(' ],');
|
|
79
79
|
}
|
|
80
|
+
if (table.ftsIndexes.length > 0) {
|
|
81
|
+
lines.push(' ftsIndexes: [');
|
|
82
|
+
for (const index of table.ftsIndexes) {
|
|
83
|
+
const cols = index.columns.map((column) => quote(column)).join(', ');
|
|
84
|
+
lines.push(` { name: ${quote(index.name)}, columns: [${cols}], tokenize: ${quote(index.tokenize)} },`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(' ],');
|
|
87
|
+
}
|
|
80
88
|
lines.push(' },');
|
|
81
89
|
}
|
|
82
90
|
lines.push(' ],');
|
package/dist/generate.js
CHANGED
|
@@ -50,6 +50,14 @@ function buildTable(manifestTable, parsed) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
const columns = applyEncryption(parsed, scopes, manifestTable.encryptedColumns);
|
|
53
|
+
for (const ftsIndex of parsed.ftsIndexes) {
|
|
54
|
+
for (const columnName of ftsIndex.columns) {
|
|
55
|
+
const column = columns.find((candidate) => candidate.name === columnName);
|
|
56
|
+
if (column?.encrypted === true) {
|
|
57
|
+
throw new TypegenError(MANIFEST_FILENAME, `table ${parsed.name}: FTS index ${JSON.stringify(ftsIndex.name)} cannot index encrypted column ${JSON.stringify(columnName)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
53
61
|
return {
|
|
54
62
|
name: parsed.name,
|
|
55
63
|
primaryKey: parsed.primaryKey,
|
|
@@ -58,6 +66,7 @@ function buildTable(manifestTable, parsed) {
|
|
|
58
66
|
// Indexes flow through from the migration parser (already validated:
|
|
59
67
|
// columns exist, names unique per schema) in declaration order.
|
|
60
68
|
indexes: parsed.indexes,
|
|
69
|
+
ftsIndexes: parsed.ftsIndexes,
|
|
61
70
|
extensions: canonicalizeExtensions(manifestTable.extensions),
|
|
62
71
|
};
|
|
63
72
|
}
|
|
@@ -256,10 +265,16 @@ export function makeQueryDb(ir) {
|
|
|
256
265
|
try {
|
|
257
266
|
const columnNames = stmt.columnNames;
|
|
258
267
|
// Execute once against the empty DB to populate decltype; result is [].
|
|
259
|
-
stmt.all();
|
|
260
|
-
const declaredTypes = stmt.declaredTypes;
|
|
261
268
|
const paramsCount = stmt
|
|
262
269
|
.paramsCount;
|
|
270
|
+
// An unbound FTS5 MATCH parameter is treated as the empty query and
|
|
271
|
+
// fails parsing. A numeric probe is valid for MATCH and LIMIT alike,
|
|
272
|
+
// while the empty database still guarantees no result rows.
|
|
273
|
+
const probe = /\bMATCH\b/i.test(sql)
|
|
274
|
+
? Array.from({ length: paramsCount }, () => 1)
|
|
275
|
+
: [];
|
|
276
|
+
stmt.all(...probe);
|
|
277
|
+
const declaredTypes = stmt.declaredTypes;
|
|
263
278
|
return { columnNames, declaredTypes, paramsCount };
|
|
264
279
|
}
|
|
265
280
|
finally {
|
package/dist/ir.d.ts
CHANGED
|
@@ -37,6 +37,14 @@ export interface IrIndex {
|
|
|
37
37
|
readonly columns: readonly string[];
|
|
38
38
|
readonly unique: boolean;
|
|
39
39
|
}
|
|
40
|
+
/** One client-local contentful FTS5 projection. It is owned by the
|
|
41
|
+
* synced table in whose `ftsIndexes` array it appears and never enters the
|
|
42
|
+
* row codec, server schema, scopes, or mutation surface. */
|
|
43
|
+
export interface IrFtsIndex {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly columns: readonly string[];
|
|
46
|
+
readonly tokenize: string;
|
|
47
|
+
}
|
|
40
48
|
export interface IrTable {
|
|
41
49
|
readonly name: string;
|
|
42
50
|
readonly primaryKey: string;
|
|
@@ -46,6 +54,8 @@ export interface IrTable {
|
|
|
46
54
|
/** Local secondary indexes (§migration subset), in declaration order.
|
|
47
55
|
* Additive under irVersion 1: absent/empty for tables that declare none. */
|
|
48
56
|
readonly indexes: readonly IrIndex[];
|
|
57
|
+
/** Client-local FTS5 projections, in migration declaration order. */
|
|
58
|
+
readonly ftsIndexes: readonly IrFtsIndex[];
|
|
49
59
|
/** Reserved per-table hook slot (WP-49); empty object for now. */
|
|
50
60
|
readonly extensions: Readonly<Record<string, unknown>>;
|
|
51
61
|
}
|
package/dist/ir.js
CHANGED
|
@@ -74,6 +74,15 @@ export function serializeIr(ir) {
|
|
|
74
74
|
})),
|
|
75
75
|
}
|
|
76
76
|
: {}),
|
|
77
|
+
...(table.ftsIndexes.length > 0
|
|
78
|
+
? {
|
|
79
|
+
ftsIndexes: table.ftsIndexes.map((index) => ({
|
|
80
|
+
name: index.name,
|
|
81
|
+
columns: index.columns,
|
|
82
|
+
tokenize: index.tokenize,
|
|
83
|
+
})),
|
|
84
|
+
}
|
|
85
|
+
: {}),
|
|
77
86
|
extensions: canonicalizeExtensions(table.extensions),
|
|
78
87
|
})),
|
|
79
88
|
subscriptions: ir.subscriptions.map((sub) => ({
|
package/dist/query.js
CHANGED
|
@@ -439,7 +439,6 @@ export function toPositionalSql(sql) {
|
|
|
439
439
|
const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
|
|
440
440
|
// `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
|
|
441
441
|
// tables that exist in the IR; SQLite has already proven every reference.
|
|
442
|
-
const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`, 'gi');
|
|
443
442
|
const RESERVED_ALIAS = new Set([
|
|
444
443
|
'on',
|
|
445
444
|
'where',
|
|
@@ -455,9 +454,14 @@ const RESERVED_ALIAS = new Set([
|
|
|
455
454
|
'limit',
|
|
456
455
|
'having',
|
|
457
456
|
]);
|
|
457
|
+
const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
|
|
458
|
+
const TABLE_REF_RE = new RegExp(`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`, 'gi');
|
|
458
459
|
export function scanTableRefs(sql, ir) {
|
|
459
460
|
const cleaned = stripCommentsAndStrings(sql);
|
|
460
|
-
const known = new
|
|
461
|
+
const known = new Set(ir.tables.flatMap((table) => [
|
|
462
|
+
table.name,
|
|
463
|
+
...table.ftsIndexes.map((index) => index.name),
|
|
464
|
+
]));
|
|
461
465
|
const refs = [];
|
|
462
466
|
for (const m of cleaned.matchAll(TABLE_REF_RE)) {
|
|
463
467
|
const table = m[1];
|
|
@@ -614,6 +618,9 @@ function inferParamType(paramName, sql, refs, ir) {
|
|
|
614
618
|
const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'i');
|
|
615
619
|
if (like.test(cleaned))
|
|
616
620
|
return 'string';
|
|
621
|
+
const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'i');
|
|
622
|
+
if (match.test(cleaned))
|
|
623
|
+
return 'string';
|
|
617
624
|
return null;
|
|
618
625
|
}
|
|
619
626
|
/**
|
|
@@ -665,6 +672,9 @@ export function inferParamTypeEvidence(paramName, sql, refs, ir) {
|
|
|
665
672
|
const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'gi');
|
|
666
673
|
if (like.test(cleaned))
|
|
667
674
|
evidence.push('string');
|
|
675
|
+
const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'gi');
|
|
676
|
+
if (match.test(cleaned))
|
|
677
|
+
evidence.push('string');
|
|
668
678
|
return evidence;
|
|
669
679
|
}
|
|
670
680
|
// -- fallback column typing (computed expressions) ----------------------------
|
|
@@ -711,7 +721,10 @@ function inferReactiveMetadata(sql, refs, ir, params, columns) {
|
|
|
711
721
|
const byParam = new Map(params.map((param) => [param.name, param]));
|
|
712
722
|
const dependencies = [];
|
|
713
723
|
const coverage = [];
|
|
714
|
-
|
|
724
|
+
const ftsOwner = new Map(ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
|
|
725
|
+
for (const tableName of [
|
|
726
|
+
...new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table)),
|
|
727
|
+
].sort()) {
|
|
715
728
|
const table = ir.tables.find((candidate) => candidate.name === tableName);
|
|
716
729
|
const tableRefs = refs.filter((ref) => ref.table === tableName);
|
|
717
730
|
const scopes = [];
|
|
@@ -821,6 +834,10 @@ export function synthesizeDdl(ir) {
|
|
|
821
834
|
const unique = index.unique ? 'UNIQUE ' : '';
|
|
822
835
|
lines.push(`CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`);
|
|
823
836
|
}
|
|
837
|
+
for (const index of table.ftsIndexes) {
|
|
838
|
+
const tokenize = index.tokenize.replaceAll("'", "''");
|
|
839
|
+
lines.push(`CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`);
|
|
840
|
+
}
|
|
824
841
|
}
|
|
825
842
|
return lines.join('\n');
|
|
826
843
|
}
|
|
@@ -864,7 +881,8 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
864
881
|
};
|
|
865
882
|
let described = analyze(sourceSql);
|
|
866
883
|
const refs = scanTableRefs(sourceSql, ir);
|
|
867
|
-
const
|
|
884
|
+
const ftsOwner = new Map(ir.tables.flatMap((table) => table.ftsIndexes.map((index) => [index.name, table.name])));
|
|
885
|
+
const tableSet = new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table));
|
|
868
886
|
const tables = [...tableSet].sort();
|
|
869
887
|
if (tables.length === 0) {
|
|
870
888
|
throw new TypegenError(file, 'query reads no synced table — every named query must read at least one IR table (for exact invalidation)');
|
package/dist/sql.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { IrColumn, IrIndex } from './ir.js';
|
|
1
|
+
import type { IrColumn, IrFtsIndex, IrIndex } from './ir.js';
|
|
2
2
|
export interface ParsedTable {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
primaryKey: string;
|
|
5
5
|
readonly columns: IrColumn[];
|
|
6
6
|
/** Local secondary indexes, in declaration order (CREATE INDEX subset). */
|
|
7
7
|
readonly indexes: IrIndex[];
|
|
8
|
+
/** Client-local contentful FTS5 projections. */
|
|
9
|
+
readonly ftsIndexes: IrFtsIndex[];
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Apply one migration file's SQL to the accumulated table map. Columns
|
package/dist/sql.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - `ALTER TABLE name ADD [COLUMN] coldef`
|
|
9
9
|
* - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`
|
|
10
10
|
* - `DROP TABLE [IF EXISTS] name`
|
|
11
|
+
* - `CREATE VIRTUAL TABLE name USING fts5(cols…, content=table,
|
|
12
|
+
* [tokenize='allowlisted tokenizer'])` (RFC 0005 local projection)
|
|
11
13
|
* - column defs: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL]
|
|
12
14
|
* [DEFAULT literal]`
|
|
13
15
|
* - `--` and C-style comments
|
|
@@ -115,7 +117,7 @@ function tokenizeStatements(sql, source) {
|
|
|
115
117
|
current.push({ kind: 'number', text: sql.slice(i, end) });
|
|
116
118
|
i = end;
|
|
117
119
|
}
|
|
118
|
-
else if (ch === '(' || ch === ')' || ch === ',') {
|
|
120
|
+
else if (ch === '(' || ch === ')' || ch === ',' || ch === '=') {
|
|
119
121
|
current.push({ kind: 'punct', text: ch });
|
|
120
122
|
i += 1;
|
|
121
123
|
}
|
|
@@ -248,6 +250,10 @@ function parseColumnDef(cursor, allowPrimaryKey) {
|
|
|
248
250
|
* `CREATE UNIQUE INDEX`. Anything else is a hard error naming the construct.
|
|
249
251
|
*/
|
|
250
252
|
function parseCreate(cursor, tables, droppedTables, source) {
|
|
253
|
+
if (cursor.eatWord('VIRTUAL')) {
|
|
254
|
+
parseCreateVirtualTable(cursor, tables);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
251
257
|
if (cursor.eatWord('TABLE')) {
|
|
252
258
|
parseCreateTable(cursor, tables, droppedTables, source);
|
|
253
259
|
return;
|
|
@@ -262,7 +268,109 @@ function parseCreate(cursor, tables, droppedTables, source) {
|
|
|
262
268
|
return;
|
|
263
269
|
}
|
|
264
270
|
const token = cursor.peek();
|
|
265
|
-
cursor.fail(`unsupported CREATE statement (only CREATE TABLE
|
|
271
|
+
cursor.fail(`unsupported CREATE statement (only CREATE TABLE, CREATE [UNIQUE] INDEX, and CREATE VIRTUAL TABLE … USING fts5), found ${JSON.stringify(token?.text ?? '')}`);
|
|
272
|
+
}
|
|
273
|
+
const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
274
|
+
'unicode61',
|
|
275
|
+
'unicode61 remove_diacritics 0',
|
|
276
|
+
'unicode61 remove_diacritics 1',
|
|
277
|
+
'unicode61 remove_diacritics 2',
|
|
278
|
+
'porter unicode61',
|
|
279
|
+
'trigram',
|
|
280
|
+
]);
|
|
281
|
+
/** Parse the deliberately narrow migration-subset v2 FTS5 form documented in
|
|
282
|
+
* RFC 0005. The virtual table is attached to its owning synced
|
|
283
|
+
* table and is not itself a synced table. */
|
|
284
|
+
function parseCreateVirtualTable(cursor, tables) {
|
|
285
|
+
cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
|
|
286
|
+
if (cursor.eatWord('IF')) {
|
|
287
|
+
cursor.expectWord('NOT', 'after IF');
|
|
288
|
+
cursor.expectWord('EXISTS', 'after IF NOT');
|
|
289
|
+
}
|
|
290
|
+
const name = cursor.identifier('an FTS virtual-table name');
|
|
291
|
+
if (tables.has(name)) {
|
|
292
|
+
cursor.fail(`FTS virtual table ${name} conflicts with a synced table`);
|
|
293
|
+
}
|
|
294
|
+
for (const table of tables.values()) {
|
|
295
|
+
if (table.indexes.some((index) => index.name === name) ||
|
|
296
|
+
table.ftsIndexes.some((index) => index.name === name)) {
|
|
297
|
+
cursor.fail(`FTS virtual table ${name} is created twice or conflicts with an index`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
cursor.expectWord('USING', `after CREATE VIRTUAL TABLE ${name}`);
|
|
301
|
+
cursor.expectWord('FTS5', `as the module for ${name}`);
|
|
302
|
+
cursor.expectPunct('(', `after USING fts5 for ${name}`);
|
|
303
|
+
const columns = [];
|
|
304
|
+
let content;
|
|
305
|
+
let tokenize = 'unicode61';
|
|
306
|
+
let tokenizeDeclared = false;
|
|
307
|
+
let sawOption = false;
|
|
308
|
+
for (;;) {
|
|
309
|
+
const field = cursor.identifier(`an FTS5 column or option for ${name}`);
|
|
310
|
+
if (cursor.peek()?.text === '=') {
|
|
311
|
+
sawOption = true;
|
|
312
|
+
cursor.next();
|
|
313
|
+
const value = cursor.next();
|
|
314
|
+
if (value.kind !== 'word' && value.kind !== 'string') {
|
|
315
|
+
cursor.fail(`FTS virtual table ${name}: ${field} needs a literal value`);
|
|
316
|
+
}
|
|
317
|
+
if (field.toUpperCase() === 'CONTENT') {
|
|
318
|
+
if (content !== undefined) {
|
|
319
|
+
cursor.fail(`FTS virtual table ${name}: content is declared twice`);
|
|
320
|
+
}
|
|
321
|
+
content = value.text;
|
|
322
|
+
}
|
|
323
|
+
else if (field.toUpperCase() === 'TOKENIZE') {
|
|
324
|
+
if (tokenizeDeclared) {
|
|
325
|
+
cursor.fail(`FTS virtual table ${name}: tokenize is declared twice`);
|
|
326
|
+
}
|
|
327
|
+
if (!ALLOWED_FTS_TOKENIZERS.has(value.text)) {
|
|
328
|
+
cursor.fail(`FTS virtual table ${name}: tokenizer ${JSON.stringify(value.text)} is not allowlisted`);
|
|
329
|
+
}
|
|
330
|
+
tokenize = value.text;
|
|
331
|
+
tokenizeDeclared = true;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
cursor.fail(`FTS virtual table ${name}: unsupported FTS5 option ${JSON.stringify(field)}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
if (sawOption) {
|
|
339
|
+
cursor.fail(`FTS virtual table ${name}: indexed columns must precede FTS5 options`);
|
|
340
|
+
}
|
|
341
|
+
if (columns.includes(field)) {
|
|
342
|
+
cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(field)} appears twice`);
|
|
343
|
+
}
|
|
344
|
+
columns.push(field);
|
|
345
|
+
}
|
|
346
|
+
const separator = cursor.next();
|
|
347
|
+
if (separator.kind === 'punct' && separator.text === ',')
|
|
348
|
+
continue;
|
|
349
|
+
if (separator.kind === 'punct' && separator.text === ')')
|
|
350
|
+
break;
|
|
351
|
+
cursor.fail(`FTS virtual table ${name}: expected "," or ")", found ${JSON.stringify(separator.text)}`);
|
|
352
|
+
}
|
|
353
|
+
cursor.expectEnd();
|
|
354
|
+
if (content === undefined) {
|
|
355
|
+
cursor.fail(`FTS virtual table ${name}: content = synced_table is required`);
|
|
356
|
+
}
|
|
357
|
+
const table = tables.get(content);
|
|
358
|
+
if (table === undefined) {
|
|
359
|
+
cursor.fail(`FTS virtual table ${name}: content table ${JSON.stringify(content)} does not exist at this point`);
|
|
360
|
+
}
|
|
361
|
+
if (columns.length === 0 || columns.length > 32) {
|
|
362
|
+
cursor.fail(`FTS virtual table ${name}: between 1 and 32 columns are required`);
|
|
363
|
+
}
|
|
364
|
+
for (const columnName of columns) {
|
|
365
|
+
const column = table.columns.find((candidate) => candidate.name === columnName);
|
|
366
|
+
if (column === undefined) {
|
|
367
|
+
cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(columnName)} does not exist on table ${content}`);
|
|
368
|
+
}
|
|
369
|
+
if (column.type !== 'string') {
|
|
370
|
+
cursor.fail(`FTS virtual table ${name}: column ${JSON.stringify(columnName)} must have TEXT/string type`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
table.ftsIndexes.push({ name, columns, tokenize });
|
|
266
374
|
}
|
|
267
375
|
function parseCreateTable(cursor, tables, droppedTables, source) {
|
|
268
376
|
if (cursor.eatWord('IF')) {
|
|
@@ -276,6 +384,12 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
|
|
|
276
384
|
if (tables.has(name)) {
|
|
277
385
|
cursor.fail(`table ${name} is created twice`);
|
|
278
386
|
}
|
|
387
|
+
for (const table of tables.values()) {
|
|
388
|
+
if (table.indexes.some((index) => index.name === name) ||
|
|
389
|
+
table.ftsIndexes.some((index) => index.name === name)) {
|
|
390
|
+
cursor.fail(`table ${name} conflicts with an index or FTS virtual table`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
279
393
|
cursor.expectPunct('(', `after CREATE TABLE ${name}`);
|
|
280
394
|
const columns = [];
|
|
281
395
|
const primaryKeys = [];
|
|
@@ -335,7 +449,13 @@ function parseCreateTable(cursor, tables, droppedTables, source) {
|
|
|
335
449
|
throw new TypegenError(source, `table ${name}: primary key ${JSON.stringify(primaryKey)} is not a column`);
|
|
336
450
|
}
|
|
337
451
|
const finalColumns = columns.map((c) => c.name === primaryKey ? { ...c, nullable: false } : c);
|
|
338
|
-
tables.set(name, {
|
|
452
|
+
tables.set(name, {
|
|
453
|
+
name,
|
|
454
|
+
primaryKey,
|
|
455
|
+
columns: finalColumns,
|
|
456
|
+
indexes: [],
|
|
457
|
+
ftsIndexes: [],
|
|
458
|
+
});
|
|
339
459
|
}
|
|
340
460
|
/**
|
|
341
461
|
* `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`.
|
|
@@ -352,9 +472,13 @@ function parseCreateIndex(cursor, tables, unique) {
|
|
|
352
472
|
cursor.expectWord('EXISTS', 'after IF NOT');
|
|
353
473
|
}
|
|
354
474
|
const name = cursor.identifier('an index name');
|
|
475
|
+
if (tables.has(name)) {
|
|
476
|
+
cursor.fail(`index ${name} conflicts with a synced table`);
|
|
477
|
+
}
|
|
355
478
|
for (const table of tables.values()) {
|
|
356
|
-
if (table.indexes.some((i) => i.name === name)
|
|
357
|
-
|
|
479
|
+
if (table.indexes.some((i) => i.name === name) ||
|
|
480
|
+
table.ftsIndexes.some((i) => i.name === name)) {
|
|
481
|
+
cursor.fail(`index ${name} is created twice or conflicts with an FTS virtual table`);
|
|
358
482
|
}
|
|
359
483
|
}
|
|
360
484
|
cursor.expectWord('ON', `after CREATE INDEX ${name}`);
|
|
@@ -459,7 +583,7 @@ export function applyMigrationSql(tables, sql, source, droppedTables = new Set()
|
|
|
459
583
|
parseDropTable(cursor, tables, droppedTables);
|
|
460
584
|
}
|
|
461
585
|
else {
|
|
462
|
-
throw new TypegenError(source, `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, ALTER TABLE … ADD COLUMN, and DROP TABLE)`);
|
|
586
|
+
throw new TypegenError(source, `unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, CREATE VIRTUAL TABLE … USING fts5, ALTER TABLE … ADD COLUMN, and DROP TABLE)`);
|
|
463
587
|
}
|
|
464
588
|
}
|
|
465
589
|
}
|
package/dist/syql-validator.js
CHANGED
|
@@ -121,6 +121,16 @@ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
|
|
|
121
121
|
'jsonb_replace',
|
|
122
122
|
'jsonb_set',
|
|
123
123
|
]);
|
|
124
|
+
/**
|
|
125
|
+
* Deterministic FTS5 auxiliary functions available only when the query reads
|
|
126
|
+
* a schema-declared FTS projection. FTS5 is an explicit Syncular client
|
|
127
|
+
* requirement in that case, not an arbitrary host extension.
|
|
128
|
+
*/
|
|
129
|
+
const SQLITE_FTS5_AUXILIARY_FUNCTIONS = new Set([
|
|
130
|
+
'bm25',
|
|
131
|
+
'highlight',
|
|
132
|
+
'snippet',
|
|
133
|
+
]);
|
|
124
134
|
const SQL_PAREN_KEYWORDS = new Set([
|
|
125
135
|
'and',
|
|
126
136
|
'as',
|
|
@@ -248,8 +258,8 @@ class Validator {
|
|
|
248
258
|
const activeStructure = this.#inspect(activeSql, location);
|
|
249
259
|
this.#validateStatementShape(activeSql, activeStructure, logical.declaration, location);
|
|
250
260
|
this.#validateDeterminism(activeSql, logical.declaration.statement.span);
|
|
251
|
-
this.#validatePortableProfile(activeSql, logical.declaration.statement.span);
|
|
252
261
|
const refs = scanTableRefs(activeSql, this.#ir);
|
|
262
|
+
this.#validatePortableProfile(activeSql, logical.declaration.statement.span, refs);
|
|
253
263
|
const bindSymbols = this.#bindSymbols(logical.declaration);
|
|
254
264
|
const bindTypes = this.#resolveBindTypes(logical, activeSql, refs, bindSymbols);
|
|
255
265
|
const sort = this.#validateSort(logical.declaration, activeStructure, location);
|
|
@@ -512,8 +522,11 @@ class Validator {
|
|
|
512
522
|
}
|
|
513
523
|
}
|
|
514
524
|
}
|
|
515
|
-
#validatePortableProfile(sql, span) {
|
|
525
|
+
#validatePortableProfile(sql, span, refs) {
|
|
516
526
|
const tokens = significant(lexSyqlSqlSource(span.file, sql));
|
|
527
|
+
const declaredFtsTables = new Set(this.#ir.tables.flatMap((table) => table.ftsIndexes.map((index) => index.name)));
|
|
528
|
+
const queryRefs = refs ?? scanTableRefs(sql, this.#ir);
|
|
529
|
+
const readsDeclaredFts = queryRefs.some((ref) => declaredFtsTables.has(ref.table));
|
|
517
530
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
518
531
|
const token = tokens[index];
|
|
519
532
|
const lower = tokenLower(token);
|
|
@@ -537,8 +550,10 @@ class Validator {
|
|
|
537
550
|
tokens[closeIndex + 2]?.text === '(') {
|
|
538
551
|
continue;
|
|
539
552
|
}
|
|
540
|
-
|
|
541
|
-
|
|
553
|
+
const allowedFtsAuxiliary = readsDeclaredFts && SQLITE_FTS5_AUXILIARY_FUNCTIONS.has(lower ?? '');
|
|
554
|
+
if (!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '') &&
|
|
555
|
+
!allowedFtsAuxiliary) {
|
|
556
|
+
this.#fail('SYQL6002_INVALID_SQL', token.span, `${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile or an auxiliary function of a referenced schema-declared FTS5 projection`);
|
|
542
557
|
}
|
|
543
558
|
const argumentCount = functionArgumentCount(tokens, index + 1);
|
|
544
559
|
if (lower === 'iif' && argumentCount !== 3) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Syncular schema-to-TypeScript type generator and the `syncular` CLI",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"!dist/**/*.test.d.ts"
|
|
49
49
|
],
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@syncular/core": "0.
|
|
52
|
-
"@syncular/server": "0.
|
|
51
|
+
"@syncular/core": "0.15.1",
|
|
52
|
+
"@syncular/server": "0.15.1"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/emit-dart.ts
CHANGED
|
@@ -91,6 +91,16 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
|
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
93
|
lines.push(`${i} ],`);
|
|
94
|
+
if (table.ftsIndexes.length > 0) {
|
|
95
|
+
lines.push(`${i} 'ftsIndexes': [`);
|
|
96
|
+
for (const index of table.ftsIndexes) {
|
|
97
|
+
const columns = index.columns.map((column) => quote(column)).join(', ');
|
|
98
|
+
lines.push(
|
|
99
|
+
`${i} {'name': ${quote(index.name)}, 'columns': [${columns}], 'tokenize': ${quote(index.tokenize)}},`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
lines.push(`${i} ],`);
|
|
103
|
+
}
|
|
94
104
|
lines.push(`${i} },`);
|
|
95
105
|
}
|
|
96
106
|
lines.push(`${i} ],`);
|
package/src/emit-kotlin.ts
CHANGED
|
@@ -92,6 +92,18 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
lines.push(`${i} )),`);
|
|
95
|
+
if (table.ftsIndexes.length > 0) {
|
|
96
|
+
lines.push(`${i} "ftsIndexes" to JsonValue.arr(listOf(`);
|
|
97
|
+
for (const index of table.ftsIndexes) {
|
|
98
|
+
const columns = index.columns
|
|
99
|
+
.map((column) => `JsonValue.of(${quote(column)})`)
|
|
100
|
+
.join(', ');
|
|
101
|
+
lines.push(
|
|
102
|
+
`${i} JsonValue.obj("name" to JsonValue.of(${quote(index.name)}), "columns" to JsonValue.arr(listOf(${columns})), "tokenize" to JsonValue.of(${quote(index.tokenize)})),`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
lines.push(`${i} )),`);
|
|
106
|
+
}
|
|
95
107
|
lines.push(`${i} ),`);
|
|
96
108
|
}
|
|
97
109
|
lines.push(`${i} )),`);
|
package/src/emit-swift.ts
CHANGED
|
@@ -96,6 +96,18 @@ function emitSchemaValue(ir: IrDocument, indent: string): string[] {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
lines.push(`${i} ]),`);
|
|
99
|
+
if (table.ftsIndexes.length > 0) {
|
|
100
|
+
lines.push(`${i} "ftsIndexes": .array([`);
|
|
101
|
+
for (const index of table.ftsIndexes) {
|
|
102
|
+
const columns = index.columns
|
|
103
|
+
.map((column) => `.string(${quote(column)})`)
|
|
104
|
+
.join(', ');
|
|
105
|
+
lines.push(
|
|
106
|
+
`${i} .object(["name": .string(${quote(index.name)}), "columns": .array([${columns}]), "tokenize": .string(${quote(index.tokenize)})]),`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
lines.push(`${i} ]),`);
|
|
110
|
+
}
|
|
99
111
|
lines.push(`${i} ]),`);
|
|
100
112
|
}
|
|
101
113
|
lines.push(`${i} ]),`);
|
package/src/emit.ts
CHANGED
|
@@ -108,6 +108,16 @@ function emitSchema(ir: IrDocument): string {
|
|
|
108
108
|
}
|
|
109
109
|
lines.push(' ],');
|
|
110
110
|
}
|
|
111
|
+
if (table.ftsIndexes.length > 0) {
|
|
112
|
+
lines.push(' ftsIndexes: [');
|
|
113
|
+
for (const index of table.ftsIndexes) {
|
|
114
|
+
const cols = index.columns.map((column) => quote(column)).join(', ');
|
|
115
|
+
lines.push(
|
|
116
|
+
` { name: ${quote(index.name)}, columns: [${cols}], tokenize: ${quote(index.tokenize)} },`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
lines.push(' ],');
|
|
120
|
+
}
|
|
111
121
|
lines.push(' },');
|
|
112
122
|
}
|
|
113
123
|
lines.push(' ],');
|
package/src/generate.ts
CHANGED
|
@@ -113,6 +113,17 @@ function buildTable(
|
|
|
113
113
|
scopes,
|
|
114
114
|
manifestTable.encryptedColumns,
|
|
115
115
|
);
|
|
116
|
+
for (const ftsIndex of parsed.ftsIndexes) {
|
|
117
|
+
for (const columnName of ftsIndex.columns) {
|
|
118
|
+
const column = columns.find((candidate) => candidate.name === columnName);
|
|
119
|
+
if (column?.encrypted === true) {
|
|
120
|
+
throw new TypegenError(
|
|
121
|
+
MANIFEST_FILENAME,
|
|
122
|
+
`table ${parsed.name}: FTS index ${JSON.stringify(ftsIndex.name)} cannot index encrypted column ${JSON.stringify(columnName)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
116
127
|
return {
|
|
117
128
|
name: parsed.name,
|
|
118
129
|
primaryKey: parsed.primaryKey,
|
|
@@ -121,6 +132,7 @@ function buildTable(
|
|
|
121
132
|
// Indexes flow through from the migration parser (already validated:
|
|
122
133
|
// columns exist, names unique per schema) in declaration order.
|
|
123
134
|
indexes: parsed.indexes,
|
|
135
|
+
ftsIndexes: parsed.ftsIndexes,
|
|
124
136
|
extensions: canonicalizeExtensions(manifestTable.extensions) as Record<
|
|
125
137
|
string,
|
|
126
138
|
unknown
|
|
@@ -410,12 +422,22 @@ export function makeQueryDb(ir: IrDocument): {
|
|
|
410
422
|
try {
|
|
411
423
|
const columnNames = stmt.columnNames;
|
|
412
424
|
// Execute once against the empty DB to populate decltype; result is [].
|
|
413
|
-
(stmt as unknown as {
|
|
425
|
+
const paramsCount = (stmt as unknown as { paramsCount: number })
|
|
426
|
+
.paramsCount;
|
|
427
|
+
// An unbound FTS5 MATCH parameter is treated as the empty query and
|
|
428
|
+
// fails parsing. A numeric probe is valid for MATCH and LIMIT alike,
|
|
429
|
+
// while the empty database still guarantees no result rows.
|
|
430
|
+
const probe = /\bMATCH\b/i.test(sql)
|
|
431
|
+
? Array.from({ length: paramsCount }, () => 1)
|
|
432
|
+
: [];
|
|
433
|
+
(
|
|
434
|
+
stmt as unknown as {
|
|
435
|
+
all: (...params: readonly number[]) => unknown[];
|
|
436
|
+
}
|
|
437
|
+
).all(...probe);
|
|
414
438
|
const declaredTypes = (
|
|
415
439
|
stmt as unknown as { declaredTypes: (string | null)[] }
|
|
416
440
|
).declaredTypes;
|
|
417
|
-
const paramsCount = (stmt as unknown as { paramsCount: number })
|
|
418
|
-
.paramsCount;
|
|
419
441
|
return { columnNames, declaredTypes, paramsCount };
|
|
420
442
|
} finally {
|
|
421
443
|
stmt.finalize();
|
package/src/ir.ts
CHANGED
|
@@ -64,6 +64,15 @@ export interface IrIndex {
|
|
|
64
64
|
readonly unique: boolean;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** One client-local contentful FTS5 projection. It is owned by the
|
|
68
|
+
* synced table in whose `ftsIndexes` array it appears and never enters the
|
|
69
|
+
* row codec, server schema, scopes, or mutation surface. */
|
|
70
|
+
export interface IrFtsIndex {
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly columns: readonly string[];
|
|
73
|
+
readonly tokenize: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
export interface IrTable {
|
|
68
77
|
readonly name: string;
|
|
69
78
|
readonly primaryKey: string;
|
|
@@ -73,6 +82,8 @@ export interface IrTable {
|
|
|
73
82
|
/** Local secondary indexes (§migration subset), in declaration order.
|
|
74
83
|
* Additive under irVersion 1: absent/empty for tables that declare none. */
|
|
75
84
|
readonly indexes: readonly IrIndex[];
|
|
85
|
+
/** Client-local FTS5 projections, in migration declaration order. */
|
|
86
|
+
readonly ftsIndexes: readonly IrFtsIndex[];
|
|
76
87
|
/** Reserved per-table hook slot (WP-49); empty object for now. */
|
|
77
88
|
readonly extensions: Readonly<Record<string, unknown>>;
|
|
78
89
|
}
|
|
@@ -176,6 +187,15 @@ export function serializeIr(ir: IrDocument): string {
|
|
|
176
187
|
})),
|
|
177
188
|
}
|
|
178
189
|
: {}),
|
|
190
|
+
...(table.ftsIndexes.length > 0
|
|
191
|
+
? {
|
|
192
|
+
ftsIndexes: table.ftsIndexes.map((index) => ({
|
|
193
|
+
name: index.name,
|
|
194
|
+
columns: index.columns,
|
|
195
|
+
tokenize: index.tokenize,
|
|
196
|
+
})),
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
179
199
|
extensions: canonicalizeExtensions(table.extensions),
|
|
180
200
|
})),
|
|
181
201
|
subscriptions: ir.subscriptions.map((sub) => ({
|
package/src/query.ts
CHANGED
|
@@ -696,11 +696,6 @@ export interface TableRef {
|
|
|
696
696
|
const IDENT = '[A-Za-z_][A-Za-z0-9_]*';
|
|
697
697
|
// `FROM tbl [AS] a`, `JOIN tbl [AS] a`. We only need the referenced base
|
|
698
698
|
// tables that exist in the IR; SQLite has already proven every reference.
|
|
699
|
-
const TABLE_REF_RE = new RegExp(
|
|
700
|
-
`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?(${IDENT}))?`,
|
|
701
|
-
'gi',
|
|
702
|
-
);
|
|
703
|
-
|
|
704
699
|
const RESERVED_ALIAS = new Set([
|
|
705
700
|
'on',
|
|
706
701
|
'where',
|
|
@@ -716,10 +711,20 @@ const RESERVED_ALIAS = new Set([
|
|
|
716
711
|
'limit',
|
|
717
712
|
'having',
|
|
718
713
|
]);
|
|
714
|
+
const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
|
|
715
|
+
const TABLE_REF_RE = new RegExp(
|
|
716
|
+
`\\b(?:FROM|JOIN)\\s+(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`,
|
|
717
|
+
'gi',
|
|
718
|
+
);
|
|
719
719
|
|
|
720
720
|
export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
721
721
|
const cleaned = stripCommentsAndStrings(sql);
|
|
722
|
-
const known = new
|
|
722
|
+
const known = new Set(
|
|
723
|
+
ir.tables.flatMap((table) => [
|
|
724
|
+
table.name,
|
|
725
|
+
...table.ftsIndexes.map((index) => index.name),
|
|
726
|
+
]),
|
|
727
|
+
);
|
|
723
728
|
const refs: TableRef[] = [];
|
|
724
729
|
for (const m of cleaned.matchAll(TABLE_REF_RE)) {
|
|
725
730
|
const table = m[1] as string;
|
|
@@ -915,6 +920,8 @@ function inferParamType(
|
|
|
915
920
|
// shape).
|
|
916
921
|
const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'i');
|
|
917
922
|
if (like.test(cleaned)) return 'string';
|
|
923
|
+
const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'i');
|
|
924
|
+
if (match.test(cleaned)) return 'string';
|
|
918
925
|
return null;
|
|
919
926
|
}
|
|
920
927
|
|
|
@@ -987,6 +994,8 @@ export function inferParamTypeEvidence(
|
|
|
987
994
|
}
|
|
988
995
|
const like = new RegExp(`\\bLIKE\\b[^()]*:${paramName}\\b`, 'gi');
|
|
989
996
|
if (like.test(cleaned)) evidence.push('string');
|
|
997
|
+
const match = new RegExp(`\\bMATCH\\b\\s*:${paramName}\\b`, 'gi');
|
|
998
|
+
if (match.test(cleaned)) evidence.push('string');
|
|
990
999
|
return evidence;
|
|
991
1000
|
}
|
|
992
1001
|
|
|
@@ -1054,8 +1063,15 @@ function inferReactiveMetadata(
|
|
|
1054
1063
|
scopes: QueryScopeBinding[];
|
|
1055
1064
|
}> = [];
|
|
1056
1065
|
const coverage: QueryCoverageBinding[] = [];
|
|
1066
|
+
const ftsOwner = new Map(
|
|
1067
|
+
ir.tables.flatMap((table) =>
|
|
1068
|
+
table.ftsIndexes.map((index) => [index.name, table.name] as const),
|
|
1069
|
+
),
|
|
1070
|
+
);
|
|
1057
1071
|
|
|
1058
|
-
for (const tableName of [
|
|
1072
|
+
for (const tableName of [
|
|
1073
|
+
...new Set(refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table)),
|
|
1074
|
+
].sort()) {
|
|
1059
1075
|
const table = ir.tables.find((candidate) => candidate.name === tableName);
|
|
1060
1076
|
const tableRefs = refs.filter((ref) => ref.table === tableName);
|
|
1061
1077
|
const scopes: QueryScopeBinding[] = [];
|
|
@@ -1192,6 +1208,12 @@ export function synthesizeDdl(ir: IrDocument): string {
|
|
|
1192
1208
|
`CREATE ${unique}INDEX ${index.name} ON ${table.name} (${index.columns.join(', ')});`,
|
|
1193
1209
|
);
|
|
1194
1210
|
}
|
|
1211
|
+
for (const index of table.ftsIndexes) {
|
|
1212
|
+
const tokenize = index.tokenize.replaceAll("'", "''");
|
|
1213
|
+
lines.push(
|
|
1214
|
+
`CREATE VIRTUAL TABLE ${index.name} USING fts5(_syncular_source_id UNINDEXED, ${index.columns.join(', ')}, tokenize='${tokenize}');`,
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1195
1217
|
}
|
|
1196
1218
|
return lines.join('\n');
|
|
1197
1219
|
}
|
|
@@ -1252,7 +1274,14 @@ export function analyzeStatement(
|
|
|
1252
1274
|
let described = analyze(sourceSql);
|
|
1253
1275
|
|
|
1254
1276
|
const refs = scanTableRefs(sourceSql, ir);
|
|
1255
|
-
const
|
|
1277
|
+
const ftsOwner = new Map(
|
|
1278
|
+
ir.tables.flatMap((table) =>
|
|
1279
|
+
table.ftsIndexes.map((index) => [index.name, table.name] as const),
|
|
1280
|
+
),
|
|
1281
|
+
);
|
|
1282
|
+
const tableSet = new Set(
|
|
1283
|
+
refs.map((ref) => ftsOwner.get(ref.table) ?? ref.table),
|
|
1284
|
+
);
|
|
1256
1285
|
const tables = [...tableSet].sort();
|
|
1257
1286
|
if (tables.length === 0) {
|
|
1258
1287
|
throw new TypegenError(
|
package/src/sql.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - `ALTER TABLE name ADD [COLUMN] coldef`
|
|
9
9
|
* - `CREATE [UNIQUE] INDEX [IF NOT EXISTS] name ON table (col [, col…])`
|
|
10
10
|
* - `DROP TABLE [IF EXISTS] name`
|
|
11
|
+
* - `CREATE VIRTUAL TABLE name USING fts5(cols…, content=table,
|
|
12
|
+
* [tokenize='allowlisted tokenizer'])` (RFC 0005 local projection)
|
|
11
13
|
* - column defs: `name TYPE [PRIMARY KEY] [NOT NULL] [NULL]
|
|
12
14
|
* [DEFAULT literal]`
|
|
13
15
|
* - `--` and C-style comments
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
* indexes — is a hard error naming the unsupported construct.
|
|
19
21
|
*/
|
|
20
22
|
import { TypegenError } from './errors';
|
|
21
|
-
import type { IrColumn, IrColumnType, IrIndex } from './ir';
|
|
23
|
+
import type { IrColumn, IrColumnType, IrFtsIndex, IrIndex } from './ir';
|
|
22
24
|
|
|
23
25
|
/** SQL type keyword → the §2.4 column types. Case-insensitive. */
|
|
24
26
|
const TYPE_MAP: Readonly<Record<string, IrColumnType>> = {
|
|
@@ -56,6 +58,8 @@ export interface ParsedTable {
|
|
|
56
58
|
readonly columns: IrColumn[];
|
|
57
59
|
/** Local secondary indexes, in declaration order (CREATE INDEX subset). */
|
|
58
60
|
readonly indexes: IrIndex[];
|
|
61
|
+
/** Client-local contentful FTS5 projections. */
|
|
62
|
+
readonly ftsIndexes: IrFtsIndex[];
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
interface Token {
|
|
@@ -122,7 +126,7 @@ function tokenizeStatements(sql: string, source: string): Token[][] {
|
|
|
122
126
|
while (end < sql.length && /[0-9.]/.test(sql[end] as string)) end += 1;
|
|
123
127
|
current.push({ kind: 'number', text: sql.slice(i, end) });
|
|
124
128
|
i = end;
|
|
125
|
-
} else if (ch === '(' || ch === ')' || ch === ',') {
|
|
129
|
+
} else if (ch === '(' || ch === ')' || ch === ',' || ch === '=') {
|
|
126
130
|
current.push({ kind: 'punct', text: ch });
|
|
127
131
|
i += 1;
|
|
128
132
|
} else {
|
|
@@ -294,6 +298,10 @@ function parseCreate(
|
|
|
294
298
|
droppedTables: ReadonlySet<string>,
|
|
295
299
|
source: string,
|
|
296
300
|
): void {
|
|
301
|
+
if (cursor.eatWord('VIRTUAL')) {
|
|
302
|
+
parseCreateVirtualTable(cursor, tables);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
297
305
|
if (cursor.eatWord('TABLE')) {
|
|
298
306
|
parseCreateTable(cursor, tables, droppedTables, source);
|
|
299
307
|
return;
|
|
@@ -309,10 +317,140 @@ function parseCreate(
|
|
|
309
317
|
}
|
|
310
318
|
const token = cursor.peek();
|
|
311
319
|
cursor.fail(
|
|
312
|
-
`unsupported CREATE statement (only CREATE TABLE
|
|
320
|
+
`unsupported CREATE statement (only CREATE TABLE, CREATE [UNIQUE] INDEX, and CREATE VIRTUAL TABLE … USING fts5), found ${JSON.stringify(token?.text ?? '')}`,
|
|
313
321
|
);
|
|
314
322
|
}
|
|
315
323
|
|
|
324
|
+
const ALLOWED_FTS_TOKENIZERS = new Set([
|
|
325
|
+
'unicode61',
|
|
326
|
+
'unicode61 remove_diacritics 0',
|
|
327
|
+
'unicode61 remove_diacritics 1',
|
|
328
|
+
'unicode61 remove_diacritics 2',
|
|
329
|
+
'porter unicode61',
|
|
330
|
+
'trigram',
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
/** Parse the deliberately narrow migration-subset v2 FTS5 form documented in
|
|
334
|
+
* RFC 0005. The virtual table is attached to its owning synced
|
|
335
|
+
* table and is not itself a synced table. */
|
|
336
|
+
function parseCreateVirtualTable(
|
|
337
|
+
cursor: Cursor,
|
|
338
|
+
tables: Map<string, ParsedTable>,
|
|
339
|
+
): void {
|
|
340
|
+
cursor.expectWord('TABLE', 'after CREATE VIRTUAL');
|
|
341
|
+
if (cursor.eatWord('IF')) {
|
|
342
|
+
cursor.expectWord('NOT', 'after IF');
|
|
343
|
+
cursor.expectWord('EXISTS', 'after IF NOT');
|
|
344
|
+
}
|
|
345
|
+
const name = cursor.identifier('an FTS virtual-table name');
|
|
346
|
+
if (tables.has(name)) {
|
|
347
|
+
cursor.fail(`FTS virtual table ${name} conflicts with a synced table`);
|
|
348
|
+
}
|
|
349
|
+
for (const table of tables.values()) {
|
|
350
|
+
if (
|
|
351
|
+
table.indexes.some((index) => index.name === name) ||
|
|
352
|
+
table.ftsIndexes.some((index) => index.name === name)
|
|
353
|
+
) {
|
|
354
|
+
cursor.fail(
|
|
355
|
+
`FTS virtual table ${name} is created twice or conflicts with an index`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
cursor.expectWord('USING', `after CREATE VIRTUAL TABLE ${name}`);
|
|
360
|
+
cursor.expectWord('FTS5', `as the module for ${name}`);
|
|
361
|
+
cursor.expectPunct('(', `after USING fts5 for ${name}`);
|
|
362
|
+
const columns: string[] = [];
|
|
363
|
+
let content: string | undefined;
|
|
364
|
+
let tokenize = 'unicode61';
|
|
365
|
+
let tokenizeDeclared = false;
|
|
366
|
+
let sawOption = false;
|
|
367
|
+
for (;;) {
|
|
368
|
+
const field = cursor.identifier(`an FTS5 column or option for ${name}`);
|
|
369
|
+
if (cursor.peek()?.text === '=') {
|
|
370
|
+
sawOption = true;
|
|
371
|
+
cursor.next();
|
|
372
|
+
const value = cursor.next();
|
|
373
|
+
if (value.kind !== 'word' && value.kind !== 'string') {
|
|
374
|
+
cursor.fail(
|
|
375
|
+
`FTS virtual table ${name}: ${field} needs a literal value`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (field.toUpperCase() === 'CONTENT') {
|
|
379
|
+
if (content !== undefined) {
|
|
380
|
+
cursor.fail(`FTS virtual table ${name}: content is declared twice`);
|
|
381
|
+
}
|
|
382
|
+
content = value.text;
|
|
383
|
+
} else if (field.toUpperCase() === 'TOKENIZE') {
|
|
384
|
+
if (tokenizeDeclared) {
|
|
385
|
+
cursor.fail(`FTS virtual table ${name}: tokenize is declared twice`);
|
|
386
|
+
}
|
|
387
|
+
if (!ALLOWED_FTS_TOKENIZERS.has(value.text)) {
|
|
388
|
+
cursor.fail(
|
|
389
|
+
`FTS virtual table ${name}: tokenizer ${JSON.stringify(value.text)} is not allowlisted`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
tokenize = value.text;
|
|
393
|
+
tokenizeDeclared = true;
|
|
394
|
+
} else {
|
|
395
|
+
cursor.fail(
|
|
396
|
+
`FTS virtual table ${name}: unsupported FTS5 option ${JSON.stringify(field)}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
if (sawOption) {
|
|
401
|
+
cursor.fail(
|
|
402
|
+
`FTS virtual table ${name}: indexed columns must precede FTS5 options`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
if (columns.includes(field)) {
|
|
406
|
+
cursor.fail(
|
|
407
|
+
`FTS virtual table ${name}: column ${JSON.stringify(field)} appears twice`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
columns.push(field);
|
|
411
|
+
}
|
|
412
|
+
const separator = cursor.next();
|
|
413
|
+
if (separator.kind === 'punct' && separator.text === ',') continue;
|
|
414
|
+
if (separator.kind === 'punct' && separator.text === ')') break;
|
|
415
|
+
cursor.fail(
|
|
416
|
+
`FTS virtual table ${name}: expected "," or ")", found ${JSON.stringify(separator.text)}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
cursor.expectEnd();
|
|
420
|
+
if (content === undefined) {
|
|
421
|
+
cursor.fail(
|
|
422
|
+
`FTS virtual table ${name}: content = synced_table is required`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const table = tables.get(content);
|
|
426
|
+
if (table === undefined) {
|
|
427
|
+
cursor.fail(
|
|
428
|
+
`FTS virtual table ${name}: content table ${JSON.stringify(content)} does not exist at this point`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (columns.length === 0 || columns.length > 32) {
|
|
432
|
+
cursor.fail(
|
|
433
|
+
`FTS virtual table ${name}: between 1 and 32 columns are required`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
for (const columnName of columns) {
|
|
437
|
+
const column = table.columns.find(
|
|
438
|
+
(candidate) => candidate.name === columnName,
|
|
439
|
+
);
|
|
440
|
+
if (column === undefined) {
|
|
441
|
+
cursor.fail(
|
|
442
|
+
`FTS virtual table ${name}: column ${JSON.stringify(columnName)} does not exist on table ${content}`,
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (column.type !== 'string') {
|
|
446
|
+
cursor.fail(
|
|
447
|
+
`FTS virtual table ${name}: column ${JSON.stringify(columnName)} must have TEXT/string type`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
table.ftsIndexes.push({ name, columns, tokenize });
|
|
452
|
+
}
|
|
453
|
+
|
|
316
454
|
function parseCreateTable(
|
|
317
455
|
cursor: Cursor,
|
|
318
456
|
tables: Map<string, ParsedTable>,
|
|
@@ -332,6 +470,14 @@ function parseCreateTable(
|
|
|
332
470
|
if (tables.has(name)) {
|
|
333
471
|
cursor.fail(`table ${name} is created twice`);
|
|
334
472
|
}
|
|
473
|
+
for (const table of tables.values()) {
|
|
474
|
+
if (
|
|
475
|
+
table.indexes.some((index) => index.name === name) ||
|
|
476
|
+
table.ftsIndexes.some((index) => index.name === name)
|
|
477
|
+
) {
|
|
478
|
+
cursor.fail(`table ${name} conflicts with an index or FTS virtual table`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
335
481
|
cursor.expectPunct('(', `after CREATE TABLE ${name}`);
|
|
336
482
|
const columns: IrColumn[] = [];
|
|
337
483
|
const primaryKeys: string[] = [];
|
|
@@ -401,7 +547,13 @@ function parseCreateTable(
|
|
|
401
547
|
const finalColumns = columns.map((c) =>
|
|
402
548
|
c.name === primaryKey ? { ...c, nullable: false } : c,
|
|
403
549
|
);
|
|
404
|
-
tables.set(name, {
|
|
550
|
+
tables.set(name, {
|
|
551
|
+
name,
|
|
552
|
+
primaryKey,
|
|
553
|
+
columns: finalColumns,
|
|
554
|
+
indexes: [],
|
|
555
|
+
ftsIndexes: [],
|
|
556
|
+
});
|
|
405
557
|
}
|
|
406
558
|
|
|
407
559
|
/**
|
|
@@ -423,9 +575,17 @@ function parseCreateIndex(
|
|
|
423
575
|
cursor.expectWord('EXISTS', 'after IF NOT');
|
|
424
576
|
}
|
|
425
577
|
const name = cursor.identifier('an index name');
|
|
578
|
+
if (tables.has(name)) {
|
|
579
|
+
cursor.fail(`index ${name} conflicts with a synced table`);
|
|
580
|
+
}
|
|
426
581
|
for (const table of tables.values()) {
|
|
427
|
-
if (
|
|
428
|
-
|
|
582
|
+
if (
|
|
583
|
+
table.indexes.some((i) => i.name === name) ||
|
|
584
|
+
table.ftsIndexes.some((i) => i.name === name)
|
|
585
|
+
) {
|
|
586
|
+
cursor.fail(
|
|
587
|
+
`index ${name} is created twice or conflicts with an FTS virtual table`,
|
|
588
|
+
);
|
|
429
589
|
}
|
|
430
590
|
}
|
|
431
591
|
cursor.expectWord('ON', `after CREATE INDEX ${name}`);
|
|
@@ -559,7 +719,7 @@ export function applyMigrationSql(
|
|
|
559
719
|
} else {
|
|
560
720
|
throw new TypegenError(
|
|
561
721
|
source,
|
|
562
|
-
`unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, ALTER TABLE … ADD COLUMN, and DROP TABLE)`,
|
|
722
|
+
`unsupported SQL statement starting with ${JSON.stringify(head.text)} (only CREATE TABLE, CREATE [UNIQUE] INDEX, CREATE VIRTUAL TABLE … USING fts5, ALTER TABLE … ADD COLUMN, and DROP TABLE)`,
|
|
563
723
|
);
|
|
564
724
|
}
|
|
565
725
|
}
|
package/src/syql-validator.ts
CHANGED
|
@@ -221,6 +221,16 @@ const SQLITE_346_PORTABLE_FUNCTIONS = new Set([
|
|
|
221
221
|
'jsonb_replace',
|
|
222
222
|
'jsonb_set',
|
|
223
223
|
]);
|
|
224
|
+
/**
|
|
225
|
+
* Deterministic FTS5 auxiliary functions available only when the query reads
|
|
226
|
+
* a schema-declared FTS projection. FTS5 is an explicit Syncular client
|
|
227
|
+
* requirement in that case, not an arbitrary host extension.
|
|
228
|
+
*/
|
|
229
|
+
const SQLITE_FTS5_AUXILIARY_FUNCTIONS = new Set([
|
|
230
|
+
'bm25',
|
|
231
|
+
'highlight',
|
|
232
|
+
'snippet',
|
|
233
|
+
]);
|
|
224
234
|
const SQL_PAREN_KEYWORDS = new Set([
|
|
225
235
|
'and',
|
|
226
236
|
'as',
|
|
@@ -376,12 +386,13 @@ class Validator {
|
|
|
376
386
|
location,
|
|
377
387
|
);
|
|
378
388
|
this.#validateDeterminism(activeSql, logical.declaration.statement.span);
|
|
389
|
+
const refs = scanTableRefs(activeSql, this.#ir);
|
|
379
390
|
this.#validatePortableProfile(
|
|
380
391
|
activeSql,
|
|
381
392
|
logical.declaration.statement.span,
|
|
393
|
+
refs,
|
|
382
394
|
);
|
|
383
395
|
|
|
384
|
-
const refs = scanTableRefs(activeSql, this.#ir);
|
|
385
396
|
const bindSymbols = this.#bindSymbols(logical.declaration);
|
|
386
397
|
const bindTypes = this.#resolveBindTypes(
|
|
387
398
|
logical,
|
|
@@ -792,8 +803,21 @@ class Validator {
|
|
|
792
803
|
}
|
|
793
804
|
}
|
|
794
805
|
|
|
795
|
-
#validatePortableProfile(
|
|
806
|
+
#validatePortableProfile(
|
|
807
|
+
sql: string,
|
|
808
|
+
span: SyqlSourceSpan,
|
|
809
|
+
refs?: readonly TableRef[],
|
|
810
|
+
): void {
|
|
796
811
|
const tokens = significant(lexSyqlSqlSource(span.file, sql));
|
|
812
|
+
const declaredFtsTables = new Set(
|
|
813
|
+
this.#ir.tables.flatMap((table) =>
|
|
814
|
+
table.ftsIndexes.map((index) => index.name),
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
const queryRefs = refs ?? scanTableRefs(sql, this.#ir);
|
|
818
|
+
const readsDeclaredFts = queryRefs.some((ref) =>
|
|
819
|
+
declaredFtsTables.has(ref.table),
|
|
820
|
+
);
|
|
797
821
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
798
822
|
const token = tokens[index] as SyqlToken;
|
|
799
823
|
const lower = tokenLower(token);
|
|
@@ -828,11 +852,16 @@ class Validator {
|
|
|
828
852
|
) {
|
|
829
853
|
continue;
|
|
830
854
|
}
|
|
831
|
-
|
|
855
|
+
const allowedFtsAuxiliary =
|
|
856
|
+
readsDeclaredFts && SQLITE_FTS5_AUXILIARY_FUNCTIONS.has(lower ?? '');
|
|
857
|
+
if (
|
|
858
|
+
!SQLITE_346_PORTABLE_FUNCTIONS.has(lower ?? '') &&
|
|
859
|
+
!allowedFtsAuxiliary
|
|
860
|
+
) {
|
|
832
861
|
this.#fail(
|
|
833
862
|
'SYQL6002_INVALID_SQL',
|
|
834
863
|
token.span,
|
|
835
|
-
`${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile`,
|
|
864
|
+
`${token.text}() is not a core built-in in the portable SQLite 3.46.0 profile or an auxiliary function of a referenced schema-declared FTS5 projection`,
|
|
836
865
|
);
|
|
837
866
|
}
|
|
838
867
|
|