@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.
@@ -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. 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
@@ -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 carries its IDs (a query
154
- // that joins the root for a label, filtered on the scoped table's tenant column).
155
- 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]) })) {
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
- 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 }))
195
202
  return;
196
203
  }
197
204
  else {
198
- // Inherited via JOINs: every join column appears, and a tenant predicate filters
199
- // the root: on its tenant ID column, or, up the hierarchy, on a column that carries
200
- // the IDs of one of its ancestors. A root outside that chain filters nothing here,
201
- // even one the scope binds.
202
- const allStepsPresent = path.join.every((step) => mentionsIdentifier(sql, extractColumnName(step.from)) && mentionsIdentifier(sql, extractColumnName(step.to)));
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. 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.
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
- return tableReads.includes(read) && sameSelect.length > 1
1079
- ? selfJoinReadFiltered(a, found, targets, read, name, sameSelect)
1080
- : 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));
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
- 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)
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
- const aliases = sameSelect.map((r) => canonicalAlias(a, r));
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` filters `read` itself.
1178
- * Unqualified, it does: the read's table has the column, so the name resolves to it or the SQL is
1179
- * ambiguous. On the read's own column, its qualifier must name that read ({@link readNames});
1180
- * on another table's column (a root filtered through a column
1181
- * that carries its IDs), a read of that table. A qualifier that names no read the check sees,
1182
- * such as a table function's alias, counts for nothing. Through `col IN (…)`, which read of
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
- const q = filter.qualifier;
1187
- if (q === undefined)
1188
- return filter.on === undefined || seenByOn(a.tokens, filter.on, read.at);
1189
- const owner = lowerEachLetter(target.table);
1190
- if (owner === table)
1191
- return read.names.has(q);
1192
- 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);
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.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))) ||
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).lower !== column.toLowerCase())
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.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)))
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`. Which read
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 === (reading === "postgres" || reading === "sqlserver" ? column : asciiLower(column));
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`s whose reads the term `tokens[start..end]` filters (#399), each with the qualifier
1658
- * that ties it to a read there. The term must be ANDed into a `WHERE`, `ON` (of an inner join) or
1659
- * `HAVING` clause: at its own level and at every enclosing parenthesized level up to that clause,
1660
- * it sits between `AND`s (or the clause's start and end), with no `OR`, `XOR` (or MySQL's `||`)
1661
- * at that level and no `NOT` in front of it. It then filters that clause's `SELECT`, through
1662
- * `qualifier`, its column's. When that `SELECT` is the whole right side of `col IN (…)` and the
1663
- * `col IN (…)` is itself such a conjunct, it filters the query around it too, through `col`'s
1664
- * 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).
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
- 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);
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
- if (tokens[clause].kw === "on" && isOuterJoinOn(tokens, clause))
1721
- return filters;
1722
- 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])))
1723
2217
  return filters;
1724
- const scope = scopes.of[clause];
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
- lo = qualifiedStart(tokens, query - 2);
1732
- hi = queryClose;
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;