@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.
- package/dist/generate.d.ts +4 -2
- package/dist/generate.js +25 -10
- package/dist/lsp.js +11 -1
- package/dist/migration-lock.d.ts +3 -1
- package/dist/migration-lock.js +17 -10
- package/dist/query.js +20 -1
- package/dist/sql.d.ts +12 -2
- package/dist/sql.js +62 -19
- package/dist/syql-validator.js +107 -11
- package/package.json +3 -3
- package/src/generate.ts +30 -13
- package/src/lsp.ts +10 -0
- package/src/migration-lock.ts +33 -11
- package/src/query.ts +20 -0
- package/src/sql.ts +77 -10
- package/src/syql-validator.ts +118 -14
package/src/syql-validator.ts
CHANGED
|
@@ -1314,7 +1314,20 @@ class Validator {
|
|
|
1314
1314
|
}
|
|
1315
1315
|
return true;
|
|
1316
1316
|
};
|
|
1317
|
-
|
|
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
|
|
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
|
-
|
|
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*(
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
|
1585
|
+
const reachable = reachableProofNodes(
|
|
1586
|
+
scopeNode(candidate.ref.alias, scope.column),
|
|
1587
|
+
);
|
|
1487
1588
|
const inherited = [...directScopeEvidence.entries()]
|
|
1488
|
-
.filter(([node]) =>
|
|
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
|
-
|
|
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
|
|