@syncular/typegen 0.15.47 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/emit-queries-rust.js +1 -1
- package/dist/emit-queries.js +12 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lsp.js +13 -2
- package/dist/query-ir.js +3 -1
- package/dist/query.d.ts +12 -0
- package/dist/query.js +170 -152
- package/dist/syql-diagnostics.d.ts +77 -0
- package/dist/syql-diagnostics.js +72 -0
- package/dist/syql-lowering.js +1 -0
- package/dist/syql-validator.js +9 -1
- package/package.json +3 -3
- package/src/emit-queries-rust.ts +1 -1
- package/src/emit-queries.ts +14 -4
- package/src/index.ts +1 -0
- package/src/lsp.ts +18 -5
- package/src/query-ir.ts +3 -1
- package/src/query.ts +200 -168
- package/src/syql-diagnostics.ts +160 -0
- package/src/syql-lowering.ts +1 -0
- package/src/syql-validator.ts +12 -1
package/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/index.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ export * from './query.js';
|
|
|
25
25
|
export * from './query-ir.js';
|
|
26
26
|
export * from './sql.js';
|
|
27
27
|
export * from './syql-ast.js';
|
|
28
|
+
export * from './syql-diagnostics.js';
|
|
28
29
|
export * from './syql-lexer.js';
|
|
29
30
|
export * from './syql-lowering.js';
|
|
30
31
|
export * from './syql-modules.js';
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export * from './query.js';
|
|
|
25
25
|
export * from './query-ir.js';
|
|
26
26
|
export * from './sql.js';
|
|
27
27
|
export * from './syql-ast.js';
|
|
28
|
+
export * from './syql-diagnostics.js';
|
|
28
29
|
export * from './syql-lexer.js';
|
|
29
30
|
export * from './syql-lowering.js';
|
|
30
31
|
export * from './syql-modules.js';
|
package/dist/lsp.js
CHANGED
|
@@ -7,6 +7,7 @@ import { formatSyql } from './fmt.js';
|
|
|
7
7
|
import { buildIr, loadMigrations, loadQueries, makeQueryDb } from './generate.js';
|
|
8
8
|
import { MANIFEST_FILENAME, parseManifest } from './manifest.js';
|
|
9
9
|
import { lockedMigrationNames, readMigrationLock } from './migration-lock.js';
|
|
10
|
+
import { syqlDiagnosticRemedy } from './syql-diagnostics.js';
|
|
10
11
|
import { SyqlFrontendError } from './syql-lexer.js';
|
|
11
12
|
import { lowerSyqlQuery } from './syql-lowering.js';
|
|
12
13
|
import { buildSyqlModuleGraph } from './syql-modules.js';
|
|
@@ -294,14 +295,23 @@ export class SyqlLanguageServer {
|
|
|
294
295
|
const message = error instanceof Error ? error.message : String(error);
|
|
295
296
|
if (error instanceof SyqlFrontendError &&
|
|
296
297
|
resolve(error.sourceFile) === file) {
|
|
298
|
+
const remedy = syqlDiagnosticRemedy(error.code);
|
|
297
299
|
return {
|
|
298
300
|
range: spanRange(text, error.span),
|
|
299
301
|
severity: 1,
|
|
300
302
|
source: 'syncular',
|
|
301
303
|
code: error.code,
|
|
304
|
+
...(remedy === undefined ? {} : { data: { remedy } }),
|
|
302
305
|
message: error.detail,
|
|
303
306
|
};
|
|
304
307
|
}
|
|
308
|
+
const projectContextError = this.#context(pathToFileURL(file).href).kind === 'error';
|
|
309
|
+
const code = error instanceof SyqlFrontendError
|
|
310
|
+
? error.code
|
|
311
|
+
: projectContextError
|
|
312
|
+
? 'SYQL9001_PROJECT_CONTEXT'
|
|
313
|
+
: undefined;
|
|
314
|
+
const remedy = code === undefined ? undefined : syqlDiagnosticRemedy(code);
|
|
305
315
|
return {
|
|
306
316
|
range: {
|
|
307
317
|
start: { line: 0, character: 0 },
|
|
@@ -312,8 +322,9 @@ export class SyqlLanguageServer {
|
|
|
312
322
|
},
|
|
313
323
|
severity: 1,
|
|
314
324
|
source: 'syncular',
|
|
315
|
-
...(
|
|
316
|
-
|
|
325
|
+
...(code === undefined ? {} : { code }),
|
|
326
|
+
...(remedy === undefined ? {} : { data: { remedy } }),
|
|
327
|
+
message: projectContextError
|
|
317
328
|
? `SYQL9001_PROJECT_CONTEXT: ${message}`
|
|
318
329
|
: message,
|
|
319
330
|
};
|
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,
|