@askdb/core 1.0.0-beta.53 → 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.
@@ -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, 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. Down a join path, the predicate
25
- * must be on a column that carries the IDs of the path's root or one of its ancestors
26
- * in the hierarchy. A polymorphic table also needs `typeColumn = '<key>'` ANDed into
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. It doesn't
34
- * check which columns a join or an `IN (…)` subquery equates (#400); database-side
35
- * row-level security is the sound boundary.
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
@@ -112,6 +114,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
112
114
  "SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
113
115
  "tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
114
116
  }
117
+ // Some engine folds a few letters into ASCII ones (CockroachDB and MySQL read U+0130 as `i`), and
118
+ // CockroachDB normalizes a name to NFC, so the checks below can't match such a name the engines'
119
+ // way: unless the schema spells it exactly, refuse it.
120
+ const apart = nameEnginesFoldApart(read, dialect, knownNames(policy, schemaNames));
121
+ if (apart !== undefined) {
122
+ warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the name ${apart}, which holds a letter some database engines read as an ASCII letter (U+0130, U+0131, U+017F or U+212A) ` +
123
+ "or isn't in Unicode normalization form C, and isn't spelled that way in the schema. The tenant check can't tell which table or column it names. " +
124
+ "Spell the name exactly as the schema does."));
125
+ }
115
126
  // Past an E'…' string continued on a later line, Postgres and the checks below read different
116
127
  // strings, so a table or call could sit in what they take for a string: refuse it (#703).
117
128
  const continued = postgresOnly.escapeStringContinuation;
@@ -141,9 +152,11 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
141
152
  if (!root || !mentionsTable(views, extractTableName(root.id)))
142
153
  continue;
143
154
  const column = extractColumnName(root.tenantIdColumn);
144
- // Filtered on its own tenant ID column, or on a column that carries its IDs (a query
145
- // that joins the root for a label, filtered on the scoped table's tenant column).
146
- if (!hasTenantPredicate(views, extractTableName(root.id), idCarriers(policy, root.id))) {
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]) })) {
147
160
  warnings.push(warn("MISSING_TENANT_PREDICATE", root.id, `Tenant root table '${extractTableName(root.id)}' is missing required tenant predicate. ` +
148
161
  `Expected: ${column} = ${placeholderForTenantRoot(root)}`));
149
162
  }
@@ -181,20 +194,19 @@ function checkScopedTable(sql, st, policy, warnings) {
181
194
  for (const path of st.scopeThrough) {
182
195
  const placeholder = placeholderFor(policy, path.root);
183
196
  if ("column" in path) {
184
- // The table's tenant column compared with its root's placeholder.
185
- if (hasTenantPredicate(sql, table, [{ placeholder, column: extractColumnName(path.column), table }]))
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 }))
186
202
  return;
187
203
  }
188
204
  else {
189
- // Inherited via JOINs: every join column appears, and a tenant predicate filters
190
- // the root: on its tenant ID column, or, up the hierarchy, on a column that carries
191
- // the IDs of one of its ancestors. A root outside that chain filters nothing here,
192
- // even one the scope binds.
193
- const allStepsPresent = path.join.every((step) => mentionsIdentifier(sql, extractColumnName(step.from)) && mentionsIdentifier(sql, extractColumnName(step.to)));
194
- if (!allStepsPresent)
195
- continue;
196
- const targets = selfAndAncestors(policy, path.root).flatMap((rootId) => idCarriers(policy, rootId));
197
- 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 }))
198
210
  return;
199
211
  }
200
212
  }
@@ -580,11 +592,13 @@ function codeView(sql, tokens, names) {
580
592
  return out.join("");
581
593
  }
582
594
  /**
583
- * A table counts as referenced when any reading sees its name in code, except as a word of
584
- * `ORDER BY` or `GROUP BY` (#828).
595
+ * A table counts as referenced when any reading sees its name in code, in any case and between
596
+ * characters that can't continue a name in any script, except as a word of `ORDER BY` or
597
+ * `GROUP BY` (#828).
585
598
  */
586
599
  function mentionsTable(views, tableName) {
587
- const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "gi");
600
+ // A name's edges are letters, digits, `_` or `$` in any script, matched in any case.
601
+ const pattern = new RegExp(`(?<![\\p{L}\\p{M}\\p{N}_$])${escapeRegex(tableName)}(?![\\p{L}\\p{M}\\p{N}_$])`, "giu");
588
602
  return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
589
603
  }
590
604
  /**
@@ -635,6 +649,9 @@ function tokenize(view) {
635
649
  const tokens = [];
636
650
  const split = lexerWordEnds(view);
637
651
  const foldsCase = view.reading.profile.id === "postgres" || view.reading.profile.id === "generic";
652
+ // Postgres folds only ASCII letters in an unquoted name and CockroachDB every letter: a name with
653
+ // an upper-case non-ASCII letter is read apart, so it names nothing ({@link READ_APART}).
654
+ const readsApart = view.reading.profile.id === "postgres";
638
655
  const quotedAt = new Map();
639
656
  for (const t of view.tokens) {
640
657
  if (t.kind === "quoted_identifier")
@@ -663,7 +680,7 @@ function tokenize(view) {
663
680
  if (t === ")")
664
681
  depth = Math.max(0, depth - 1);
665
682
  const lower = t.toLowerCase();
666
- const name = foldsCase ? t.replace(/[A-Z]+/g, (s) => s.toLowerCase()) : t;
683
+ const name = !foldsCase ? t : readsApart && lower !== asciiLower(t) ? `${READ_APART}${offset}` : asciiLower(t);
667
684
  tokens.push({ text: t, lower, name, kw: lower, depth, offset });
668
685
  if (t === "(")
669
686
  depth++;
@@ -814,7 +831,7 @@ function scopeOpen(scope) {
814
831
  * spellings finds more reads, so the check only gets stricter on engines that keep it.
815
832
  */
816
833
  function tableNameOf(t) {
817
- return t.quoted === true ? t.lower.trimEnd() : t.lower;
834
+ return lowerEachLetter(t.quoted === true ? t.name.trimEnd() : t.text);
818
835
  }
819
836
  /** Whether `token` may be a name: a quoted name, whatever it holds, or a word that starts with a letter or `_`. */
820
837
  function isWord(token) {
@@ -1035,10 +1052,13 @@ function occurrences(a, targets) {
1035
1052
  * `SELECT`, and tied to that read by its qualifier. Each branch of a `UNION` / `INTERSECT` /
1036
1053
  * `EXCEPT` is its own `SELECT`, and so is every CTE body, derived table and subquery. A read in a
1037
1054
  * derived table or CTE is also filtered from outside, when that source passes the column through
1038
- * unrenamed and every reference to it is filtered on it. See {@link validateTenantGuardrails}.
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.
1039
1059
  */
1040
- function hasTenantPredicate(views, table, targets) {
1041
- const name = table.toLowerCase();
1060
+ function hasTenantPredicate(views, table, targets, joinProof) {
1061
+ const name = lowerEachLetter(table);
1042
1062
  return views.every((view) => {
1043
1063
  const a = analyze(view);
1044
1064
  // Read the Postgres way, a column list after a read's alias, or after a parenthesized source it
@@ -1054,9 +1074,19 @@ function hasTenantPredicate(views, table, targets) {
1054
1074
  const tableReads = reads.filter((r) => startsSource(a.tokens, qualifiedStart(a.tokens, r.at)));
1055
1075
  return reads.every((read) => {
1056
1076
  const sameSelect = tableReads.filter((r) => r.scope === read.scope);
1057
- return tableReads.includes(read) && sameSelect.length > 1
1058
- ? selfJoinReadFiltered(a, found, targets, read, name, sameSelect)
1059
- : readFiltered(a, found, targets, read, name);
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));
1060
1090
  });
1061
1091
  });
1062
1092
  }
@@ -1087,7 +1117,8 @@ const NOT_A_TABLE_ALIAS = new Set([
1087
1117
  */
1088
1118
  function renamesColumns(tokens, name) {
1089
1119
  for (let i = 0; i < tokens.length; i++) {
1090
- if (tokens[i].lower !== name)
1120
+ // Matched as the table's other reads are ({@link tableNameOf}): `ΟΣ` is `οσ`, as CockroachDB reads it.
1121
+ if (tableNameOf(tokens[i]) !== name)
1091
1122
  continue;
1092
1123
  for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
1093
1124
  if (subqueryUse(tokens, open) !== "table")
@@ -1119,15 +1150,17 @@ function aliasWithColumnList(tokens, j) {
1119
1150
  * it fails closed.
1120
1151
  */
1121
1152
  function selfJoinReadFiltered(a, found, targets, read, table, sameSelect) {
1122
- const aliases = sameSelect.map((r) => canonicalAlias(a, r));
1123
- if (aliases.some((alias) => alias === undefined))
1124
- return false;
1125
- if (new Set(aliases.map((alias) => alias.toLowerCase())).size !== aliases.length)
1153
+ if (!distinctAliases(a, sameSelect))
1126
1154
  return false;
1127
1155
  const mine = canonicalAlias(a, read);
1128
- 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) &&
1129
1157
  (!o.target.discriminator || hasDiscriminator(a, read, o.target))));
1130
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
+ }
1131
1164
  /** The plain alias of a read written `table alias` or `table AS alias`, at its `SELECT`'s own level; else undefined. */
1132
1165
  function canonicalAlias(a, read) {
1133
1166
  const { tokens } = a;
@@ -1150,25 +1183,32 @@ function readFiltered(a, found, targets, read, table) {
1150
1183
  return true;
1151
1184
  }
1152
1185
  }
1153
- return targets.some((target) => target.table.toLowerCase() === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
1186
+ return targets.some((target) => lowerEachLetter(target.table) === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
1154
1187
  }
1155
1188
  /**
1156
- * Whether a predicate that filters `read`'s `SELECT` through `filter` filters `read` itself.
1157
- * Unqualified, it does: the read's table has the column, so the name resolves to it or the SQL is
1158
- * ambiguous. On the read's own column, its qualifier must name that read ({@link readNames});
1159
- * on another table's column (a root filtered through a column
1160
- * that carries its IDs), a read of that table. A qualifier that names no read the check sees,
1161
- * such as a table function's alias, counts for nothing. Through `col IN (…)`, which read of
1162
- * 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).
1163
1195
  */
1164
1196
  function filtersRead(a, filter, target, read, table) {
1165
- const q = filter.qualifier;
1166
- if (q === undefined)
1167
- return filter.on === undefined || seenByOn(a.tokens, filter.on, read.at);
1168
- const owner = target.table.toLowerCase();
1169
- if (owner === table)
1170
- return read.names.has(q);
1171
- return !filter.ownSelect || readsOf(a, owner).some((r) => r.names.has(q));
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);
1172
1212
  }
1173
1213
  /**
1174
1214
  * Whether `read`, inside a derived table or CTE, is filtered from outside on `target`'s column:
@@ -1194,7 +1234,7 @@ function filteredFromOutside(a, found, read, target, depth) {
1194
1234
  const refs = sourceRefs(a, open);
1195
1235
  filtered = refs !== undefined && refs.length > 0 && passesThrough(a, open, read.names, target.column) &&
1196
1236
  refs.every((ref) => found.some((o) => o.target === target &&
1197
- o.filters.some((f) => f.ownSelect && f.scope === ref.scope && (f.qualifier === undefined ? f.on === undefined || seenByOn(a.tokens, f.on, ref.at) : ref.names.has(f.qualifier)))) ||
1237
+ o.filters.some((f) => f.scope === ref.scope && canName(a.tokens, f.qualifier, f.on, ref))) ||
1198
1238
  filteredFromOutside(a, found, ref, target, depth + 1));
1199
1239
  }
1200
1240
  memo.set(key, filtered);
@@ -1325,7 +1365,7 @@ function passesThrough(a, open, names, column) {
1325
1365
  return false; // an expression, a call or an aggregate
1326
1366
  const name = expr[expr.length - 1];
1327
1367
  // An item that yields the name in any case is a candidate; it must name the column the way the engine reads it.
1328
- if ((alias ?? name).lower !== column.toLowerCase())
1368
+ if (tableNameOf(alias ?? name) !== lowerEachLetter(column))
1329
1369
  continue;
1330
1370
  if (!namesColumn(alias ?? name, column, reading) || !namesColumn(name, column, reading))
1331
1371
  return false;
@@ -1390,11 +1430,468 @@ function hasDiscriminator(a, read, target) {
1390
1430
  if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
1391
1431
  continue;
1392
1432
  const filters = filteredScopes(a, qualifiedStart(tokens, i), i + 1, qualifier, i);
1393
- if (filters.some((f) => f.ownSelect && f.scope === read.scope && (qualifier !== undefined || f.on === undefined || seenByOn(tokens, f.on, read.at))))
1433
+ if (filters.some((f) => f.scope === read.scope && canName(tokens, qualifier, f.on, read)))
1394
1434
  return true;
1395
1435
  }
1396
1436
  return false;
1397
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))
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
+ }
1828
+ }
1829
+ return false;
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
+ }
1398
1895
  /** An identifier token: a word that isn't one of the predicate forms' keywords. */
1399
1896
  function isIdentifier(token) {
1400
1897
  return token.quoted === true || (/^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw));
@@ -1416,14 +1913,14 @@ function qualifiedStart(tokens, end) {
1416
1913
  /**
1417
1914
  * The span `[start, end]` and column index of a tenant predicate around the placeholder at `k`:
1418
1915
  * `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a possibly
1419
- * qualified name) is `column`, as {@link namesColumn} compares them under `reading`. Which read
1420
- * 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.
1421
1918
  */
1422
1919
  function predicateAt(tokens, k, column, reading) {
1423
1920
  const at = (i) => tokens[i];
1424
1921
  const matches = (i) => {
1425
1922
  const t = at(i);
1426
- return t !== undefined && isIdentifier(t) && namesColumn(t, column, reading);
1923
+ return t !== undefined && isIdentifier(t) && (column === undefined || namesColumn(t, column, reading));
1427
1924
  };
1428
1925
  // col = P
1429
1926
  if (at(k - 1)?.text === "=" && matches(k - 2))
@@ -1448,25 +1945,58 @@ function predicateAt(tokens, k, column, reading) {
1448
1945
  return undefined;
1449
1946
  }
1450
1947
  /**
1451
- * Whether the written column `t` is the policy's `column` under the `reading`'s rule, compared with
1452
- * the column's own spelling, which the policy writes as the schema does. A name that compares as
1453
- * another column may be another source's, so it ties nothing to the policy's.
1454
- * - Read the Postgres way (`postgres`, `cockroachdb`): a quoted name exactly, an unquoted one with
1455
- * its ASCII letters lower-cased. So `"AGENCY_ID"` is another column than `agency_id`, and an
1456
- * unquoted `agencyid` another than `"agencyId"`.
1457
- * - Read the SQL Server way, the name as written: the database's collation may compare names
1948
+ * The name every engine that shares the `reading` reads for the written name `t`, to compare with a
1949
+ * column's spelling ({@link namesColumn}) or with another written name ({@link sameName}); undefined
1950
+ * when those engines read it apart, so it names no column. A name read as another column may be
1951
+ * another source's, so it ties nothing to the policy's.
1952
+ * - Read the Postgres way (`postgres`, `cockroachdb`): a quoted name exactly; an unquoted one with
1953
+ * its ASCII letters lower-cased, as Postgres folds it. CockroachDB lower-cases every letter, so an
1954
+ * unquoted name with an upper-case non-ASCII letter (`Égency_id`) is read apart.
1955
+ * - Read the SQL Server way, as written: the database's collation may compare names
1458
1956
  * case-sensitively, and the check can't see it, so `agencyid` is another column than `agencyId`.
1459
- * - Read the MySQL way (MySQL and MariaDB), lower-cased.
1460
- * - Otherwise (SQLite, and the generic reading), with ASCII letters lower-cased, quoted or not, so
1461
- * `égency_id` is another column than `Égency_id`.
1462
- * A custom id or none is read every way, so the column has to match under each.
1957
+ * - Read the MySQL way (MySQL and MariaDB) and otherwise (SQLite, the generic reading), with only
1958
+ * ASCII letters lower-cased, quoted or not: SQLite folds only those, and MariaDB keeps apart
1959
+ * letters Unicode lower-casing merges (U+212A KELVIN SIGN and `k`).
1960
+ * A custom id or none is read every way, so a name has to match under each.
1961
+ */
1962
+ function engineName(t, reading) {
1963
+ if (t.name.startsWith(READ_APART))
1964
+ return undefined;
1965
+ return reading === "sqlserver" || reading === "postgres" ? t.name : asciiLower(t.name);
1966
+ }
1967
+ /**
1968
+ * The start of the name a token gets when the engines that share its reading read it apart (an
1969
+ * unquoted name with an upper-case non-ASCII letter, read the Postgres way), followed by its
1970
+ * offset: no other name equals it, so an alias, a qualifier or a column spelled that way names
1971
+ * nothing ({@link engineName}).
1463
1972
  */
1973
+ const READ_APART = "\u0000";
1974
+ /** Whether the written column `t` is the policy's `column`, whose spelling the policy writes as the schema does ({@link engineName}). */
1464
1975
  function namesColumn(t, column, reading) {
1465
- if (reading === "postgres" || reading === "sqlserver")
1466
- return t.name === column;
1976
+ const name = engineName(t, reading);
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) {
1467
1988
  if (reading === "mysql")
1468
- return t.lower === column.toLowerCase();
1469
- return asciiLower(t.name) === asciiLower(column);
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);
1995
+ }
1996
+ /** Whether the written names `t` and `u` are the same name for every engine that shares the `reading` ({@link engineName}). */
1997
+ function sameName(t, u, reading) {
1998
+ const name = engineName(t, reading);
1999
+ return name !== undefined && name === engineName(u, reading);
1470
2000
  }
1471
2001
  /** `name` with its ASCII letters lower-cased, as SQLite folds a name. */
1472
2002
  function asciiLower(name) {
@@ -1539,6 +2069,9 @@ function isOuterJoinOn(tokens, on) {
1539
2069
  continue;
1540
2070
  if (t.kw === "on" || t.kw === "using")
1541
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;
1542
2075
  if (t.kw !== "join")
1543
2076
  continue;
1544
2077
  for (let k = i - 1; k >= 0; k--) {
@@ -1617,14 +2150,14 @@ function takesNoOn(tokens, join) {
1617
2150
  return false;
1618
2151
  }
1619
2152
  /**
1620
- * The `SELECT`s whose reads the term `tokens[start..end]` filters (#399), each with the qualifier
1621
- * that ties it to a read there. The term must be ANDed into a `WHERE`, `ON` (of an inner join) or
1622
- * `HAVING` clause: at its own level and at every enclosing parenthesized level up to that clause,
1623
- * it sits between `AND`s (or the clause's start and end), with no `OR`, `XOR` (or MySQL's `||`)
1624
- * at that level and no `NOT` in front of it. It then filters that clause's `SELECT`, through
1625
- * `qualifier`, its column's. When that `SELECT` is the whole right side of `col IN (…)` and the
1626
- * `col IN (…)` is itself such a conjunct, it filters the query around it too, through `col`'s
1627
- * qualifier. A level opened by a function call or a comparison doesn't count.
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).
1628
2161
  */
1629
2162
  function filteredScopes(a, start, end, qualifier, termColumn) {
1630
2163
  const { tokens, scopes } = a;
@@ -1634,15 +2167,13 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
1634
2167
  let lo = start;
1635
2168
  let hi = end;
1636
2169
  let depth = tokens[start].depth;
1637
- let q = qualifier;
1638
- let ownSelect = true;
1639
- let column = termColumn;
1640
2170
  for (;;) {
1641
2171
  // What is directly around the term, at its level.
1642
2172
  const before = tokens[lo - 1];
1643
2173
  const after = tokens[hi + 1];
1644
2174
  const leftOk = before !== undefined && (conjunctAnd(tokens, lo - 1) || before.text === "(" || FILTER_CLAUSE.has(before.kw));
1645
- const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || endsClause(hi + 1);
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);
1646
2177
  if (!leftOk || !rightOk)
1647
2178
  return filters;
1648
2179
  // Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
@@ -1674,30 +2205,20 @@ function filteredScopes(a, start, end, qualifier, termColumn) {
1674
2205
  }
1675
2206
  if (t.depth > depth)
1676
2207
  continue;
1677
- 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))
1678
2209
  break;
1679
2210
  if (disjunction(t))
1680
2211
  return filters;
1681
2212
  }
1682
2213
  if (clause >= 0) {
1683
- if (tokens[clause].kw === "on" && isOuterJoinOn(tokens, clause))
1684
- return filters;
1685
- if (tokens[clause].kw === "having" && !onGroupKey(a, clause, column))
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])))
1686
2217
  return filters;
1687
- const scope = scopes.of[clause];
1688
- filters.push({ scope, qualifier: q, ownSelect, ...(tokens[clause].kw === "on" ? { on: clause } : {}) });
1689
- // `col IN (<this SELECT>)` limits the query around it to the rows this SELECT returns.
1690
- const query = scopeOpen(scope);
1691
- const queryClose = scopes.close.get(query);
1692
- 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)))
1693
2219
  return filters;
1694
- lo = qualifiedStart(tokens, query - 2);
1695
- hi = queryClose;
1696
- depth = tokens[query].depth;
1697
- q = qualifierOf(tokens, query - 2);
1698
- column = query - 2;
1699
- ownSelect = false;
1700
- continue;
2220
+ filters.push(tokens[clause].kw === "on" ? { scope: scopes.of[clause], qualifier, on: clause } : { scope: scopes.of[clause], qualifier });
2221
+ return filters;
1701
2222
  }
1702
2223
  if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
1703
2224
  return filters;
@@ -1771,7 +2292,7 @@ function endsFilterScan(tokens, j, engine) {
1771
2292
  /**
1772
2293
  * Whether a predicate on the column at `column`, ANDed into the `HAVING` at `having`, filters rows:
1773
2294
  * only when that column is a plain key of its `SELECT`'s `GROUP BY` (`agency_id`, or `o.agency_id`
1774
- * written the same way). On any other column, a group holds every row it covers, and SQLite and
2295
+ * written the same way, the name compared by {@link sameName}). On any other column, a group holds every row it covers, and SQLite and
1775
2296
  * MariaDB take the column's value from one of them. Read the MySQL way, an unqualified column that a
1776
2297
  * select-list item is aliased to never counts: MySQL and MariaDB resolve such a `HAVING` name to the
1777
2298
  * alias in some forms and to the `GROUP BY` column in others, so the check refuses the name either way.
@@ -1822,7 +2343,7 @@ function onGroupKey(a, having, column) {
1822
2343
  }
1823
2344
  return itemRanges(groupBy + 1, having).some(([s, e]) => {
1824
2345
  const last = tokens[e];
1825
- if (!isIdentifier(last) || last.lower !== name)
2346
+ if (!isIdentifier(last) || !sameName(last, tokens[column], a.view.reading.profile.id))
1826
2347
  return false;
1827
2348
  const plain = e === s || (e === s + 2 && tokens[s + 1]?.text === ".");
1828
2349
  return plain && qualifierOf(tokens, e) === qualifier;