@syncular/typegen 0.15.42 → 0.15.43

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.
@@ -1314,7 +1314,20 @@ class Validator {
1314
1314
  }
1315
1315
  return true;
1316
1316
  };
1317
- const requiredJoinEqualityAt = (index: number): boolean => {
1317
+ type JoinEqualityContext =
1318
+ | { readonly kind: 'inner' }
1319
+ | {
1320
+ readonly kind: 'outer';
1321
+ /** Lowercased alias introduced by the JOIN that owns this ON. */
1322
+ readonly introducedAlias: string;
1323
+ /** The operand side this outer join null-extends. An outer join's
1324
+ * ON constrains matched rows of the null-extended side only;
1325
+ * preserved-side rows appear even when the ON predicate fails. */
1326
+ readonly nullExtended: 'introduced' | 'prior';
1327
+ };
1328
+ const joinEqualityContextAt = (
1329
+ index: number,
1330
+ ): JoinEqualityContext | undefined => {
1318
1331
  const prefix = cleaned.slice(0, index);
1319
1332
  const clauses = [
1320
1333
  ...prefix.matchAll(
@@ -1322,7 +1335,7 @@ class Validator {
1322
1335
  ),
1323
1336
  ];
1324
1337
  const last = clauses.at(-1);
1325
- if (last?.[1]?.toUpperCase() !== 'ON') return false;
1338
+ if (last?.[1]?.toUpperCase() !== 'ON') return undefined;
1326
1339
  const start = (last.index ?? 0) + last[0].length;
1327
1340
  const next =
1328
1341
  /\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/i.exec(
@@ -1330,7 +1343,30 @@ class Validator {
1330
1343
  );
1331
1344
  const end = next === null ? cleaned.length : index + next.index;
1332
1345
  const clause = cleaned.slice(start, end);
1333
- return !/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause);
1346
+ if (/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause)) return undefined;
1347
+ const join = clauses.at(-2);
1348
+ if (join?.[1]?.toUpperCase() !== 'JOIN') return undefined;
1349
+ const joinIndex = join.index ?? 0;
1350
+ const outerKind = /\b(LEFT|RIGHT|FULL)(?:\s+OUTER)?\s*$/i
1351
+ .exec(prefix.slice(0, joinIndex))?.[1]
1352
+ ?.toUpperCase();
1353
+ if (outerKind === undefined) return { kind: 'inner' };
1354
+ // A FULL join preserves both operands, so its ON constrains neither.
1355
+ if (outerKind === 'FULL') return undefined;
1356
+ const introducedAlias = (
1357
+ prefix
1358
+ .slice(joinIndex + join[0].length, last.index)
1359
+ .match(new RegExp(IDENT_SOURCE, 'g')) ?? []
1360
+ )
1361
+ .filter((word) => word.toUpperCase() !== 'AS')
1362
+ .at(-1)
1363
+ ?.toLowerCase();
1364
+ if (introducedAlias === undefined) return undefined;
1365
+ return {
1366
+ kind: 'outer',
1367
+ introducedAlias,
1368
+ nullExtended: outerKind === 'LEFT' ? 'introduced' : 'prior',
1369
+ };
1334
1370
  };
1335
1371
  const refByAlias = new Map(
1336
1372
  refs.map((ref) => [ref.alias.toLowerCase(), ref] as const),
@@ -1354,6 +1390,42 @@ class Validator {
1354
1390
  const rightRoot = find(right);
1355
1391
  if (leftRoot !== rightRoot) parents.set(rightRoot, leftRoot);
1356
1392
  };
1393
+ /** Directed scope inheritance for outer-join ON equalities: `target`
1394
+ * inherits `source`'s proof on every row where target's table actually
1395
+ * contributes (matched rows of the null-extended side). The preserved
1396
+ * side gains nothing from such an equality. */
1397
+ const inheritSources = new Map<string, Set<string>>();
1398
+ const addInheritEdge = (target: string, source: string): void => {
1399
+ find(target);
1400
+ find(source);
1401
+ const sources = inheritSources.get(target) ?? new Set<string>();
1402
+ sources.add(source);
1403
+ inheritSources.set(target, sources);
1404
+ };
1405
+ /** Every node whose direct proof also constrains `start`: the union
1406
+ * class of `start`, plus everything reachable through directed
1407
+ * inheritance edges (each hop stays sound because a satisfied equality
1408
+ * needs both operands non-NULL, so the source relation contributed the
1409
+ * row). */
1410
+ const reachableProofNodes = (start: string): ReadonlySet<string> => {
1411
+ const seen = new Set<string>();
1412
+ const queue = [start];
1413
+ while (queue.length > 0) {
1414
+ const node = queue.pop() as string;
1415
+ if (seen.has(node)) continue;
1416
+ seen.add(node);
1417
+ const root = find(node);
1418
+ for (const classmate of [...parents.keys()]) {
1419
+ if (!seen.has(classmate) && find(classmate) === root) {
1420
+ queue.push(classmate);
1421
+ }
1422
+ }
1423
+ for (const source of inheritSources.get(node) ?? []) {
1424
+ if (!seen.has(source)) queue.push(source);
1425
+ }
1426
+ }
1427
+ return seen;
1428
+ };
1357
1429
  const scopeColumn = (alias: string, column: string): boolean => {
1358
1430
  const ref = refByAlias.get(alias.toLowerCase());
1359
1431
  const table = this.#ir.tables.find((item) => item.name === ref?.table);
@@ -1363,8 +1435,12 @@ class Validator {
1363
1435
  ) === true
1364
1436
  );
1365
1437
  };
1438
+ // Scope inheritance across a join relies on a satisfied predicate proving
1439
+ // both operands non-NULL. `IS` is null-safe (`NULL IS NULL` holds), so it
1440
+ // would let a null-extended row on an outer join's preserved side carry a
1441
+ // scope proof it never satisfies; only `=`/`==` qualify here.
1366
1442
  const qualifiedEquality = new RegExp(
1367
- `(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==|\\bIS\\b)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`,
1443
+ `(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`,
1368
1444
  'gi',
1369
1445
  );
1370
1446
  for (const match of cleaned.matchAll(qualifiedEquality)) {
@@ -1374,16 +1450,39 @@ class Validator {
1374
1450
  const rightColumn = match[4] as string;
1375
1451
  if (
1376
1452
  !scopeColumn(leftAlias, leftColumn) ||
1377
- !scopeColumn(rightAlias, rightColumn) ||
1378
- (!requiredAt(match.index ?? 0) &&
1379
- !requiredJoinEqualityAt(match.index ?? 0))
1453
+ !scopeColumn(rightAlias, rightColumn)
1380
1454
  ) {
1381
1455
  continue;
1382
1456
  }
1383
- union(
1384
- scopeNode(leftAlias, leftColumn),
1385
- scopeNode(rightAlias, rightColumn),
1386
- );
1457
+ const index = match.index ?? 0;
1458
+ const leftNode = scopeNode(leftAlias, leftColumn);
1459
+ const rightNode = scopeNode(rightAlias, rightColumn);
1460
+ if (requiredAt(index)) {
1461
+ union(leftNode, rightNode);
1462
+ continue;
1463
+ }
1464
+ const context = joinEqualityContextAt(index);
1465
+ if (context === undefined) continue;
1466
+ if (context.kind === 'inner') {
1467
+ // An inner join's ON filters every result row, so the equality
1468
+ // constrains both operands symmetrically.
1469
+ union(leftNode, rightNode);
1470
+ continue;
1471
+ }
1472
+ const leftIntroduced =
1473
+ leftAlias.toLowerCase() === context.introducedAlias;
1474
+ const rightIntroduced =
1475
+ rightAlias.toLowerCase() === context.introducedAlias;
1476
+ // An equality with both operands on one side of the outer join proves
1477
+ // nothing across it; preserved-side rows appear regardless of the ON.
1478
+ if (leftIntroduced === rightIntroduced) continue;
1479
+ const introducedNode = leftIntroduced ? leftNode : rightNode;
1480
+ const priorNode = leftIntroduced ? rightNode : leftNode;
1481
+ if (context.nullExtended === 'introduced') {
1482
+ addInheritEdge(introducedNode, priorNode);
1483
+ } else {
1484
+ addInheritEdge(priorNode, introducedNode);
1485
+ }
1387
1486
  }
1388
1487
  const required = new Set(
1389
1488
  [...symbols.values()].flatMap((symbol) =>
@@ -1483,9 +1582,11 @@ class Validator {
1483
1582
  const scopes = table.scopes.flatMap((scope): InferredScope[] => {
1484
1583
  const direct = directByVariable.get(scope.variable);
1485
1584
  if (direct !== undefined) return [direct];
1486
- const root = find(scopeNode(candidate.ref.alias, scope.column));
1585
+ const reachable = reachableProofNodes(
1586
+ scopeNode(candidate.ref.alias, scope.column),
1587
+ );
1487
1588
  const inherited = [...directScopeEvidence.entries()]
1488
- .filter(([node]) => find(node) === root)
1589
+ .filter(([node]) => reachable.has(node))
1489
1590
  .map(([, evidence]) => evidence);
1490
1591
  const params = [
1491
1592
  ...new Set(inherited.flatMap((evidence) => evidence.binding.params)),
@@ -1680,7 +1781,10 @@ class Validator {
1680
1781
  fixedScopes,
1681
1782
  };
1682
1783
  });
1683
- coverage.sort((left, right) => left.table.localeCompare(right.table));
1784
+ // Code-point order keeps emitted metadata byte-identical across locales.
1785
+ coverage.sort((left, right) =>
1786
+ left.table < right.table ? -1 : left.table > right.table ? 1 : 0,
1787
+ );
1684
1788
  return { dependencies, coverage };
1685
1789
  }
1686
1790