@askdb/core 1.0.0-beta.41 → 1.0.0-beta.43

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.
Files changed (105) hide show
  1. package/README.md +44 -4
  2. package/dist/ask.d.ts +129 -2
  3. package/dist/ask.d.ts.map +1 -1
  4. package/dist/ask.js +391 -15
  5. package/dist/ask.js.map +1 -1
  6. package/dist/errors.d.ts +87 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +29 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +8 -5
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +5 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/logging/log-events.d.ts +13 -0
  15. package/dist/logging/log-events.d.ts.map +1 -1
  16. package/dist/logging/log-events.js +13 -0
  17. package/dist/logging/log-events.js.map +1 -1
  18. package/dist/schema/v2/index.d.ts +1 -1
  19. package/dist/schema/v2/index.d.ts.map +1 -1
  20. package/dist/schema/v2/loader.d.ts +9 -1
  21. package/dist/schema/v2/loader.d.ts.map +1 -1
  22. package/dist/schema/v2/loader.js +171 -49
  23. package/dist/schema/v2/loader.js.map +1 -1
  24. package/dist/schema/v2/normalized.d.ts +33 -0
  25. package/dist/schema/v2/normalized.d.ts.map +1 -1
  26. package/dist/schema/v2/parser.d.ts +9 -0
  27. package/dist/schema/v2/parser.d.ts.map +1 -1
  28. package/dist/schema/v2/parser.js +18 -2
  29. package/dist/schema/v2/parser.js.map +1 -1
  30. package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
  31. package/dist/schema/v2/tenant-policy-loader.js +7 -3
  32. package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
  33. package/dist/schema/v2/tenant-policy.d.ts +28 -21
  34. package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
  35. package/dist/schema/v2/tenant-policy.js +67 -11
  36. package/dist/schema/v2/tenant-policy.js.map +1 -1
  37. package/dist/sql/bind.d.ts +102 -0
  38. package/dist/sql/bind.d.ts.map +1 -0
  39. package/dist/sql/bind.js +422 -0
  40. package/dist/sql/bind.js.map +1 -0
  41. package/dist/sql/dialect-spec.d.ts +24 -1
  42. package/dist/sql/dialect-spec.d.ts.map +1 -1
  43. package/dist/sql/dialect-spec.js +82 -3
  44. package/dist/sql/dialect-spec.js.map +1 -1
  45. package/dist/sql/extract-sql.d.ts +8 -1
  46. package/dist/sql/extract-sql.d.ts.map +1 -1
  47. package/dist/sql/extract-sql.js +27 -4
  48. package/dist/sql/extract-sql.js.map +1 -1
  49. package/dist/sql/generate.d.ts +22 -0
  50. package/dist/sql/generate.d.ts.map +1 -1
  51. package/dist/sql/generate.js +92 -16
  52. package/dist/sql/generate.js.map +1 -1
  53. package/dist/sql/index.d.ts +4 -2
  54. package/dist/sql/index.d.ts.map +1 -1
  55. package/dist/sql/index.js +4 -2
  56. package/dist/sql/index.js.map +1 -1
  57. package/dist/sql/lexer.d.ts +114 -0
  58. package/dist/sql/lexer.d.ts.map +1 -0
  59. package/dist/sql/lexer.js +397 -0
  60. package/dist/sql/lexer.js.map +1 -0
  61. package/dist/sql/parameter-manifest.d.ts +34 -0
  62. package/dist/sql/parameter-manifest.d.ts.map +1 -0
  63. package/dist/sql/parameter-manifest.js +150 -0
  64. package/dist/sql/parameter-manifest.js.map +1 -0
  65. package/dist/sql/prompt.d.ts +7 -1
  66. package/dist/sql/prompt.d.ts.map +1 -1
  67. package/dist/sql/prompt.js +24 -1
  68. package/dist/sql/prompt.js.map +1 -1
  69. package/dist/sql/sensitive-guardrail.d.ts +62 -0
  70. package/dist/sql/sensitive-guardrail.d.ts.map +1 -0
  71. package/dist/sql/sensitive-guardrail.js +876 -0
  72. package/dist/sql/sensitive-guardrail.js.map +1 -0
  73. package/dist/sql/tenant-guardrail.d.ts +70 -8
  74. package/dist/sql/tenant-guardrail.d.ts.map +1 -1
  75. package/dist/sql/tenant-guardrail.js +799 -52
  76. package/dist/sql/tenant-guardrail.js.map +1 -1
  77. package/dist/sql/tenant-hierarchy.d.ts +19 -0
  78. package/dist/sql/tenant-hierarchy.d.ts.map +1 -0
  79. package/dist/sql/tenant-hierarchy.js +53 -0
  80. package/dist/sql/tenant-hierarchy.js.map +1 -0
  81. package/dist/sql/tenant-placeholders.d.ts +60 -9
  82. package/dist/sql/tenant-placeholders.d.ts.map +1 -1
  83. package/dist/sql/tenant-placeholders.js +237 -63
  84. package/dist/sql/tenant-placeholders.js.map +1 -1
  85. package/dist/sql/tenant-prompt.d.ts +4 -0
  86. package/dist/sql/tenant-prompt.d.ts.map +1 -1
  87. package/dist/sql/tenant-prompt.js +79 -11
  88. package/dist/sql/tenant-prompt.js.map +1 -1
  89. package/dist/sql/tenant-scope-validate.d.ts +3 -1
  90. package/dist/sql/tenant-scope-validate.d.ts.map +1 -1
  91. package/dist/sql/tenant-scope-validate.js +5 -2
  92. package/dist/sql/tenant-scope-validate.js.map +1 -1
  93. package/dist/sql/validate.d.ts +24 -4
  94. package/dist/sql/validate.d.ts.map +1 -1
  95. package/dist/sql/validate.js +123 -83
  96. package/dist/sql/validate.js.map +1 -1
  97. package/package.json +9 -7
  98. package/dist/exec/postgres.d.ts +0 -6
  99. package/dist/exec/postgres.d.ts.map +0 -1
  100. package/dist/exec/postgres.js +0 -30
  101. package/dist/exec/postgres.js.map +0 -1
  102. package/dist/modes/post-execute-log.d.ts +0 -9
  103. package/dist/modes/post-execute-log.d.ts.map +0 -1
  104. package/dist/modes/post-execute-log.js +0 -26
  105. package/dist/modes/post-execute-log.js.map +0 -1
@@ -1,41 +1,92 @@
1
1
  import { TenantGuardrailError, } from "../errors.js";
2
+ import { AskDbLogEvent } from "../logging/log-events.js";
3
+ import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
4
+ import { isBuiltInDialectId } from "./dialect-spec.js";
5
+ import { startsDashComment } from "./lexer.js";
2
6
  /**
3
- * Validate generated SQL against the tenant policy and runtime scope.
7
+ * Best-effort lint of generated SQL against the tenant policy and runtime scope.
4
8
  *
5
- * Uses heuristic pattern matching to verify that tenant-scoped tables
6
- * have the required predicates. Falls back to conservative rejection
7
- * when the SQL cannot be proven safe.
9
+ * Pass the SQL **before** tenant substitution: the model's statement, with the
10
+ * `:tenant_<root>_ids` placeholders still in place. That is the untrusted input;
11
+ * `resolveTenantSql()` then only swaps each placeholder for literals or driver
12
+ * markers. `ask()` and `generateSelectSql()` check that form.
8
13
  *
9
- * In `strict` mode, throws `TenantGuardrailError` on failure.
10
- * In `warn` mode, returns warnings without throwing.
14
+ * This is a heuristic, not a SQL parser. For each tenant-scoped table the query
15
+ * mentions, it looks for a tenant predicate: the table's tenant column compared
16
+ * with its root's placeholder (`col = :tenant_agency_ids`, `col IN
17
+ * (:tenant_agency_ids)`, `col = ANY(:tenant_agency_ids)`, either side), ANDed into
18
+ * a `WHERE`, `ON` or `HAVING` clause. A predicate beside an `OR` or `XOR` at any
19
+ * enclosing level, under `NOT`, or anywhere but a filter clause (the select list,
20
+ * a `CASE`) doesn't count, and neither does a literal ID or a bare mention of the
21
+ * column. A root table the scope covers (`org.agency` under an `agency` scope)
22
+ * needs the same predicate on its tenant ID column. Down a join path, the predicate
23
+ * must be on a column that carries the IDs of the path's root or one of its ancestors
24
+ * in the hierarchy. A polymorphic table also needs `typeColumn = '<key>'` ANDed into
25
+ * the same query block, with a `mapping` key of the root whose placeholder filters
26
+ * its id column. It does not tie a predicate
27
+ * to one table reference, so an unfiltered reference beside a filtered one can
28
+ * still pass; database-side row-level security is the sound boundary.
29
+ *
30
+ * **This is not a security boundary.** Its purpose is to catch model mistakes
31
+ * (a forgotten or widened tenant filter, an unclassified table) early and
32
+ * cheaply. Real tenant isolation must come from the database (for example
33
+ * row-level security) or from the host applying the tenant predicate itself.
34
+ *
35
+ * Identifiers are matched only in code regions: text inside string literals and
36
+ * comments never counts as a table reference or a tenant predicate. Regions are
37
+ * read the way `options.dialect` reads them. Without a dialect (a custom
38
+ * `AskDialect`, or a direct call without `options.dialect`) the statement is read
39
+ * the standard-SQL way, the Postgres way (`E'…'` escape strings), and the MySQL
40
+ * way (`--` starts a comment only before whitespace, and a `/*! … *\/` body is read
41
+ * both as code and as a comment): a table counts as referenced if any reading sees it, and a tenant
42
+ * predicate counts only if every reading does. So SQL whose scoping depends on
43
+ * the dialect (`"agency_id"`, `'it\'s …'`, `E'it\'s …'`) is flagged, never
44
+ * passed. A tenant placeholder counts only in its exact lowercase form, the only
45
+ * form `resolveTenantSql()` substitutes.
46
+ *
47
+ * `global` scope skips the check. In `strict` mode, throws `TenantGuardrailError`
48
+ * when the check finds a problem. In `warn` mode, returns warnings without throwing.
11
49
  */
12
- export function validateTenantGuardrails(sql, policy, scope) {
50
+ export function validateTenantGuardrails(sql, policy, scope, options) {
13
51
  // Global scope bypasses tenant guardrails
14
52
  if (scope.access.kind === "global") {
15
53
  return { passed: true, warnings: [] };
16
54
  }
17
55
  const warnings = [];
18
- const normalizedSql = normalizeSql(sql);
56
+ const views = codeViews(sql, options?.dialect);
57
+ // Check the roots this scope covers: a query on the root table itself is scoped by its tenant ID column.
58
+ for (const rootId of scopeRootIds(scope)) {
59
+ const root = policy.roots.find((r) => r.id === rootId);
60
+ if (!root || !mentionsTable(views, extractTableName(root.id)))
61
+ continue;
62
+ const column = extractColumnName(root.tenantIdColumn);
63
+ // Filtered on its own tenant ID column, or on a column that carries its IDs (a query
64
+ // that joins the root for a label, filtered on the scoped table's tenant column).
65
+ if (!hasTenantPredicate(views, extractTableName(root.id), idCarriers(policy, root.id))) {
66
+ warnings.push(warn("MISSING_TENANT_PREDICATE", root.id, `Tenant root table '${extractTableName(root.id)}' is missing required tenant predicate. ` +
67
+ `Expected: ${column} = ${placeholderForTenantRoot(root)}`));
68
+ }
69
+ }
19
70
  // Check scoped tables
20
71
  for (const st of policy.scopedTables) {
21
72
  const tableName = extractTableName(st.id);
22
- if (!mentionsTable(normalizedSql, tableName))
73
+ if (!mentionsTable(views, tableName))
23
74
  continue;
24
- checkScopedTable(normalizedSql, st, policy, warnings);
75
+ checkScopedTable(views, st, policy, warnings);
25
76
  }
26
77
  // Check polymorphic tables
27
78
  for (const pt of policy.polymorphicTables) {
28
79
  const tableName = extractTableName(pt.id);
29
- if (!mentionsTable(normalizedSql, tableName))
80
+ if (!mentionsTable(views, tableName))
30
81
  continue;
31
- checkPolymorphicTable(normalizedSql, pt, policy, warnings);
82
+ checkPolymorphicTable(views, pt, policy, warnings);
32
83
  }
33
84
  // Check unknown tables
34
85
  for (const entry of policy.coverage) {
35
86
  if (entry.classification !== "unknown")
36
87
  continue;
37
88
  const tableName = extractTableName(entry.tableId);
38
- if (mentionsTable(normalizedSql, tableName)) {
89
+ if (mentionsTable(views, tableName)) {
39
90
  warnings.push(warn("UNKNOWN_TABLE_REFERENCED", entry.tableId, `Query references unclassified table '${tableName}'. Classify it in tenant-policy.md.`));
40
91
  }
41
92
  }
@@ -45,39 +96,83 @@ export function validateTenantGuardrails(sql, policy, scope) {
45
96
  }
46
97
  return { passed, warnings };
47
98
  }
99
+ /**
100
+ * Run {@link validateTenantGuardrails} over every SQL form the caller is about to
101
+ * hand out (e.g. the bound `sql` and, when present, the `unboundSql`), merge the
102
+ * findings into one result, and log a single pass/fail event.
103
+ *
104
+ * Every form is checked in `warn` mode first so the merged result lists all
105
+ * findings; when the policy is `strict` and anything failed, a single
106
+ * `TenantGuardrailError` is thrown afterwards. `extra` lets a caller fold in a
107
+ * result reported by a custom generator so its findings are never dropped.
108
+ * `dialect` is the target engine, when known (undefined for a custom `AskDialect`).
109
+ *
110
+ * Internal to `@askdb/core` — `ask()` and `generateSelectSql()` share it so the
111
+ * check always runs on the SQL that is actually returned.
112
+ */
113
+ export function enforceTenantGuardrails(sqls, policy, scope, logger, extra, dialect) {
114
+ const collectPolicy = { ...policy, enforcement: "warn" };
115
+ const warnings = [];
116
+ const seen = new Set();
117
+ const add = (w) => {
118
+ const key = `${w.rule}\u0000${w.tableId}\u0000${w.message}`;
119
+ if (seen.has(key))
120
+ return;
121
+ seen.add(key);
122
+ warnings.push(w);
123
+ };
124
+ const checked = new Set();
125
+ for (const sql of sqls) {
126
+ if (sql === undefined || checked.has(sql))
127
+ continue;
128
+ checked.add(sql);
129
+ for (const w of validateTenantGuardrails(sql, collectPolicy, scope, { dialect }).warnings)
130
+ add(w);
131
+ }
132
+ for (const w of extra?.warnings ?? [])
133
+ add(w);
134
+ const passed = warnings.length === 0 && extra?.passed !== false;
135
+ if (passed) {
136
+ logger?.info({ event: AskDbLogEvent.TenantGuardrailPassed }, "tenant guardrail validation passed");
137
+ }
138
+ else {
139
+ logger?.info({
140
+ event: AskDbLogEvent.TenantGuardrailFailed,
141
+ warningCount: warnings.length,
142
+ enforcement: policy.enforcement,
143
+ }, "tenant guardrail validation found issues");
144
+ }
145
+ if (!passed && policy.enforcement === "strict") {
146
+ const detail = warnings.length > 0
147
+ ? warnings.map((w) => w.message).join("; ")
148
+ : "the SQL generator reported a failed tenant guardrail";
149
+ throw new TenantGuardrailError(`Tenant guardrail validation failed (strict mode): ${detail}`, warnings);
150
+ }
151
+ return { passed, warnings };
152
+ }
48
153
  // ---------------------------------------------------------------------------
49
154
  // Per-table checks
50
155
  // ---------------------------------------------------------------------------
51
156
  function checkScopedTable(sql, st, policy, warnings) {
157
+ const table = extractTableName(st.id);
52
158
  for (const path of st.scopeThrough) {
159
+ const placeholder = placeholderFor(policy, path.root);
53
160
  if ("column" in path) {
54
- const colName = extractColumnName(path.column);
55
- const rootLabel = policy.roots.find((r) => r.id === path.root)?.label ?? path.root;
56
- const placeholder = `:tenant_${rootLabel.toLowerCase().replace(/[^a-z0-9]+/g, "_")}_ids`;
57
- // Check if the tenant column or placeholder appears in the SQL
58
- if (mentionsIdentifier(sql, colName) || mentionsIdentifier(sql, placeholder)) {
59
- return; // At least one scope path is satisfied
60
- }
161
+ // The table's tenant column compared with its root's placeholder.
162
+ if (hasTenantPredicate(sql, table, [{ placeholder, column: extractColumnName(path.column), table }]))
163
+ return;
61
164
  }
62
165
  else {
63
- // Inherited via JOINs — check if the join path columns appear
64
- const allStepsPresent = path.join.every((step) => {
65
- const fromCol = extractColumnName(step.from);
66
- const toCol = extractColumnName(step.to);
67
- return mentionsIdentifier(sql, fromCol) && mentionsIdentifier(sql, toCol);
68
- });
69
- if (allStepsPresent) {
70
- // Also verify the root table's tenant column appears somewhere
71
- const rootTenantCol = policy.roots.find((r) => r.id === path.root);
72
- if (rootTenantCol) {
73
- const rootColName = extractColumnName(rootTenantCol.tenantIdColumn);
74
- const rootLabel = rootTenantCol.label;
75
- const placeholder = `:tenant_${rootLabel.toLowerCase().replace(/[^a-z0-9]+/g, "_")}_ids`;
76
- if (mentionsIdentifier(sql, rootColName) || mentionsIdentifier(sql, placeholder)) {
77
- return; // Join path + root filter present
78
- }
79
- }
80
- }
166
+ // Inherited via JOINs: every join column appears, and a tenant predicate filters
167
+ // the root: on its tenant ID column, or, up the hierarchy, on a column that carries
168
+ // the IDs of one of its ancestors. A root outside that chain filters nothing here,
169
+ // even one the scope binds.
170
+ const allStepsPresent = path.join.every((step) => mentionsIdentifier(sql, extractColumnName(step.from)) && mentionsIdentifier(sql, extractColumnName(step.to)));
171
+ if (!allStepsPresent)
172
+ continue;
173
+ const targets = selfAndAncestors(policy, path.root).flatMap((rootId) => idCarriers(policy, rootId));
174
+ if (hasTenantPredicate(sql, table, targets))
175
+ return;
81
176
  }
82
177
  }
83
178
  // None of the scope paths were satisfied
@@ -87,16 +182,107 @@ function checkScopedTable(sql, st, policy, warnings) {
87
182
  return p.join.map((j) => `${extractColumnName(j.from)}→${extractColumnName(j.to)}`).join(", ");
88
183
  });
89
184
  warnings.push(warn("MISSING_TENANT_PREDICATE", st.id, `Tenant-scoped table '${extractTableName(st.id)}' is missing required tenant predicate. ` +
90
- `Expected one of: ${pathDescriptions.join(" OR ")}`));
185
+ `Expected one of: ${pathDescriptions.join(" OR ")}, compared with its tenant placeholder in a WHERE/ON/HAVING clause, ANDed (not OR-ed) with the rest`));
91
186
  }
92
- function checkPolymorphicTable(sql, pt, _policy, warnings) {
187
+ function checkPolymorphicTable(sql, pt, policy, warnings) {
93
188
  const typeColName = extractColumnName(pt.typeColumn);
94
189
  const idColName = extractColumnName(pt.idColumn);
95
- if (!mentionsIdentifier(sql, typeColName)) {
96
- warnings.push(warn("MISSING_TYPE_DISCRIMINATOR", pt.id, `Polymorphic table '${extractTableName(pt.id)}' is missing type discriminator column '${typeColName}' in WHERE clause.`));
190
+ const table = extractTableName(pt.id);
191
+ const typeMentioned = mentionsIdentifier(sql, typeColName);
192
+ if (!typeMentioned) {
193
+ warnings.push(warn("MISSING_TYPE_DISCRIMINATOR", pt.id, `Polymorphic table '${table}' is missing type discriminator column '${typeColName}' in WHERE clause.`));
194
+ }
195
+ const rootIds = [...new Set(Object.values(pt.mapping))];
196
+ const targets = rootIds.map((rootId) => ({ placeholder: placeholderFor(policy, rootId), column: idColName, table }));
197
+ if (!hasTenantPredicate(sql, table, targets)) {
198
+ warnings.push(warn("MISSING_TENANT_PREDICATE", pt.id, `Polymorphic table '${table}' is missing a tenant predicate on '${idColName}' (compared with a tenant placeholder in a WHERE/ON/HAVING clause).`));
199
+ return;
200
+ }
201
+ // The id predicate filters by one root's IDs, so the discriminator must pick that root's
202
+ // rows: `owner_type = 'agency' AND owner_id = :tenant_agency_ids`, ANDed in the same block.
203
+ const paired = rootIds.map((rootId, i) => ({
204
+ ...targets[i],
205
+ discriminator: {
206
+ column: typeColName,
207
+ values: Object.entries(pt.mapping).filter(([, r]) => r === rootId).map(([key]) => key),
208
+ },
209
+ }));
210
+ if (typeMentioned && !hasTenantPredicate(sql, table, paired)) {
211
+ const [key, rootId] = Object.entries(pt.mapping)[0] ?? ["<key>", ""];
212
+ warnings.push(warn("MISSING_TYPE_DISCRIMINATOR", pt.id, `Polymorphic table '${table}' needs '${typeColName}' compared with the mapping key of the root whose placeholder filters '${idColName}', ` +
213
+ `ANDed into the same clause (e.g. ${typeColName} = '${key}' AND ${idColName} = ${placeholderFor(policy, rootId)}).`));
214
+ }
215
+ }
216
+ /** The placeholder `resolveTenantSql()` substitutes for a root (by its label). */
217
+ function placeholderFor(policy, rootId) {
218
+ return placeholderForTenantRoot(policy.roots.find((r) => r.id === rootId) ?? { id: rootId, label: rootId });
219
+ }
220
+ /**
221
+ * The columns that hold a root's tenant IDs, each with its root's placeholder: the root's
222
+ * own `tenantIdColumn`; the foreign key of each child root pointing at it (`hierarchy[]`,
223
+ * `roots[].parent`), e.g. `sub_agencies.agency_id`; the tenant column of each table
224
+ * scoped directly through it; and the last hop of each join path that reaches it (the
225
+ * `from` column of a step whose `to` is the root's tenant ID column).
226
+ */
227
+ function idCarriers(policy, rootId) {
228
+ const root = policy.roots.find((r) => r.id === rootId);
229
+ if (!root)
230
+ return [];
231
+ const placeholder = placeholderForTenantRoot(root);
232
+ const carriers = [
233
+ { placeholder, column: extractColumnName(root.tenantIdColumn), table: extractTableName(root.id) },
234
+ ];
235
+ for (const edge of policy.hierarchy) {
236
+ if (edge.parent === rootId)
237
+ carriers.push({ placeholder, column: extractColumnName(edge.foreignKey), table: extractTableName(edge.child) });
238
+ }
239
+ for (const child of policy.roots) {
240
+ if (child.parent?.root === rootId) {
241
+ carriers.push({ placeholder, column: extractColumnName(child.parent.foreignKey), table: extractTableName(child.id) });
242
+ }
243
+ }
244
+ for (const st of policy.scopedTables) {
245
+ for (const path of st.scopeThrough) {
246
+ if (path.root !== rootId)
247
+ continue;
248
+ if ("column" in path) {
249
+ carriers.push({ placeholder, column: extractColumnName(path.column), table: extractTableName(st.id) });
250
+ continue;
251
+ }
252
+ for (const step of path.join) {
253
+ if (step.to !== root.tenantIdColumn)
254
+ continue;
255
+ carriers.push({ placeholder, column: extractColumnName(step.from), table: tableOfColumn(step.from) });
256
+ }
257
+ }
97
258
  }
98
- if (!mentionsIdentifier(sql, idColName)) {
99
- warnings.push(warn("MISSING_TENANT_PREDICATE", pt.id, `Polymorphic table '${extractTableName(pt.id)}' is missing id column '${idColName}' in WHERE/JOIN clause.`));
259
+ return carriers;
260
+ }
261
+ /** `rootId` and every root above it in the policy's hierarchy (`hierarchy[]`, `roots[].parent`). */
262
+ function selfAndAncestors(policy, rootId) {
263
+ const chain = [rootId];
264
+ for (let i = 0; i < chain.length; i++) {
265
+ const parents = [
266
+ ...policy.hierarchy.filter((edge) => edge.child === chain[i]).map((edge) => edge.parent),
267
+ policy.roots.find((r) => r.id === chain[i])?.parent?.root,
268
+ ];
269
+ for (const parent of parents)
270
+ if (parent !== undefined && !chain.includes(parent))
271
+ chain.push(parent);
272
+ }
273
+ return chain;
274
+ }
275
+ /** The tenant roots a scope binds IDs for (`global` binds none). */
276
+ function scopeRootIds(scope) {
277
+ const access = scope.access;
278
+ switch (access.kind) {
279
+ case "ids":
280
+ case "subtree":
281
+ return [access.tenantRoot];
282
+ case "multi_root":
283
+ return access.scopes.map((s) => s.tenantRoot);
284
+ default:
285
+ return [];
100
286
  }
101
287
  }
102
288
  // ---------------------------------------------------------------------------
@@ -110,21 +296,582 @@ function extractTableName(tableId) {
110
296
  const dot = tableId.lastIndexOf(".");
111
297
  return dot !== -1 ? tableId.slice(dot + 1) : tableId;
112
298
  }
299
+ function tableOfColumn(columnId) {
300
+ // "table:public.orders#agency_id" → "orders"
301
+ const hash = columnId.lastIndexOf("#");
302
+ return extractTableName(hash !== -1 ? columnId.slice(0, hash) : columnId);
303
+ }
113
304
  function extractColumnName(columnId) {
114
305
  // "table:public.orders#agency_id" → "agency_id"
115
306
  const hash = columnId.lastIndexOf("#");
116
307
  return hash !== -1 ? columnId.slice(hash + 1) : columnId;
117
308
  }
118
- function normalizeSql(sql) {
119
- return sql.toLowerCase();
309
+ const STANDARD_READING = {
310
+ doubleQuoted: "identifier",
311
+ backslashEscapes: false,
312
+ hashComments: false,
313
+ eStrings: false,
314
+ pipesAreOr: false,
315
+ dashCommentNeedsSpace: false,
316
+ executableComments: false,
317
+ };
318
+ const POSTGRES_READING = { ...STANDARD_READING, eStrings: true };
319
+ const MYSQL_READING = {
320
+ doubleQuoted: "string",
321
+ backslashEscapes: true,
322
+ hashComments: true,
323
+ eStrings: false,
324
+ pipesAreOr: true,
325
+ dashCommentNeedsSpace: true,
326
+ executableComments: true,
327
+ };
328
+ /** MySQL with its executable comments read as comments: a server too old for their version skips them. */
329
+ const MYSQL_SKIPPING_EXECUTABLE = { ...MYSQL_READING, executableComments: false };
330
+ /**
331
+ * A known dialect gets its own single reading. An unknown one gets every reading
332
+ * (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
333
+ * ambiguous exactly where they differ, and the matchers below resolve that
334
+ * ambiguity toward a warning (tables: any view; predicates: every view). A MySQL
335
+ * server running with `ANSI_QUOTES` reads `"…"` as an identifier; the MySQL
336
+ * reading still treats it as a string, so a predicate written only as
337
+ * `"agency_id"` is flagged there, never passed. A MySQL reading comes twice, with the
338
+ * bodies of `/*! … *\/` executable comments read as code and as comments, since whether
339
+ * the server runs one depends on its version.
340
+ */
341
+ function codeViews(sql, dialect) {
342
+ if (dialect === undefined || !isBuiltInDialectId(dialect.id)) {
343
+ return [STANDARD_READING, POSTGRES_READING, MYSQL_READING, MYSQL_SKIPPING_EXECUTABLE].map((r) => view(sql, r));
344
+ }
345
+ const backslashEscapes = dialect.backslashEscapes === true;
346
+ const bases = dialect.id === "mysql" || dialect.id === "mariadb"
347
+ ? [MYSQL_READING, MYSQL_SKIPPING_EXECUTABLE]
348
+ : dialect.id === "postgres" || dialect.id === "cockroachdb"
349
+ ? [POSTGRES_READING]
350
+ : [STANDARD_READING];
351
+ return bases.map((base) => view(sql, { ...base, backslashEscapes }));
352
+ }
353
+ function view(sql, reading) {
354
+ return { text: codeView(sql, reading), source: sql, reading };
355
+ }
356
+ /**
357
+ * What a quoted identifier's delimiters become in a code view: not a word character, so
358
+ * identifier matching still sees `"agency_id"` as `agency_id`, but the tokenizer can tell
359
+ * a quoted `"where"` (an identifier) from the keyword `WHERE`.
360
+ */
361
+ const QUOTE_MARK = "\u0001";
362
+ /** A character that continues an identifier, so an `E` before it is not a string prefix. */
363
+ const IDENTIFIER_CONTINUE = /[A-Za-z0-9_$\u0080-\uffff]/;
364
+ /**
365
+ * Blank out everything that is not SQL code, so identifier checks only see code
366
+ * regions. String literals (`'…'`, `$tag$…$tag$` bodies, and `"…"` when the
367
+ * reading says so) and comments become spaces. A comment starts where the reading
368
+ * says (`--` only before whitespace on MySQL, see `startsDashComment` in the shared
369
+ * lexer), and an executable comment's body stays code when the reading says so. The output has the same length
370
+ * and keeps the original case (placeholders are case-sensitive), so word
371
+ * boundaries at the seams are unchanged.
372
+ *
373
+ * Quoted identifiers keep their contents; their delimiters become {@link QUOTE_MARK}:
374
+ * `"agency_id"` *is* the identifier `agency_id` on Postgres, and blanking it
375
+ * would hide `FROM "orders"` from the table check and skip that table.
376
+ */
377
+ function codeView(sql, reading) {
378
+ const out = sql.split("");
379
+ const blank = (from, to) => {
380
+ for (let k = from; k < to; k++)
381
+ if (out[k] !== "\n")
382
+ out[k] = " ";
383
+ };
384
+ /**
385
+ * End (exclusive) of the string literal opened by `quote` at `open`. `escapes`
386
+ * says whether backslash escapes the next character inside it.
387
+ */
388
+ const stringEnd = (open, quote, escapes) => {
389
+ let j = open + 1;
390
+ while (j < sql.length) {
391
+ const c = sql[j];
392
+ if (c === "\\" && escapes) {
393
+ j += 2;
394
+ continue;
395
+ }
396
+ if (c === quote) {
397
+ if (sql[j + 1] === quote) {
398
+ j += 2;
399
+ continue;
400
+ }
401
+ return j + 1;
402
+ }
403
+ j++;
404
+ }
405
+ return sql.length;
406
+ };
407
+ const dollarTag = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y;
408
+ const executableOpener = /\/\*M?!\d*/y;
409
+ let inExecutable = false;
410
+ let i = 0;
411
+ while (i < sql.length) {
412
+ const ch = sql[i];
413
+ const next = sql[i + 1];
414
+ if (startsDashComment(sql, i, reading.dashCommentNeedsSpace) || (ch === "#" && reading.hashComments)) {
415
+ const newline = sql.indexOf("\n", i);
416
+ const end = newline === -1 ? sql.length : newline;
417
+ blank(i, end);
418
+ i = end;
419
+ continue;
420
+ }
421
+ if (ch === "*" && next === "/" && inExecutable) {
422
+ blank(i, i + 2);
423
+ inExecutable = false;
424
+ i += 2;
425
+ continue;
426
+ }
427
+ if (ch === "/" && next === "*" && reading.executableComments && !inExecutable) {
428
+ executableOpener.lastIndex = i;
429
+ const opener = executableOpener.exec(sql);
430
+ if (opener) {
431
+ blank(i, i + opener[0].length);
432
+ inExecutable = true;
433
+ i += opener[0].length;
434
+ continue;
435
+ }
436
+ }
437
+ if (ch === "/" && next === "*") {
438
+ const close = sql.indexOf("*/", i + 2);
439
+ const end = close === -1 ? sql.length : close + 2;
440
+ blank(i, end);
441
+ i = end;
442
+ continue;
443
+ }
444
+ if (ch === "'" || (ch === '"' && reading.doubleQuoted === "string")) {
445
+ const escapes = reading.backslashEscapes || (ch === "'" && isEStringPrefix(out, i, reading));
446
+ const end = stringEnd(i, ch, escapes);
447
+ blank(i, end);
448
+ i = end;
449
+ continue;
450
+ }
451
+ if (ch === "$") {
452
+ dollarTag.lastIndex = i;
453
+ const tag = dollarTag.exec(sql);
454
+ const close = tag ? sql.indexOf(tag[0], i + tag[0].length) : -1;
455
+ if (tag && close !== -1) {
456
+ const end = close + tag[0].length;
457
+ blank(i, end);
458
+ i = end;
459
+ continue;
460
+ }
461
+ i++;
462
+ continue;
463
+ }
464
+ if (ch === '"' || ch === "`" || ch === "[") {
465
+ const close = sql.indexOf(ch === "[" ? "]" : ch, i + 1);
466
+ out[i] = QUOTE_MARK;
467
+ if (close === -1)
468
+ break;
469
+ out[close] = QUOTE_MARK;
470
+ i = close + 1;
471
+ continue;
472
+ }
473
+ i++;
474
+ }
475
+ return out.join("");
476
+ }
477
+ /**
478
+ * Whether the `'` at `quote` opens a Postgres `E'…'` escape string: the character
479
+ * before it is `E`/`e` and that `E` starts a new token (it is not the tail of an
480
+ * identifier such as `date'…'`). `out` is the view built so far, where blanked
481
+ * strings, comments, and quoted-identifier delimiters are spaces.
482
+ */
483
+ function isEStringPrefix(out, quote, reading) {
484
+ if (!reading.eStrings || quote === 0)
485
+ return false;
486
+ const prefix = out[quote - 1];
487
+ if (prefix !== "E" && prefix !== "e")
488
+ return false;
489
+ return quote < 2 || !IDENTIFIER_CONTINUE.test(out[quote - 2]);
490
+ }
491
+ /** A table counts as referenced when any reading sees it. */
492
+ function mentionsTable(views, tableName) {
493
+ const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "i");
494
+ return views.some((view) => pattern.test(view.text));
495
+ }
496
+ /** A tenant column counts only when every reading sees it in code. */
497
+ function mentionsIdentifier(views, identifier) {
498
+ const pattern = new RegExp(`\\b${escapeRegex(identifier)}\\b`, "i");
499
+ return views.every((view) => pattern.test(view.text));
500
+ }
501
+ const TOKEN = /:[A-Za-z0-9_]+|::|\|\||<>|!=|<=|>=|[A-Za-z_\u0080-\uffff][\w$\u0080-\uffff]*|\d[\w.]*|\S/g;
502
+ /** Split a code view into tokens with their nesting depth. Strings and comments are already blanked. */
503
+ function tokenize(text) {
504
+ const tokens = [];
505
+ let depth = 0;
506
+ let quoted = false;
507
+ for (const m of text.matchAll(TOKEN)) {
508
+ const t = m[0];
509
+ if (t === QUOTE_MARK) {
510
+ quoted = !quoted;
511
+ continue;
512
+ }
513
+ if (t === ")")
514
+ depth = Math.max(0, depth - 1);
515
+ const lower = t.toLowerCase();
516
+ tokens.push({ text: t, lower, kw: quoted ? "" : lower, depth, offset: m.index });
517
+ if (t === "(")
518
+ depth++;
519
+ }
520
+ return tokens;
521
+ }
522
+ /** Keywords that open a clause whose conjuncts filter rows. */
523
+ const FILTER_CLAUSE = new Set(["where", "on", "having"]);
524
+ /** Keywords that end a filter clause at its own nesting level. */
525
+ const CLAUSE_END = new Set([
526
+ "group", "order", "limit", "offset", "fetch", "union", "intersect", "except", "minus", "window",
527
+ "qualify", "returning", "for", "lock", "into", "having", "where", "join", "inner", "left", "right",
528
+ "full", "cross", "outer", "natural", "straight_join", "apply", "using",
529
+ ]);
530
+ /** Words that put a predicate somewhere other than a filter conjunct (select list, `CASE`, assignment, …). */
531
+ const NOT_A_FILTER = new Set(["select", "from", "by", "when", "then", "else", "case", "end", "as", "set", "values", "distinct", "is"]);
532
+ /** Keywords that combine query blocks; each block is checked on its own. */
533
+ const SET_OPERATORS = new Set(["union", "intersect", "except", "minus"]);
534
+ /** Words other than identifiers that the predicate forms use. */
535
+ const PREDICATE_WORDS = new Set(["in", "any", "and", "or", "not", "xor"]);
536
+ /** Keywords a table can be named after only when quoted (`"order"`, `[group]`). */
537
+ const RESERVED = new Set([
538
+ ...FILTER_CLAUSE, ...CLAUSE_END, ...NOT_A_FILTER, ...SET_OPERATORS, ...PREDICATE_WORDS,
539
+ "order", "group", "user", "table", "limit", "offset", "all", "exists",
540
+ ]);
541
+ /**
542
+ * Whether the statement has a tenant predicate for `table` on every reading: a column
543
+ * and placeholder from `targets`, compared as a conjunct of a filter clause. Every query
544
+ * block that mentions the table needs its own: the statement, and each branch of every
545
+ * `UNION` / `INTERSECT` / `EXCEPT`, at the top level or inside a derived table or CTE.
546
+ * See {@link validateTenantGuardrails} for the rule.
547
+ */
548
+ function hasTenantPredicate(views, table, targets) {
549
+ const name = table.toLowerCase();
550
+ // A table named after a keyword (`order`) can only be written quoted, so its unquoted
551
+ // spelling (`ORDER BY`) is the keyword, not the table.
552
+ const mentions = (t) => t.lower === name && (t.kw === "" || !RESERVED.has(t.kw));
553
+ return views.every((view) => {
554
+ const tokens = tokenize(view.text);
555
+ return queryBlocks(tokens)
556
+ .filter(([from, to]) => tokens.slice(from, to).some(mentions))
557
+ .every(([from, to]) => {
558
+ for (let k = from; k < to; k++) {
559
+ for (const target of targets) {
560
+ if (tokens[k].text !== target.placeholder)
561
+ continue;
562
+ const span = predicateSpan(tokens, k, target);
563
+ if (!span || !isFilterConjunct(tokens, span[0], span[1], view.reading))
564
+ continue;
565
+ if (!target.discriminator || hasDiscriminator(tokens, from, to, view, target))
566
+ return true;
567
+ }
568
+ }
569
+ return false;
570
+ });
571
+ });
572
+ }
573
+ /**
574
+ * Whether the block `tokens[from, to)` ANDs in `col = '<value>'` for `target.discriminator`:
575
+ * its column (optionally qualified with the target's table or alias), `=`, and a plain
576
+ * `'…'` literal, read from `view.source` at the offsets where the view blanked it, whose
577
+ * value is one of the discriminator's values and which nothing but whitespace follows up
578
+ * to the next token (no `''`-escape, no adjacent literal, no comment).
579
+ */
580
+ function hasDiscriminator(tokens, from, to, view, target) {
581
+ const { column, values } = target.discriminator;
582
+ const source = view.source;
583
+ for (let i = from; i < to; i++) {
584
+ const t = tokens[i];
585
+ const eq = tokens[i + 1];
586
+ if (!isIdentifier(t) || t.lower !== column.toLowerCase() || eq?.text !== "=")
587
+ continue;
588
+ const qualifier = tokens[i - 1]?.text === "." ? tokens[i - 2] : undefined;
589
+ if (qualifier !== undefined && namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase()))
590
+ continue;
591
+ let open = eq.offset + 1;
592
+ while (/\s/.test(source[open] ?? ""))
593
+ open++;
594
+ if (source[open] !== "'")
595
+ continue;
596
+ const close = source.indexOf("'", open + 1);
597
+ if (close < 0)
598
+ continue;
599
+ const value = source.slice(open + 1, close);
600
+ if (value.includes("\\") || !values.includes(value))
601
+ continue;
602
+ // The view must read it as a string, and it must end there.
603
+ const next = tokens[i + 2]?.offset ?? source.length;
604
+ if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
605
+ continue;
606
+ if (isFilterConjunct(tokens, qualifiedStart(tokens, i), i + 1, view.reading))
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+ /**
612
+ * Token ranges `[from, to)` of the statement's query blocks: the whole statement, and
613
+ * each branch of every set operation at any depth (bounded by the parentheses around it).
614
+ */
615
+ function queryBlocks(tokens) {
616
+ const blocks = [[0, tokens.length]];
617
+ const splits = new Map(); // enclosing "(" (or -1) → set-operator positions
618
+ tokens.forEach((t, i) => {
619
+ if (!SET_OPERATORS.has(t.kw))
620
+ return;
621
+ const open = enclosing(tokens, i, "(");
622
+ splits.set(open, [...(splits.get(open) ?? []), i]);
623
+ });
624
+ for (const [open, positions] of splits) {
625
+ const end = open < 0 ? tokens.length : enclosing(tokens, open + 1, ")");
626
+ let from = open + 1;
627
+ for (const at of positions) {
628
+ blocks.push([from, at]);
629
+ from = at + 1;
630
+ }
631
+ blocks.push([from, end < 0 ? tokens.length : end]);
632
+ }
633
+ return blocks;
634
+ }
635
+ /** An identifier token: a word that isn't one of the predicate forms' keywords. */
636
+ function isIdentifier(token) {
637
+ return /^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw);
638
+ }
639
+ /**
640
+ * A clause keyword used as a function name (`LEFT(status, 1)`, `RIGHT(…)`) is an identifier.
641
+ * A set operator never is: `UNION (SELECT …)` opens a parenthesized operand.
642
+ */
643
+ function isFunctionCall(tokens, i) {
644
+ return tokens[i + 1]?.text === "(" && isIdentifier(tokens[i]) && !SET_OPERATORS.has(tokens[i].kw);
645
+ }
646
+ /** Start index of a possibly qualified column (`o.agency_id`, `s.o.agency_id`) that ends at `end`. */
647
+ function qualifiedStart(tokens, end) {
648
+ let start = end;
649
+ while (tokens[start - 1]?.text === "." && tokens[start - 2] !== undefined && isIdentifier(tokens[start - 2]))
650
+ start -= 2;
651
+ return start;
120
652
  }
121
- function mentionsTable(normalizedSql, tableName) {
122
- const pattern = new RegExp(`\\b${escapeRegex(tableName.toLowerCase())}\\b`);
123
- return pattern.test(normalizedSql);
653
+ /**
654
+ * The token span `[start, end]` of a tenant predicate around the placeholder at `k`:
655
+ * `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a
656
+ * possibly qualified name) is the target's column. A qualifier that names, or is the
657
+ * alias of, a table other than the target's makes it no predicate for that table.
658
+ */
659
+ function predicateSpan(tokens, k, target) {
660
+ const at = (i) => tokens[i];
661
+ const column = target.column.toLowerCase();
662
+ const matches = (i) => {
663
+ const t = at(i);
664
+ if (t === undefined || !isIdentifier(t) || t.lower !== column)
665
+ return false;
666
+ const qualifier = at(i - 1)?.text === "." ? at(i - 2) : undefined;
667
+ return qualifier === undefined || !namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase());
668
+ };
669
+ // col = P
670
+ if (at(k - 1)?.text === "=" && matches(k - 2))
671
+ return [qualifiedStart(tokens, k - 2), k];
672
+ const closed = at(k + 1)?.text === ")";
673
+ // col IN ( P )
674
+ if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "in" && matches(k - 3)) {
675
+ return [qualifiedStart(tokens, k - 3), k + 1];
676
+ }
677
+ // col = ANY ( P )
678
+ if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "any" && at(k - 3)?.text === "=" && matches(k - 4)) {
679
+ return [qualifiedStart(tokens, k - 4), k + 1];
680
+ }
681
+ // P = col
682
+ if (at(k + 1)?.text === "=") {
683
+ let end = k + 2;
684
+ while (at(end + 1)?.text === "." && at(end + 2) !== undefined && isIdentifier(at(end + 2)))
685
+ end += 2;
686
+ if (matches(end))
687
+ return [k, end];
688
+ }
689
+ return undefined;
690
+ }
691
+ /**
692
+ * Whether the qualifier `name` names a table other than `table`: `name` is that other
693
+ * table, or is declared as its alias (`FROM appointments a`, `JOIN appointments AS a`).
694
+ * A qualifier the statement doesn't declare (a derived table's or CTE's name) names no
695
+ * known table, so it doesn't rule the predicate out.
696
+ */
697
+ function namesOtherTable(tokens, name, table) {
698
+ if (name === table)
699
+ return false;
700
+ for (let i = 1; i < tokens.length; i++) {
701
+ if (tokens[i].lower !== name || !isIdentifier(tokens[i]))
702
+ continue;
703
+ const source = tokens[i - 1]?.kw === "as" ? i - 2 : i - 1;
704
+ const src = tokens[source];
705
+ if (src === undefined || !isIdentifier(src) || tokens[source + 1]?.text === ".")
706
+ continue;
707
+ const before = tokens[qualifiedStart(tokens, source) - 1];
708
+ if (before !== undefined && (before.kw === "from" || before.kw === "join" || before.text === ",")) {
709
+ return src.lower !== table;
710
+ }
711
+ }
712
+ return false;
124
713
  }
125
- function mentionsIdentifier(normalizedSql, identifier) {
126
- const pattern = new RegExp(`\\b${escapeRegex(identifier.toLowerCase())}\\b`);
127
- return pattern.test(normalizedSql);
714
+ /**
715
+ * Whether the subquery opened by the `(` at `open` is a row source (a derived table in
716
+ * `FROM`/`JOIN`, a CTE body after `AS`), the right side of `col IN (…)`, or anything else.
717
+ * `EXISTS (…)` is "other": its filter limits the subquery's rows, not the outer query's.
718
+ * A parenthesized statement, or a parenthesized operand of a set operation (after `(`,
719
+ * or after `UNION`/`INTERSECT`/`EXCEPT`/`MINUS` [`ALL`|`DISTINCT`]), produces the rows of
720
+ * the query around it, so it is used however that query is.
721
+ */
722
+ function subqueryUse(tokens, open) {
723
+ const opener = tokens[open - 1];
724
+ const setOperand = opener !== undefined &&
725
+ (SET_OPERATORS.has(opener.kw) ||
726
+ ((opener.kw === "all" || opener.kw === "distinct") && SET_OPERATORS.has(tokens[open - 2]?.kw ?? "")));
727
+ if (opener === undefined || opener.text === "(" || setOperand) {
728
+ const around = opener?.text === "(" ? open - 1 : enclosing(tokens, open, "(");
729
+ if (around < 0)
730
+ return "table"; // the statement itself
731
+ // `col IN ((…))`: the caller reads `col IN` right before the subquery, so don't claim it.
732
+ const use = subqueryUse(tokens, around);
733
+ return use === "in" ? "other" : use;
734
+ }
735
+ if (opener.kw === "in")
736
+ return tokens[open - 2] !== undefined && isIdentifier(tokens[open - 2]) ? "in" : "other";
737
+ if (opener.kw === "as" || opener.kw === "lateral")
738
+ return "table";
739
+ const depth = tokens[open].depth;
740
+ for (let i = open - 1; i >= 0; i--) {
741
+ const t = tokens[i];
742
+ if (t.depth < depth)
743
+ return "other";
744
+ if (t.depth > depth)
745
+ continue;
746
+ // OUTER APPLY keeps every left-side row, like an outer join; CROSS APPLY filters.
747
+ if (t.kw === "apply")
748
+ return tokens[i - 1]?.kw === "outer" ? "other" : "table";
749
+ if (t.kw === "from" || t.kw === "join")
750
+ return "table";
751
+ if (t.text !== "," && !(isIdentifier(t) && !CLAUSE_END.has(t.kw) && !NOT_A_FILTER.has(t.kw)))
752
+ return "other";
753
+ }
754
+ return "other";
755
+ }
756
+ /** SQL Server join hints, written between the join kind and `JOIN` (`LEFT HASH JOIN`). */
757
+ const JOIN_HINTS = new Set(["loop", "hash", "merge", "remote"]);
758
+ /** Whether the `ON` at `on` belongs to an outer join, whose `ON` doesn't filter the preserved side. */
759
+ function isOuterJoinOn(tokens, on) {
760
+ const depth = tokens[on].depth;
761
+ for (let i = on - 1; i >= 0; i--) {
762
+ const t = tokens[i];
763
+ if (t.depth < depth)
764
+ return false;
765
+ if (t.depth > depth || t.kw !== "join")
766
+ continue;
767
+ let kind = i - 1;
768
+ if (JOIN_HINTS.has(tokens[kind]?.kw ?? ""))
769
+ kind--;
770
+ if (tokens[kind]?.kw === "outer")
771
+ kind--;
772
+ return ["left", "right", "full"].includes(tokens[kind]?.kw ?? "");
773
+ }
774
+ return false;
775
+ }
776
+ /**
777
+ * Whether the term `tokens[start..end]` is ANDed into a `WHERE`, `ON` (of an inner join)
778
+ * or `HAVING` clause of the statement: at its own level and at every enclosing
779
+ * parenthesized level up to that clause, it sits between `AND`s (or the clause's start
780
+ * and end), with no `OR`, `XOR` (or MySQL's `||`) at that level and no `NOT` in front of
781
+ * it. A clause inside a subquery counts only when the subquery is a row source (a
782
+ * derived table or CTE), or when the whole `col IN (…)` around it is itself such a
783
+ * conjunct; not inside `EXISTS (…)`, a scalar subquery, or a comparison. A level opened by a function call or a comparison doesn't count.
784
+ */
785
+ function isFilterConjunct(tokens, start, end, reading) {
786
+ const disjunction = (t) => t.kw === "or" || t.kw === "xor" || (t.text === "||" && reading.pipesAreOr);
787
+ const endsClause = (i) => CLAUSE_END.has(tokens[i].kw) && !isFunctionCall(tokens, i);
788
+ let lo = start;
789
+ let hi = end;
790
+ let depth = tokens[start].depth;
791
+ for (;;) {
792
+ // What is directly around the term, at its level.
793
+ const before = tokens[lo - 1];
794
+ const after = tokens[hi + 1];
795
+ const leftOk = before !== undefined && (before.kw === "and" || before.text === "(" || FILTER_CLAUSE.has(before.kw));
796
+ const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || endsClause(hi + 1);
797
+ if (!leftOk || !rightOk)
798
+ return false;
799
+ // Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
800
+ let clause = -1;
801
+ let open = -1;
802
+ for (let i = lo - 1; i >= 0; i--) {
803
+ const t = tokens[i];
804
+ if (t.depth < depth) {
805
+ open = i; // the "(" opening this level
806
+ break;
807
+ }
808
+ if (t.depth > depth)
809
+ continue;
810
+ if (disjunction(t) || t.kw === "not")
811
+ return false;
812
+ if (FILTER_CLAUSE.has(t.kw)) {
813
+ clause = i;
814
+ break;
815
+ }
816
+ if ((endsClause(i) || NOT_A_FILTER.has(t.kw)) && !isFunctionCall(tokens, i))
817
+ return false;
818
+ }
819
+ let close = tokens.length;
820
+ for (let j = hi + 1; j < tokens.length; j++) {
821
+ const t = tokens[j];
822
+ if (t.depth < depth) {
823
+ close = j; // the ")" closing this level
824
+ break;
825
+ }
826
+ if (t.depth > depth)
827
+ continue;
828
+ if (t.text === ";" || endsClause(j))
829
+ break;
830
+ if (disjunction(t))
831
+ return false;
832
+ }
833
+ if (clause >= 0) {
834
+ if (tokens[clause].kw === "on" && isOuterJoinOn(tokens, clause))
835
+ return false;
836
+ if (depth === 0)
837
+ return true;
838
+ // The clause is inside a subquery: how does the query around it use that subquery?
839
+ const blockOpen = enclosing(tokens, clause, "(");
840
+ const blockClose = enclosing(tokens, clause, ")");
841
+ if (blockOpen < 0 || blockClose < 0)
842
+ return false;
843
+ const use = subqueryUse(tokens, blockOpen);
844
+ if (use === "table")
845
+ return true;
846
+ if (use === "other")
847
+ return false;
848
+ lo = qualifiedStart(tokens, blockOpen - 2); // `col IN (…)`
849
+ hi = blockClose;
850
+ depth = tokens[blockOpen].depth;
851
+ continue;
852
+ }
853
+ if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
854
+ return false;
855
+ // A parenthesized group counts only as a plain boolean conjunct: opened right after
856
+ // WHERE/ON/HAVING, AND, or another "(". Anything else (`f(`, `IN (`, `EXISTS (`,
857
+ // `NOT (`, `= (`) makes it something other than a filter conjunct.
858
+ const opener = tokens[open - 1];
859
+ if (opener === undefined || !(FILTER_CLAUSE.has(opener.kw) || opener.kw === "and" || opener.text === "("))
860
+ return false;
861
+ lo = open;
862
+ hi = close;
863
+ depth = tokens[open].depth;
864
+ }
865
+ }
866
+ /** Index of the `(` or `)` that encloses the token at `i`, or -1 at the top level. */
867
+ function enclosing(tokens, i, paren) {
868
+ const depth = tokens[i].depth;
869
+ const step = paren === "(" ? -1 : 1;
870
+ for (let k = i + step; k >= 0 && k < tokens.length; k += step) {
871
+ if (tokens[k].depth < depth)
872
+ return tokens[k].text === paren ? k : -1;
873
+ }
874
+ return -1;
128
875
  }
129
876
  function escapeRegex(str) {
130
877
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");