@syncular/typegen 0.15.48 → 0.17.0

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/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,
@@ -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 },
@@ -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
- const refs = scanTableRefs(activeSql, this.#ir);
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.15.48",
3
+ "version": "0.17.0",
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.15.48"
59
+ "@syncular/core": "0.17.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@syncular/server": "0.15.48"
62
+ "@syncular/server": "0.17.0"
63
63
  }
64
64
  }
@@ -892,7 +892,7 @@ function emitSupport(clientCrate: string): string {
892
892
  ' ));',
893
893
  ' }',
894
894
  ' hex.as_bytes()',
895
- ' .chunks_exact(2)',
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"))?;',
@@ -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
- ? `{ sql: string; bind: (params: ${Params}) => QueryValue[] }`
318
- : '{ sql: string; bind: () => QueryValue[] }';
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: 3,
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
- const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
721
- const TABLE_REF_RE = new RegExp(
722
- `\\b(FROM|(?:NATURAL\\s+)?(?:(LEFT|RIGHT|FULL)(?:\\s+OUTER)?|INNER|CROSS)?\\s*JOIN)\\s+((?:\\(\\s*)*)(${IDENT})(?:\\s+(?:AS\\s+)?((?!(?:${RESERVED_ALIAS_PATTERN})\\b)${IDENT}))?`,
723
- 'gi',
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 matchingParenthesis(sql: string, open: number): number {
736
- let depth = 0;
737
- for (let cursor = open; cursor < sql.length; cursor += 1) {
738
- if (sql[cursor] === '(') depth += 1;
739
- else if (sql[cursor] === ')') {
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((index) => index.name.toLowerCase()),
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
- 'WHERE',
758
- 'GROUP',
759
- 'HAVING',
760
- 'ORDER',
761
- 'LIMIT',
762
- 'WINDOW',
763
- 'UNION',
764
- 'EXCEPT',
765
- 'INTERSECT',
766
- 'RETURNING',
777
+ 'where',
778
+ 'group',
779
+ 'having',
780
+ 'order',
781
+ 'limit',
782
+ 'window',
783
+ 'union',
784
+ 'except',
785
+ 'intersect',
786
+ 'returning',
767
787
  ]);
768
- let depth = 0;
769
- let cursor = 0;
770
- /** The previous significant token: an uppercased word or a single
771
- * punctuation character. Distinguishes a table-source group a `(` after
772
- * FROM, JOIN, `,` or another `(` from a function call or parenthesized
773
- * expression, whose `(` follows an identifier or an operator. */
774
- let previous: string | undefined;
775
- const nextIdentifier = (start: number): string | undefined => {
776
- let index = start;
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 (char === '(') {
794
- const opensTableSource =
795
- previous === 'FROM' ||
796
- previous === 'JOIN' ||
797
- previous === ',' ||
798
- previous === '(';
799
- const next = nextIdentifier(cursor + 1);
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
- opensTableSource &&
802
- activeFromDepths.has(depth) &&
803
- next !== undefined &&
804
- known.has(next.toLowerCase())
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
- depth += 1;
809
- previous = '(';
810
- cursor += 1;
811
- continue;
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
- if (char === ')') {
814
- activeFromDepths.delete(depth);
815
- depth = Math.max(0, depth - 1);
816
- previous = ')';
817
- cursor += 1;
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
- if (char === ',' && activeFromDepths.has(depth)) {
821
- const next = nextIdentifier(cursor + 1);
822
- if (next !== undefined && known.has(next.toLowerCase())) return true;
823
- previous = ',';
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
- for (const m of cleaned.matchAll(TABLE_REF_RE)) {
867
- const operator = (m[1] as string).toUpperCase();
868
- const outerKind = m[2]?.toUpperCase();
869
- const rawTable = m[4] as string;
870
- const operatorDepth = parenthesisDepthAt(cleaned, m.index ?? 0);
871
- const relativeTableIndex = m[0]
872
- .toLowerCase()
873
- .indexOf(rawTable.toLowerCase(), (m[1] as string).length);
874
- const tableIndex =
875
- (m.index ?? 0) + Math.max((m[1] as string).length, relativeTableIndex);
876
- const tableDepth = parenthesisDepthAt(cleaned, tableIndex);
877
- if (operator === 'FROM') {
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
- relationStartByDepth.set(tableDepth, refs.length);
880
- } else if (
881
- tableDepth > operatorDepth &&
882
- !relationStartByDepth.has(tableDepth)
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
- relationStartByDepth.set(tableDepth, refs.length);
880
+ nullableGroups.push({
881
+ start: cursor,
882
+ end: closes.get(cursor) ?? tokens.length,
883
+ });
885
884
  }
886
- if (outerKind === 'RIGHT' || outerKind === 'FULL') {
887
- const relationStart =
888
- relationStartByDepth.get(operatorDepth) ?? refs.length;
889
- for (let index = relationStart; index < refs.length; index += 1) {
890
- const prior = refs[index];
891
- if (prior !== undefined && !prior.nullable) {
892
- refs[index] = { ...prior, nullable: true };
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
- const table = known.get(rawTable.toLowerCase());
897
- if (table === undefined) continue; // e.g. a derived SELECT/CTE name
898
- let alias = m[5];
899
- if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
900
- alias = undefined;
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
- alias: alias ?? table,
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 === 'LEFT' ||
907
- outerKind === 'FULL' ||
939
+ outerKind === 'left' ||
940
+ outerKind === 'full' ||
908
941
  nullableGroups.some(
909
- (group) => tableIndex > group.open && tableIndex < group.close,
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,
@@ -470,6 +470,7 @@ function lowerStatement(
470
470
  ...(activationMask === undefined ? {} : { activationMask }),
471
471
  sql: namedSql,
472
472
  positionalSql: analyzed.positionalSql,
473
+ relations: analyzed.relations,
473
474
  binds: bindNames.map((name) =>
474
475
  planBind(
475
476
  name,
@@ -450,7 +450,18 @@ class Validator {
450
450
  location,
451
451
  );
452
452
  this.#validateDeterminism(activeSql, logical.declaration.statement.span);
453
- const refs = scanTableRefs(activeSql, this.#ir);
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,