@syncular/typegen 0.15.27 → 0.15.29
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 +31 -10
- package/dist/emit-queries-rust.js +4 -2
- package/dist/query.d.ts +2 -0
- package/dist/query.js +196 -31
- package/dist/syql-validator.js +166 -44
- package/package.json +3 -3
- package/src/emit-queries-rust.ts +9 -7
- package/src/query.ts +224 -29
- package/src/syql-validator.ts +233 -58
package/README.md
CHANGED
|
@@ -557,7 +557,7 @@ boundary):
|
|
|
557
557
|
|
|
558
558
|
| Result column | Type source | Nullability | Fidelity |
|
|
559
559
|
|---|---|---|---|
|
|
560
|
-
| **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's `
|
|
560
|
+
| **plain column ref** (`title`, `t.title`, `title AS x`) | resolved to the IR column (decltype confirms) — exact IR type, incl. `json`/`blob_ref`/`crdt` | the IR column's nullability, lifted to nullable whenever `LEFT`, `RIGHT`, or `FULL OUTER JOIN` can null-extend its table alias | **exact** |
|
|
561
561
|
| **computed expression** (`count(*)`, `done + 1`, `:p AS l`) | decltype is null → documented fallback: aggregate/arithmetic → number, else string | always nullable (an expression's nullability is not knowable from decltype) | **fallback** |
|
|
562
562
|
|
|
563
563
|
Every local synced table also has the protocol-owned `_sync_version` column.
|
|
@@ -572,7 +572,9 @@ query getTodo(listId, todoId) {
|
|
|
572
572
|
}
|
|
573
573
|
```
|
|
574
574
|
|
|
575
|
-
The generated `serverVersion` field is an exact, non-null `integer
|
|
575
|
+
The generated `serverVersion` field is an exact, non-null `integer` for a base
|
|
576
|
+
or required join relation; it is nullable when projected from an optional side
|
|
577
|
+
of an outer join. The
|
|
576
578
|
physical name is intentionally not a public result name: project it with an
|
|
577
579
|
alias such as `server_version`. `_sync_version` is query-only, is excluded from
|
|
578
580
|
schema and mutation types, and is not added by `select *`; this prevents client
|
|
@@ -585,9 +587,17 @@ server assigns one.
|
|
|
585
587
|
run the statement against the empty DB), and `paramsCount`. It does **not**
|
|
586
588
|
expose column origin (table/column), param **names**, or `NOT NULL` flags — so
|
|
587
589
|
typegen resolves plain refs against the IR itself (parse the SELECT list +
|
|
588
|
-
FROM/JOIN, match `name`/`alias.name`) to attach
|
|
589
|
-
`:name` placeholders from the SQL text
|
|
590
|
-
|
|
590
|
+
FROM/JOIN, match `name`/`alias.name`) to attach schema nullability plus SQL
|
|
591
|
+
outer-join null-extension, and parses `:name` placeholders from the SQL text
|
|
592
|
+
for param names. Each join chain is evaluated left-to-right: `LEFT` lifts the
|
|
593
|
+
new right relation unit, `RIGHT` lifts every prior relation in that chain, and
|
|
594
|
+
`FULL` lifts both. A parenthesized relation group is one unit for its enclosing
|
|
595
|
+
join while its nested join order remains intact. `INNER`, `CROSS`, and ordinary
|
|
596
|
+
`JOIN` do not remove nullability already introduced earlier in the chain.
|
|
597
|
+
Unqualified coalesced `USING`/`NATURAL` columns are not assigned a guessed
|
|
598
|
+
physical origin and stay on the honest nullable fallback path. Want an exact
|
|
599
|
+
type for a computed column? Alias it to a plain ref, or accept the honest
|
|
600
|
+
fallback.
|
|
591
601
|
|
|
592
602
|
**Parameters** use the `:name` convention. A param's type is **inferred** where
|
|
593
603
|
it compares against a plain column ref — `WHERE list_id = :listId` (equality,
|
|
@@ -623,8 +633,8 @@ identifier and keeping those that name an IR table. This captures subquery /
|
|
|
623
633
|
`WHERE EXISTS (…)` tables (they still appear under `FROM`/`JOIN`), and the
|
|
624
634
|
prepare() still guarantees the SQL itself is correct.
|
|
625
635
|
|
|
626
|
-
Inference is deliberately conservative around `OR`,
|
|
627
|
-
computed identity. Ordinary scope predicates construct exact reactive facts;
|
|
636
|
+
Inference is deliberately conservative around `OR`, grouping, ambiguous joins,
|
|
637
|
+
and computed identity. Ordinary scope predicates construct exact reactive facts;
|
|
628
638
|
`sync query` additionally requests checked coverage:
|
|
629
639
|
|
|
630
640
|
```syql
|
|
@@ -634,10 +644,21 @@ sync query compareLists(left, right) {
|
|
|
634
644
|
}
|
|
635
645
|
```
|
|
636
646
|
|
|
637
|
-
Coverage must resolve
|
|
647
|
+
Coverage must resolve every read schema table, with one instance per table, and
|
|
648
|
+
bind every declared scope. A required scope bind can propagate across a
|
|
649
|
+
qualified scope-column equality in an unconditional outer `WHERE` clause or a
|
|
650
|
+
simple mandatory `ON` clause. The generated descriptor contains aggregate
|
|
651
|
+
coverage for every table, and readiness requires all of it. Self-joins and
|
|
652
|
+
`ON` clauses containing `OR`, `NOT`, or nested SQL cannot claim coverage. Only
|
|
638
653
|
required, non-null, exactly typed binds are allowed. Result identity is inferred
|
|
639
|
-
from schema keys and the projection. Without constructive proof
|
|
640
|
-
falls back to table-wide dependency, no coverage, and/or unkeyed
|
|
654
|
+
from schema keys and the projection. Without constructive proof an ordinary
|
|
655
|
+
query falls back to table-wide dependency, no coverage, and/or unkeyed
|
|
656
|
+
reconciliation; a `sync query` fails generation rather than claiming partial
|
|
657
|
+
completeness.
|
|
658
|
+
|
|
659
|
+
Use explicit `JOIN ... ON` relations. SQLite comma joins are rejected because
|
|
660
|
+
the compiler will not emit reactive metadata unless every read relation is
|
|
661
|
+
represented by the same proof model.
|
|
641
662
|
|
|
642
663
|
**Emitted shape, per language** (abbreviated):
|
|
643
664
|
|
|
@@ -299,7 +299,9 @@ function emitSelect(query) {
|
|
|
299
299
|
const name = field(limit.langName);
|
|
300
300
|
lines.push(` let effective_${name} = params.${name}.unwrap_or(${limit.defaultSize});`, ` if !(1..=${limit.maxSize}).contains(&effective_${name}) {`, ' return Err(QueryError::Input {', ' code: "SYQL_RUNTIME_INVALID_LIMIT",', ' query: ID,', ` message: ${quote(`${query.name}: limit must be an integer from 1 through ${limit.maxSize}`)}.to_owned(),`, ' });', ' }');
|
|
301
301
|
}
|
|
302
|
-
|
|
302
|
+
const usesActivationMask = metadata.plan.backend === 'variants' &&
|
|
303
|
+
metadata.plan.activationControls.length > 0;
|
|
304
|
+
if (usesActivationMask) {
|
|
303
305
|
lines.push(' let mut activation_mask = 0usize;');
|
|
304
306
|
metadata.plan.activationControls.forEach((control, index) => {
|
|
305
307
|
lines.push(` if ${syqlControlActive(query, control)} {`, ` activation_mask |= ${2 ** index};`, ' }');
|
|
@@ -315,7 +317,7 @@ function emitSelect(query) {
|
|
|
315
317
|
}
|
|
316
318
|
const sortIndex = sort?.kind === 'sort' ? 'sort_index' : '0usize';
|
|
317
319
|
const profileCount = sort?.kind === 'sort' ? sort.profiles.length : 1;
|
|
318
|
-
const index =
|
|
320
|
+
const index = usesActivationMask
|
|
319
321
|
? profileCount === 1
|
|
320
322
|
? 'activation_mask'
|
|
321
323
|
: `activation_mask * ${profileCount} + ${sortIndex}`
|
package/dist/query.d.ts
CHANGED
|
@@ -221,6 +221,8 @@ export interface TableRef {
|
|
|
221
221
|
readonly table: string;
|
|
222
222
|
/** Alias (or the table name when un-aliased). */
|
|
223
223
|
readonly alias: string;
|
|
224
|
+
/** True when an enclosing flat join chain can null-extend this relation. */
|
|
225
|
+
readonly nullable: boolean;
|
|
224
226
|
}
|
|
225
227
|
export declare function scanTableRefs(sql: string, ir: IrDocument): TableRef[];
|
|
226
228
|
/**
|
package/dist/query.js
CHANGED
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
* origin column's exact declared type. We map it back through the SAME
|
|
29
29
|
* TYPE_MAP the migration parser uses → the exact IR column type. For
|
|
30
30
|
* NULLABILITY we resolve the ref against the IR ourselves (parse the SELECT
|
|
31
|
-
* list + FROM/JOIN, match `name`/`alias.name` to an IR table column)
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* list + FROM/JOIN, match `name`/`alias.name` to an IR table column), then
|
|
32
|
+
* lift it when an outer join can synthesize NULL for that relation. This is
|
|
33
|
+
* the drift-kill: schema nullability plus SQL join null-extension, with
|
|
34
|
+
* SQLite still proving every reference.
|
|
34
35
|
* - **Computed expression** (`count(*)`, `done + 1`, `:label AS l`): decltype
|
|
35
36
|
* is null. We fall back to a documented honest type from the raw column
|
|
36
37
|
* name/shape: aggregate/arith → nullable number, anything else → nullable
|
|
@@ -450,6 +451,7 @@ const RESERVED_ALIAS = new Set([
|
|
|
450
451
|
'left',
|
|
451
452
|
'right',
|
|
452
453
|
'outer',
|
|
454
|
+
'natural',
|
|
453
455
|
'join',
|
|
454
456
|
'cross',
|
|
455
457
|
'using',
|
|
@@ -457,23 +459,159 @@ const RESERVED_ALIAS = new Set([
|
|
|
457
459
|
'having',
|
|
458
460
|
]);
|
|
459
461
|
const RESERVED_ALIAS_PATTERN = [...RESERVED_ALIAS].join('|');
|
|
460
|
-
const TABLE_REF_RE = new RegExp(`\\b(
|
|
461
|
-
|
|
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;
|
|
485
|
+
}
|
|
486
|
+
function hasCommaJoinedSchemaTable(sql, ir) {
|
|
462
487
|
const cleaned = stripCommentsAndStrings(sql);
|
|
463
488
|
const known = new Set(ir.tables.flatMap((table) => [
|
|
464
|
-
table.name,
|
|
465
|
-
...table.ftsIndexes.map((index) => index.name),
|
|
489
|
+
table.name.toLowerCase(),
|
|
490
|
+
...table.ftsIndexes.map((index) => index.name.toLowerCase()),
|
|
491
|
+
]));
|
|
492
|
+
const activeFromDepths = new Set();
|
|
493
|
+
const clauseEnders = new Set([
|
|
494
|
+
'WHERE',
|
|
495
|
+
'GROUP',
|
|
496
|
+
'HAVING',
|
|
497
|
+
'ORDER',
|
|
498
|
+
'LIMIT',
|
|
499
|
+
'WINDOW',
|
|
500
|
+
'UNION',
|
|
501
|
+
'EXCEPT',
|
|
502
|
+
'INTERSECT',
|
|
503
|
+
'RETURNING',
|
|
504
|
+
]);
|
|
505
|
+
let depth = 0;
|
|
506
|
+
let cursor = 0;
|
|
507
|
+
const nextIdentifier = (start) => {
|
|
508
|
+
let index = start;
|
|
509
|
+
while (index < cleaned.length &&
|
|
510
|
+
(/\s/.test(cleaned[index]) || cleaned[index] === '(')) {
|
|
511
|
+
index += 1;
|
|
512
|
+
}
|
|
513
|
+
const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cleaned.slice(index));
|
|
514
|
+
return match?.[0];
|
|
515
|
+
};
|
|
516
|
+
while (cursor < cleaned.length) {
|
|
517
|
+
const char = cleaned[cursor];
|
|
518
|
+
if (char === '(') {
|
|
519
|
+
const next = nextIdentifier(cursor + 1);
|
|
520
|
+
if (activeFromDepths.has(depth) &&
|
|
521
|
+
next !== undefined &&
|
|
522
|
+
known.has(next.toLowerCase())) {
|
|
523
|
+
activeFromDepths.add(depth + 1);
|
|
524
|
+
}
|
|
525
|
+
depth += 1;
|
|
526
|
+
cursor += 1;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
if (char === ')') {
|
|
530
|
+
activeFromDepths.delete(depth);
|
|
531
|
+
depth = Math.max(0, depth - 1);
|
|
532
|
+
cursor += 1;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (char === ',' && activeFromDepths.has(depth)) {
|
|
536
|
+
const next = nextIdentifier(cursor + 1);
|
|
537
|
+
if (next !== undefined && known.has(next.toLowerCase()))
|
|
538
|
+
return true;
|
|
539
|
+
cursor += 1;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (/[A-Za-z_]/.test(char)) {
|
|
543
|
+
let end = cursor + 1;
|
|
544
|
+
while (end < cleaned.length &&
|
|
545
|
+
/[A-Za-z0-9_]/.test(cleaned[end])) {
|
|
546
|
+
end += 1;
|
|
547
|
+
}
|
|
548
|
+
const word = cleaned.slice(cursor, end).toUpperCase();
|
|
549
|
+
if (word === 'FROM')
|
|
550
|
+
activeFromDepths.add(depth);
|
|
551
|
+
else if (clauseEnders.has(word))
|
|
552
|
+
activeFromDepths.delete(depth);
|
|
553
|
+
cursor = end;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
cursor += 1;
|
|
557
|
+
}
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
export function scanTableRefs(sql, ir) {
|
|
561
|
+
const cleaned = stripCommentsAndStrings(sql);
|
|
562
|
+
const known = new Map(ir.tables.flatMap((table) => [
|
|
563
|
+
[table.name.toLowerCase(), table.name],
|
|
564
|
+
...table.ftsIndexes.map((index) => [index.name.toLowerCase(), index.name]),
|
|
466
565
|
]));
|
|
566
|
+
const nullableGroups = [
|
|
567
|
+
...cleaned.matchAll(/\b(?:LEFT|FULL)(?:\s+OUTER)?\s+JOIN\s*(\()/gi),
|
|
568
|
+
].map((match) => {
|
|
569
|
+
const open = (match.index ?? 0) + match[0].lastIndexOf('(');
|
|
570
|
+
return { open, close: matchingParenthesis(cleaned, open) };
|
|
571
|
+
});
|
|
467
572
|
const refs = [];
|
|
573
|
+
const relationStartByDepth = new Map();
|
|
468
574
|
for (const m of cleaned.matchAll(TABLE_REF_RE)) {
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
575
|
+
const operator = m[1].toUpperCase();
|
|
576
|
+
const outerKind = m[2]?.toUpperCase();
|
|
577
|
+
const rawTable = m[4];
|
|
578
|
+
const operatorDepth = parenthesisDepthAt(cleaned, m.index ?? 0);
|
|
579
|
+
const relativeTableIndex = m[0]
|
|
580
|
+
.toLowerCase()
|
|
581
|
+
.indexOf(rawTable.toLowerCase(), m[1].length);
|
|
582
|
+
const tableIndex = (m.index ?? 0) + Math.max(m[1].length, relativeTableIndex);
|
|
583
|
+
const tableDepth = parenthesisDepthAt(cleaned, tableIndex);
|
|
584
|
+
if (operator === 'FROM') {
|
|
585
|
+
relationStartByDepth.set(operatorDepth, refs.length);
|
|
586
|
+
relationStartByDepth.set(tableDepth, refs.length);
|
|
587
|
+
}
|
|
588
|
+
else if (tableDepth > operatorDepth &&
|
|
589
|
+
!relationStartByDepth.has(tableDepth)) {
|
|
590
|
+
relationStartByDepth.set(tableDepth, refs.length);
|
|
591
|
+
}
|
|
592
|
+
if (outerKind === 'RIGHT' || outerKind === 'FULL') {
|
|
593
|
+
const relationStart = relationStartByDepth.get(operatorDepth) ?? refs.length;
|
|
594
|
+
for (let index = relationStart; index < refs.length; index += 1) {
|
|
595
|
+
const prior = refs[index];
|
|
596
|
+
if (prior !== undefined && !prior.nullable) {
|
|
597
|
+
refs[index] = { ...prior, nullable: true };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const table = known.get(rawTable.toLowerCase());
|
|
602
|
+
if (table === undefined)
|
|
603
|
+
continue; // e.g. a derived SELECT/CTE name
|
|
604
|
+
let alias = m[5];
|
|
473
605
|
if (alias !== undefined && RESERVED_ALIAS.has(alias.toLowerCase())) {
|
|
474
606
|
alias = undefined;
|
|
475
607
|
}
|
|
476
|
-
refs.push({
|
|
608
|
+
refs.push({
|
|
609
|
+
table,
|
|
610
|
+
alias: alias ?? table,
|
|
611
|
+
nullable: outerKind === 'LEFT' ||
|
|
612
|
+
outerKind === 'FULL' ||
|
|
613
|
+
nullableGroups.some((group) => tableIndex > group.open && tableIndex < group.close),
|
|
614
|
+
});
|
|
477
615
|
}
|
|
478
616
|
return refs;
|
|
479
617
|
}
|
|
@@ -532,43 +670,66 @@ function resolveSource(item, refs, ir) {
|
|
|
532
670
|
const columnName = m[2];
|
|
533
671
|
const byName = new Map(ir.tables.map((t) => [t.name, t]));
|
|
534
672
|
if (qualifier !== undefined) {
|
|
535
|
-
const ref = refs.find((
|
|
673
|
+
const ref = refs.find((candidate) => candidate.alias.toLowerCase() === qualifier.toLowerCase());
|
|
536
674
|
if (ref === undefined)
|
|
537
675
|
return null;
|
|
538
676
|
const table = byName.get(ref.table);
|
|
539
|
-
const col = table?.columns.find((
|
|
540
|
-
if (col !== undefined)
|
|
541
|
-
return {
|
|
677
|
+
const col = table?.columns.find((candidate) => candidate.name.toLowerCase() === columnName.toLowerCase());
|
|
678
|
+
if (col !== undefined) {
|
|
679
|
+
return {
|
|
680
|
+
table: ref.table,
|
|
681
|
+
column: col,
|
|
682
|
+
nullableByJoin: ref.nullable,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
542
685
|
if (columnName === FTS_SOURCE_ID_COLUMN &&
|
|
543
686
|
declaredFtsProjection(ir, ref.table)) {
|
|
544
|
-
return {
|
|
687
|
+
return {
|
|
688
|
+
table: ref.table,
|
|
689
|
+
column: FTS_SOURCE_ID_IR_COLUMN,
|
|
690
|
+
nullableByJoin: ref.nullable,
|
|
691
|
+
};
|
|
545
692
|
}
|
|
546
693
|
return null;
|
|
547
694
|
}
|
|
548
695
|
// Unqualified: search every FROM/JOIN table (SQLite already resolved
|
|
549
|
-
// ambiguity; a single-table query is the common case).
|
|
696
|
+
// ambiguity; a single-table query is the common case). A USING/NATURAL
|
|
697
|
+
// coalesced name can have more than one physical origin, so keep it on the
|
|
698
|
+
// honest nullable fallback path instead of guessing one side.
|
|
699
|
+
const matches = [];
|
|
550
700
|
for (const ref of refs) {
|
|
551
701
|
const table = byName.get(ref.table);
|
|
552
|
-
const col = table?.columns.find((
|
|
553
|
-
if (col !== undefined)
|
|
554
|
-
|
|
702
|
+
const col = table?.columns.find((candidate) => candidate.name.toLowerCase() === columnName.toLowerCase());
|
|
703
|
+
if (col !== undefined) {
|
|
704
|
+
matches.push({
|
|
705
|
+
table: ref.table,
|
|
706
|
+
column: col,
|
|
707
|
+
nullableByJoin: ref.nullable,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
555
710
|
if (columnName === FTS_SOURCE_ID_COLUMN &&
|
|
556
711
|
declaredFtsProjection(ir, ref.table)) {
|
|
557
|
-
|
|
712
|
+
matches.push({
|
|
713
|
+
table: ref.table,
|
|
714
|
+
column: FTS_SOURCE_ID_IR_COLUMN,
|
|
715
|
+
nullableByJoin: ref.nullable,
|
|
716
|
+
});
|
|
558
717
|
}
|
|
559
718
|
}
|
|
560
|
-
return null;
|
|
719
|
+
return matches.length === 1 ? matches[0] : null;
|
|
561
720
|
}
|
|
562
721
|
/** Resolve the one supported query-only protocol column. SQLite still proves
|
|
563
722
|
* qualifier/ambiguity against the synthesized local tables. */
|
|
564
|
-
function
|
|
723
|
+
function syncVersionSourceNullability(item, refs) {
|
|
565
724
|
const match = PLAIN_REF_RE.exec(item.expr);
|
|
566
725
|
if (match === null || match[2] !== SYNC_VERSION_COLUMN)
|
|
567
|
-
return
|
|
726
|
+
return null;
|
|
568
727
|
const qualifier = match[1];
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
728
|
+
if (qualifier === undefined) {
|
|
729
|
+
return refs.length === 1 ? (refs[0]?.nullable ?? null) : null;
|
|
730
|
+
}
|
|
731
|
+
const matches = refs.filter((ref) => ref.alias.toLowerCase() === qualifier.toLowerCase());
|
|
732
|
+
return matches.length === 1 ? (matches[0]?.nullable ?? null) : null;
|
|
572
733
|
}
|
|
573
734
|
// -- param type inference -----------------------------------------------------
|
|
574
735
|
//
|
|
@@ -883,6 +1044,9 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
883
1044
|
if (sourceSql.length === 0) {
|
|
884
1045
|
throw new TypegenError(file, 'query file is empty');
|
|
885
1046
|
}
|
|
1047
|
+
if (hasCommaJoinedSchemaTable(sourceSql, ir)) {
|
|
1048
|
+
throw new TypegenError(file, 'comma-separated table sources are unsupported because reactive proof requires every relation; use an explicit JOIN ... ON clause');
|
|
1049
|
+
}
|
|
886
1050
|
// SELECT-only (the read tier). A `WITH` is allowed when its main statement
|
|
887
1051
|
// is a SELECT (SQLite also allows WITH … INSERT/UPDATE/DELETE — writes).
|
|
888
1052
|
const firstKeyword = /^\s*([A-Za-z]+)/.exec(stripCommentsAndStrings(statementText).trimStart())?.[1];
|
|
@@ -944,7 +1108,8 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
944
1108
|
const source = item !== undefined ? resolveSource(item, refs, ir) : null;
|
|
945
1109
|
const sqlName = sqlNames[index] ?? colName;
|
|
946
1110
|
const langName = langNames[index] ?? colName;
|
|
947
|
-
|
|
1111
|
+
const syncVersionNullable = item !== undefined ? syncVersionSourceNullability(item, refs) : null;
|
|
1112
|
+
if (syncVersionNullable !== null) {
|
|
948
1113
|
if (sqlName === SYNC_VERSION_COLUMN) {
|
|
949
1114
|
throw new TypegenError(file, `${SYNC_VERSION_COLUMN} is stripped from app-facing query rows unless explicitly aliased (for example \`${SYNC_VERSION_COLUMN} AS server_version\`)`);
|
|
950
1115
|
}
|
|
@@ -952,7 +1117,7 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
952
1117
|
name: sqlName,
|
|
953
1118
|
langName,
|
|
954
1119
|
type: 'integer',
|
|
955
|
-
nullable:
|
|
1120
|
+
nullable: syncVersionNullable,
|
|
956
1121
|
fidelity: 'exact',
|
|
957
1122
|
};
|
|
958
1123
|
}
|
|
@@ -968,7 +1133,7 @@ export function analyzeStatement(name, location, statementText, ir, db, naming =
|
|
|
968
1133
|
name: sqlName,
|
|
969
1134
|
langName,
|
|
970
1135
|
type: sourceType,
|
|
971
|
-
nullable: source.column.nullable,
|
|
1136
|
+
nullable: source.column.nullable || source.nullableByJoin,
|
|
972
1137
|
fidelity: 'exact',
|
|
973
1138
|
origin: { table: source.table, column: source.column.name },
|
|
974
1139
|
};
|