@askdb/core 1.0.0-beta.42 → 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.
- package/README.md +6 -4
- package/dist/ask.d.ts +92 -4
- package/dist/ask.d.ts.map +1 -1
- package/dist/ask.js +234 -23
- package/dist/ask.js.map +1 -1
- package/dist/errors.d.ts +41 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/schema/v2/index.d.ts +1 -1
- package/dist/schema/v2/index.d.ts.map +1 -1
- package/dist/schema/v2/loader.d.ts +9 -1
- package/dist/schema/v2/loader.d.ts.map +1 -1
- package/dist/schema/v2/loader.js +171 -49
- package/dist/schema/v2/loader.js.map +1 -1
- package/dist/schema/v2/normalized.d.ts +33 -0
- package/dist/schema/v2/normalized.d.ts.map +1 -1
- package/dist/schema/v2/parser.d.ts +9 -0
- package/dist/schema/v2/parser.d.ts.map +1 -1
- package/dist/schema/v2/parser.js +18 -2
- package/dist/schema/v2/parser.js.map +1 -1
- package/dist/schema/v2/tenant-policy-loader.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy-loader.js +7 -3
- package/dist/schema/v2/tenant-policy-loader.js.map +1 -1
- package/dist/schema/v2/tenant-policy.d.ts +28 -21
- package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy.js +67 -11
- package/dist/schema/v2/tenant-policy.js.map +1 -1
- package/dist/sql/bind.d.ts +17 -15
- package/dist/sql/bind.d.ts.map +1 -1
- package/dist/sql/bind.js +34 -172
- package/dist/sql/bind.js.map +1 -1
- package/dist/sql/dialect-spec.d.ts +21 -2
- package/dist/sql/dialect-spec.d.ts.map +1 -1
- package/dist/sql/dialect-spec.js +74 -3
- package/dist/sql/dialect-spec.js.map +1 -1
- package/dist/sql/generate.d.ts +12 -0
- package/dist/sql/generate.d.ts.map +1 -1
- package/dist/sql/generate.js +24 -16
- package/dist/sql/generate.js.map +1 -1
- package/dist/sql/lexer.d.ts +114 -0
- package/dist/sql/lexer.d.ts.map +1 -0
- package/dist/sql/lexer.js +397 -0
- package/dist/sql/lexer.js.map +1 -0
- package/dist/sql/sensitive-guardrail.d.ts +18 -3
- package/dist/sql/sensitive-guardrail.d.ts.map +1 -1
- package/dist/sql/sensitive-guardrail.js +440 -153
- package/dist/sql/sensitive-guardrail.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +70 -8
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +799 -52
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/dist/sql/tenant-hierarchy.d.ts +19 -0
- package/dist/sql/tenant-hierarchy.d.ts.map +1 -0
- package/dist/sql/tenant-hierarchy.js +53 -0
- package/dist/sql/tenant-hierarchy.js.map +1 -0
- package/dist/sql/tenant-placeholders.d.ts +60 -10
- package/dist/sql/tenant-placeholders.d.ts.map +1 -1
- package/dist/sql/tenant-placeholders.js +229 -58
- package/dist/sql/tenant-placeholders.js.map +1 -1
- package/dist/sql/tenant-prompt.d.ts +4 -0
- package/dist/sql/tenant-prompt.d.ts.map +1 -1
- package/dist/sql/tenant-prompt.js +79 -11
- package/dist/sql/tenant-prompt.js.map +1 -1
- package/dist/sql/tenant-scope-validate.d.ts +3 -1
- package/dist/sql/tenant-scope-validate.d.ts.map +1 -1
- package/dist/sql/tenant-scope-validate.js +5 -2
- package/dist/sql/tenant-scope-validate.js.map +1 -1
- package/dist/sql/validate.d.ts +24 -4
- package/dist/sql/validate.d.ts.map +1 -1
- package/dist/sql/validate.js +123 -37
- package/dist/sql/validate.js.map +1 -1
- package/package.json +6 -5
- package/dist/exec/postgres.d.ts +0 -6
- package/dist/exec/postgres.d.ts.map +0 -1
- package/dist/exec/postgres.js +0 -30
- package/dist/exec/postgres.js.map +0 -1
- package/dist/modes/post-execute-log.d.ts +0 -9
- package/dist/modes/post-execute-log.d.ts.map +0 -1
- package/dist/modes/post-execute-log.js +0 -26
- 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
|
-
*
|
|
7
|
+
* Best-effort lint of generated SQL against the tenant policy and runtime scope.
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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(
|
|
73
|
+
if (!mentionsTable(views, tableName))
|
|
23
74
|
continue;
|
|
24
|
-
checkScopedTable(
|
|
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(
|
|
80
|
+
if (!mentionsTable(views, tableName))
|
|
30
81
|
continue;
|
|
31
|
-
checkPolymorphicTable(
|
|
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(
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
187
|
+
function checkPolymorphicTable(sql, pt, policy, warnings) {
|
|
93
188
|
const typeColName = extractColumnName(pt.typeColumn);
|
|
94
189
|
const idColName = extractColumnName(pt.idColumn);
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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, "\\$&");
|