@syncular/typegen 0.15.28 → 0.15.30

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.
@@ -101,6 +101,7 @@ interface BindSymbol {
101
101
  readonly authoredType?: SyqlValueType;
102
102
  }
103
103
 
104
+ const IDENT_SOURCE = '[A-Za-z_][A-Za-z0-9_]*';
104
105
  const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
105
106
  const NONDETERMINISTIC_FUNCTIONS = new Set([
106
107
  'random',
@@ -1065,6 +1066,77 @@ class Validator {
1065
1066
  }
1066
1067
  return true;
1067
1068
  };
1069
+ const requiredJoinEqualityAt = (index: number): boolean => {
1070
+ const prefix = cleaned.slice(0, index);
1071
+ const clauses = [
1072
+ ...prefix.matchAll(
1073
+ /\b(ON|JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/gi,
1074
+ ),
1075
+ ];
1076
+ const last = clauses.at(-1);
1077
+ if (last?.[1]?.toUpperCase() !== 'ON') return false;
1078
+ const start = (last.index ?? 0) + last[0].length;
1079
+ const next =
1080
+ /\b(?:JOIN|WHERE|GROUP\s+BY|ORDER\s+BY|HAVING|LIMIT|UNION|EXCEPT|INTERSECT)\b/i.exec(
1081
+ cleaned.slice(index),
1082
+ );
1083
+ const end = next === null ? cleaned.length : index + next.index;
1084
+ const clause = cleaned.slice(start, end);
1085
+ return !/\b(?:OR|NOT|SELECT|WITH)\b/i.test(clause);
1086
+ };
1087
+ const refByAlias = new Map(
1088
+ refs.map((ref) => [ref.alias.toLowerCase(), ref] as const),
1089
+ );
1090
+ const scopeNode = (alias: string, column: string): string =>
1091
+ `${alias.toLowerCase()}\0${column.toLowerCase()}`;
1092
+ const parents = new Map<string, string>();
1093
+ const find = (node: string): string => {
1094
+ const parent = parents.get(node);
1095
+ if (parent === undefined) {
1096
+ parents.set(node, node);
1097
+ return node;
1098
+ }
1099
+ if (parent === node) return node;
1100
+ const root = find(parent);
1101
+ parents.set(node, root);
1102
+ return root;
1103
+ };
1104
+ const union = (left: string, right: string): void => {
1105
+ const leftRoot = find(left);
1106
+ const rightRoot = find(right);
1107
+ if (leftRoot !== rightRoot) parents.set(rightRoot, leftRoot);
1108
+ };
1109
+ const scopeColumn = (alias: string, column: string): boolean => {
1110
+ const ref = refByAlias.get(alias.toLowerCase());
1111
+ const table = this.#ir.tables.find((item) => item.name === ref?.table);
1112
+ return (
1113
+ table?.scopes.some(
1114
+ (scope) => scope.column.toLowerCase() === column.toLowerCase(),
1115
+ ) === true
1116
+ );
1117
+ };
1118
+ const qualifiedEquality = new RegExp(
1119
+ `(${IDENT_SOURCE})\\.(${IDENT_SOURCE})\\s*(?:=|==|\\bIS\\b)\\s*(${IDENT_SOURCE})\\.(${IDENT_SOURCE})`,
1120
+ 'gi',
1121
+ );
1122
+ for (const match of cleaned.matchAll(qualifiedEquality)) {
1123
+ const leftAlias = match[1] as string;
1124
+ const leftColumn = match[2] as string;
1125
+ const rightAlias = match[3] as string;
1126
+ const rightColumn = match[4] as string;
1127
+ if (
1128
+ !scopeColumn(leftAlias, leftColumn) ||
1129
+ !scopeColumn(rightAlias, rightColumn) ||
1130
+ (!requiredAt(match.index ?? 0) &&
1131
+ !requiredJoinEqualityAt(match.index ?? 0))
1132
+ ) {
1133
+ continue;
1134
+ }
1135
+ union(
1136
+ scopeNode(leftAlias, leftColumn),
1137
+ scopeNode(rightAlias, rightColumn),
1138
+ );
1139
+ }
1068
1140
  const required = new Set(
1069
1141
  [...symbols.values()].flatMap((symbol) =>
1070
1142
  symbol.parameter.kind === 'value' &&
@@ -1075,6 +1147,7 @@ class Validator {
1075
1147
  ),
1076
1148
  );
1077
1149
  const candidates: Candidate[] = [];
1150
+ const directScopeEvidence = new Map<string, InferredScope>();
1078
1151
  for (const ref of refs) {
1079
1152
  const table = this.#ir.tables.find((item) => item.name === ref.table);
1080
1153
  if (table === undefined) continue;
@@ -1133,7 +1206,7 @@ class Validator {
1133
1206
  }
1134
1207
  const params = [...new Set(found.map((item) => item.name))];
1135
1208
  if (params.length > 0) {
1136
- inferred.push({
1209
+ const evidence: InferredScope = {
1137
1210
  binding: {
1138
1211
  table: table.name,
1139
1212
  variable: scope.variable,
@@ -1143,12 +1216,50 @@ class Validator {
1143
1216
  operator: found.some((item) => item.operator === 'in')
1144
1217
  ? 'in'
1145
1218
  : 'equal',
1146
- });
1219
+ };
1220
+ inferred.push(evidence);
1221
+ directScopeEvidence.set(scopeNode(ref.alias, scope.column), evidence);
1147
1222
  }
1148
1223
  }
1149
1224
  candidates.push({ ref, scopes: inferred });
1150
1225
  }
1151
1226
 
1227
+ const resolvedCandidates = candidates.map((candidate): Candidate => {
1228
+ const table = this.#ir.tables.find(
1229
+ (item) => item.name === candidate.ref.table,
1230
+ );
1231
+ if (table === undefined) return candidate;
1232
+ const directByVariable = new Map(
1233
+ candidate.scopes.map((scope) => [scope.binding.variable, scope]),
1234
+ );
1235
+ const scopes = table.scopes.flatMap((scope): InferredScope[] => {
1236
+ const direct = directByVariable.get(scope.variable);
1237
+ if (direct !== undefined) return [direct];
1238
+ const root = find(scopeNode(candidate.ref.alias, scope.column));
1239
+ const inherited = [...directScopeEvidence.entries()]
1240
+ .filter(([node]) => find(node) === root)
1241
+ .map(([, evidence]) => evidence);
1242
+ const params = [
1243
+ ...new Set(inherited.flatMap((evidence) => evidence.binding.params)),
1244
+ ];
1245
+ if (params.length === 0) return [];
1246
+ return [
1247
+ {
1248
+ binding: {
1249
+ table: table.name,
1250
+ variable: scope.variable,
1251
+ pattern: scope.pattern,
1252
+ params,
1253
+ },
1254
+ operator: inherited.some((evidence) => evidence.operator === 'in')
1255
+ ? 'in'
1256
+ : 'equal',
1257
+ },
1258
+ ];
1259
+ });
1260
+ return { ...candidate, scopes };
1261
+ });
1262
+
1152
1263
  const ftsOwners = new Map(
1153
1264
  this.#ir.tables.flatMap((table) =>
1154
1265
  table.ftsIndexes.map((index) => [index.name, table.name] as const),
@@ -1159,8 +1270,13 @@ class Validator {
1159
1270
  ]
1160
1271
  .sort()
1161
1272
  .map((table) => {
1162
- const instances = candidates.filter((item) => item.ref.table === table);
1163
- if (instances.some((item) => item.scopes.length === 0)) {
1273
+ const instances = resolvedCandidates.filter(
1274
+ (item) => item.ref.table === table,
1275
+ );
1276
+ if (
1277
+ instances.length !== 1 ||
1278
+ instances.some((item) => item.scopes.length === 0)
1279
+ ) {
1164
1280
  return { table, scopes: [] };
1165
1281
  }
1166
1282
  const scopes = instances
@@ -1182,83 +1298,142 @@ class Validator {
1182
1298
  });
1183
1299
 
1184
1300
  if (!logical.declaration.sync) return { dependencies, coverage: [] };
1185
- let eligible = candidates.filter((candidate) => {
1301
+ const syncedCandidates = resolvedCandidates.filter((candidate) =>
1302
+ this.#ir.tables.some((table) => table.name === candidate.ref.table),
1303
+ );
1304
+ const eligible = syncedCandidates.filter((candidate) => {
1186
1305
  const table = this.#ir.tables.find(
1187
1306
  (item) => item.name === candidate.ref.table,
1188
1307
  );
1189
1308
  return (
1190
1309
  table !== undefined &&
1191
- candidates.filter((item) => item.ref.table === candidate.ref.table)
1192
- .length === 1 &&
1310
+ syncedCandidates.filter(
1311
+ (item) => item.ref.table === candidate.ref.table,
1312
+ ).length === 1 &&
1193
1313
  table.scopes.length > 0 &&
1194
1314
  candidate.scopes.length === table.scopes.length
1195
1315
  );
1196
1316
  });
1197
1317
  const syncBy = logical.declaration.syncBy;
1198
- if (syncBy !== undefined) {
1199
- eligible = eligible.filter(
1200
- (candidate) => candidate.ref.alias === syncBy.qualifier,
1201
- );
1202
- }
1203
- if (eligible.length !== 1) {
1318
+ if (eligible.length === 0 || eligible.length !== syncedCandidates.length) {
1204
1319
  this.#fail(
1205
1320
  'SYQL6005_INVALID_SYNC_QUERY',
1206
1321
  logical.declaration.syncBy?.span ?? logical.declaration.nameSpan,
1207
- eligible.length === 0
1208
- ? 'sync query coverage cannot be proven from required equality/IN predicates over every declared scope'
1209
- : 'sync query resolves to multiple coverable table instances; split the query or select one with `by alias.scope`',
1322
+ 'sync query coverage cannot be proven for every read table from required equality/IN predicates over every declared scope; split the query or add an exact compatible scope proof',
1210
1323
  );
1211
1324
  }
1212
- const candidate = eligible[0] as Candidate;
1213
- const table = this.#ir.tables.find(
1214
- (item) => item.name === candidate.ref.table,
1215
- );
1216
- if (table === undefined) throw new Error('eligible table disappeared');
1217
- if (table.scopes.length > 1 && syncBy === undefined) {
1325
+ const byCandidate =
1326
+ syncBy === undefined
1327
+ ? undefined
1328
+ : eligible.find(
1329
+ (candidate) => candidate.ref.alias === syncBy.qualifier,
1330
+ );
1331
+ if (syncBy !== undefined && byCandidate === undefined) {
1218
1332
  this.#fail(
1219
1333
  'SYQL6005_INVALID_SYNC_QUERY',
1220
- logical.declaration.nameSpan,
1221
- `sync query for multi-scope table ${table.name} must select its unit dimension with \`by ${candidate.ref.alias}.scope_column\``,
1334
+ syncBy.span,
1335
+ `sync query \`by ${syncBy.qualifier}.${syncBy.column}\` does not name one fully coverable table instance`,
1222
1336
  );
1223
1337
  }
1224
- const dimensionColumn = syncBy?.column ?? table.scopes[0]?.column;
1225
- const dimension = table.scopes.find(
1226
- (scope) => scope.column === dimensionColumn,
1227
- );
1228
- if (dimension === undefined) {
1338
+ if (
1339
+ syncBy === undefined &&
1340
+ eligible.some((candidate) => {
1341
+ const table = this.#ir.tables.find(
1342
+ (item) => item.name === candidate.ref.table,
1343
+ );
1344
+ return (table?.scopes.length ?? 0) > 1;
1345
+ })
1346
+ ) {
1229
1347
  this.#fail(
1230
1348
  'SYQL6005_INVALID_SYNC_QUERY',
1231
- syncBy?.span ?? logical.declaration.nameSpan,
1232
- `${candidate.ref.alias}.${dimensionColumn ?? ''} is not a declared scope`,
1349
+ logical.declaration.nameSpan,
1350
+ 'a sync query reading any multi-scope table must select an anchor unit dimension with `by alias.scope_column`',
1233
1351
  );
1234
1352
  }
1235
- const byVariable = new Map(
1236
- candidate.scopes.map((scope) => [scope.binding.variable, scope]),
1237
- );
1238
- const unit = byVariable.get(dimension.variable) as InferredScope;
1239
- const fixedScopes = table.scopes
1240
- .filter((scope) => scope.variable !== dimension.variable)
1241
- .map((scope) => {
1242
- const fixed = byVariable.get(scope.variable) as InferredScope;
1243
- if (fixed.operator !== 'equal' || fixed.binding.params.length !== 1) {
1244
- this.#fail(
1245
- 'SYQL6005_INVALID_SYNC_QUERY',
1246
- syncBy?.span ?? logical.declaration.nameSpan,
1247
- `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`,
1248
- );
1249
- }
1250
- return {
1251
- variable: fixed.binding.variable,
1252
- params: fixed.binding.params,
1253
- };
1254
- });
1255
- const coverage: QueryCoverageBinding = {
1256
- table: table.name,
1257
- variable: unit.binding.variable,
1258
- units: unit.binding.params,
1259
- fixedScopes,
1260
- };
1261
- return { dependencies, coverage: [coverage] };
1353
+
1354
+ let anchorUnit: InferredScope | undefined;
1355
+ if (byCandidate !== undefined && syncBy !== undefined) {
1356
+ const table = this.#ir.tables.find(
1357
+ (item) => item.name === byCandidate.ref.table,
1358
+ );
1359
+ const dimension = table?.scopes.find(
1360
+ (scope) => scope.column === syncBy.column,
1361
+ );
1362
+ anchorUnit = byCandidate.scopes.find(
1363
+ (scope) => scope.binding.variable === dimension?.variable,
1364
+ );
1365
+ if (dimension === undefined || anchorUnit === undefined) {
1366
+ this.#fail(
1367
+ 'SYQL6005_INVALID_SYNC_QUERY',
1368
+ syncBy.span,
1369
+ `${byCandidate.ref.alias}.${syncBy.column} is not a proven declared scope`,
1370
+ );
1371
+ }
1372
+ }
1373
+
1374
+ const sameParams = (
1375
+ left: readonly string[],
1376
+ right: readonly string[],
1377
+ ): boolean =>
1378
+ left.length === right.length &&
1379
+ left.every((value) => right.includes(value));
1380
+ const coverage = eligible.map((candidate): QueryCoverageBinding => {
1381
+ const table = this.#ir.tables.find(
1382
+ (item) => item.name === candidate.ref.table,
1383
+ );
1384
+ if (table === undefined) throw new Error('eligible table disappeared');
1385
+ const byVariable = new Map(
1386
+ candidate.scopes.map((scope) => [scope.binding.variable, scope]),
1387
+ );
1388
+ let unit: InferredScope | undefined;
1389
+ if (candidate === byCandidate) {
1390
+ unit = anchorUnit;
1391
+ } else if (table.scopes.length === 1) {
1392
+ unit = candidate.scopes[0];
1393
+ } else if (anchorUnit !== undefined) {
1394
+ const matching = candidate.scopes.filter((scope) =>
1395
+ sameParams(scope.binding.params, anchorUnit.binding.params),
1396
+ );
1397
+ if (matching.length === 1) unit = matching[0];
1398
+ }
1399
+ if (unit === undefined) {
1400
+ this.#fail(
1401
+ 'SYQL6005_INVALID_SYNC_QUERY',
1402
+ syncBy?.span ?? logical.declaration.nameSpan,
1403
+ `joined multi-scope table ${table.name} has no unambiguous unit dimension compatible with the selected anchor; split the query`,
1404
+ );
1405
+ }
1406
+ const fixedScopes = table.scopes
1407
+ .filter((scope) => scope.variable !== unit.binding.variable)
1408
+ .map((scope) => {
1409
+ const fixed = byVariable.get(scope.variable) as
1410
+ | InferredScope
1411
+ | undefined;
1412
+ if (
1413
+ fixed === undefined ||
1414
+ fixed.operator !== 'equal' ||
1415
+ fixed.binding.params.length !== 1
1416
+ ) {
1417
+ this.#fail(
1418
+ 'SYQL6005_INVALID_SYNC_QUERY',
1419
+ syncBy?.span ?? logical.declaration.nameSpan,
1420
+ `fixed sync scope ${table.name}.${scope.column} must use one required equality bind`,
1421
+ );
1422
+ }
1423
+ return {
1424
+ variable: fixed.binding.variable,
1425
+ params: fixed.binding.params,
1426
+ };
1427
+ });
1428
+ return {
1429
+ table: table.name,
1430
+ variable: unit.binding.variable,
1431
+ units: unit.binding.params,
1432
+ fixedScopes,
1433
+ };
1434
+ });
1435
+ coverage.sort((left, right) => left.table.localeCompare(right.table));
1436
+ return { dependencies, coverage };
1262
1437
  }
1263
1438
 
1264
1439
  #validateSort(