@askdb/core 1.0.0-beta.54 → 1.0.0-beta.55
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/ask.js +1 -1
- package/dist/ask.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/sql/bind.d.ts +10 -2
- package/dist/sql/bind.d.ts.map +1 -1
- package/dist/sql/bind.js +33 -12
- package/dist/sql/bind.js.map +1 -1
- package/dist/sql/readings.d.ts +26 -3
- package/dist/sql/readings.d.ts.map +1 -1
- package/dist/sql/readings.js +47 -4
- package/dist/sql/readings.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +8 -6
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +573 -89
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/dist/sql/tenant-prompt.d.ts.map +1 -1
- package/dist/sql/tenant-prompt.js +7 -1
- package/dist/sql/tenant-prompt.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
|
|
|
2
2
|
import { SQLSERVER_DIALECT } from "./dialect-spec.js";
|
|
3
3
|
import { decide, tenantFindings, throwIfDenied } from "./guardrail-decide.js";
|
|
4
4
|
import { findPostgresOnlyForms } from "./lexer.js";
|
|
5
|
-
import { mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, nameEnginesFoldApart, unmatchedNonAsciiName, } from "./readings.js";
|
|
5
|
+
import { lowerEachLetter, mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, nameEnginesFoldApart, unmatchedNonAsciiName, } from "./readings.js";
|
|
6
6
|
import { EXPRESSION_KEYWORDS, EXPRESSION_PUNCTUATION } from "./tsql-batch.js";
|
|
7
7
|
import { denylistFor } from "./validate.js";
|
|
8
8
|
/**
|
|
@@ -21,18 +21,20 @@ import { denylistFor } from "./validate.js";
|
|
|
21
21
|
* enclosing level, under `NOT`, or anywhere but a filter clause (the select list,
|
|
22
22
|
* a `CASE`) doesn't count, and neither does a literal ID or a bare mention of the
|
|
23
23
|
* column. A root table the scope covers (`org.agency` under an `agency` scope)
|
|
24
|
-
* needs the same predicate on its tenant ID column
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* needs the same predicate on its tenant ID column, or a join on it to a read whose
|
|
25
|
+
* column holding its IDs is filtered. Down a join path, each hop must be joined on the
|
|
26
|
+
* columns the policy declares, up to a read filtered by the path's root or one of its
|
|
27
|
+
* ancestors in the hierarchy; a join on other columns links nothing (#400). A
|
|
28
|
+
* polymorphic table also needs `typeColumn = '<key>'` ANDed into
|
|
27
29
|
* the same `SELECT`, with a `mapping` key of the root whose placeholder filters
|
|
28
30
|
* its id column. Each read of a table needs its own predicate in its own `SELECT`
|
|
29
31
|
* (#399): a CTE body, a derived table and a subquery are each their own, and a
|
|
30
32
|
* qualified predicate counts only for the read its qualifier names. A read in a
|
|
31
33
|
* derived table or CTE also counts as filtered when every reference to that source
|
|
32
34
|
* is filtered on the column, which the source passes through unrenamed without
|
|
33
|
-
* computing across its rows.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* computing across its rows. A join it can't follow (through a derived table or CTE,
|
|
36
|
+
* or across `SELECT`s) fails closed; database-side row-level security is the sound
|
|
37
|
+
* boundary.
|
|
36
38
|
*
|
|
37
39
|
* **This is not a security boundary.** Its purpose is to catch model mistakes
|
|
38
40
|
* (a forgotten or widened tenant filter, an unclassified table) early and
|
|
@@ -150,9 +152,11 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
|
150
152
|
if (!root || !mentionsTable(views, extractTableName(root.id)))
|
|
151
153
|
continue;
|
|
152
154
|
const column = extractColumnName(root.tenantIdColumn);
|
|
153
|
-
// Filtered on its own tenant ID column, or on a column that
|
|
154
|
-
// that joins the root for a label, filtered on the scoped table's tenant column).
|
|
155
|
-
|
|
155
|
+
// Filtered on its own tenant ID column, or joined on it to a column that holds its IDs (a
|
|
156
|
+
// query that joins the root for a label, filtered on the scoped table's tenant column; #400).
|
|
157
|
+
const placeholder = placeholderForTenantRoot(root);
|
|
158
|
+
const ownIdTarget = { placeholder, column, table: extractTableName(root.id) };
|
|
159
|
+
if (!hasTenantPredicate(views, ownIdTarget.table, [ownIdTarget], { policy, allowed: new Set([placeholder]) })) {
|
|
156
160
|
warnings.push(warn("MISSING_TENANT_PREDICATE", root.id, `Tenant root table '${extractTableName(root.id)}' is missing required tenant predicate. ` +
|
|
157
161
|
`Expected: ${column} = ${placeholderForTenantRoot(root)}`));
|
|
158
162
|
}
|
|
@@ -190,20 +194,19 @@ function checkScopedTable(sql, st, policy, warnings) {
|
|
|
190
194
|
for (const path of st.scopeThrough) {
|
|
191
195
|
const placeholder = placeholderFor(policy, path.root);
|
|
192
196
|
if ("column" in path) {
|
|
193
|
-
// The table's tenant column compared with its root's placeholder.
|
|
194
|
-
|
|
197
|
+
// The table's tenant column compared with its root's placeholder, directly or through a join.
|
|
198
|
+
// Through a join, only from that column: another path's column on this table (a join-path step's
|
|
199
|
+
// key) proves nothing about the rows this path scopes.
|
|
200
|
+
const target = { placeholder, column: extractColumnName(path.column), table };
|
|
201
|
+
if (hasTenantPredicate(sql, table, [target], { policy, allowed: new Set([placeholder]), start: target.column }))
|
|
195
202
|
return;
|
|
196
203
|
}
|
|
197
204
|
else {
|
|
198
|
-
// Inherited via JOINs:
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (!allStepsPresent)
|
|
204
|
-
continue;
|
|
205
|
-
const targets = selfAndAncestors(policy, path.root).flatMap((rootId) => idCarriers(policy, rootId));
|
|
206
|
-
if (hasTenantPredicate(sql, table, targets))
|
|
205
|
+
// Inherited via JOINs (#400): each hop joined on the columns the policy declares, to a read
|
|
206
|
+
// filtered by the path's root or, up the hierarchy, one of its ancestors. A root outside that
|
|
207
|
+
// chain filters nothing here, even one the scope binds.
|
|
208
|
+
const allowed = new Set(selfAndAncestors(policy, path.root).map((rootId) => placeholderFor(policy, rootId)));
|
|
209
|
+
if (hasTenantPredicate(sql, table, [], { policy, allowed, join: path.join }))
|
|
207
210
|
return;
|
|
208
211
|
}
|
|
209
212
|
}
|
|
@@ -830,13 +833,6 @@ function scopeOpen(scope) {
|
|
|
830
833
|
function tableNameOf(t) {
|
|
831
834
|
return lowerEachLetter(t.quoted === true ? t.name.trimEnd() : t.text);
|
|
832
835
|
}
|
|
833
|
-
/**
|
|
834
|
-
* `name` with each letter lower-cased on its own, as CockroachDB lower-cases an unquoted name: a
|
|
835
|
-
* final capital sigma is `σ`, which `toLowerCase` writes `ς` at the end of a word.
|
|
836
|
-
*/
|
|
837
|
-
function lowerEachLetter(name) {
|
|
838
|
-
return Array.from(name, (c) => c.toLowerCase()).join("");
|
|
839
|
-
}
|
|
840
836
|
/** Whether `token` may be a name: a quoted name, whatever it holds, or a word that starts with a letter or `_`. */
|
|
841
837
|
function isWord(token) {
|
|
842
838
|
return token !== undefined && (token.quoted === true || /^[A-Za-z_\u0080-\uffff]/.test(token.text));
|
|
@@ -1056,9 +1052,12 @@ function occurrences(a, targets) {
|
|
|
1056
1052
|
* `SELECT`, and tied to that read by its qualifier. Each branch of a `UNION` / `INTERSECT` /
|
|
1057
1053
|
* `EXCEPT` is its own `SELECT`, and so is every CTE body, derived table and subquery. A read in a
|
|
1058
1054
|
* derived table or CTE is also filtered from outside, when that source passes the column through
|
|
1059
|
-
* unrenamed and every reference to it is filtered on it.
|
|
1055
|
+
* unrenamed and every reference to it is filtered on it. With `joinProof`, a read also counts when
|
|
1056
|
+
* the policy's keys prove it in scope through joins in its `SELECT` (#400): {@link pathInScope} for
|
|
1057
|
+
* a join path, else {@link inScope}. `checkScopedTable` passes one for each scope path, and
|
|
1058
|
+
* {@link validateTenantGuardrails} one for each root the scope covers.
|
|
1060
1059
|
*/
|
|
1061
|
-
function hasTenantPredicate(views, table, targets) {
|
|
1060
|
+
function hasTenantPredicate(views, table, targets, joinProof) {
|
|
1062
1061
|
const name = lowerEachLetter(table);
|
|
1063
1062
|
return views.every((view) => {
|
|
1064
1063
|
const a = analyze(view);
|
|
@@ -1075,9 +1074,19 @@ function hasTenantPredicate(views, table, targets) {
|
|
|
1075
1074
|
const tableReads = reads.filter((r) => startsSource(a.tokens, qualifiedStart(a.tokens, r.at)));
|
|
1076
1075
|
return reads.every((read) => {
|
|
1077
1076
|
const sameSelect = tableReads.filter((r) => r.scope === read.scope);
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1077
|
+
const selfJoin = tableReads.includes(read) && sameSelect.length > 1;
|
|
1078
|
+
const filtered = selfJoin ? selfJoinReadFiltered(a, found, targets, read, name, sameSelect) : readFiltered(a, found, targets, read, name);
|
|
1079
|
+
// A join proof names reads too, so a self-join's reads must be told apart for it as well.
|
|
1080
|
+
if (filtered)
|
|
1081
|
+
return true;
|
|
1082
|
+
if (joinProof === undefined || (selfJoin && !distinctAliases(a, sameSelect)))
|
|
1083
|
+
return false;
|
|
1084
|
+
// The policy's names, read the way this view's reading compares a written name ({@link columnKey}).
|
|
1085
|
+
const reading = a.view.reading.profile.id;
|
|
1086
|
+
const proof = proofFor(a, policyGraph(joinProof.policy, reading));
|
|
1087
|
+
return joinProof.join !== undefined
|
|
1088
|
+
? pathInScope(proof, { ...read, table: name }, joinPathProof(joinProof.policy, table, joinProof.join, reading), joinProof.allowed)
|
|
1089
|
+
: inScope(proof, { ...read, table: name }, joinProof.allowed, undefined, joinProof.start === undefined ? undefined : columnKey(joinProof.start, reading));
|
|
1081
1090
|
});
|
|
1082
1091
|
});
|
|
1083
1092
|
}
|
|
@@ -1108,7 +1117,8 @@ const NOT_A_TABLE_ALIAS = new Set([
|
|
|
1108
1117
|
*/
|
|
1109
1118
|
function renamesColumns(tokens, name) {
|
|
1110
1119
|
for (let i = 0; i < tokens.length; i++) {
|
|
1111
|
-
|
|
1120
|
+
// Matched as the table's other reads are ({@link tableNameOf}): `ΟΣ` is `οσ`, as CockroachDB reads it.
|
|
1121
|
+
if (tableNameOf(tokens[i]) !== name)
|
|
1112
1122
|
continue;
|
|
1113
1123
|
for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
1114
1124
|
if (subqueryUse(tokens, open) !== "table")
|
|
@@ -1140,15 +1150,17 @@ function aliasWithColumnList(tokens, j) {
|
|
|
1140
1150
|
* it fails closed.
|
|
1141
1151
|
*/
|
|
1142
1152
|
function selfJoinReadFiltered(a, found, targets, read, table, sameSelect) {
|
|
1143
|
-
|
|
1144
|
-
if (aliases.some((alias) => alias === undefined))
|
|
1145
|
-
return false;
|
|
1146
|
-
if (new Set(aliases.map((alias) => alias.toLowerCase())).size !== aliases.length)
|
|
1153
|
+
if (!distinctAliases(a, sameSelect))
|
|
1147
1154
|
return false;
|
|
1148
1155
|
const mine = canonicalAlias(a, read);
|
|
1149
|
-
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && targets.includes(o.target) &&
|
|
1156
|
+
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && canName(a.tokens, mine, f.on, read) && targets.includes(o.target) &&
|
|
1150
1157
|
(!o.target.discriminator || hasDiscriminator(a, read, o.target))));
|
|
1151
1158
|
}
|
|
1159
|
+
/** Whether every read in `reads` is canonical, with a plain alias no other of them shares, in any case. */
|
|
1160
|
+
function distinctAliases(a, reads) {
|
|
1161
|
+
const aliases = reads.map((r) => canonicalAlias(a, r));
|
|
1162
|
+
return aliases.every((alias) => alias !== undefined) && new Set(aliases.map((alias) => alias.toLowerCase())).size === aliases.length;
|
|
1163
|
+
}
|
|
1152
1164
|
/** The plain alias of a read written `table alias` or `table AS alias`, at its `SELECT`'s own level; else undefined. */
|
|
1153
1165
|
function canonicalAlias(a, read) {
|
|
1154
1166
|
const { tokens } = a;
|
|
@@ -1174,22 +1186,29 @@ function readFiltered(a, found, targets, read, table) {
|
|
|
1174
1186
|
return targets.some((target) => lowerEachLetter(target.table) === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
|
|
1175
1187
|
}
|
|
1176
1188
|
/**
|
|
1177
|
-
* Whether a predicate that filters `read`'s `SELECT` through `filter`
|
|
1178
|
-
* Unqualified, it does: the read's table has the column, so the name
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
* another table `col` filters is left to the join.
|
|
1189
|
+
* Whether a predicate on the read's own table that filters `read`'s `SELECT` through `filter`
|
|
1190
|
+
* filters `read` itself. Unqualified, it does: the read's table has the column, so the name
|
|
1191
|
+
* resolves to it or the SQL is ambiguous. Qualified, its qualifier must name that read
|
|
1192
|
+
* ({@link readNames}); a qualifier that names no read the check sees, such as a
|
|
1193
|
+
* table function's alias, counts for nothing. A predicate on another table's column counts only
|
|
1194
|
+
* through a join {@link inScope} can follow (#400).
|
|
1184
1195
|
*/
|
|
1185
1196
|
function filtersRead(a, filter, target, read, table) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1197
|
+
if (lowerEachLetter(target.table) !== table)
|
|
1198
|
+
return false;
|
|
1199
|
+
return canName(a.tokens, filter.qualifier, filter.on, read);
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Whether a column written with qualifier `q`, in the `ON` at `on` if it sits in one, can name
|
|
1203
|
+
* `read`: qualified, the qualifier must name it. In an `ON`, qualified or not, it names only one of
|
|
1204
|
+
* its join's operands ({@link seenByOn}): a qualifier for a read joined after it, or outside its
|
|
1205
|
+
* parentheses, names some other read, such as an outer query's by correlation. Anywhere else it
|
|
1206
|
+
* names any read of its `SELECT`.
|
|
1207
|
+
*/
|
|
1208
|
+
function canName(tokens, q, on, read) {
|
|
1209
|
+
if (q !== undefined && !read.names.has(q))
|
|
1210
|
+
return false;
|
|
1211
|
+
return on === undefined || seenByOn(tokens, on, read.at);
|
|
1193
1212
|
}
|
|
1194
1213
|
/**
|
|
1195
1214
|
* Whether `read`, inside a derived table or CTE, is filtered from outside on `target`'s column:
|
|
@@ -1215,7 +1234,7 @@ function filteredFromOutside(a, found, read, target, depth) {
|
|
|
1215
1234
|
const refs = sourceRefs(a, open);
|
|
1216
1235
|
filtered = refs !== undefined && refs.length > 0 && passesThrough(a, open, read.names, target.column) &&
|
|
1217
1236
|
refs.every((ref) => found.some((o) => o.target === target &&
|
|
1218
|
-
o.filters.some((f) => f.
|
|
1237
|
+
o.filters.some((f) => f.scope === ref.scope && canName(a.tokens, f.qualifier, f.on, ref))) ||
|
|
1219
1238
|
filteredFromOutside(a, found, ref, target, depth + 1));
|
|
1220
1239
|
}
|
|
1221
1240
|
memo.set(key, filtered);
|
|
@@ -1346,7 +1365,7 @@ function passesThrough(a, open, names, column) {
|
|
|
1346
1365
|
return false; // an expression, a call or an aggregate
|
|
1347
1366
|
const name = expr[expr.length - 1];
|
|
1348
1367
|
// An item that yields the name in any case is a candidate; it must name the column the way the engine reads it.
|
|
1349
|
-
if ((alias ?? name)
|
|
1368
|
+
if (tableNameOf(alias ?? name) !== lowerEachLetter(column))
|
|
1350
1369
|
continue;
|
|
1351
1370
|
if (!namesColumn(alias ?? name, column, reading) || !namesColumn(name, column, reading))
|
|
1352
1371
|
return false;
|
|
@@ -1411,11 +1430,468 @@ function hasDiscriminator(a, read, target) {
|
|
|
1411
1430
|
if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
|
|
1412
1431
|
continue;
|
|
1413
1432
|
const filters = filteredScopes(a, qualifiedStart(tokens, i), i + 1, qualifier, i);
|
|
1414
|
-
if (filters.some((f) => f.
|
|
1433
|
+
if (filters.some((f) => f.scope === read.scope && canName(tokens, qualifier, f.on, read)))
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
return false;
|
|
1437
|
+
}
|
|
1438
|
+
const graphs = new WeakMap();
|
|
1439
|
+
/**
|
|
1440
|
+
* {@link PolicyGraph} for `policy`, its table names lower-cased letter by letter ({@link tableNameOf})
|
|
1441
|
+
* and its column names read the way `reading` compares a written name ({@link columnKey}).
|
|
1442
|
+
*/
|
|
1443
|
+
function policyGraph(policy, reading) {
|
|
1444
|
+
const byReading = graphs.get(policy) ?? new Map();
|
|
1445
|
+
graphs.set(policy, byReading);
|
|
1446
|
+
let graph = byReading.get(reading);
|
|
1447
|
+
if (graph !== undefined)
|
|
1448
|
+
return graph;
|
|
1449
|
+
const carriers = new Map();
|
|
1450
|
+
const columns = new Map();
|
|
1451
|
+
const spellings = new Map();
|
|
1452
|
+
const tableKey = (table) => {
|
|
1453
|
+
const key = lowerEachLetter(table);
|
|
1454
|
+
spellings.set(key, (spellings.get(key) ?? new Set()).add(table));
|
|
1455
|
+
return key;
|
|
1456
|
+
};
|
|
1457
|
+
const name = (columnId) => [tableKey(tableOfColumn(columnId)), columnKey(extractColumnName(columnId), reading)];
|
|
1458
|
+
const know = (columnId) => {
|
|
1459
|
+
const [table, column] = name(columnId);
|
|
1460
|
+
columns.set(table, (columns.get(table) ?? new Set()).add(column));
|
|
1461
|
+
};
|
|
1462
|
+
for (const root of policy.roots) {
|
|
1463
|
+
for (const carrier of idCarriers(policy, root.id)) {
|
|
1464
|
+
const table = tableKey(carrier.table);
|
|
1465
|
+
const column = columnKey(carrier.column, reading);
|
|
1466
|
+
const byColumn = carriers.get(table) ?? new Map();
|
|
1467
|
+
byColumn.set(column, (byColumn.get(column) ?? new Set()).add(carrier.placeholder));
|
|
1468
|
+
carriers.set(table, byColumn);
|
|
1469
|
+
columns.set(table, (columns.get(table) ?? new Set()).add(column));
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
const hops = [];
|
|
1473
|
+
const hop = (fromId, toId) => {
|
|
1474
|
+
know(fromId);
|
|
1475
|
+
know(toId);
|
|
1476
|
+
const [from, fromColumn] = name(fromId);
|
|
1477
|
+
const [to, toColumn] = name(toId);
|
|
1478
|
+
hops.push({ from, fromColumn, to, toColumn });
|
|
1479
|
+
};
|
|
1480
|
+
const idColumn = (rootId) => policy.roots.find((r) => r.id === rootId)?.tenantIdColumn;
|
|
1481
|
+
for (const edge of policy.hierarchy) {
|
|
1482
|
+
const to = idColumn(edge.parent);
|
|
1483
|
+
if (to !== undefined)
|
|
1484
|
+
hop(edge.foreignKey, to);
|
|
1485
|
+
}
|
|
1486
|
+
for (const root of policy.roots) {
|
|
1487
|
+
const to = root.parent ? idColumn(root.parent.root) : undefined;
|
|
1488
|
+
if (root.parent && to !== undefined)
|
|
1489
|
+
hop(root.parent.foreignKey, to);
|
|
1490
|
+
}
|
|
1491
|
+
for (const st of policy.scopedTables) {
|
|
1492
|
+
for (const path of st.scopeThrough) {
|
|
1493
|
+
if ("column" in path) {
|
|
1494
|
+
const to = idColumn(path.root);
|
|
1495
|
+
if (to !== undefined)
|
|
1496
|
+
hop(path.column, to);
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
for (const step of path.join)
|
|
1500
|
+
hop(step.from, step.to);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
graph = { carriers, hops, columns, spellings, placeholders: new Set(policy.roots.map((r) => placeholderForTenantRoot(r))) };
|
|
1505
|
+
byReading.set(reading, graph);
|
|
1506
|
+
return graph;
|
|
1507
|
+
}
|
|
1508
|
+
const proofs = new WeakMap();
|
|
1509
|
+
function proofFor(a, graph) {
|
|
1510
|
+
const byGraph = proofs.get(a) ?? new WeakMap();
|
|
1511
|
+
proofs.set(a, byGraph);
|
|
1512
|
+
let proof = byGraph.get(graph);
|
|
1513
|
+
if (proof !== undefined)
|
|
1514
|
+
return proof;
|
|
1515
|
+
// Every CTE's name, whatever its body (a data-modifying one opens no query): a read of that name may be the CTE.
|
|
1516
|
+
const ctes = new Set(a.tokens.flatMap((t, open) => (t.text === "(" ? [cteNameAt(a.tokens, open)] : [])).filter((n) => n !== undefined));
|
|
1517
|
+
const reading = a.view.reading.profile.id;
|
|
1518
|
+
const readsAsPolicyTable = (at, table) => [...(graph.spellings.get(table) ?? [])].some((spelling) => namesTable(a.tokens[at], spelling, reading));
|
|
1519
|
+
const nodes = [...graph.columns.keys()]
|
|
1520
|
+
.filter((table) => !ctes.has(table))
|
|
1521
|
+
.flatMap((table) => readsOf(a, table).filter((read) => isTableReference(a.tokens, read.at) && readsAsPolicyTable(read.at, table)).map((read) => ({ ...read, table })));
|
|
1522
|
+
proof = { a, graph, nodes, equalities: equalities(a), bound: new Map(), links: new Map() };
|
|
1523
|
+
byGraph.set(graph, proof);
|
|
1524
|
+
return proof;
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* The name of the CTE whose body opens at `open`, with or without a column list (`x AS (`,
|
|
1528
|
+
* `x (a, b) AS (`, `x AS MATERIALIZED (`), or undefined when it opens none.
|
|
1529
|
+
*/
|
|
1530
|
+
function cteNameAt(tokens, open) {
|
|
1531
|
+
let k = open - 1;
|
|
1532
|
+
if (tokens[k]?.kw === "materialized")
|
|
1533
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
1534
|
+
if (tokens[k]?.kw !== "as")
|
|
1535
|
+
return undefined;
|
|
1536
|
+
k--;
|
|
1537
|
+
if (tokens[k]?.text === ")") {
|
|
1538
|
+
const list = tokens[k - 1]?.text === "(" ? k - 1 : enclosing(tokens, k - 1, "(");
|
|
1539
|
+
if (list < 0 || tokens[list].depth !== tokens[k].depth)
|
|
1540
|
+
return undefined;
|
|
1541
|
+
k = list - 1;
|
|
1542
|
+
}
|
|
1543
|
+
const before = tokens[k - 1];
|
|
1544
|
+
if (!isWord(tokens[k]) || !(before?.kw === "with" || before?.kw === "recursive" || before?.text === ","))
|
|
1545
|
+
return undefined;
|
|
1546
|
+
return tableNameOf(tokens[k]);
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Whether the name at `i` is a table read in a `FROM` clause: written right after a `SELECT`'s own
|
|
1550
|
+
* `FROM`, after `JOIN` or MySQL's `STRAIGHT_JOIN`, after the `(` of a parenthesized join group in
|
|
1551
|
+
* one of those places, or after a comma of that `FROM` list (joins included), after any schema
|
|
1552
|
+
* qualifier, and not a function's name. The alias of a derived table, of a table function or of
|
|
1553
|
+
* another table is not, so the policy's columns are never trusted on a source that only borrows a
|
|
1554
|
+
* table's name (#400).
|
|
1555
|
+
*/
|
|
1556
|
+
function isTableReference(tokens, i) {
|
|
1557
|
+
if (tokens[i + 1]?.text === "(")
|
|
1558
|
+
return false;
|
|
1559
|
+
return startsSource(tokens, qualifiedStart(tokens, i));
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* The column written at `i`, read the way the view's reading compares it with the policy's
|
|
1563
|
+
* ({@link engineName}, {@link columnKey}); undefined when the engines of that reading read it apart,
|
|
1564
|
+
* so it ties nothing. `"AGENCY_ID"`, read the Postgres way, is another column than `agency_id`.
|
|
1565
|
+
*/
|
|
1566
|
+
function writtenColumn(a, i) {
|
|
1567
|
+
return engineName(a.tokens[i], a.view.reading.profile.id);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* The `col = col` terms ANDed into a `WHERE`, `ON` or `HAVING` clause, with their `SELECT` and the
|
|
1571
|
+
* `ON` they sit in, if any. In a `SELECT` that may keep unmatched rows ({@link mayKeepUnmatchedRows}),
|
|
1572
|
+
* an `ON` equality holds only on the rows its join matched, so it links nothing: there only `WHERE`
|
|
1573
|
+
* and `HAVING` count, as {@link filteredScopes} reads them (#400).
|
|
1574
|
+
*/
|
|
1575
|
+
function equalities(a) {
|
|
1576
|
+
const { tokens } = a;
|
|
1577
|
+
const found = [];
|
|
1578
|
+
tokens.forEach((t, i) => {
|
|
1579
|
+
if (t.text !== "=" || !tokens[i - 1] || !isIdentifier(tokens[i - 1]) || !tokens[i + 1] || !isIdentifier(tokens[i + 1]))
|
|
1580
|
+
return;
|
|
1581
|
+
let end = i + 1;
|
|
1582
|
+
while (tokens[end + 1]?.text === "." && tokens[end + 2] !== undefined && isIdentifier(tokens[end + 2]))
|
|
1583
|
+
end += 2;
|
|
1584
|
+
const leftColumn = writtenColumn(a, i - 1);
|
|
1585
|
+
const rightColumn = writtenColumn(a, end);
|
|
1586
|
+
if (leftColumn === undefined || rightColumn === undefined)
|
|
1587
|
+
return;
|
|
1588
|
+
const left = { q: qualifierOf(tokens, i - 1), column: leftColumn };
|
|
1589
|
+
const right = { q: qualifierOf(tokens, end), column: rightColumn };
|
|
1590
|
+
// In `HAVING`, each side must be a `GROUP BY` key ({@link onGroupKey}).
|
|
1591
|
+
for (const f of filteredScopes(a, qualifiedStart(tokens, i - 1), end, undefined, [i - 1, end]))
|
|
1592
|
+
found.push({ left, right, scope: f.scope, on: f.on });
|
|
1593
|
+
});
|
|
1594
|
+
return found;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Whether a `SELECT` may keep rows a join's `ON` never matched: `LEFT`, `RIGHT`, `FULL` or `OUTER`
|
|
1598
|
+
* appears in it as a word, whatever hint follows (`RIGHT STRAIGHT JOIN`, `OUTER APPLY`). No `ON`
|
|
1599
|
+
* there filters, seeds, links or hops: only `WHERE` and `HAVING` do (#400).
|
|
1600
|
+
*/
|
|
1601
|
+
function mayKeepUnmatchedRows(a, scope) {
|
|
1602
|
+
const { tokens, scopes } = a;
|
|
1603
|
+
return tokens.some((t, i) => scopes.of[i] === scope && ["left", "right", "full", "outer"].includes(t.kw) && tokens[i + 1]?.text !== "(");
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* The read a column refers to in `scope`: the one its qualifier names, or, unqualified, the one
|
|
1607
|
+
* whose table the policy says has the column, among the reads it can name ({@link canName}: in an
|
|
1608
|
+
* `ON`, that join's operands). Another source with the same column would make the SQL ambiguous,
|
|
1609
|
+
* so it can't name a different one. Undefined when no single read fits.
|
|
1610
|
+
*/
|
|
1611
|
+
function resolve(p, scope, ref, on) {
|
|
1612
|
+
const hits = p.nodes.filter((n) => n.scope === scope && canName(p.a.tokens, ref.q, on, n) && (ref.q !== undefined || p.graph.columns.get(n.table)?.has(ref.column)));
|
|
1613
|
+
return hits.length === 1 ? hits[0] : undefined;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* The placeholders whose IDs each column of a read holds in `scope`: a column compared with a
|
|
1617
|
+
* placeholder (`= P`, `IN (P)`, `= ANY(P)`), ANDed into a filter clause, holds its root's IDs; so does
|
|
1618
|
+
* a column equal to one that does, through `col = col` conjuncts; and so does `col IN (SELECT x …)`
|
|
1619
|
+
* when the subquery's `x` does in its own `SELECT`.
|
|
1620
|
+
*/
|
|
1621
|
+
function boundIn(p, scope) {
|
|
1622
|
+
const cached = p.bound.get(scope);
|
|
1623
|
+
if (cached !== undefined)
|
|
1624
|
+
return cached;
|
|
1625
|
+
const { a } = p;
|
|
1626
|
+
const { tokens, scopes } = a;
|
|
1627
|
+
const parent = new Map();
|
|
1628
|
+
const find = (x) => {
|
|
1629
|
+
let r = x;
|
|
1630
|
+
while (parent.has(r) && parent.get(r) !== r)
|
|
1631
|
+
r = parent.get(r);
|
|
1632
|
+
parent.set(x, r);
|
|
1633
|
+
return r;
|
|
1634
|
+
};
|
|
1635
|
+
const term = (node, column) => `${node.at}.${column}`;
|
|
1636
|
+
for (const eq of p.equalities) {
|
|
1637
|
+
if (eq.scope !== scope)
|
|
1638
|
+
continue;
|
|
1639
|
+
const l = resolve(p, scope, eq.left, eq.on);
|
|
1640
|
+
const r = resolve(p, scope, eq.right, eq.on);
|
|
1641
|
+
if (l && r)
|
|
1642
|
+
parent.set(find(term(l, eq.left.column)), find(term(r, eq.right.column)));
|
|
1643
|
+
}
|
|
1644
|
+
const seeds = new Map();
|
|
1645
|
+
const seed = (key, placeholders) => {
|
|
1646
|
+
const root = find(key);
|
|
1647
|
+
seeds.set(root, new Set([...(seeds.get(root) ?? []), ...placeholders]));
|
|
1648
|
+
};
|
|
1649
|
+
tokens.forEach((t, k) => {
|
|
1650
|
+
// A placeholder counts only outside a quoted name: `":tenant_agency_ids"` is a column.
|
|
1651
|
+
if (t.quoted === true || !p.graph.placeholders.has(t.text))
|
|
1652
|
+
return;
|
|
1653
|
+
const predicate = predicateAt(tokens, k, undefined, a.view.reading.profile.id);
|
|
1654
|
+
const column = predicate === undefined ? undefined : writtenColumn(a, predicate.column);
|
|
1655
|
+
if (predicate === undefined || column === undefined)
|
|
1656
|
+
return;
|
|
1657
|
+
const ref = { q: qualifierOf(tokens, predicate.column), column };
|
|
1658
|
+
const filter = filteredScopes(a, predicate.start, predicate.end, ref.q, predicate.column).find((f) => f.scope === scope);
|
|
1659
|
+
if (filter === undefined)
|
|
1660
|
+
return;
|
|
1661
|
+
const node = resolve(p, scope, ref, filter.on);
|
|
1662
|
+
if (node)
|
|
1663
|
+
seed(term(node, ref.column), [t.text]);
|
|
1664
|
+
});
|
|
1665
|
+
for (const link of inLinks(p, scope)) {
|
|
1666
|
+
const node = resolve(p, scope, link.ref, link.on);
|
|
1667
|
+
if (link.inner && node)
|
|
1668
|
+
seed(term(node, link.ref.column), boundIn(p, link.innerScope)(link.inner, link.item.column));
|
|
1669
|
+
}
|
|
1670
|
+
const lookup = (node, column) => seeds.get(find(term(node, column))) ?? new Set();
|
|
1671
|
+
p.bound.set(scope, lookup);
|
|
1672
|
+
return lookup;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Each `col IN (SELECT x …)` ANDed into a filter clause of `scope`, over a subquery with no set
|
|
1676
|
+
* operation that selects one plain column: `col` as written, the subquery's `SELECT`, the column it
|
|
1677
|
+
* selects, and the read that column resolves to there, if any.
|
|
1678
|
+
*/
|
|
1679
|
+
function inLinks(p, scope) {
|
|
1680
|
+
const cached = p.links.get(scope);
|
|
1681
|
+
if (cached !== undefined)
|
|
1682
|
+
return cached;
|
|
1683
|
+
const { tokens, scopes } = p.a;
|
|
1684
|
+
const links = [];
|
|
1685
|
+
p.links.set(scope, links);
|
|
1686
|
+
for (const [open, close] of scopes.close) {
|
|
1687
|
+
const column = open - 2;
|
|
1688
|
+
if (scopes.of[column] !== scope || scopes.branched.has(open) || subqueryUse(tokens, open) !== "in")
|
|
1689
|
+
continue;
|
|
1690
|
+
const written = writtenColumn(p.a, column);
|
|
1691
|
+
if (written === undefined)
|
|
1692
|
+
continue;
|
|
1693
|
+
const ref = { q: qualifierOf(tokens, column), column: written };
|
|
1694
|
+
const filter = filteredScopes(p.a, qualifiedStart(tokens, column), close, ref.q, column).find((f) => f.scope === scope);
|
|
1695
|
+
if (filter === undefined)
|
|
1696
|
+
continue;
|
|
1697
|
+
const item = selectedColumn(p.a, open);
|
|
1698
|
+
if (item === undefined)
|
|
1699
|
+
continue;
|
|
1700
|
+
const innerScope = scopeKey(open, 0);
|
|
1701
|
+
links.push({ ref, innerScope, item, inner: resolve(p, innerScope, item), on: filter.on });
|
|
1702
|
+
}
|
|
1703
|
+
return links;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The reads that `node`'s `hop` links it to in its `SELECT`: a conjunct `node.fromColumn = y.toColumn`
|
|
1707
|
+
* (either way round), or `node.fromColumn IN (SELECT y.toColumn …)`, where `y` reads the hop's table.
|
|
1708
|
+
*/
|
|
1709
|
+
function hopTargets(p, node, hop) {
|
|
1710
|
+
const { tokens, scopes } = p.a;
|
|
1711
|
+
const targets = [];
|
|
1712
|
+
for (const eq of p.equalities) {
|
|
1713
|
+
if (eq.scope !== node.scope)
|
|
1714
|
+
continue;
|
|
1715
|
+
for (const [mine, theirs] of [[eq.left, eq.right], [eq.right, eq.left]]) {
|
|
1716
|
+
if (mine.column !== hop.fromColumn || theirs.column !== hop.toColumn || resolve(p, node.scope, mine, eq.on)?.at !== node.at)
|
|
1717
|
+
continue;
|
|
1718
|
+
const y = resolve(p, node.scope, theirs, eq.on);
|
|
1719
|
+
if (y?.table === hop.to)
|
|
1720
|
+
targets.push(y);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
for (const link of inLinks(p, node.scope)) {
|
|
1724
|
+
if (link.ref.column !== hop.fromColumn || resolve(p, node.scope, link.ref, link.on)?.at !== node.at)
|
|
1725
|
+
continue;
|
|
1726
|
+
if (link.item.column === hop.toColumn && link.inner?.table === hop.to)
|
|
1727
|
+
targets.push(link.inner);
|
|
1728
|
+
}
|
|
1729
|
+
return targets;
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* The proof for the join path `join` of the scoped `table`. The steps run in the order they chain from
|
|
1733
|
+
* `table` (each one leaving the table the one before reached), whatever order the policy lists them in;
|
|
1734
|
+
* when they don't chain that way (a fork or a gap), in the listed order.
|
|
1735
|
+
*/
|
|
1736
|
+
function joinPathProof(policy, table, join, reading) {
|
|
1737
|
+
const hop = (fromId, toId) => ({
|
|
1738
|
+
from: lowerEachLetter(tableOfColumn(fromId)),
|
|
1739
|
+
fromColumn: columnKey(extractColumnName(fromId), reading),
|
|
1740
|
+
to: lowerEachLetter(tableOfColumn(toId)),
|
|
1741
|
+
toColumn: columnKey(extractColumnName(toId), reading),
|
|
1742
|
+
});
|
|
1743
|
+
const idColumn = (rootId) => policy.roots.find((r) => r.id === rootId)?.tenantIdColumn;
|
|
1744
|
+
const roots = new Map();
|
|
1745
|
+
for (const root of policy.roots) {
|
|
1746
|
+
const parents = [];
|
|
1747
|
+
for (const edge of policy.hierarchy) {
|
|
1748
|
+
const to = edge.child === root.id ? idColumn(edge.parent) : undefined;
|
|
1749
|
+
if (to !== undefined)
|
|
1750
|
+
parents.push(hop(edge.foreignKey, to));
|
|
1751
|
+
}
|
|
1752
|
+
const to = root.parent ? idColumn(root.parent.root) : undefined;
|
|
1753
|
+
if (root.parent && to !== undefined)
|
|
1754
|
+
parents.push(hop(root.parent.foreignKey, to));
|
|
1755
|
+
roots.set(lowerEachLetter(tableOfColumn(root.tenantIdColumn)), {
|
|
1756
|
+
idColumn: columnKey(extractColumnName(root.tenantIdColumn), reading),
|
|
1757
|
+
placeholder: placeholderFor(policy, root.id),
|
|
1758
|
+
parents,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
const listed = join.map((step) => hop(step.from, step.to));
|
|
1762
|
+
const left = [...listed];
|
|
1763
|
+
const chained = [];
|
|
1764
|
+
let at = lowerEachLetter(table);
|
|
1765
|
+
while (left.length > 0) {
|
|
1766
|
+
const next = left.filter((h) => h.from === at);
|
|
1767
|
+
if (next.length !== 1)
|
|
1768
|
+
break;
|
|
1769
|
+
chained.push(next[0]);
|
|
1770
|
+
left.splice(left.indexOf(next[0]), 1);
|
|
1771
|
+
at = next[0].to;
|
|
1772
|
+
}
|
|
1773
|
+
return { steps: left.length === 0 ? chained : listed, roots };
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Whether `node`, read of a table scoped by a join path, is within scope through that path alone
|
|
1777
|
+
* (#400): step `k` of `path` from `node` (a declared key, on exactly its columns) to a read of the
|
|
1778
|
+
* next table, and so on in order, then the path's root, through its ID or the hierarchy above it
|
|
1779
|
+
* ({@link rootInScope}). On the last step, the step's own column carries the root's IDs, so a filter
|
|
1780
|
+
* on it counts (`appointments.client_id IN (:tenant_client_ids)`). A table along the way counts only
|
|
1781
|
+
* through the path's own steps, never through its own scope column or another path's key.
|
|
1782
|
+
*/
|
|
1783
|
+
function pathInScope(p, node, path, allowed, k = 0, visited = new Set([node.at])) {
|
|
1784
|
+
if (k === path.steps.length)
|
|
1785
|
+
return rootInScope(p, node, path, allowed, visited);
|
|
1786
|
+
const step = path.steps[k];
|
|
1787
|
+
if (node.table !== step.from)
|
|
1788
|
+
return false;
|
|
1789
|
+
if (k === path.steps.length - 1) {
|
|
1790
|
+
const holds = p.graph.carriers.get(node.table)?.get(step.fromColumn);
|
|
1791
|
+
for (const placeholder of boundIn(p, node.scope)(node, step.fromColumn)) {
|
|
1792
|
+
if (holds?.has(placeholder) && allowed.has(placeholder))
|
|
1793
|
+
return true;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
for (const next of hopTargets(p, node, step)) {
|
|
1797
|
+
if (visited.has(next.at))
|
|
1798
|
+
continue;
|
|
1799
|
+
visited.add(next.at);
|
|
1800
|
+
if (pathInScope(p, next, path, allowed, k + 1, visited))
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
return false;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Whether `node`, a read of a root table a join path reaches, is within scope: its ID column holds
|
|
1807
|
+
* its own placeholder, a key to a parent root holds that parent's placeholder, or such a key links it
|
|
1808
|
+
* to a parent root's read that is, in turn, within scope. Only the root's ID and the hierarchy count.
|
|
1809
|
+
*/
|
|
1810
|
+
function rootInScope(p, node, path, allowed, visited) {
|
|
1811
|
+
const link = path.roots.get(node.table);
|
|
1812
|
+
if (link === undefined)
|
|
1813
|
+
return false;
|
|
1814
|
+
const bound = boundIn(p, node.scope);
|
|
1815
|
+
if (allowed.has(link.placeholder) && bound(node, link.idColumn).has(link.placeholder))
|
|
1816
|
+
return true;
|
|
1817
|
+
for (const up of link.parents) {
|
|
1818
|
+
const parent = path.roots.get(up.to);
|
|
1819
|
+
if (parent !== undefined && allowed.has(parent.placeholder) && bound(node, up.fromColumn).has(parent.placeholder))
|
|
1415
1820
|
return true;
|
|
1821
|
+
for (const next of hopTargets(p, node, up)) {
|
|
1822
|
+
if (visited.has(next.at))
|
|
1823
|
+
continue;
|
|
1824
|
+
visited.add(next.at);
|
|
1825
|
+
if (rootInScope(p, next, path, allowed, visited))
|
|
1826
|
+
return true;
|
|
1827
|
+
}
|
|
1416
1828
|
}
|
|
1417
1829
|
return false;
|
|
1418
1830
|
}
|
|
1831
|
+
/**
|
|
1832
|
+
* Whether `node` reads only rows within the scope of the roots whose placeholders are `allowed`
|
|
1833
|
+
* (#400): a column of its table that holds an allowed root's IDs (by the policy) is bound to that
|
|
1834
|
+
* root's placeholder in its `SELECT` (see {@link boundIn}), or a foreign key the policy declares links
|
|
1835
|
+
* it, on exactly the declared columns, to a read that is itself within that scope. A join on any
|
|
1836
|
+
* other pair of columns links nothing. With `start`, `node` counts only through that column of its
|
|
1837
|
+
* table: a table scoped by a column is proven through that column, never another path's key on it.
|
|
1838
|
+
*/
|
|
1839
|
+
function inScope(p, node, allowed, visited = new Set([node.at]), start) {
|
|
1840
|
+
const bound = boundIn(p, node.scope);
|
|
1841
|
+
for (const [column, holds] of p.graph.carriers.get(node.table) ?? []) {
|
|
1842
|
+
if (start !== undefined && column !== start)
|
|
1843
|
+
continue;
|
|
1844
|
+
for (const placeholder of bound(node, column))
|
|
1845
|
+
if (holds.has(placeholder) && allowed.has(placeholder))
|
|
1846
|
+
return true;
|
|
1847
|
+
}
|
|
1848
|
+
for (const hop of p.graph.hops) {
|
|
1849
|
+
if (hop.from !== node.table || (start !== undefined && hop.fromColumn !== start))
|
|
1850
|
+
continue;
|
|
1851
|
+
for (const next of hopTargets(p, node, hop)) {
|
|
1852
|
+
if (visited.has(next.at))
|
|
1853
|
+
continue;
|
|
1854
|
+
visited.add(next.at);
|
|
1855
|
+
if (inScope(p, next, allowed, visited))
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* The column the query at `open` selects, when its select list is one plain column (`col` or
|
|
1863
|
+
* `q.col`, under any alias), else undefined.
|
|
1864
|
+
*/
|
|
1865
|
+
function selectedColumn(a, open) {
|
|
1866
|
+
const { tokens, scopes, view } = a;
|
|
1867
|
+
const scope = scopeKey(open, 0);
|
|
1868
|
+
const depth = tokens[open].depth + 1;
|
|
1869
|
+
if (tokens[open + 1]?.kw !== "select")
|
|
1870
|
+
return undefined;
|
|
1871
|
+
let first = open + 2;
|
|
1872
|
+
if (tokens[first]?.kw === "all" || tokens[first]?.kw === "distinct")
|
|
1873
|
+
first++;
|
|
1874
|
+
let from = first;
|
|
1875
|
+
while (from < tokens.length && !(scopes.of[from] === scope && tokens[from].depth === depth && tokens[from].kw === "from"))
|
|
1876
|
+
from++;
|
|
1877
|
+
const item = tokens.slice(first, from);
|
|
1878
|
+
if (item.length === 0 || from >= tokens.length || item.some((t) => t.text === ","))
|
|
1879
|
+
return undefined;
|
|
1880
|
+
const last = item[item.length - 1];
|
|
1881
|
+
for (let at = item[0].offset; at < last.offset + last.text.length; at++) {
|
|
1882
|
+
if (view.text[at] !== view.source[at] && view.text[at] !== QUOTE_MARK)
|
|
1883
|
+
return undefined;
|
|
1884
|
+
}
|
|
1885
|
+
const n = item[1]?.text === "." ? 3 : 1;
|
|
1886
|
+
const rest = item.slice(n);
|
|
1887
|
+
const aliased = rest.length === 0 || (rest.length === 1 && isWord(rest[0])) || (rest.length === 2 && rest[0].kw === "as" && isWord(rest[1]));
|
|
1888
|
+
if (!aliased || !isIdentifier(item[n - 1]) || (n === 3 && !isWord(item[0])))
|
|
1889
|
+
return undefined;
|
|
1890
|
+
const column = writtenColumn(a, first + n - 1);
|
|
1891
|
+
if (column === undefined)
|
|
1892
|
+
return undefined;
|
|
1893
|
+
return { q: n === 3 ? item[0].name : undefined, column };
|
|
1894
|
+
}
|
|
1419
1895
|
/** An identifier token: a word that isn't one of the predicate forms' keywords. */
|
|
1420
1896
|
function isIdentifier(token) {
|
|
1421
1897
|
return token.quoted === true || (/^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw));
|
|
@@ -1437,14 +1913,14 @@ function qualifiedStart(tokens, end) {
|
|
|
1437
1913
|
/**
|
|
1438
1914
|
* The span `[start, end]` and column index of a tenant predicate around the placeholder at `k`:
|
|
1439
1915
|
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a possibly
|
|
1440
|
-
* qualified name) is `column`, as {@link namesColumn} compares them under `reading
|
|
1441
|
-
* the column belongs to is its qualifier's to say.
|
|
1916
|
+
* qualified name) is `column`, as {@link namesColumn} compares them under `reading`, or any column
|
|
1917
|
+
* when none is given. Which read the column belongs to is its qualifier's to say.
|
|
1442
1918
|
*/
|
|
1443
1919
|
function predicateAt(tokens, k, column, reading) {
|
|
1444
1920
|
const at = (i) => tokens[i];
|
|
1445
1921
|
const matches = (i) => {
|
|
1446
1922
|
const t = at(i);
|
|
1447
|
-
return t !== undefined && isIdentifier(t) && namesColumn(t, column, reading);
|
|
1923
|
+
return t !== undefined && isIdentifier(t) && (column === undefined || namesColumn(t, column, reading));
|
|
1448
1924
|
};
|
|
1449
1925
|
// col = P
|
|
1450
1926
|
if (at(k - 1)?.text === "=" && matches(k - 2))
|
|
@@ -1498,7 +1974,24 @@ const READ_APART = "\u0000";
|
|
|
1498
1974
|
/** Whether the written column `t` is the policy's `column`, whose spelling the policy writes as the schema does ({@link engineName}). */
|
|
1499
1975
|
function namesColumn(t, column, reading) {
|
|
1500
1976
|
const name = engineName(t, reading);
|
|
1501
|
-
return name !== undefined && name === (
|
|
1977
|
+
return name !== undefined && name === columnKey(column, reading);
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Whether the table read written `t` is the policy's `table`, spelled as the schema spells it, for
|
|
1981
|
+
* every engine that shares `reading`, so the join proof may count it (#400). Read as a column is
|
|
1982
|
+
* ({@link namesColumn}), except the MySQL way, where it is compared as written: MySQL and MariaDB
|
|
1983
|
+
* keep a table name's case on some servers (`lower_case_table_names = 0`). The table gate finds
|
|
1984
|
+
* reads more loosely ({@link tableNameOf}), which only finds more of them; the join proof counts
|
|
1985
|
+
* reads, so `"CLIENTS"`, which Postgres reads as another table, isn't `clients` there.
|
|
1986
|
+
*/
|
|
1987
|
+
function namesTable(t, table, reading) {
|
|
1988
|
+
if (reading === "mysql")
|
|
1989
|
+
return !t.name.startsWith(READ_APART) && t.name === table;
|
|
1990
|
+
return namesColumn(t, table, reading);
|
|
1991
|
+
}
|
|
1992
|
+
/** A column's spelling, as the policy writes it from the schema, read the way `reading` compares a written name ({@link engineName}). */
|
|
1993
|
+
function columnKey(column, reading) {
|
|
1994
|
+
return reading === "postgres" || reading === "sqlserver" ? column : asciiLower(column);
|
|
1502
1995
|
}
|
|
1503
1996
|
/** Whether the written names `t` and `u` are the same name for every engine that shares the `reading` ({@link engineName}). */
|
|
1504
1997
|
function sameName(t, u, reading) {
|
|
@@ -1576,6 +2069,9 @@ function isOuterJoinOn(tokens, on) {
|
|
|
1576
2069
|
continue;
|
|
1577
2070
|
if (t.kw === "on" || t.kw === "using")
|
|
1578
2071
|
return true;
|
|
2072
|
+
// MySQL has no outer `STRAIGHT_JOIN`, so an `ON` that closes one is an inner join's.
|
|
2073
|
+
if (t.kw === "straight_join")
|
|
2074
|
+
return false;
|
|
1579
2075
|
if (t.kw !== "join")
|
|
1580
2076
|
continue;
|
|
1581
2077
|
for (let k = i - 1; k >= 0; k--) {
|
|
@@ -1654,14 +2150,14 @@ function takesNoOn(tokens, join) {
|
|
|
1654
2150
|
return false;
|
|
1655
2151
|
}
|
|
1656
2152
|
/**
|
|
1657
|
-
* The `SELECT`
|
|
1658
|
-
*
|
|
1659
|
-
* `
|
|
1660
|
-
*
|
|
1661
|
-
* at that level and no `NOT` in front of it.
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
2153
|
+
* The `SELECT` whose reads the term `tokens[start..end]` filters (#399), with `qualifier`, its
|
|
2154
|
+
* column's, which ties it to a read there: none, or one. The term must be ANDed into a `WHERE`,
|
|
2155
|
+
* `ON` (of an inner join, in a `SELECT` that keeps no unmatched rows) or `HAVING` clause: at its own level and
|
|
2156
|
+
* at every enclosing parenthesized level up to that clause, it sits between `AND`s (or the clause's
|
|
2157
|
+
* start and end), with no `OR`, `XOR` (or MySQL's `||`) at that level and no `NOT` in front of it.
|
|
2158
|
+
* A level opened by a function call or a comparison doesn't count. In `HAVING`, each of the term's
|
|
2159
|
+
* columns at `termColumn` must be a `GROUP BY` key ({@link onGroupKey}). A `SELECT` used as
|
|
2160
|
+
* `col IN (…)` limits the query around it only through the join {@link inScope} follows (#400).
|
|
1665
2161
|
*/
|
|
1666
2162
|
function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
1667
2163
|
const { tokens, scopes } = a;
|
|
@@ -1671,15 +2167,13 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
|
1671
2167
|
let lo = start;
|
|
1672
2168
|
let hi = end;
|
|
1673
2169
|
let depth = tokens[start].depth;
|
|
1674
|
-
let q = qualifier;
|
|
1675
|
-
let ownSelect = true;
|
|
1676
|
-
let column = termColumn;
|
|
1677
2170
|
for (;;) {
|
|
1678
2171
|
// What is directly around the term, at its level.
|
|
1679
2172
|
const before = tokens[lo - 1];
|
|
1680
2173
|
const after = tokens[hi + 1];
|
|
1681
2174
|
const leftOk = before !== undefined && (conjunctAnd(tokens, lo - 1) || before.text === "(" || FILTER_CLAUSE.has(before.kw));
|
|
1682
|
-
|
|
2175
|
+
// A `,` ends a join's `ON` when another source follows in the `FROM` list; elsewhere the opener check below refuses it.
|
|
2176
|
+
const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || after.text === "," || endsClause(hi + 1);
|
|
1683
2177
|
if (!leftOk || !rightOk)
|
|
1684
2178
|
return filters;
|
|
1685
2179
|
// Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
|
|
@@ -1711,30 +2205,20 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
|
1711
2205
|
}
|
|
1712
2206
|
if (t.depth > depth)
|
|
1713
2207
|
continue;
|
|
1714
|
-
if (t.text === ";" || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
2208
|
+
if (t.text === ";" || t.text === "," || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
1715
2209
|
break;
|
|
1716
2210
|
if (disjunction(t))
|
|
1717
2211
|
return filters;
|
|
1718
2212
|
}
|
|
1719
2213
|
if (clause >= 0) {
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
if (tokens[clause].kw === "
|
|
2214
|
+
// An outer join keeps rows its ON doesn't match, and in a SELECT with one, an inner join's ON
|
|
2215
|
+
// filters only the rows that join builds; there only WHERE and HAVING filter every row (#400).
|
|
2216
|
+
if (tokens[clause].kw === "on" && (isOuterJoinOn(tokens, clause) || mayKeepUnmatchedRows(a, scopes.of[clause])))
|
|
1723
2217
|
return filters;
|
|
1724
|
-
|
|
1725
|
-
filters.push({ scope, qualifier: q, ownSelect, ...(tokens[clause].kw === "on" ? { on: clause } : {}) });
|
|
1726
|
-
// `col IN (<this SELECT>)` limits the query around it to the rows this SELECT returns.
|
|
1727
|
-
const query = scopeOpen(scope);
|
|
1728
|
-
const queryClose = scopes.close.get(query);
|
|
1729
|
-
if (query < 0 || queryClose === undefined || scopes.branched.has(query) || subqueryUse(tokens, query) !== "in")
|
|
2218
|
+
if (tokens[clause].kw === "having" && !(typeof termColumn === "number" ? [termColumn] : termColumn).every((c) => onGroupKey(a, clause, c)))
|
|
1730
2219
|
return filters;
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
depth = tokens[query].depth;
|
|
1734
|
-
q = qualifierOf(tokens, query - 2);
|
|
1735
|
-
column = query - 2;
|
|
1736
|
-
ownSelect = false;
|
|
1737
|
-
continue;
|
|
2220
|
+
filters.push(tokens[clause].kw === "on" ? { scope: scopes.of[clause], qualifier, on: clause } : { scope: scopes.of[clause], qualifier });
|
|
2221
|
+
return filters;
|
|
1738
2222
|
}
|
|
1739
2223
|
if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
|
|
1740
2224
|
return filters;
|