@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 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
- ' .chunks_exact(2)',
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"))?;',
@@ -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
- ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
227
- : '{ sql: string; bind: () => QueryValue[] }';
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
- ...(error instanceof SyqlFrontendError ? { code: error.code } : {}),
316
- message: this.#context(pathToFileURL(file).href).kind === 'error'
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: 3,
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
- const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
462
- const TABLE_REF_RE = new RegExp(`\\b(FROM|(?:NATURAL\\s+)?(?:(LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`, 'gi');
463
- function parenthesisDepthAt(sql, index) {
464
- let depth = 0;
465
- for (let cursor = 0; cursor < index; cursor += 1) {
466
- if (sql[cursor] === '(')
467
- depth += 1;
468
- else if (sql[cursor] === ')')
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 hasCommaJoinedSchemaTable(sql, ir) {
487
- const cleaned = stripCommentsAndStrings(sql);
488
- const known = new Set(ir.tables.flatMap((table) => [
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
- 'WHERE',
495
- 'GROUP',
496
- 'HAVING',
497
- 'ORDER',
498
- 'LIMIT',
499
- 'WINDOW',
500
- 'UNION',
501
- 'EXCEPT',
502
- 'INTERSECT',
503
- 'RETURNING',
502
+ 'where',
503
+ 'group',
504
+ 'having',
505
+ 'order',
506
+ 'limit',
507
+ 'window',
508
+ 'union',
509
+ 'except',
510
+ 'intersect',
511
+ 'returning',
504
512
  ]);
505
- let depth = 0;
506
- let cursor = 0;
507
- /** The previous significant token: an uppercased word or a single
508
- * punctuation character. Distinguishes a table-source group a `(` after
509
- * FROM, JOIN, `,` or another `(` from a function call or parenthesized
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
- const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
519
- return match?.[0];
520
- };
521
- while (cursor < cleaned.length) {
522
- const char = cleaned[cursor];
523
- if (/\s/.test(char)) {
524
- cursor += 1;
525
- continue;
526
- }
527
- if (char === '(') {
528
- const opensTableSource = previous === 'FROM' ||
529
- previous === 'JOIN' ||
530
- previous === ',' ||
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 (char === ')') {
545
- activeFromDepths.delete(depth);
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
- if (/[A-Za-z_]/.test(char)) {
560
- let end = cursor + 1;
561
- while (end < cleaned.length &&
562
- /[A-Za-z0-9_]/.test(cleaned[end])) {
563
- end += 1;
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
- for (const m of cleaned.matchAll(TABLE_REF_RE)) {
594
- const operator = m[1].toUpperCase();
595
- const outerKind = m[2]?.toUpperCase();
596
- const rawTable = m[4];
597
- const operatorDepth = parenthesisDepthAt(cleaned, m.index ?? 0);
598
- const relativeTableIndex = m[0]
599
- .toLowerCase()
600
- .indexOf(rawTable.toLowerCase(), m[1].length);
601
- const tableIndex = (m.index ?? 0) + Math.max(m[1].length, relativeTableIndex);
602
- const tableDepth = parenthesisDepthAt(cleaned, tableIndex);
603
- if (operator === 'FROM') {
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
- relationStartByDepth.set(tableDepth, refs.length);
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
- else if (tableDepth > operatorDepth &&
608
- !relationStartByDepth.has(tableDepth)) {
609
- relationStartByDepth.set(tableDepth, refs.length);
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
- if (outerKind === 'RIGHT' || outerKind === 'FULL') {
612
- const relationStart = relationStartByDepth.get(operatorDepth) ?? refs.length;
613
- for (let index = relationStart; index < refs.length; index += 1) {
614
- const prior = refs[index];
615
- if (prior !== undefined && !prior.nullable) {
616
- refs[index] = { ...prior, nullable: true };
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
- continue; // e.g. a derived SELECT/CTE name
623
- let alias = m[5];
624
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
625
- alias = undefined;
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
- alias: alias ?? table,
630
- nullable: outerKind === 'LEFT' ||
631
- outerKind === 'FULL' ||
632
- nullableGroups.some((group) => tableIndex > group.open && tableIndex < group.close),
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,