@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.
- 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/src/syql-validator.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
1163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1192
|
-
.
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
1221
|
-
`sync query
|
|
1334
|
+
syncBy.span,
|
|
1335
|
+
`sync query \`by ${syncBy.qualifier}.${syncBy.column}\` does not name one fully coverable table instance`,
|
|
1222
1336
|
);
|
|
1223
1337
|
}
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
(
|
|
1227
|
-
|
|
1228
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
)
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
.
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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(
|