@syncular/typegen 0.15.48 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/emit-queries-rust.js +1 -1
- package/dist/emit-queries.js +12 -4
- package/dist/query-ir.js +3 -1
- package/dist/query.d.ts +12 -0
- package/dist/query.js +170 -152
- package/dist/syql-lowering.js +1 -0
- package/dist/syql-validator.js +9 -1
- package/package.json +3 -3
- package/src/emit-queries-rust.ts +1 -1
- package/src/emit-queries.ts +14 -4
- package/src/query-ir.ts +3 -1
- package/src/query.ts +200 -168
- package/src/syql-lowering.ts +1 -0
- package/src/syql-validator.ts +12 -1
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@ syncular init [--manifest-dir <dir>]
|
|
|
15
15
|
|
|
16
16
|
`generate` reads `<dir>/syncular.json` plus its migrations directory and
|
|
17
17
|
writes the migration lock, IR JSON, and configured generated modules.
|
|
18
|
+
QueryIR version 4 includes positional table-relation boundaries for every
|
|
19
|
+
physical SQL statement. Generated TypeScript descriptors expose these as
|
|
20
|
+
`relationPlans` for remote query registration. Regenerate existing query
|
|
21
|
+
modules before upgrading the server.
|
|
18
22
|
`--check` regenerates in memory and exits 1 unless every output on disk matches
|
|
19
23
|
**byte-exactly**. `--watch` regenerates on any change under the manifest dir
|
|
20
24
|
(Bun's recursive `fs.watch`, debounced; it skips the write when outputs are
|
|
@@ -675,7 +675,7 @@ function emitSupport(clientCrate) {
|
|
|
675
675
|
' ));',
|
|
676
676
|
' }',
|
|
677
677
|
' hex.as_bytes()',
|
|
678
|
-
' .
|
|
678
|
+
' .chunks(2)',
|
|
679
679
|
' .map(|pair| {',
|
|
680
680
|
' let pair = std::str::from_utf8(pair)',
|
|
681
681
|
' .map_err(|_| decode_error(query, column, "bytes", "non-ASCII $bytes envelope"))?;',
|
package/dist/emit-queries.js
CHANGED
|
@@ -223,14 +223,14 @@ function emitSyqlQuery(query, hash) {
|
|
|
223
223
|
: `raw?: ${Params}`;
|
|
224
224
|
const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
|
|
225
225
|
const statementType = hasParams
|
|
226
|
-
? `
|
|
227
|
-
: '
|
|
228
|
-
lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
|
|
226
|
+
? `QueryRelationPlan & { bind: (params: ${Params}) => QueryValue[] }`
|
|
227
|
+
: 'QueryRelationPlan & { bind: () => QueryValue[] }';
|
|
228
|
+
lines.push(`const ${query.name}Statements: (${statementType})[] = [`);
|
|
229
229
|
for (const statement of metadata.plan.statements) {
|
|
230
230
|
const binds = statement.binds
|
|
231
231
|
.map((bind) => syqlBindExpr(query, bind, 'params'))
|
|
232
232
|
.join(', ');
|
|
233
|
-
lines.push(` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
|
|
233
|
+
lines.push(` { sql: ${quote(statement.positionalSql)}, relations: ${JSON.stringify(statement.relations)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`);
|
|
234
234
|
}
|
|
235
235
|
lines.push('];');
|
|
236
236
|
const sort = inputs.find((input) => input.kind === 'sort');
|
|
@@ -272,6 +272,7 @@ function emitSyqlQuery(query, hash) {
|
|
|
272
272
|
lines.push(` sqlFor: (params: ${Params}) => ${query.name}Select(params).sql,`);
|
|
273
273
|
}
|
|
274
274
|
lines.push(` tables: ${query.name}Tables,`);
|
|
275
|
+
lines.push(` relationPlans: ${query.name}Statements,`);
|
|
275
276
|
lines.push(` resultColumns: [${query.columns.map((column) => `{ name: ${quote(column.langName)}, type: ${quote(column.type)}, nullable: ${column.nullable} }`).join(', ')}],`);
|
|
276
277
|
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
277
278
|
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
@@ -360,6 +361,7 @@ function emitQuery(query, hash) {
|
|
|
360
361
|
lines.push(` sql: ${sqlConst},`);
|
|
361
362
|
lines.push(` mapRow: ${query.name}MapRow,`);
|
|
362
363
|
lines.push(` tables: ${query.name}Tables,`);
|
|
364
|
+
lines.push(` relationPlans: [{ sql: ${sqlConst}, relations: ${JSON.stringify(query.relations)} }],`);
|
|
363
365
|
lines.push(` resultColumns: [${query.columns.map((column) => `{ name: ${quote(column.langName)}, type: ${quote(column.type)}, nullable: ${column.nullable} }`).join(', ')}],`);
|
|
364
366
|
const reactiveUsesParams = query.reactive.dependencies.some((dependency) => dependency.scopes.some((scope) => scope.params.length > 0));
|
|
365
367
|
lines.push(` dependencies: (${reactiveUsesParams ? 'params' : ''}) => [`);
|
|
@@ -468,6 +470,11 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
468
470
|
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
469
471
|
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
470
472
|
' * selects a checked revision-1 SYQL physical statement when needed. */',
|
|
473
|
+
'export interface QueryRelationPlan {',
|
|
474
|
+
' readonly sql: string;',
|
|
475
|
+
' readonly relations: readonly { readonly table: string; readonly start: number; readonly end: number; readonly alias?: string }[];',
|
|
476
|
+
'}',
|
|
477
|
+
'',
|
|
471
478
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
472
479
|
' readonly id: string;',
|
|
473
480
|
' readonly hasParams: boolean;',
|
|
@@ -475,6 +482,7 @@ export function emitQueriesModule(queries, hash, irVersion) {
|
|
|
475
482
|
' readonly mapRow: (row: Readonly<Record<string, unknown>>) => Row;',
|
|
476
483
|
' readonly tables: readonly string[];',
|
|
477
484
|
' readonly resultColumns: readonly QueryResultColumn[];',
|
|
485
|
+
' readonly relationPlans: readonly QueryRelationPlan[];',
|
|
478
486
|
' readonly bind: (params: Params) => readonly QueryValue[];',
|
|
479
487
|
' readonly sqlFor?: (params: Params) => string;',
|
|
480
488
|
' readonly dependencies: (params: Params) => readonly QueryDependency[];',
|
package/dist/query-ir.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/** Serialize analyzed queries as the deterministic QueryIR JSON document. */
|
|
2
2
|
export function serializeQueryIr(queries) {
|
|
3
3
|
const doc = {
|
|
4
|
-
queryIrVersion:
|
|
4
|
+
queryIrVersion: 4,
|
|
5
5
|
queries: queries.map((query) => ({
|
|
6
6
|
name: query.name,
|
|
7
7
|
file: query.file,
|
|
8
8
|
sourceSql: query.sourceSql,
|
|
9
9
|
sql: query.sql,
|
|
10
10
|
positionalSql: query.positionalSql,
|
|
11
|
+
relations: query.relations,
|
|
11
12
|
params: query.params.map((param) => ({
|
|
12
13
|
name: param.name,
|
|
13
14
|
langName: param.langName,
|
|
@@ -89,6 +90,7 @@ export function serializeQueryIr(queries) {
|
|
|
89
90
|
: { activationMask: statement.activationMask }),
|
|
90
91
|
sql: statement.sql,
|
|
91
92
|
positionalSql: statement.positionalSql,
|
|
93
|
+
relations: statement.relations,
|
|
92
94
|
binds: statement.binds.map((bind) => ({ ...bind })),
|
|
93
95
|
})),
|
|
94
96
|
},
|
package/dist/query.d.ts
CHANGED
|
@@ -135,6 +135,7 @@ export interface QuerySyqlStatement {
|
|
|
135
135
|
readonly sql: string;
|
|
136
136
|
readonly positionalSql: string;
|
|
137
137
|
readonly binds: readonly QuerySyqlPlanBind[];
|
|
138
|
+
readonly relations: readonly QueryRelation[];
|
|
138
139
|
}
|
|
139
140
|
/** The target-neutral physical plan every emitter must implement exactly. */
|
|
140
141
|
export interface QuerySyqlExecutionPlan {
|
|
@@ -154,6 +155,13 @@ export interface QuerySyqlMetadata {
|
|
|
154
155
|
readonly plan: QuerySyqlExecutionPlan;
|
|
155
156
|
readonly identity?: readonly string[];
|
|
156
157
|
}
|
|
158
|
+
/** Physical relation boundaries in the exact positional SQL statement. */
|
|
159
|
+
export interface QueryRelation {
|
|
160
|
+
readonly table: string;
|
|
161
|
+
readonly start: number;
|
|
162
|
+
readonly end: number;
|
|
163
|
+
readonly alias?: string;
|
|
164
|
+
}
|
|
157
165
|
export interface AnalyzedQuery {
|
|
158
166
|
/** camelCase function name (path-derived, or a `-- name:` override). */
|
|
159
167
|
readonly name: string;
|
|
@@ -168,6 +176,7 @@ export interface AnalyzedQuery {
|
|
|
168
176
|
readonly sql: string;
|
|
169
177
|
/** The lowered SQL with `:name` rewritten to positional `?`. */
|
|
170
178
|
readonly positionalSql: string;
|
|
179
|
+
readonly relations: readonly QueryRelation[];
|
|
171
180
|
/** Params in first-occurrence (positional) order. */
|
|
172
181
|
readonly params: readonly QueryParam[];
|
|
173
182
|
/** Result columns in SELECT order. */
|
|
@@ -219,6 +228,9 @@ export declare function stripCommentsAndStrings(sql: string): string;
|
|
|
219
228
|
export declare function toPositionalSql(sql: string): string;
|
|
220
229
|
export interface TableRef {
|
|
221
230
|
readonly table: string;
|
|
231
|
+
readonly start: number;
|
|
232
|
+
readonly end: number;
|
|
233
|
+
readonly explicitAlias?: string;
|
|
222
234
|
/** Alias (or the table name when un-aliased). */
|
|
223
235
|
readonly alias: string;
|
|
224
236
|
/** True when an enclosing flat join chain can null-extend this relation. */
|
package/dist/query.js
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* for the query shapes this tier supports.
|
|
63
63
|
*/
|
|
64
64
|
import { TypegenError } from './errors.js';
|
|
65
|
+
import { isSyqlTrivia, lexSyqlSqlSource } from './syql-lexer.js';
|
|
65
66
|
import { lowerProjection, mainVerbAfterWith } from './lower.js';
|
|
66
67
|
import { buildNamingMap } from './naming.js';
|
|
67
68
|
/** The SQL decltype keyword → §2.4 type map (mirrors sql.ts TYPE_MAP so a
|
|
@@ -450,6 +451,7 @@ const RESERVED_ALIAS = new Set([
|
|
|
450
451
|
'inner',
|
|
451
452
|
'left',
|
|
452
453
|
'right',
|
|
454
|
+
'full',
|
|
453
455
|
'outer',
|
|
454
456
|
'natural',
|
|
455
457
|
'join',
|
|
@@ -457,179 +459,192 @@ const RESERVED_ALIAS = new Set([
|
|
|
457
459
|
'using',
|
|
458
460
|
'limit',
|
|
459
461
|
'having',
|
|
462
|
+
'union',
|
|
463
|
+
'intersect',
|
|
464
|
+
'except',
|
|
465
|
+
'offset',
|
|
466
|
+
'window',
|
|
467
|
+
'indexed',
|
|
468
|
+
'not',
|
|
460
469
|
]);
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
depth = Math.max(0, depth - 1);
|
|
470
|
-
}
|
|
471
|
-
return depth;
|
|
472
|
-
}
|
|
473
|
-
function matchingParenthesis(sql, open) {
|
|
474
|
-
let depth = 0;
|
|
475
|
-
for (let cursor = open; cursor < sql.length; cursor += 1) {
|
|
476
|
-
if (sql[cursor] === '(')
|
|
477
|
-
depth += 1;
|
|
478
|
-
else if (sql[cursor] === ')') {
|
|
479
|
-
depth -= 1;
|
|
480
|
-
if (depth === 0)
|
|
481
|
-
return cursor;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
return sql.length;
|
|
470
|
+
function sqlIdentifier(token) {
|
|
471
|
+
if (token?.kind === 'identifier')
|
|
472
|
+
return token.text;
|
|
473
|
+
if (token?.kind !== 'quoted-identifier')
|
|
474
|
+
return undefined;
|
|
475
|
+
const quote = token.text[0];
|
|
476
|
+
const value = token.text.slice(1, -1);
|
|
477
|
+
return quote === '[' ? value : value.split(`${quote}${quote}`).join(quote);
|
|
485
478
|
}
|
|
486
|
-
function
|
|
487
|
-
const
|
|
488
|
-
const known = new
|
|
489
|
-
table.name.toLowerCase(),
|
|
490
|
-
...table.ftsIndexes.map((index) => index.name.toLowerCase()),
|
|
479
|
+
export function scanTableRefs(sql, ir) {
|
|
480
|
+
const tokens = lexSyqlSqlSource('query SQL', sql).filter((token) => !isSyqlTrivia(token) && token.kind !== 'eof');
|
|
481
|
+
const known = new Map(ir.tables.flatMap((table) => [
|
|
482
|
+
[table.name.toLowerCase(), table.name],
|
|
483
|
+
...table.ftsIndexes.map((index) => [index.name.toLowerCase(), index.name]),
|
|
491
484
|
]));
|
|
485
|
+
const closes = new Map();
|
|
486
|
+
const parents = [];
|
|
487
|
+
const depths = [];
|
|
488
|
+
const stack = [];
|
|
489
|
+
tokens.forEach((token, index) => {
|
|
490
|
+
if (token.text === ')') {
|
|
491
|
+
const open = stack.pop();
|
|
492
|
+
if (open !== undefined)
|
|
493
|
+
closes.set(open, index);
|
|
494
|
+
}
|
|
495
|
+
parents[index] = stack.at(-1) ?? -1;
|
|
496
|
+
depths[index] = stack.length;
|
|
497
|
+
if (token.text === '(')
|
|
498
|
+
stack.push(index);
|
|
499
|
+
});
|
|
492
500
|
const activeFromDepths = new Set();
|
|
493
501
|
const clauseEnders = new Set([
|
|
494
|
-
'
|
|
495
|
-
'
|
|
496
|
-
'
|
|
497
|
-
'
|
|
498
|
-
'
|
|
499
|
-
'
|
|
500
|
-
'
|
|
501
|
-
'
|
|
502
|
-
'
|
|
503
|
-
'
|
|
502
|
+
'where',
|
|
503
|
+
'group',
|
|
504
|
+
'having',
|
|
505
|
+
'order',
|
|
506
|
+
'limit',
|
|
507
|
+
'window',
|
|
508
|
+
'union',
|
|
509
|
+
'except',
|
|
510
|
+
'intersect',
|
|
511
|
+
'returning',
|
|
504
512
|
]);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
* expression, whose `(` follows an identifier or an operator. */
|
|
511
|
-
let previous;
|
|
512
|
-
const nextIdentifier = (start) => {
|
|
513
|
-
let index = start;
|
|
514
|
-
while (index < cleaned.length &&
|
|
515
|
-
(/\s/.test(cleaned[index]) || cleaned[index] === '(')) {
|
|
516
|
-
index += 1;
|
|
513
|
+
for (const [index, token] of tokens.entries()) {
|
|
514
|
+
const depth = depths[index] ?? 0;
|
|
515
|
+
const word = token.kind === 'identifier' ? token.text.toLowerCase() : undefined;
|
|
516
|
+
if (word === 'in' && sqlIdentifier(tokens[index + 1]) !== undefined) {
|
|
517
|
+
throw new TypegenError('query SQL', 'table-name IN expressions are unsupported; use an explicit SELECT subquery');
|
|
517
518
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
previous === '(';
|
|
532
|
-
const next = nextIdentifier(cursor + 1);
|
|
533
|
-
if (opensTableSource &&
|
|
534
|
-
activeFromDepths.has(depth) &&
|
|
535
|
-
next !== undefined &&
|
|
536
|
-
known.has(next.toLowerCase())) {
|
|
519
|
+
if (word === 'from')
|
|
520
|
+
activeFromDepths.add(depth);
|
|
521
|
+
else if (word !== undefined && clauseEnders.has(word))
|
|
522
|
+
activeFromDepths.delete(depth);
|
|
523
|
+
else if (token.text === ')')
|
|
524
|
+
activeFromDepths.delete(depth + 1);
|
|
525
|
+
else if (token.text === '(' && activeFromDepths.has(depth)) {
|
|
526
|
+
const previous = tokens[index - 1]?.text.toLowerCase();
|
|
527
|
+
const next = tokens[index + 1];
|
|
528
|
+
if (previous !== undefined &&
|
|
529
|
+
['from', 'join', ',', '('].includes(previous) &&
|
|
530
|
+
!(next?.kind === 'identifier' &&
|
|
531
|
+
['select', 'with', 'values'].includes(next.text.toLowerCase()))) {
|
|
537
532
|
activeFromDepths.add(depth + 1);
|
|
538
533
|
}
|
|
539
|
-
depth += 1;
|
|
540
|
-
previous = '(';
|
|
541
|
-
cursor += 1;
|
|
542
|
-
continue;
|
|
543
534
|
}
|
|
544
|
-
if (
|
|
545
|
-
|
|
546
|
-
depth = Math.max(0, depth - 1);
|
|
547
|
-
previous = ')';
|
|
548
|
-
cursor += 1;
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
if (char === ',' && activeFromDepths.has(depth)) {
|
|
552
|
-
const next = nextIdentifier(cursor + 1);
|
|
553
|
-
if (next !== undefined && known.has(next.toLowerCase()))
|
|
554
|
-
return true;
|
|
555
|
-
previous = ',';
|
|
556
|
-
cursor += 1;
|
|
557
|
-
continue;
|
|
535
|
+
else if (token.text === ',' && activeFromDepths.has(depth)) {
|
|
536
|
+
throw new TypegenError('query SQL', 'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause');
|
|
558
537
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
}
|
|
565
|
-
const word = cleaned.slice(cursor, end).toUpperCase();
|
|
566
|
-
if (word === 'FROM')
|
|
567
|
-
activeFromDepths.add(depth);
|
|
568
|
-
else if (clauseEnders.has(word))
|
|
569
|
-
activeFromDepths.delete(depth);
|
|
570
|
-
previous = word;
|
|
571
|
-
cursor = end;
|
|
538
|
+
}
|
|
539
|
+
const ctes = [];
|
|
540
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
541
|
+
const token = tokens[index];
|
|
542
|
+
if (token?.kind !== 'identifier' || token.text.toLowerCase() !== 'with')
|
|
572
543
|
continue;
|
|
544
|
+
const scopeEnd = closes.get(parents[index] ?? -1) ?? tokens.length;
|
|
545
|
+
let cursor = index + 1;
|
|
546
|
+
if (tokens[cursor]?.text.toLowerCase() === 'recursive')
|
|
547
|
+
cursor += 1;
|
|
548
|
+
for (;;) {
|
|
549
|
+
const name = sqlIdentifier(tokens[cursor]);
|
|
550
|
+
if (name === undefined)
|
|
551
|
+
break;
|
|
552
|
+
cursor += 1;
|
|
553
|
+
if (tokens[cursor]?.text === '(')
|
|
554
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
555
|
+
if (tokens[cursor]?.text.toLowerCase() !== 'as')
|
|
556
|
+
break;
|
|
557
|
+
cursor += 1;
|
|
558
|
+
if (tokens[cursor]?.text.toLowerCase() === 'not')
|
|
559
|
+
cursor += 1;
|
|
560
|
+
if (tokens[cursor]?.text.toLowerCase() === 'materialized')
|
|
561
|
+
cursor += 1;
|
|
562
|
+
if (tokens[cursor]?.text !== '(')
|
|
563
|
+
break;
|
|
564
|
+
ctes.push({ name: name.toLowerCase(), start: index, end: scopeEnd });
|
|
565
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
566
|
+
if (tokens[cursor]?.text !== ',')
|
|
567
|
+
break;
|
|
568
|
+
cursor += 1;
|
|
573
569
|
}
|
|
574
|
-
previous = char;
|
|
575
|
-
cursor += 1;
|
|
576
570
|
}
|
|
577
|
-
return false;
|
|
578
|
-
}
|
|
579
|
-
export function scanTableRefs(sql, ir) {
|
|
580
|
-
const cleaned = stripCommentsAndStrings(sql);
|
|
581
|
-
const known = new Map(ir.tables.flatMap((table) => [
|
|
582
|
-
[table.name.toLowerCase(), table.name],
|
|
583
|
-
...table.ftsIndexes.map((index) => [index.name.toLowerCase(), index.name]),
|
|
584
|
-
]));
|
|
585
|
-
const nullableGroups = [
|
|
586
|
-
...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
|
|
587
|
-
].map((match) => {
|
|
588
|
-
const open = (match.index ?? 0) + match[0].lastIndexOf('(');
|
|
589
|
-
return { open, close: matchingParenthesis(cleaned, open) };
|
|
590
|
-
});
|
|
591
571
|
const refs = [];
|
|
592
572
|
const relationStartByDepth = new Map();
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
573
|
+
const nullableGroups = [];
|
|
574
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
575
|
+
const token = tokens[index];
|
|
576
|
+
if (token?.kind !== 'identifier')
|
|
577
|
+
continue;
|
|
578
|
+
const operator = token.text.toLowerCase();
|
|
579
|
+
if (operator !== 'from' && operator !== 'join')
|
|
580
|
+
continue;
|
|
581
|
+
let outerKind;
|
|
582
|
+
if (operator === 'join') {
|
|
583
|
+
let previous = index - 1;
|
|
584
|
+
if (tokens[previous]?.text.toLowerCase() === 'outer')
|
|
585
|
+
previous -= 1;
|
|
586
|
+
if (tokens[previous]?.kind === 'identifier')
|
|
587
|
+
outerKind = tokens[previous]?.text.toLowerCase();
|
|
588
|
+
}
|
|
589
|
+
const operatorDepth = depths[index] ?? 0;
|
|
590
|
+
let cursor = index + 1;
|
|
591
|
+
if (operator === 'from')
|
|
604
592
|
relationStartByDepth.set(operatorDepth, refs.length);
|
|
605
|
-
|
|
593
|
+
if (outerKind === 'right' || outerKind === 'full') {
|
|
594
|
+
for (let prior = relationStartByDepth.get(operatorDepth) ?? refs.length; prior < refs.length; prior += 1) {
|
|
595
|
+
const ref = refs[prior];
|
|
596
|
+
if (ref !== undefined)
|
|
597
|
+
refs[prior] = { ...ref, nullable: true };
|
|
598
|
+
}
|
|
606
599
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
600
|
+
if ((outerKind === 'left' || outerKind === 'full') &&
|
|
601
|
+
tokens[cursor]?.text === '(') {
|
|
602
|
+
nullableGroups.push({
|
|
603
|
+
start: cursor,
|
|
604
|
+
end: closes.get(cursor) ?? tokens.length,
|
|
605
|
+
});
|
|
610
606
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
607
|
+
while (tokens[cursor]?.text === '(')
|
|
608
|
+
cursor += 1;
|
|
609
|
+
const tableToken = tokens[cursor];
|
|
610
|
+
if (tableToken?.kind === 'identifier' &&
|
|
611
|
+
['select', 'with', 'values'].includes(tableToken.text.toLowerCase()))
|
|
612
|
+
continue;
|
|
613
|
+
const rawTable = sqlIdentifier(tableToken);
|
|
614
|
+
if (rawTable === undefined || tableToken === undefined) {
|
|
615
|
+
throw new TypegenError('query SQL', 'cannot resolve table relation');
|
|
619
616
|
}
|
|
617
|
+
if (tokens[cursor + 1]?.text === '.') {
|
|
618
|
+
throw new TypegenError('query SQL', 'schema-qualified relations are unsupported; use application table names');
|
|
619
|
+
}
|
|
620
|
+
if (ctes.some((cte) => cte.name === rawTable.toLowerCase() &&
|
|
621
|
+
cursor > cte.start &&
|
|
622
|
+
cursor < cte.end))
|
|
623
|
+
continue;
|
|
620
624
|
const table = known.get(rawTable.toLowerCase());
|
|
621
625
|
if (table === undefined)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
|
|
626
|
+
throw new TypegenError('query SQL', `unresolved table relation ${JSON.stringify(rawTable)}`);
|
|
627
|
+
const tableDepth = depths[cursor] ?? operatorDepth;
|
|
628
|
+
if (operator === 'from' || !relationStartByDepth.has(tableDepth))
|
|
629
|
+
relationStartByDepth.set(tableDepth, refs.length);
|
|
630
|
+
let aliasToken = tokens[cursor + 1];
|
|
631
|
+
if (aliasToken?.kind === 'identifier' &&
|
|
632
|
+
aliasToken.text.toLowerCase() === 'as')
|
|
633
|
+
aliasToken = tokens[cursor + 2];
|
|
634
|
+
const explicitAlias = aliasToken?.kind === 'quoted-identifier' ||
|
|
635
|
+
(aliasToken?.kind === 'identifier' &&
|
|
636
|
+
!RESERVED_ALIAS.has(aliasToken.text.toLowerCase()))
|
|
637
|
+
? sqlIdentifier(aliasToken)
|
|
638
|
+
: undefined;
|
|
627
639
|
refs.push({
|
|
628
640
|
table,
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
641
|
+
start: tableToken.span.start.offset,
|
|
642
|
+
end: tableToken.span.end.offset,
|
|
643
|
+
alias: explicitAlias ?? table,
|
|
644
|
+
...(explicitAlias === undefined ? {} : { explicitAlias }),
|
|
645
|
+
nullable: outerKind === 'left' ||
|
|
646
|
+
outerKind === 'full' ||
|
|
647
|
+
nullableGroups.some((group) => cursor > group.start && cursor < group.end),
|
|
633
648
|
});
|
|
634
649
|
}
|
|
635
650
|
return refs;
|
|
@@ -1063,9 +1078,6 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
1063
1078
|
if (sourceSql.length === 0) {
|
|
1064
1079
|
throw new TypegenError(file, 'query file is empty');
|
|
1065
1080
|
}
|
|
1066
|
-
if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
|
|
1067
|
-
throw new TypegenError(file, 'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause');
|
|
1068
|
-
}
|
|
1069
1081
|
// SELECT-only (the read tier). A `WITH` is allowed when its main statement
|
|
1070
1082
|
// is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
|
|
1071
1083
|
const firstKeyword = /^\s*([A-Za-z]+)/.exec(stripCommentsAndStrings(statementText).trimStart())?.[1];
|
|
@@ -1207,6 +1219,12 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
1207
1219
|
sourceSql,
|
|
1208
1220
|
sql,
|
|
1209
1221
|
positionalSql: toPositionalSql(sql),
|
|
1222
|
+
relations: scanTableRefs(toPositionalSql(sql), ir).map((ref) => ({
|
|
1223
|
+
table: ref.table,
|
|
1224
|
+
start: ref.start,
|
|
1225
|
+
end: ref.end,
|
|
1226
|
+
...(ref.explicitAlias === undefined ? {} : { alias: ref.explicitAlias }),
|
|
1227
|
+
})),
|
|
1210
1228
|
params,
|
|
1211
1229
|
columns,
|
|
1212
1230
|
tables,
|
package/dist/syql-lowering.js
CHANGED
|
@@ -309,6 +309,7 @@ function lowerStatement(validated, ir, db, naming, sql, sortProfile, activationM
|
|
|
309
309
|
...(activationMask === undefined ? {} : { activationMask }),
|
|
310
310
|
sql: namedSql,
|
|
311
311
|
positionalSql: analyzed.positionalSql,
|
|
312
|
+
relations: analyzed.relations,
|
|
312
313
|
binds: bindNames.map((name) => planBind(name, owners, conditionBinds, query.conditions, validated.limit, query)),
|
|
313
314
|
},
|
|
314
315
|
analysis: { ...analyzed, sourceSql: namedSql, sql: namedSql },
|
package/dist/syql-validator.js
CHANGED
|
@@ -303,7 +303,15 @@ class Validator {
|
|
|
303
303
|
const activeStructure = this.#inspect(activeSql, location);
|
|
304
304
|
this.#validateStatementShape(activeSql, activeStructure, logical.declaration, location);
|
|
305
305
|
this.#validateDeterminism(activeSql, logical.declaration.statement.span);
|
|
306
|
-
|
|
306
|
+
let refs;
|
|
307
|
+
try {
|
|
308
|
+
refs = scanTableRefs(activeSql, this.#ir);
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
// Preserve SQLite's source-spanned diagnostics for invalid relations.
|
|
312
|
+
this.#validateSqlite(activeSql, logical, []);
|
|
313
|
+
this.#fail('SYQL6002_INVALID_SQL', logical.declaration.statement.span, error instanceof Error ? error.message : String(error));
|
|
314
|
+
}
|
|
307
315
|
this.#validatePortableProfile(activeSql, logical.declaration.statement.span, refs);
|
|
308
316
|
this.#validateSqlite(activeSql, logical, refs);
|
|
309
317
|
const bindSymbols = this.#bindSymbols(logical.declaration);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/typegen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"!dist/**/*.test.d.ts"
|
|
57
57
|
],
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@syncular/core": "0.
|
|
59
|
+
"@syncular/core": "0.16.1"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@syncular/server": "0.
|
|
62
|
+
"@syncular/server": "0.16.1"
|
|
63
63
|
}
|
|
64
64
|
}
|
package/src/emit-queries-rust.ts
CHANGED
|
@@ -892,7 +892,7 @@ function emitSupport(clientCrate: string): string {
|
|
|
892
892
|
' ));',
|
|
893
893
|
' }',
|
|
894
894
|
' hex.as_bytes()',
|
|
895
|
-
' .
|
|
895
|
+
' .chunks(2)',
|
|
896
896
|
' .map(|pair| {',
|
|
897
897
|
' let pair = std::str::from_utf8(pair)',
|
|
898
898
|
' .map_err(|_| decode_error(query, column, "bytes", "non-ASCII $bytes envelope"))?;',
|
package/src/emit-queries.ts
CHANGED
|
@@ -314,15 +314,15 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
314
314
|
: `raw?: ${Params}`;
|
|
315
315
|
const validated = hasParams ? `${query.name}Validate(raw)` : undefined;
|
|
316
316
|
const statementType = hasParams
|
|
317
|
-
? `
|
|
318
|
-
: '
|
|
319
|
-
lines.push(`const ${query.name}Statements: ${statementType}[] = [`);
|
|
317
|
+
? `QueryRelationPlan & { bind: (params: ${Params}) => QueryValue[] }`
|
|
318
|
+
: 'QueryRelationPlan & { bind: () => QueryValue[] }';
|
|
319
|
+
lines.push(`const ${query.name}Statements: (${statementType})[] = [`);
|
|
320
320
|
for (const statement of metadata.plan.statements) {
|
|
321
321
|
const binds = statement.binds
|
|
322
322
|
.map((bind) => syqlBindExpr(query, bind, 'params'))
|
|
323
323
|
.join(', ');
|
|
324
324
|
lines.push(
|
|
325
|
-
` { sql: ${quote(statement.positionalSql)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`,
|
|
325
|
+
` { sql: ${quote(statement.positionalSql)}, relations: ${JSON.stringify(statement.relations)}, bind: (${hasParams ? 'params' : ''}) => [${binds}] },`,
|
|
326
326
|
);
|
|
327
327
|
}
|
|
328
328
|
lines.push('];');
|
|
@@ -399,6 +399,7 @@ function emitSyqlQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
399
399
|
);
|
|
400
400
|
}
|
|
401
401
|
lines.push(` tables: ${query.name}Tables,`);
|
|
402
|
+
lines.push(` relationPlans: ${query.name}Statements,`);
|
|
402
403
|
lines.push(
|
|
403
404
|
` resultColumns: [${query.columns.map((column) => `{ name: ${quote(column.langName)}, type: ${quote(column.type)}, nullable: ${column.nullable} }`).join(', ')}],`,
|
|
404
405
|
);
|
|
@@ -540,6 +541,9 @@ function emitQuery(query: AnalyzedQuery, hash: string): string {
|
|
|
540
541
|
lines.push(` sql: ${sqlConst},`);
|
|
541
542
|
lines.push(` mapRow: ${query.name}MapRow,`);
|
|
542
543
|
lines.push(` tables: ${query.name}Tables,`);
|
|
544
|
+
lines.push(
|
|
545
|
+
` relationPlans: [{ sql: ${sqlConst}, relations: ${JSON.stringify(query.relations)} }],`,
|
|
546
|
+
);
|
|
543
547
|
lines.push(
|
|
544
548
|
` resultColumns: [${query.columns.map((column) => `{ name: ${quote(column.langName)}, type: ${quote(column.type)}, nullable: ${column.nullable} }`).join(', ')}],`,
|
|
545
549
|
);
|
|
@@ -683,6 +687,11 @@ export function emitQueriesModule(
|
|
|
683
687
|
" * `@syncular/react`'s `useQuery`. `Row` is the projection row",
|
|
684
688
|
' * type; `Params` is `undefined` for a param-less query. `sqlFor`',
|
|
685
689
|
' * selects a checked revision-1 SYQL physical statement when needed. */',
|
|
690
|
+
'export interface QueryRelationPlan {',
|
|
691
|
+
' readonly sql: string;',
|
|
692
|
+
' readonly relations: readonly { readonly table: string; readonly start: number; readonly end: number; readonly alias?: string }[];',
|
|
693
|
+
'}',
|
|
694
|
+
'',
|
|
686
695
|
'export interface NamedQuery<Row, Params = undefined> {',
|
|
687
696
|
' readonly id: string;',
|
|
688
697
|
' readonly hasParams: boolean;',
|
|
@@ -690,6 +699,7 @@ export function emitQueriesModule(
|
|
|
690
699
|
' readonly mapRow: (row: Readonly<Record<string, unknown>>) => Row;',
|
|
691
700
|
' readonly tables: readonly string[];',
|
|
692
701
|
' readonly resultColumns: readonly QueryResultColumn[];',
|
|
702
|
+
' readonly relationPlans: readonly QueryRelationPlan[];',
|
|
693
703
|
' readonly bind: (params: Params) => readonly QueryValue[];',
|
|
694
704
|
' readonly sqlFor?: (params: Params) => string;',
|
|
695
705
|
' readonly dependencies: (params: Params) => readonly QueryDependency[];',
|
package/src/query-ir.ts
CHANGED
|
@@ -14,13 +14,14 @@ import type { AnalyzedQuery } from './query';
|
|
|
14
14
|
/** Serialize analyzed queries as the deterministic QueryIR JSON document. */
|
|
15
15
|
export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
|
|
16
16
|
const doc = {
|
|
17
|
-
queryIrVersion:
|
|
17
|
+
queryIrVersion: 4,
|
|
18
18
|
queries: queries.map((query) => ({
|
|
19
19
|
name: query.name,
|
|
20
20
|
file: query.file,
|
|
21
21
|
sourceSql: query.sourceSql,
|
|
22
22
|
sql: query.sql,
|
|
23
23
|
positionalSql: query.positionalSql,
|
|
24
|
+
relations: query.relations,
|
|
24
25
|
params: query.params.map((param) => ({
|
|
25
26
|
name: param.name,
|
|
26
27
|
langName: param.langName,
|
|
@@ -101,6 +102,7 @@ export function serializeQueryIr(queries: readonly AnalyzedQuery[]): string {
|
|
|
101
102
|
: { activationMask: statement.activationMask }),
|
|
102
103
|
sql: statement.sql,
|
|
103
104
|
positionalSql: statement.positionalSql,
|
|
105
|
+
relations: statement.relations,
|
|
104
106
|
binds: statement.binds.map((bind) => ({ ...bind })),
|
|
105
107
|
})),
|
|
106
108
|
},
|
package/src/query.ts
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
* for the query shapes this tier supports.
|
|
63
63
|
*/
|
|
64
64
|
import { TypegenError } from './errors';
|
|
65
|
+
import { isSyqlTrivia, lexSyqlSqlSource, type SyqlToken } from './syql-lexer';
|
|
65
66
|
import type { IrColumn, IrColumnType, IrDocument, IrTable } from './ir';
|
|
66
67
|
import { lowerProjection, mainVerbAfterWith } from './lower';
|
|
67
68
|
import { buildNamingMap, type NamingMode, type NamingTarget } from './naming';
|
|
@@ -245,6 +246,7 @@ export interface QuerySyqlStatement {
|
|
|
245
246
|
readonly sql: string;
|
|
246
247
|
readonly positionalSql: string;
|
|
247
248
|
readonly binds: readonly QuerySyqlPlanBind[];
|
|
249
|
+
readonly relations: readonly QueryRelation[];
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
/** The target-neutral physical plan every emitter must implement exactly. */
|
|
@@ -267,6 +269,14 @@ export interface QuerySyqlMetadata {
|
|
|
267
269
|
readonly identity?: readonly string[];
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
/** Physical relation boundaries in the exact positional SQL statement. */
|
|
273
|
+
export interface QueryRelation {
|
|
274
|
+
readonly table: string;
|
|
275
|
+
readonly start: number;
|
|
276
|
+
readonly end: number;
|
|
277
|
+
readonly alias?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
270
280
|
export interface AnalyzedQuery {
|
|
271
281
|
/** camelCase function name (path-derived, or a `-- name:` override). */
|
|
272
282
|
readonly name: string;
|
|
@@ -281,6 +291,7 @@ export interface AnalyzedQuery {
|
|
|
281
291
|
readonly sql: string;
|
|
282
292
|
/** The lowered SQL with `:name` rewritten to positional `?`. */
|
|
283
293
|
readonly positionalSql: string;
|
|
294
|
+
readonly relations: readonly QueryRelation[];
|
|
284
295
|
/** Params in first-occurrence (positional) order. */
|
|
285
296
|
readonly params: readonly QueryParam[];
|
|
286
297
|
/** Result columns in SELECT order. */
|
|
@@ -692,6 +703,9 @@ export function toPositionalSql(sql: string): string {
|
|
|
692
703
|
|
|
693
704
|
export interface TableRef {
|
|
694
705
|
readonly table: string;
|
|
706
|
+
readonly start: number;
|
|
707
|
+
readonly end: number;
|
|
708
|
+
readonly explicitAlias?: string;
|
|
695
709
|
/** Alias (or the table name when un-aliased). */
|
|
696
710
|
readonly alias: string;
|
|
697
711
|
/** True when an enclosing flat join chain can null-extend this relation. */
|
|
@@ -709,6 +723,7 @@ const RESERVED_ALIAS = new Set([
|
|
|
709
723
|
'inner',
|
|
710
724
|
'left',
|
|
711
725
|
'right',
|
|
726
|
+
'full',
|
|
712
727
|
'outer',
|
|
713
728
|
'natural',
|
|
714
729
|
'join',
|
|
@@ -716,197 +731,215 @@ const RESERVED_ALIAS = new Set([
|
|
|
716
731
|
'using',
|
|
717
732
|
'limit',
|
|
718
733
|
'having',
|
|
734
|
+
'union',
|
|
735
|
+
'intersect',
|
|
736
|
+
'except',
|
|
737
|
+
'offset',
|
|
738
|
+
'window',
|
|
739
|
+
'indexed',
|
|
740
|
+
'not',
|
|
719
741
|
]);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
);
|
|
725
|
-
|
|
726
|
-
function parenthesisDepthAt(sql: string, index: number): number {
|
|
727
|
-
let depth = 0;
|
|
728
|
-
for (let cursor = 0; cursor < index; cursor += 1) {
|
|
729
|
-
if (sql[cursor] === '(') depth += 1;
|
|
730
|
-
else if (sql[cursor] === ')') depth = Math.max(0, depth - 1);
|
|
731
|
-
}
|
|
732
|
-
return depth;
|
|
742
|
+
function sqlIdentifier(token: SyqlToken | undefined): string | undefined {
|
|
743
|
+
if (token?.kind === 'identifier') return token.text;
|
|
744
|
+
if (token?.kind !== 'quoted-identifier') return undefined;
|
|
745
|
+
const quote = token.text[0];
|
|
746
|
+
const value = token.text.slice(1, -1);
|
|
747
|
+
return quote === '[' ? value : value.split(`${quote}${quote}`).join(quote);
|
|
733
748
|
}
|
|
734
749
|
|
|
735
|
-
function
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
depth -= 1;
|
|
741
|
-
if (depth === 0) return cursor;
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
return sql.length;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function hasCommaJoinedSchemaTable(sql: string, ir: IrDocument): boolean {
|
|
748
|
-
const cleaned = stripCommentsAndStrings(sql);
|
|
749
|
-
const known = new Set(
|
|
750
|
+
export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
751
|
+
const tokens = lexSyqlSqlSource('query SQL', sql).filter(
|
|
752
|
+
(token) => !isSyqlTrivia(token) && token.kind !== 'eof',
|
|
753
|
+
);
|
|
754
|
+
const known = new Map(
|
|
750
755
|
ir.tables.flatMap((table) => [
|
|
751
|
-
table.name.toLowerCase(),
|
|
752
|
-
...table.ftsIndexes.map(
|
|
756
|
+
[table.name.toLowerCase(), table.name] as const,
|
|
757
|
+
...table.ftsIndexes.map(
|
|
758
|
+
(index) => [index.name.toLowerCase(), index.name] as const,
|
|
759
|
+
),
|
|
753
760
|
]),
|
|
754
761
|
);
|
|
762
|
+
const closes = new Map<number, number>();
|
|
763
|
+
const parents: number[] = [];
|
|
764
|
+
const depths: number[] = [];
|
|
765
|
+
const stack: number[] = [];
|
|
766
|
+
tokens.forEach((token, index) => {
|
|
767
|
+
if (token.text === ')') {
|
|
768
|
+
const open = stack.pop();
|
|
769
|
+
if (open !== undefined) closes.set(open, index);
|
|
770
|
+
}
|
|
771
|
+
parents[index] = stack.at(-1) ?? -1;
|
|
772
|
+
depths[index] = stack.length;
|
|
773
|
+
if (token.text === '(') stack.push(index);
|
|
774
|
+
});
|
|
755
775
|
const activeFromDepths = new Set<number>();
|
|
756
776
|
const clauseEnders = new Set([
|
|
757
|
-
'
|
|
758
|
-
'
|
|
759
|
-
'
|
|
760
|
-
'
|
|
761
|
-
'
|
|
762
|
-
'
|
|
763
|
-
'
|
|
764
|
-
'
|
|
765
|
-
'
|
|
766
|
-
'
|
|
777
|
+
'where',
|
|
778
|
+
'group',
|
|
779
|
+
'having',
|
|
780
|
+
'order',
|
|
781
|
+
'limit',
|
|
782
|
+
'window',
|
|
783
|
+
'union',
|
|
784
|
+
'except',
|
|
785
|
+
'intersect',
|
|
786
|
+
'returning',
|
|
767
787
|
]);
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
while (
|
|
778
|
-
index < cleaned.length &&
|
|
779
|
-
(/\s/.test(cleaned[index] as string) || cleaned[index] === '(')
|
|
780
|
-
) {
|
|
781
|
-
index += 1;
|
|
782
|
-
}
|
|
783
|
-
const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
|
|
784
|
-
return match?.[0];
|
|
785
|
-
};
|
|
786
|
-
|
|
787
|
-
while (cursor < cleaned.length) {
|
|
788
|
-
const char = cleaned[cursor] as string;
|
|
789
|
-
if (/\s/.test(char)) {
|
|
790
|
-
cursor += 1;
|
|
791
|
-
continue;
|
|
788
|
+
for (const [index, token] of tokens.entries()) {
|
|
789
|
+
const depth = depths[index] ?? 0;
|
|
790
|
+
const word =
|
|
791
|
+
token.kind === 'identifier' ? token.text.toLowerCase() : undefined;
|
|
792
|
+
if (word === 'in' && sqlIdentifier(tokens[index + 1]) !== undefined) {
|
|
793
|
+
throw new TypegenError(
|
|
794
|
+
'query SQL',
|
|
795
|
+
'table-name IN expressions are unsupported; use an explicit SELECT subquery',
|
|
796
|
+
);
|
|
792
797
|
}
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
const next =
|
|
798
|
+
if (word === 'from') activeFromDepths.add(depth);
|
|
799
|
+
else if (word !== undefined && clauseEnders.has(word))
|
|
800
|
+
activeFromDepths.delete(depth);
|
|
801
|
+
else if (token.text === ')') activeFromDepths.delete(depth + 1);
|
|
802
|
+
else if (token.text === '(' && activeFromDepths.has(depth)) {
|
|
803
|
+
const previous = tokens[index - 1]?.text.toLowerCase();
|
|
804
|
+
const next = tokens[index + 1];
|
|
800
805
|
if (
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
806
|
+
previous !== undefined &&
|
|
807
|
+
['from', 'join', ',', '('].includes(previous) &&
|
|
808
|
+
!(
|
|
809
|
+
next?.kind === 'identifier' &&
|
|
810
|
+
['select', 'with', 'values'].includes(next.text.toLowerCase())
|
|
811
|
+
)
|
|
805
812
|
) {
|
|
806
813
|
activeFromDepths.add(depth + 1);
|
|
807
814
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
815
|
+
} else if (token.text === ',' && activeFromDepths.has(depth)) {
|
|
816
|
+
throw new TypegenError(
|
|
817
|
+
'query SQL',
|
|
818
|
+
'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause',
|
|
819
|
+
);
|
|
812
820
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
821
|
+
}
|
|
822
|
+
const ctes: { name: string; start: number; end: number }[] = [];
|
|
823
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
824
|
+
const token = tokens[index];
|
|
825
|
+
if (token?.kind !== 'identifier' || token.text.toLowerCase() !== 'with')
|
|
818
826
|
continue;
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
827
|
+
const scopeEnd = closes.get(parents[index] ?? -1) ?? tokens.length;
|
|
828
|
+
let cursor = index + 1;
|
|
829
|
+
if (tokens[cursor]?.text.toLowerCase() === 'recursive') cursor += 1;
|
|
830
|
+
for (;;) {
|
|
831
|
+
const name = sqlIdentifier(tokens[cursor]);
|
|
832
|
+
if (name === undefined) break;
|
|
833
|
+
cursor += 1;
|
|
834
|
+
if (tokens[cursor]?.text === '(')
|
|
835
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
836
|
+
if (tokens[cursor]?.text.toLowerCase() !== 'as') break;
|
|
837
|
+
cursor += 1;
|
|
838
|
+
if (tokens[cursor]?.text.toLowerCase() === 'not') cursor += 1;
|
|
839
|
+
if (tokens[cursor]?.text.toLowerCase() === 'materialized') cursor += 1;
|
|
840
|
+
if (tokens[cursor]?.text !== '(') break;
|
|
841
|
+
ctes.push({ name: name.toLowerCase(), start: index, end: scopeEnd });
|
|
842
|
+
cursor = (closes.get(cursor) ?? scopeEnd) + 1;
|
|
843
|
+
if (tokens[cursor]?.text !== ',') break;
|
|
824
844
|
cursor += 1;
|
|
825
|
-
continue;
|
|
826
|
-
}
|
|
827
|
-
if (/[A-Za-z_]/.test(char)) {
|
|
828
|
-
let end = cursor + 1;
|
|
829
|
-
while (
|
|
830
|
-
end < cleaned.length &&
|
|
831
|
-
/[A-Za-z0-9_]/.test(cleaned[end] as string)
|
|
832
|
-
) {
|
|
833
|
-
end += 1;
|
|
834
|
-
}
|
|
835
|
-
const word = cleaned.slice(cursor, end).toUpperCase();
|
|
836
|
-
if (word === 'FROM') activeFromDepths.add(depth);
|
|
837
|
-
else if (clauseEnders.has(word)) activeFromDepths.delete(depth);
|
|
838
|
-
previous = word;
|
|
839
|
-
cursor = end;
|
|
840
|
-
continue;
|
|
841
845
|
}
|
|
842
|
-
previous = char;
|
|
843
|
-
cursor += 1;
|
|
844
846
|
}
|
|
845
|
-
return false;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
export function scanTableRefs(sql: string, ir: IrDocument): TableRef[] {
|
|
849
|
-
const cleaned = stripCommentsAndStrings(sql);
|
|
850
|
-
const known = new Map(
|
|
851
|
-
ir.tables.flatMap((table) => [
|
|
852
|
-
[table.name.toLowerCase(), table.name] as const,
|
|
853
|
-
...table.ftsIndexes.map(
|
|
854
|
-
(index) => [index.name.toLowerCase(), index.name] as const,
|
|
855
|
-
),
|
|
856
|
-
]),
|
|
857
|
-
);
|
|
858
|
-
const nullableGroups = [
|
|
859
|
-
...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
|
|
860
|
-
].map((match) => {
|
|
861
|
-
const open = (match.index ?? 0) + match[0].lastIndexOf('(');
|
|
862
|
-
return { open, close: matchingParenthesis(cleaned, open) };
|
|
863
|
-
});
|
|
864
847
|
const refs: TableRef[] = [];
|
|
865
848
|
const relationStartByDepth = new Map<number, number>();
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
const
|
|
869
|
-
|
|
870
|
-
const
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
(
|
|
876
|
-
|
|
877
|
-
|
|
849
|
+
const nullableGroups: { start: number; end: number }[] = [];
|
|
850
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
851
|
+
const token = tokens[index];
|
|
852
|
+
if (token?.kind !== 'identifier') continue;
|
|
853
|
+
const operator = token.text.toLowerCase();
|
|
854
|
+
if (operator !== 'from' && operator !== 'join') continue;
|
|
855
|
+
let outerKind: string | undefined;
|
|
856
|
+
if (operator === 'join') {
|
|
857
|
+
let previous = index - 1;
|
|
858
|
+
if (tokens[previous]?.text.toLowerCase() === 'outer') previous -= 1;
|
|
859
|
+
if (tokens[previous]?.kind === 'identifier')
|
|
860
|
+
outerKind = tokens[previous]?.text.toLowerCase();
|
|
861
|
+
}
|
|
862
|
+
const operatorDepth = depths[index] ?? 0;
|
|
863
|
+
let cursor = index + 1;
|
|
864
|
+
if (operator === 'from')
|
|
878
865
|
relationStartByDepth.set(operatorDepth, refs.length);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
866
|
+
if (outerKind === 'right' || outerKind === 'full') {
|
|
867
|
+
for (
|
|
868
|
+
let prior = relationStartByDepth.get(operatorDepth) ?? refs.length;
|
|
869
|
+
prior < refs.length;
|
|
870
|
+
prior += 1
|
|
871
|
+
) {
|
|
872
|
+
const ref = refs[prior];
|
|
873
|
+
if (ref !== undefined) refs[prior] = { ...ref, nullable: true };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (
|
|
877
|
+
(outerKind === 'left' || outerKind === 'full') &&
|
|
878
|
+
tokens[cursor]?.text === '('
|
|
883
879
|
) {
|
|
884
|
-
|
|
880
|
+
nullableGroups.push({
|
|
881
|
+
start: cursor,
|
|
882
|
+
end: closes.get(cursor) ?? tokens.length,
|
|
883
|
+
});
|
|
885
884
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
885
|
+
while (tokens[cursor]?.text === '(') cursor += 1;
|
|
886
|
+
const tableToken = tokens[cursor];
|
|
887
|
+
if (
|
|
888
|
+
tableToken?.kind === 'identifier' &&
|
|
889
|
+
['select', 'with', 'values'].includes(tableToken.text.toLowerCase())
|
|
890
|
+
)
|
|
891
|
+
continue;
|
|
892
|
+
const rawTable = sqlIdentifier(tableToken);
|
|
893
|
+
if (rawTable === undefined || tableToken === undefined) {
|
|
894
|
+
throw new TypegenError('query SQL', 'cannot resolve table relation');
|
|
895
895
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
896
|
+
if (tokens[cursor + 1]?.text === '.') {
|
|
897
|
+
throw new TypegenError(
|
|
898
|
+
'query SQL',
|
|
899
|
+
'schema-qualified relations are unsupported; use application table names',
|
|
900
|
+
);
|
|
901
901
|
}
|
|
902
|
+
if (
|
|
903
|
+
ctes.some(
|
|
904
|
+
(cte) =>
|
|
905
|
+
cte.name === rawTable.toLowerCase() &&
|
|
906
|
+
cursor > cte.start &&
|
|
907
|
+
cursor < cte.end,
|
|
908
|
+
)
|
|
909
|
+
)
|
|
910
|
+
continue;
|
|
911
|
+
const table = known.get(rawTable.toLowerCase());
|
|
912
|
+
if (table === undefined)
|
|
913
|
+
throw new TypegenError(
|
|
914
|
+
'query SQL',
|
|
915
|
+
`unresolved table relation ${JSON.stringify(rawTable)}`,
|
|
916
|
+
);
|
|
917
|
+
const tableDepth = depths[cursor] ?? operatorDepth;
|
|
918
|
+
if (operator === 'from' || !relationStartByDepth.has(tableDepth))
|
|
919
|
+
relationStartByDepth.set(tableDepth, refs.length);
|
|
920
|
+
let aliasToken = tokens[cursor + 1];
|
|
921
|
+
if (
|
|
922
|
+
aliasToken?.kind === 'identifier' &&
|
|
923
|
+
aliasToken.text.toLowerCase() === 'as'
|
|
924
|
+
)
|
|
925
|
+
aliasToken = tokens[cursor + 2];
|
|
926
|
+
const explicitAlias =
|
|
927
|
+
aliasToken?.kind === 'quoted-identifier' ||
|
|
928
|
+
(aliasToken?.kind === 'identifier' &&
|
|
929
|
+
!RESERVED_ALIAS.has(aliasToken.text.toLowerCase()))
|
|
930
|
+
? sqlIdentifier(aliasToken)
|
|
931
|
+
: undefined;
|
|
902
932
|
refs.push({
|
|
903
933
|
table,
|
|
904
|
-
|
|
934
|
+
start: tableToken.span.start.offset,
|
|
935
|
+
end: tableToken.span.end.offset,
|
|
936
|
+
alias: explicitAlias ?? table,
|
|
937
|
+
...(explicitAlias === undefined ? {} : { explicitAlias }),
|
|
905
938
|
nullable:
|
|
906
|
-
outerKind === '
|
|
907
|
-
outerKind === '
|
|
939
|
+
outerKind === 'left' ||
|
|
940
|
+
outerKind === 'full' ||
|
|
908
941
|
nullableGroups.some(
|
|
909
|
-
(group) =>
|
|
942
|
+
(group) => cursor > group.start && cursor < group.end,
|
|
910
943
|
),
|
|
911
944
|
});
|
|
912
945
|
}
|
|
@@ -1486,13 +1519,6 @@ export function analyzeStatement(
|
|
|
1486
1519
|
if (sourceSql.length === 0) {
|
|
1487
1520
|
throw new TypegenError(file, 'query file is empty');
|
|
1488
1521
|
}
|
|
1489
|
-
if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
|
|
1490
|
-
throw new TypegenError(
|
|
1491
|
-
file,
|
|
1492
|
-
'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause',
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
1522
|
// SELECT-only (the read tier). A `WITH` is allowed when its main statement
|
|
1497
1523
|
// is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
|
|
1498
1524
|
const firstKeyword = /^\s*([A-Za-z]+)/.exec(
|
|
@@ -1695,6 +1721,12 @@ export function analyzeStatement(
|
|
|
1695
1721
|
sourceSql,
|
|
1696
1722
|
sql,
|
|
1697
1723
|
positionalSql: toPositionalSql(sql),
|
|
1724
|
+
relations: scanTableRefs(toPositionalSql(sql), ir).map((ref) => ({
|
|
1725
|
+
table: ref.table,
|
|
1726
|
+
start: ref.start,
|
|
1727
|
+
end: ref.end,
|
|
1728
|
+
...(ref.explicitAlias === undefined ? {} : { alias: ref.explicitAlias }),
|
|
1729
|
+
})),
|
|
1698
1730
|
params,
|
|
1699
1731
|
columns,
|
|
1700
1732
|
tables,
|
package/src/syql-lowering.ts
CHANGED
package/src/syql-validator.ts
CHANGED
|
@@ -450,7 +450,18 @@ class Validator {
|
|
|
450
450
|
location,
|
|
451
451
|
);
|
|
452
452
|
this.#validateDeterminism(activeSql, logical.declaration.statement.span);
|
|
453
|
-
|
|
453
|
+
let refs: TableRef[];
|
|
454
|
+
try {
|
|
455
|
+
refs = scanTableRefs(activeSql, this.#ir);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// Preserve SQLite's source-spanned diagnostics for invalid relations.
|
|
458
|
+
this.#validateSqlite(activeSql, logical, []);
|
|
459
|
+
this.#fail(
|
|
460
|
+
'SYQL6002_INVALID_SQL',
|
|
461
|
+
logical.declaration.statement.span,
|
|
462
|
+
error instanceof Error ? error.message : String(error),
|
|
463
|
+
);
|
|
464
|
+
}
|
|
454
465
|
this.#validatePortableProfile(
|
|
455
466
|
activeSql,
|
|
456
467
|
logical.declaration.statement.span,
|