@askdb/core 1.0.0-beta.51 → 1.0.0-beta.53
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 -6
- package/dist/errors.d.ts +13 -8
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -9
- package/dist/index.js.map +1 -1
- package/dist/logging/pino-supported-levels.d.ts +1 -1
- package/dist/logging/pino-supported-levels.js +1 -1
- package/dist/retrieval/synthesize-ddl.d.ts +19 -4
- package/dist/retrieval/synthesize-ddl.d.ts.map +1 -1
- package/dist/retrieval/synthesize-ddl.js +82 -19
- package/dist/retrieval/synthesize-ddl.js.map +1 -1
- package/dist/schema/v2/format.d.ts +55 -9
- package/dist/schema/v2/format.d.ts.map +1 -1
- package/dist/schema/v2/format.js +131 -11
- package/dist/schema/v2/format.js.map +1 -1
- package/dist/schema/v2/loader.js +37 -2
- package/dist/schema/v2/loader.js.map +1 -1
- package/dist/schema/v2/normalized.d.ts +4 -0
- package/dist/schema/v2/normalized.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy.d.ts +0 -9
- package/dist/schema/v2/tenant-policy.d.ts.map +1 -1
- package/dist/schema/v2/tenant-policy.js +0 -11
- package/dist/schema/v2/tenant-policy.js.map +1 -1
- package/dist/sql/bind.d.ts.map +1 -1
- package/dist/sql/bind.js +3 -2
- package/dist/sql/bind.js.map +1 -1
- package/dist/sql/guardrails.d.ts.map +1 -1
- package/dist/sql/guardrails.js +2 -2
- package/dist/sql/guardrails.js.map +1 -1
- package/dist/sql/lexer.d.ts +12 -2
- package/dist/sql/lexer.d.ts.map +1 -1
- package/dist/sql/lexer.js +17 -4
- package/dist/sql/lexer.js.map +1 -1
- package/dist/sql/prompt.js +1 -1
- package/dist/sql/prompt.js.map +1 -1
- package/dist/sql/readings.d.ts +113 -0
- package/dist/sql/readings.d.ts.map +1 -0
- package/dist/sql/readings.js +310 -0
- package/dist/sql/readings.js.map +1 -0
- package/dist/sql/schema-question-precheck.d.ts.map +1 -1
- package/dist/sql/schema-question-precheck.js +2 -2
- package/dist/sql/schema-question-precheck.js.map +1 -1
- package/dist/sql/sensitive-guardrail.d.ts +5 -0
- package/dist/sql/sensitive-guardrail.d.ts.map +1 -1
- package/dist/sql/sensitive-guardrail.js +49 -24
- package/dist/sql/sensitive-guardrail.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +12 -5
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +1179 -372
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/dist/sql/tenant-placeholders.d.ts +2 -2
- package/dist/sql/tenant-placeholders.d.ts.map +1 -1
- package/dist/sql/tenant-placeholders.js +2 -2
- package/dist/sql/tenant-placeholders.js.map +1 -1
- package/dist/sql/validate.d.ts +8 -5
- package/dist/sql/validate.d.ts.map +1 -1
- package/dist/sql/validate.js +33 -21
- package/dist/sql/validate.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
|
|
2
|
-
import {
|
|
2
|
+
import { SQLSERVER_DIALECT } from "./dialect-spec.js";
|
|
3
3
|
import { decide, tenantFindings, throwIfDenied } from "./guardrail-decide.js";
|
|
4
|
-
import {
|
|
4
|
+
import { findPostgresOnlyForms } from "./lexer.js";
|
|
5
|
+
import { mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, unmatchedNonAsciiName, } from "./readings.js";
|
|
5
6
|
import { EXPRESSION_KEYWORDS, EXPRESSION_PUNCTUATION } from "./tsql-batch.js";
|
|
6
7
|
import { denylistFor } from "./validate.js";
|
|
7
8
|
/**
|
|
@@ -23,10 +24,15 @@ import { denylistFor } from "./validate.js";
|
|
|
23
24
|
* needs the same predicate on its tenant ID column. Down a join path, the predicate
|
|
24
25
|
* must be on a column that carries the IDs of the path's root or one of its ancestors
|
|
25
26
|
* in the hierarchy. A polymorphic table also needs `typeColumn = '<key>'` ANDed into
|
|
26
|
-
* the same
|
|
27
|
-
* its id column.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* the same `SELECT`, with a `mapping` key of the root whose placeholder filters
|
|
28
|
+
* its id column. Each read of a table needs its own predicate in its own `SELECT`
|
|
29
|
+
* (#399): a CTE body, a derived table and a subquery are each their own, and a
|
|
30
|
+
* qualified predicate counts only for the read its qualifier names. A read in a
|
|
31
|
+
* derived table or CTE also counts as filtered when every reference to that source
|
|
32
|
+
* is filtered on the column, which the source passes through unrenamed without
|
|
33
|
+
* computing across its rows. It doesn't
|
|
34
|
+
* check which columns a join or an `IN (…)` subquery equates (#400); database-side
|
|
35
|
+
* row-level security is the sound boundary.
|
|
30
36
|
*
|
|
31
37
|
* **This is not a security boundary.** Its purpose is to catch model mistakes
|
|
32
38
|
* (a forgotten or widened tenant filter, an unclassified table) early and
|
|
@@ -58,17 +64,20 @@ export function validateTenantGuardrails(sql, policy, scope, options) {
|
|
|
58
64
|
/**
|
|
59
65
|
* The tenant rule alone: what {@link validateTenantGuardrails} finds in `sql`, with no
|
|
60
66
|
* mode. Internal to `@askdb/core`; the guardrail checks and the validator share it.
|
|
67
|
+
* `schemaNames` are the schema's table, namespace and column names, when the caller has the
|
|
68
|
+
* schema: a name outside printable ASCII counts when it is one of them or a name in the policy.
|
|
61
69
|
*/
|
|
62
|
-
export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
70
|
+
export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
63
71
|
// Global scope bypasses tenant guardrails
|
|
64
72
|
if (scope.access.kind === "global")
|
|
65
73
|
return [];
|
|
66
74
|
const warnings = [];
|
|
75
|
+
const read = readSql(sql);
|
|
67
76
|
// The per-table checks below read queries. A statement that isn't one (a `DO` block, `CALL`,
|
|
68
77
|
// `EXPLAIN`, `SHOW`) can touch tables they never see, a `DO` body as a dollar-quoted string,
|
|
69
78
|
// so it is refused here, under every dialect: read-only validation may be a custom
|
|
70
79
|
// AskDialect's job (ADR 0010), tenant enforcement isn't (#698).
|
|
71
|
-
const keyword = nonQueryKeyword(
|
|
80
|
+
const keyword = nonQueryKeyword(read, dialect);
|
|
72
81
|
if (keyword !== undefined) {
|
|
73
82
|
warnings.push(warn("NOT_SELECT_OR_WITH", "", `A statement starts with ${keyword}, not SELECT or WITH. The tenant check can only read a query's ` +
|
|
74
83
|
"tenant predicates, so under a tenant policy it refuses any other statement."));
|
|
@@ -78,7 +87,7 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
78
87
|
// reads. The read-only check allows some of these as reads, and a custom AskDialect skips
|
|
79
88
|
// it, so the tenant check refuses them under every dialect (#702). A listed name counts in any
|
|
80
89
|
// code position, since an engine can reach a function without `(` right after its name.
|
|
81
|
-
const name = uncheckableName(
|
|
90
|
+
const name = uncheckableName(read, dialect);
|
|
82
91
|
if (name !== undefined) {
|
|
83
92
|
warnings.push(warn("UNCHECKABLE_FUNCTION", "", `The SQL names ${name}, a function that reads or scopes rows the tenant check can't see. Under a ` +
|
|
84
93
|
"tenant policy, query the tables directly (pivot with CASE or FILTER, not crosstab), and don't " +
|
|
@@ -94,6 +103,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
94
103
|
warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the Unicode-escaped name ${escaped}, which the tenant check can't read. ` +
|
|
95
104
|
'Write the name in plain double quotes ("orders") or unquoted.'));
|
|
96
105
|
}
|
|
106
|
+
// The checks below match names by spelling. SQL Server reads a name through the database
|
|
107
|
+
// collation, which ignores some characters and reads others as ASCII letters, so a name outside
|
|
108
|
+
// printable ASCII may reach a table they never see: unless the schema spells it exactly, refuse it.
|
|
109
|
+
const folded = unmatchedNonAsciiName(read, dialect, knownNames(policy, schemaNames));
|
|
110
|
+
if (folded !== undefined) {
|
|
111
|
+
warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the name ${folded}, which has a character outside printable ASCII and isn't spelled that way in the schema. ` +
|
|
112
|
+
"SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
|
|
113
|
+
"tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
|
|
114
|
+
}
|
|
97
115
|
// Past an E'…' string continued on a later line, Postgres and the checks below read different
|
|
98
116
|
// strings, so a table or call could sit in what they take for a string: refuse it (#703).
|
|
99
117
|
const continued = postgresOnly.escapeStringContinuation;
|
|
@@ -108,7 +126,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
108
126
|
warnings.push(warn("ESCAPED_BYTE_STRING", "", `The SQL has a b'…' byte string at offset ${bytes.start} with a backslash or continued on a later line, which ` +
|
|
109
127
|
"CockroachDB reads with backslash escapes and the tenant check doesn't. Write the bytes as a hex string cast to bytea ('\\x00'::bytea)."));
|
|
110
128
|
}
|
|
111
|
-
|
|
129
|
+
// On a Postgres reading the lexer reads `$1OR` as one parameter, while PostgreSQL 14 and earlier
|
|
130
|
+
// and CockroachDB end the parameter at its last digit and read the word as a keyword the checks
|
|
131
|
+
// below would miss. Refuse it rather than guess how the server splits it (GHSA-rhwf-7742-8hwj).
|
|
132
|
+
const glued = parameterAgainstWord(read, dialect);
|
|
133
|
+
if (glued !== undefined) {
|
|
134
|
+
warnings.push(warn("NOT_SELECT_OR_WITH", "", `The SQL writes a word right after a parameter ('${glued}'), which PostgreSQL 14 and CockroachDB read as a separate ` +
|
|
135
|
+
"keyword, so the tenant check can't tell which words are keywords. Put a space after the parameter."));
|
|
136
|
+
}
|
|
137
|
+
const views = codeViews(read, dialect);
|
|
112
138
|
// Check the roots this scope covers: a query on the root table itself is scoped by its tenant ID column.
|
|
113
139
|
for (const rootId of scopeRootIds(scope)) {
|
|
114
140
|
const root = policy.roots.find((r) => r.id === rootId);
|
|
@@ -196,7 +222,7 @@ function checkPolymorphicTable(sql, pt, policy, warnings) {
|
|
|
196
222
|
return;
|
|
197
223
|
}
|
|
198
224
|
// The id predicate filters by one root's IDs, so the discriminator must pick that root's
|
|
199
|
-
// rows: `owner_type = 'agency' AND owner_id = :tenant_agency_ids`, ANDed in the same
|
|
225
|
+
// rows: `owner_type = 'agency' AND owner_id = :tenant_agency_ids`, ANDed in the same `SELECT`.
|
|
200
226
|
const paired = rootIds.map((rootId, i) => ({
|
|
201
227
|
...targets[i],
|
|
202
228
|
discriminator: {
|
|
@@ -255,6 +281,57 @@ function idCarriers(policy, rootId) {
|
|
|
255
281
|
}
|
|
256
282
|
return carriers;
|
|
257
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* The names a statement may spell outside printable ASCII: every table the policy covers and its
|
|
286
|
+
* namespace, every column the policy names, and `schemaNames` when the caller has the schema.
|
|
287
|
+
*/
|
|
288
|
+
function knownNames(policy, schemaNames) {
|
|
289
|
+
const names = new Set(schemaNames);
|
|
290
|
+
const table = (id) => {
|
|
291
|
+
const path = id.replace(/^table:/, "");
|
|
292
|
+
const dot = path.lastIndexOf(".");
|
|
293
|
+
names.add(path.slice(dot + 1));
|
|
294
|
+
if (dot !== -1)
|
|
295
|
+
names.add(path.slice(0, dot));
|
|
296
|
+
};
|
|
297
|
+
const column = (id) => {
|
|
298
|
+
const hash = id.lastIndexOf("#");
|
|
299
|
+
if (hash !== -1)
|
|
300
|
+
table(id.slice(0, hash));
|
|
301
|
+
names.add(extractColumnName(id));
|
|
302
|
+
};
|
|
303
|
+
for (const entry of policy.coverage)
|
|
304
|
+
table(entry.tableId);
|
|
305
|
+
for (const id of policy.globalTables)
|
|
306
|
+
table(id);
|
|
307
|
+
for (const root of policy.roots) {
|
|
308
|
+
table(root.id);
|
|
309
|
+
column(root.tenantIdColumn);
|
|
310
|
+
if (root.parent)
|
|
311
|
+
column(root.parent.foreignKey);
|
|
312
|
+
}
|
|
313
|
+
for (const edge of policy.hierarchy)
|
|
314
|
+
column(edge.foreignKey);
|
|
315
|
+
for (const st of policy.scopedTables) {
|
|
316
|
+
table(st.id);
|
|
317
|
+
for (const path of st.scopeThrough) {
|
|
318
|
+
if ("column" in path)
|
|
319
|
+
column(path.column);
|
|
320
|
+
else {
|
|
321
|
+
for (const step of path.join) {
|
|
322
|
+
column(step.from);
|
|
323
|
+
column(step.to);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const pt of policy.polymorphicTables) {
|
|
329
|
+
table(pt.id);
|
|
330
|
+
column(pt.typeColumn);
|
|
331
|
+
column(pt.idColumn);
|
|
332
|
+
}
|
|
333
|
+
return names;
|
|
334
|
+
}
|
|
258
335
|
/** `rootId` and every root above it in the policy's hierarchy (`hierarchy[]`, `roots[].parent`). */
|
|
259
336
|
function selfAndAncestors(policy, rootId) {
|
|
260
337
|
const chain = [rootId];
|
|
@@ -303,21 +380,6 @@ function continuesExpression(prev) {
|
|
|
303
380
|
return prev.text !== ")" && EXPRESSION_PUNCTUATION.has(prev.text);
|
|
304
381
|
return prev.kind === "word" && (EXPRESSION_KEYWORDS.has(prev.lower) || prev.lower === "as" || prev.lower === "for");
|
|
305
382
|
}
|
|
306
|
-
/**
|
|
307
|
-
* Every way a server may lex `sql` into statements: the dialect's profile when it names a
|
|
308
|
-
* built-in engine, otherwise every engine's, and for Postgres and MySQL both string settings
|
|
309
|
-
* (a backslash ends a literal under one and escapes the quote under the other). A MySQL
|
|
310
|
-
* profile is read with its `/*!NNNNN … *\/` bodies as code and as comments, since a server
|
|
311
|
-
* older than NNNNN skips them.
|
|
312
|
-
*/
|
|
313
|
-
function statementReadings(dialect) {
|
|
314
|
-
const known = dialect?.id !== undefined ? lexerProfileFor(dialect) : undefined;
|
|
315
|
-
// A `postgres` dialect may point at CockroachDB, where only `\n` ends a line comment: read both ways.
|
|
316
|
-
const profiles = known
|
|
317
|
-
? known.id === "postgres" ? [known, { ...known, lineCommentEndsAtLfOnly: true }] : [known]
|
|
318
|
-
: ENGINE_LEXER_PROFILES.flatMap((p) => p.id === "postgres" || p.id === "mysql" ? [p, { ...p, backslashEscapes: !p.backslashEscapes }] : [p]);
|
|
319
|
-
return profiles.flatMap((p) => (p.executableComments ? [p, { ...p, executableComments: false }] : [p]));
|
|
320
|
-
}
|
|
321
383
|
/**
|
|
322
384
|
* The leading token of the first statement in `sql` that isn't a query, upper-cased (a
|
|
323
385
|
* keyword, or the token's own text when it isn't a word), or undefined when every statement
|
|
@@ -325,15 +387,15 @@ function statementReadings(dialect) {
|
|
|
325
387
|
* reading also at a top-level word from {@link TSQL_STATEMENT_WORDS} that doesn't
|
|
326
388
|
* {@link continuesExpression continue an expression}. Its first token is read
|
|
327
389
|
* past comments and `(` with the shared lexer, under every reading from
|
|
328
|
-
*
|
|
390
|
+
* `tenantStatementReadings`, so a statement any of them reads as a non-query counts.
|
|
329
391
|
*/
|
|
330
|
-
function nonQueryKeyword(
|
|
331
|
-
for (const profile of
|
|
392
|
+
function nonQueryKeyword(read, dialect) {
|
|
393
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
332
394
|
const tsql = profile.id === "sqlserver";
|
|
333
395
|
let statementStart = true;
|
|
334
396
|
let depth = 0;
|
|
335
397
|
let prev;
|
|
336
|
-
for (const token of
|
|
398
|
+
for (const token of read.tokens(profile)) {
|
|
337
399
|
if (token.kind === "comment")
|
|
338
400
|
continue;
|
|
339
401
|
const before = prev;
|
|
@@ -362,6 +424,20 @@ function nonQueryKeyword(sql, dialect) {
|
|
|
362
424
|
}
|
|
363
425
|
return undefined;
|
|
364
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* The first `$N` parameter with a word written right after its digits (`$1OR`) under a reading
|
|
429
|
+
* from `tenantStatementReadings` on the Postgres lexer, where the lexer reads both as one token.
|
|
430
|
+
*/
|
|
431
|
+
function parameterAgainstWord(read, dialect) {
|
|
432
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
433
|
+
if (profile.id !== "postgres")
|
|
434
|
+
continue;
|
|
435
|
+
const token = read.tokens(profile).find((t) => t.kind === "parameter" && /^\$\d+\D/.test(t.text));
|
|
436
|
+
if (token)
|
|
437
|
+
return token.text.slice(0, 40);
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
365
441
|
/**
|
|
366
442
|
* Functions whose reads the tenant check can't see, lowercase (#702). A function belongs here when
|
|
367
443
|
* it is built into Postgres or SQL Server, or comes from an extension that ships with Postgres
|
|
@@ -392,11 +468,11 @@ const UNCHECKABLE_FUNCTIONS = new Set([
|
|
|
392
468
|
* in any code position, in any case, bare, quoted or qualified, not only right before `(`: an
|
|
393
469
|
* engine can reach a function in forms with no `(` after its name, so a column, an alias or a
|
|
394
470
|
* placeholder named like a listed function counts too. Text in comments and single-quoted strings
|
|
395
|
-
* doesn't. Read under every reading from
|
|
471
|
+
* doesn't. Read under every reading from `tenantStatementReadings`, so a name any of them sees counts.
|
|
396
472
|
*/
|
|
397
|
-
function uncheckableName(
|
|
398
|
-
for (const profile of
|
|
399
|
-
for (const token of
|
|
473
|
+
function uncheckableName(read, dialect) {
|
|
474
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
475
|
+
for (const token of read.tokens(profile)) {
|
|
400
476
|
const name = nameOf(token);
|
|
401
477
|
if (name !== undefined && UNCHECKABLE_FUNCTIONS.has(name))
|
|
402
478
|
return name;
|
|
@@ -436,209 +512,109 @@ function extractColumnName(columnId) {
|
|
|
436
512
|
const hash = columnId.lastIndexOf("#");
|
|
437
513
|
return hash !== -1 ? columnId.slice(hash + 1) : columnId;
|
|
438
514
|
}
|
|
439
|
-
const STANDARD_READING = {
|
|
440
|
-
doubleQuoted: "identifier",
|
|
441
|
-
backslashEscapes: false,
|
|
442
|
-
hashComments: false,
|
|
443
|
-
eStrings: false,
|
|
444
|
-
dollarQuotes: false,
|
|
445
|
-
pipesAreOr: false,
|
|
446
|
-
dashCommentNeedsSpace: false,
|
|
447
|
-
lineCommentEndsAtCr: false,
|
|
448
|
-
executableComments: false,
|
|
449
|
-
};
|
|
450
|
-
const POSTGRES_READING = { ...STANDARD_READING, eStrings: true, dollarQuotes: true };
|
|
451
|
-
const MYSQL_READING = {
|
|
452
|
-
doubleQuoted: "string",
|
|
453
|
-
backslashEscapes: true,
|
|
454
|
-
hashComments: true,
|
|
455
|
-
eStrings: false,
|
|
456
|
-
dollarQuotes: false,
|
|
457
|
-
pipesAreOr: true,
|
|
458
|
-
dashCommentNeedsSpace: true,
|
|
459
|
-
lineCommentEndsAtCr: false,
|
|
460
|
-
executableComments: true,
|
|
461
|
-
};
|
|
462
|
-
/** MySQL with its executable comments read as comments: a server too old for their version skips them. */
|
|
463
|
-
const MYSQL_SKIPPING_EXECUTABLE = { ...MYSQL_READING, executableComments: false };
|
|
464
515
|
/**
|
|
465
|
-
* A
|
|
466
|
-
* (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
|
|
467
|
-
* ambiguous exactly where they differ, and the matchers below resolve that
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* the server runs one depends on its version.
|
|
516
|
+
* The statement's code views. A built-in engine gets its family's reading; any other id, or none,
|
|
517
|
+
* gets every reading (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
|
|
518
|
+
* ambiguous exactly where they differ, and the matchers below resolve that ambiguity toward a
|
|
519
|
+
* warning (tables: any view; predicates: every view). A MySQL server running with `ANSI_QUOTES`
|
|
520
|
+
* reads `"…"` as an identifier; the MySQL reading still treats it as a string, so a predicate
|
|
521
|
+
* written only as `"agency_id"` is flagged there, never passed. A MySQL reading comes twice, with
|
|
522
|
+
* the bodies of `/*! … *\/` executable comments read as code and as comments, since whether the
|
|
523
|
+
* server runs one depends on its version.
|
|
474
524
|
*/
|
|
475
|
-
function codeViews(
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const bases = dialect.id === "mysql" || dialect.id === "mariadb"
|
|
489
|
-
? [MYSQL_READING, MYSQL_SKIPPING_EXECUTABLE]
|
|
490
|
-
: dialect.id === "postgres" || dialect.id === "cockroachdb"
|
|
491
|
-
? [POSTGRES_READING, crEnds(POSTGRES_READING)]
|
|
492
|
-
: dialect.id === "sqlserver"
|
|
493
|
-
? [crEnds(STANDARD_READING)]
|
|
494
|
-
: [STANDARD_READING];
|
|
495
|
-
return bases.map((base) => view(sql, { ...base, backslashEscapes }));
|
|
496
|
-
}
|
|
497
|
-
function view(sql, reading) {
|
|
498
|
-
return { text: codeView(sql, reading), source: sql, reading };
|
|
525
|
+
function codeViews(read, dialect) {
|
|
526
|
+
return tenantCodeReadings(dialect).map((reading) => {
|
|
527
|
+
const tokens = read.tokens(reading.profile);
|
|
528
|
+
// A reading that may be SQLite's reads a '…' in a table's place as the table's name, and one right
|
|
529
|
+
// after a table or CTE reference as its alias.
|
|
530
|
+
const names = mayBeSqlite(reading.profile) ? stringsSqliteReadsAsNames(tokens, true) : new Set();
|
|
531
|
+
const quoted = new Uint8Array(read.sql.length);
|
|
532
|
+
tokens.forEach((token, i) => {
|
|
533
|
+
if (token.kind === "quoted_identifier" || names.has(i))
|
|
534
|
+
quoted.fill(1, token.start, token.end);
|
|
535
|
+
});
|
|
536
|
+
return { text: codeView(read.sql, tokens, names), source: read.sql, reading, tokens, quoted };
|
|
537
|
+
});
|
|
499
538
|
}
|
|
500
539
|
/**
|
|
501
540
|
* What a quoted identifier's delimiters become in a code view: not a word character, so
|
|
502
|
-
* identifier matching still sees `"agency_id"` as `agency_id
|
|
503
|
-
* a quoted `"where"` (an identifier) from the keyword `WHERE
|
|
541
|
+
* identifier matching still sees `"agency_id"` as `agency_id`. The tokenizer skips it, and
|
|
542
|
+
* tells a quoted `"where"` (an identifier) from the keyword `WHERE` by the lexer's spans.
|
|
504
543
|
*/
|
|
505
544
|
const QUOTE_MARK = "\u0001";
|
|
506
|
-
/** A character that continues an identifier, so an `E` before it is not a string prefix. */
|
|
507
|
-
const IDENTIFIER_CONTINUE = /[A-Za-z0-9_$\u0080-\uffff]/;
|
|
508
545
|
/**
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
* reading
|
|
512
|
-
*
|
|
513
|
-
* lexer), and an executable comment's body stays code when the reading says so. The output has the same length
|
|
514
|
-
* and keeps the original case (placeholders are case-sensitive), so word
|
|
546
|
+
* `sql` with everything that is not code blanked, from the shared lexer's `tokens`, so identifier
|
|
547
|
+
* checks only see code. Strings and comments become spaces (a newline stays); the body of an
|
|
548
|
+
* executable comment the reading runs is code, since the lexer reads it as tokens. The output has
|
|
549
|
+
* the same length and keeps the original case (placeholders are case-sensitive), so word
|
|
515
550
|
* boundaries at the seams are unchanged.
|
|
516
551
|
*
|
|
517
|
-
* Quoted identifiers keep their contents
|
|
518
|
-
* `"agency_id"` *is* the identifier
|
|
519
|
-
* would hide `FROM "orders"` from the table check
|
|
552
|
+
* Quoted identifiers keep their contents, and each of their delimiters becomes
|
|
553
|
+
* {@link QUOTE_MARK}, a doubled one inside them too: `"agency_id"` *is* the identifier
|
|
554
|
+
* `agency_id` on Postgres, and blanking it would hide `FROM "orders"` from the table check. The
|
|
555
|
+
* strings at the indices in `names` are read the same way, as SQLite reads them (`FROM 'orders'`).
|
|
520
556
|
*/
|
|
521
|
-
function codeView(sql,
|
|
557
|
+
function codeView(sql, tokens, names) {
|
|
522
558
|
const out = sql.split("");
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* End (exclusive) of the string literal opened by `quote` at `open`. `escapes`
|
|
530
|
-
* says whether backslash escapes the next character inside it.
|
|
531
|
-
*/
|
|
532
|
-
const stringEnd = (open, quote, escapes) => {
|
|
533
|
-
let j = open + 1;
|
|
534
|
-
while (j < sql.length) {
|
|
535
|
-
const c = sql[j];
|
|
536
|
-
if (c === "\\" && escapes) {
|
|
537
|
-
j += 2;
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
if (c === quote) {
|
|
541
|
-
if (sql[j + 1] === quote) {
|
|
542
|
-
j += 2;
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
return j + 1;
|
|
546
|
-
}
|
|
547
|
-
j++;
|
|
548
|
-
}
|
|
549
|
-
return sql.length;
|
|
550
|
-
};
|
|
551
|
-
// The tag may hold non-ASCII letters, as the shared lexer's DOLLAR_TAG and Postgres read it.
|
|
552
|
-
const dollarTag = /\$(?:[A-Za-z_\u0080-][A-Za-z0-9_\u0080-]*)?\$/y;
|
|
553
|
-
const executableOpener = /\/\*M?!\d*/y;
|
|
554
|
-
const lineEnd = reading.lineCommentEndsAtCr ? /[\n\r]/g : /\n/g;
|
|
555
|
-
let inExecutable = false;
|
|
556
|
-
let i = 0;
|
|
557
|
-
while (i < sql.length) {
|
|
558
|
-
const ch = sql[i];
|
|
559
|
-
const next = sql[i + 1];
|
|
560
|
-
if (startsDashComment(sql, i, reading.dashCommentNeedsSpace) || (ch === "#" && reading.hashComments)) {
|
|
561
|
-
lineEnd.lastIndex = i;
|
|
562
|
-
const end = lineEnd.exec(sql)?.index ?? sql.length;
|
|
563
|
-
blank(i, end);
|
|
564
|
-
i = end;
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
if (ch === "*" && next === "/" && inExecutable) {
|
|
568
|
-
blank(i, i + 2);
|
|
569
|
-
inExecutable = false;
|
|
570
|
-
i += 2;
|
|
571
|
-
continue;
|
|
572
|
-
}
|
|
573
|
-
if (ch === "/" && next === "*" && reading.executableComments && !inExecutable) {
|
|
574
|
-
executableOpener.lastIndex = i;
|
|
575
|
-
const opener = executableOpener.exec(sql);
|
|
576
|
-
if (opener) {
|
|
577
|
-
blank(i, i + opener[0].length);
|
|
578
|
-
inExecutable = true;
|
|
579
|
-
i += opener[0].length;
|
|
580
|
-
continue;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
if (ch === "/" && next === "*") {
|
|
584
|
-
const close = sql.indexOf("*/", i + 2);
|
|
585
|
-
const end = close === -1 ? sql.length : close + 2;
|
|
586
|
-
blank(i, end);
|
|
587
|
-
i = end;
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
if (ch === "'" || (ch === '"' && reading.doubleQuoted === "string")) {
|
|
591
|
-
const escapes = reading.backslashEscapes || (ch === "'" && isEStringPrefix(out, i, reading));
|
|
592
|
-
const end = stringEnd(i, ch, escapes);
|
|
593
|
-
blank(i, end);
|
|
594
|
-
i = end;
|
|
595
|
-
continue;
|
|
559
|
+
for (const [index, token] of tokens.entries()) {
|
|
560
|
+
if (names.has(index)) {
|
|
561
|
+
for (let k = token.start; k < token.end; k++)
|
|
562
|
+
if (sql[k] === "'")
|
|
563
|
+
out[k] = QUOTE_MARK;
|
|
596
564
|
}
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const end = close + tag[0].length;
|
|
604
|
-
blank(i, end);
|
|
605
|
-
i = end;
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
i++;
|
|
609
|
-
continue;
|
|
565
|
+
else if (token.kind === "string" || token.kind === "comment") {
|
|
566
|
+
// A string's prefix letter (`E'…'`, `N'…'`) stays code, as the check has always read it.
|
|
567
|
+
const from = token.kind === "string" && /^[A-Za-z]/.test(token.text) ? token.start + 1 : token.start;
|
|
568
|
+
for (let k = from; k < token.end; k++)
|
|
569
|
+
if (out[k] !== "\n")
|
|
570
|
+
out[k] = " ";
|
|
610
571
|
}
|
|
611
|
-
if (
|
|
612
|
-
const close =
|
|
613
|
-
out[
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
i = close + 1;
|
|
618
|
-
continue;
|
|
572
|
+
else if (token.kind === "quoted_identifier") {
|
|
573
|
+
const close = token.quote === "[" ? "]" : token.quote;
|
|
574
|
+
out[token.start] = QUOTE_MARK;
|
|
575
|
+
for (let k = token.start + 1; k < token.end; k++)
|
|
576
|
+
if (sql[k] === close)
|
|
577
|
+
out[k] = QUOTE_MARK;
|
|
619
578
|
}
|
|
620
|
-
i++;
|
|
621
579
|
}
|
|
622
580
|
return out.join("");
|
|
623
581
|
}
|
|
624
582
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
|
|
628
|
-
|
|
583
|
+
* A table counts as referenced when any reading sees its name in code, except as a word of
|
|
584
|
+
* `ORDER BY` or `GROUP BY` (#828).
|
|
585
|
+
*/
|
|
586
|
+
function mentionsTable(views, tableName) {
|
|
587
|
+
const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "gi");
|
|
588
|
+
return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Whether `text[start, end)` in a code view is a word of `ORDER BY` or `GROUP BY`. `ORDER` and
|
|
592
|
+
* `GROUP` are reserved on every engine, so a table named after one is written quoted (`"order"`,
|
|
593
|
+
* whose closing quote mark breaks the phrase) or qualified (`billing.order`, which Postgres takes
|
|
594
|
+
* unquoted), and the `BY` after them is the keyword. A `.` before the word makes it a name even with
|
|
595
|
+
* whitespace or a comment (blanked in the view) between them, as every engine reads `billing . order`.
|
|
629
596
|
*/
|
|
630
|
-
function
|
|
631
|
-
|
|
597
|
+
function inOrderOrGroupBy(text, start, end) {
|
|
598
|
+
const afterDot = (at) => /\.\s*$/.test(text.slice(0, at));
|
|
599
|
+
if (afterDot(start))
|
|
600
|
+
return false;
|
|
601
|
+
const word = text.slice(start, end).toLowerCase();
|
|
602
|
+
if (word === "order" || word === "group")
|
|
603
|
+
return /^\s+by(?![\w$\u0080-\uffff])/i.test(text.slice(end));
|
|
604
|
+
if (word !== "by")
|
|
632
605
|
return false;
|
|
633
|
-
const
|
|
634
|
-
if (
|
|
606
|
+
const before = /(?:order|group)\s+$/i.exec(text.slice(0, start));
|
|
607
|
+
if (before === null || /[\w$\u0080-\uffff\u0001]/.test(text[before.index - 1] ?? ""))
|
|
635
608
|
return false;
|
|
636
|
-
return
|
|
609
|
+
return !afterDot(before.index);
|
|
637
610
|
}
|
|
638
|
-
/**
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
611
|
+
/**
|
|
612
|
+
* Whether a reading sees `tableName` as a name of its own, not part of a longer one (`orders$archive`):
|
|
613
|
+
* anywhere in code, or inside a quoted name, except as a word of `ORDER BY` or `GROUP BY`.
|
|
614
|
+
*/
|
|
615
|
+
function mentionsAsName(view, tableName) {
|
|
616
|
+
const pattern = new RegExp(`(?<![\\w$\\u0080-\\uffff])${escapeRegex(tableName)}(?![\\w$\\u0080-\\uffff])`, "gi");
|
|
617
|
+
return [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length));
|
|
642
618
|
}
|
|
643
619
|
/** A tenant column counts only when every reading sees it in code. */
|
|
644
620
|
function mentionsIdentifier(views, identifier) {
|
|
@@ -646,26 +622,109 @@ function mentionsIdentifier(views, identifier) {
|
|
|
646
622
|
return views.every((view) => pattern.test(view.text));
|
|
647
623
|
}
|
|
648
624
|
const TOKEN = /:[A-Za-z0-9_]+|::|\|\||<>|!=|<=|>=|[A-Za-z_\u0080-\uffff][\w$\u0080-\uffff]*|\d[\w.]*|\S/g;
|
|
649
|
-
/**
|
|
650
|
-
|
|
625
|
+
/** A {@link TOKEN} match that is a word or a number. */
|
|
626
|
+
const WORD_OR_NUMBER = /^[\w\u0080-\uffff]/;
|
|
627
|
+
/**
|
|
628
|
+
* Split a code view into tokens with their nesting depth. Strings and comments are already blanked.
|
|
629
|
+
* A quoted identifier is one token, its whole name as the lexer reads it (escapes undone), so a
|
|
630
|
+
* space, dot or bracket inside it is never read as code, and a QUOTE_MARK character written inside
|
|
631
|
+
* it changes nothing. So is a string the reading takes as a name, as SQLite does (`FROM 'orders'`,
|
|
632
|
+
* `orders 'o'`): `'join'` there is a name, never the keyword.
|
|
633
|
+
*/
|
|
634
|
+
function tokenize(view) {
|
|
651
635
|
const tokens = [];
|
|
636
|
+
const split = lexerWordEnds(view);
|
|
637
|
+
const foldsCase = view.reading.profile.id === "postgres" || view.reading.profile.id === "generic";
|
|
638
|
+
const quotedAt = new Map();
|
|
639
|
+
for (const t of view.tokens) {
|
|
640
|
+
if (t.kind === "quoted_identifier")
|
|
641
|
+
quotedAt.set(t.start, { end: t.end, name: t.value });
|
|
642
|
+
else if (t.kind === "string" && view.quoted[t.start] === 1 && t.text.startsWith("'") && t.unterminated !== true) {
|
|
643
|
+
quotedAt.set(t.start, { end: t.end, name: t.text.slice(1, -1).replaceAll("''", "'") });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
652
646
|
let depth = 0;
|
|
653
|
-
let
|
|
654
|
-
for (const m of text.matchAll(TOKEN)) {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
647
|
+
let skipUntil = 0;
|
|
648
|
+
for (const m of view.text.matchAll(TOKEN)) {
|
|
649
|
+
if (m.index < skipUntil)
|
|
650
|
+
continue;
|
|
651
|
+
if (m[0] === QUOTE_MARK) {
|
|
652
|
+
const q = quotedAt.get(m.index);
|
|
653
|
+
if (q === undefined)
|
|
654
|
+
continue;
|
|
655
|
+
const name = q.name;
|
|
656
|
+
// A name spelled like a mark (`"."`, `[,]`) keeps a prefix, so it never reads as that mark.
|
|
657
|
+
const text = /^[^\w\u0080-\uffff]{1,2}$/.test(name) ? QUOTE_MARK + name : name;
|
|
658
|
+
tokens.push({ text, lower: name.toLowerCase(), name, kw: "", depth, offset: m.index + 1, quoted: true });
|
|
659
|
+
skipUntil = q.end;
|
|
658
660
|
continue;
|
|
659
661
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
depth
|
|
662
|
+
for (const [t, offset] of WORD_OR_NUMBER.test(m[0]) ? split(m[0], m.index) : [[m[0], m.index]]) {
|
|
663
|
+
if (t === ")")
|
|
664
|
+
depth = Math.max(0, depth - 1);
|
|
665
|
+
const lower = t.toLowerCase();
|
|
666
|
+
const name = foldsCase ? t.replace(/[A-Z]+/g, (s) => s.toLowerCase()) : t;
|
|
667
|
+
tokens.push({ text: t, lower, name, kw: lower, depth, offset });
|
|
668
|
+
if (t === "(")
|
|
669
|
+
depth++;
|
|
670
|
+
}
|
|
666
671
|
}
|
|
667
672
|
return tokens;
|
|
668
673
|
}
|
|
674
|
+
/**
|
|
675
|
+
* Where a word or number of the view ends under its reading (#623, GHSA-rhwf-7742-8hwj), so a
|
|
676
|
+
* keyword written right after one is a word of its own, as the engine reads it:
|
|
677
|
+
*
|
|
678
|
+
* - SQL Server: wherever the shared lexer ends a token. That is after `0x` or `1E`, after a number
|
|
679
|
+
* before a word, and at whitespace such as U+00A0.
|
|
680
|
+
* - PostgreSQL and CockroachDB: after a number, before a word written right after it (`1OR`,
|
|
681
|
+
* `1.0OR`), as PostgreSQL 14 and earlier read it. A `$N` parameter written against a word
|
|
682
|
+
* (`$1OR`) is refused instead ({@link parameterAgainstWord}).
|
|
683
|
+
* - MySQL and MariaDB: after a number with a decimal point or an exponent (`1.0OR`, `1e1OR`). A
|
|
684
|
+
* name that starts with digits (`3d_models`, `1OR`) is one name there.
|
|
685
|
+
*
|
|
686
|
+
* Any other reading keeps the check's own word ends. Returns a function that cuts a word or number
|
|
687
|
+
* of the view at those ends; under SQL Server it also drops the characters the lexer reads as
|
|
688
|
+
* whitespace. A quoted name is one lexer token, so it is never cut.
|
|
689
|
+
*/
|
|
690
|
+
function lexerWordEnds(view) {
|
|
691
|
+
const { profile } = view.reading;
|
|
692
|
+
const owner = new Int32Array(view.text.length).fill(-1);
|
|
693
|
+
if (profile.tsqlLiterals) {
|
|
694
|
+
view.tokens.forEach((token, index) => owner.fill(index, token.start, token.end));
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
// Every character is code to this reading; a new piece starts only at a number's end.
|
|
698
|
+
owner.fill(0);
|
|
699
|
+
let piece = 0;
|
|
700
|
+
view.tokens.forEach((token, index) => {
|
|
701
|
+
const next = view.tokens[index + 1];
|
|
702
|
+
const wordAfter = next !== undefined && next.start === token.end && next.kind === "word";
|
|
703
|
+
const numberEnds = token.kind === "number" &&
|
|
704
|
+
wordAfter &&
|
|
705
|
+
(profile.id === "postgres" || (profile.id === "mysql" && /^(?!0x)[\d.]*[.eE]/i.test(token.text)));
|
|
706
|
+
if (numberEnds)
|
|
707
|
+
owner.fill(++piece, next.start);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
return (text, offset) => {
|
|
711
|
+
const pieces = [];
|
|
712
|
+
let start = -1;
|
|
713
|
+
const cut = (at) => {
|
|
714
|
+
if (start >= 0)
|
|
715
|
+
pieces.push([view.text.slice(start, at), start]);
|
|
716
|
+
start = -1;
|
|
717
|
+
};
|
|
718
|
+
for (let k = offset; k < offset + text.length; k++) {
|
|
719
|
+
if (owner[k] === -1 || (start >= 0 && owner[k] !== owner[start]))
|
|
720
|
+
cut(k);
|
|
721
|
+
if (owner[k] !== -1 && start < 0)
|
|
722
|
+
start = k;
|
|
723
|
+
}
|
|
724
|
+
cut(offset + text.length);
|
|
725
|
+
return pieces;
|
|
726
|
+
};
|
|
727
|
+
}
|
|
669
728
|
/** Keywords that open a clause whose conjuncts filter rows. */
|
|
670
729
|
const FILTER_CLAUSE = new Set(["where", "on", "having"]);
|
|
671
730
|
/** Keywords that end a filter clause at its own nesting level. */
|
|
@@ -676,7 +735,7 @@ const CLAUSE_END = new Set([
|
|
|
676
735
|
]);
|
|
677
736
|
/** Words that put a predicate somewhere other than a filter conjunct (select list, `CASE`, assignment, …). */
|
|
678
737
|
const NOT_A_FILTER = new Set(["select", "from", "by", "when", "then", "else", "case", "end", "as", "set", "values", "distinct", "is"]);
|
|
679
|
-
/** Keywords that combine
|
|
738
|
+
/** Keywords that combine `SELECT`s; each branch is checked on its own. */
|
|
680
739
|
const SET_OPERATORS = new Set(["union", "intersect", "except", "minus"]);
|
|
681
740
|
/** Words other than identifiers that the predicate forms use. */
|
|
682
741
|
const PREDICATE_WORDS = new Set(["in", "any", "and", "or", "not", "xor"]);
|
|
@@ -684,67 +743,636 @@ const PREDICATE_WORDS = new Set(["in", "any", "and", "or", "not", "xor"]);
|
|
|
684
743
|
* Whether the unquoted word at `i` is part of `ORDER BY` or `GROUP BY`. `ORDER` and `GROUP` are
|
|
685
744
|
* reserved on every engine, so a table named after one is written quoted. Any other keyword may
|
|
686
745
|
* name a table, unquoted, on some engine (`user` on MySQL, `lock` on Postgres), so its spelling
|
|
687
|
-
* always counts as the table.
|
|
746
|
+
* always counts as the table. After a `.` (`billing.order by`), `order` is a table's name and
|
|
747
|
+
* `by` its alias.
|
|
688
748
|
*/
|
|
689
749
|
function isOrderOrGroupBy(tokens, i) {
|
|
690
750
|
const kw = tokens[i].kw;
|
|
691
751
|
if (kw === "order" || kw === "group")
|
|
692
|
-
return tokens[i + 1]?.kw === "by";
|
|
693
|
-
return kw === "by" && (tokens[i - 1]?.kw === "order" || tokens[i - 1]?.kw === "group");
|
|
752
|
+
return tokens[i + 1]?.kw === "by" && tokens[i - 1]?.text !== ".";
|
|
753
|
+
return kw === "by" && (tokens[i - 1]?.kw === "order" || tokens[i - 1]?.kw === "group") && tokens[i - 2]?.text !== ".";
|
|
754
|
+
}
|
|
755
|
+
/** Words that start a query right after its `(`. */
|
|
756
|
+
const QUERY_START = QUERY_START_WORDS;
|
|
757
|
+
/** Words that end a row source with no alias under every reading: no engine takes one as an alias. */
|
|
758
|
+
const ENDS_A_SOURCE = new Set([
|
|
759
|
+
"where", "group", "order", "having", "union", "intersect", "except", "join", "inner", "left", "right", "cross", "outer", "on",
|
|
760
|
+
]);
|
|
761
|
+
/** The words of a join, which put a second source in a `FROM`. */
|
|
762
|
+
const JOIN_WORDS = new Set(["join", "inner", "left", "right", "full", "cross", "outer", "natural", "straight_join", "apply", "using"]);
|
|
763
|
+
const analyses = new WeakMap();
|
|
764
|
+
/** The view's tokens and `SELECT`s, read once per view. */
|
|
765
|
+
function analyze(view) {
|
|
766
|
+
let analysis = analyses.get(view);
|
|
767
|
+
if (analysis === undefined) {
|
|
768
|
+
const tokens = tokenize(view);
|
|
769
|
+
analysis = { view, tokens, scopes: readScopes(tokens) };
|
|
770
|
+
analyses.set(view, analysis);
|
|
771
|
+
}
|
|
772
|
+
return analysis;
|
|
773
|
+
}
|
|
774
|
+
/** Whether the `(` at `open` opens a query: `SELECT`, `WITH`, `VALUES` or `TABLE` right after it, past any further `(`. */
|
|
775
|
+
function opensQuery(tokens, open) {
|
|
776
|
+
let j = open + 1;
|
|
777
|
+
while (tokens[j]?.text === "(")
|
|
778
|
+
j++;
|
|
779
|
+
return QUERY_START.has(tokens[j]?.kw ?? "");
|
|
780
|
+
}
|
|
781
|
+
function readScopes(tokens) {
|
|
782
|
+
const of = [];
|
|
783
|
+
const close = new Map();
|
|
784
|
+
const branched = new Set();
|
|
785
|
+
const frames = [{ open: -1, branch: 0, depth: 0 }];
|
|
786
|
+
tokens.forEach((t, i) => {
|
|
787
|
+
let top = frames[frames.length - 1];
|
|
788
|
+
if (t.text === ")" && top.open >= 0 && t.depth === tokens[top.open].depth) {
|
|
789
|
+
close.set(top.open, i);
|
|
790
|
+
frames.pop();
|
|
791
|
+
top = frames[frames.length - 1];
|
|
792
|
+
}
|
|
793
|
+
of.push(scopeKey(top.open, top.branch));
|
|
794
|
+
if (SET_OPERATORS.has(t.kw) && t.depth === top.depth) {
|
|
795
|
+
top.branch++;
|
|
796
|
+
branched.add(top.open);
|
|
797
|
+
}
|
|
798
|
+
if (t.text === "(" && opensQuery(tokens, i))
|
|
799
|
+
frames.push({ open: i, branch: 0, depth: t.depth + 1 });
|
|
800
|
+
});
|
|
801
|
+
return { of, close, branched };
|
|
802
|
+
}
|
|
803
|
+
/** A `SELECT`'s key: the `(` that opens its query (-1 for the statement) and its set-operation branch. */
|
|
804
|
+
function scopeKey(open, branch) {
|
|
805
|
+
return `${open}:${branch}`;
|
|
806
|
+
}
|
|
807
|
+
/** The `(` that opens a `SELECT`'s query, or -1 for the statement's (see {@link scopeKey}). */
|
|
808
|
+
function scopeOpen(scope) {
|
|
809
|
+
return Number.parseInt(scope, 10);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* A token's name for matching a table or CTE: lower-cased, and for a quoted name without its
|
|
813
|
+
* trailing whitespace, which SQL Server ignores (`[orders ]` reads `orders`). Matching more
|
|
814
|
+
* spellings finds more reads, so the check only gets stricter on engines that keep it.
|
|
815
|
+
*/
|
|
816
|
+
function tableNameOf(t) {
|
|
817
|
+
return t.quoted === true ? t.lower.trimEnd() : t.lower;
|
|
818
|
+
}
|
|
819
|
+
/** Whether `token` may be a name: a quoted name, whatever it holds, or a word that starts with a letter or `_`. */
|
|
820
|
+
function isWord(token) {
|
|
821
|
+
return token !== undefined && (token.quoted === true || /^[A-Za-z_\u0080-\uffff]/.test(token.text));
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Whether the token at `k`, right after a row source, ends that source with no alias in `view`'s
|
|
825
|
+
* reading: the end of the statement, `,`, `)`, `;`, a word in {@link ENDS_A_SOURCE}, a join word
|
|
826
|
+
* the reading's engine reserves (`FULL` but on MySQL, `NATURAL` and `USING` but on SQL Server,
|
|
827
|
+
* MySQL's `STRAIGHT_JOIN`), a SQL Server `WITH (` hint, or a MySQL `FORCE`/`USE`/`IGNORE INDEX`.
|
|
828
|
+
* Any other word may be an alias on some engine (`force`, `lock`, `qualify` are on Postgres).
|
|
829
|
+
*/
|
|
830
|
+
function endsSource(a, k) {
|
|
831
|
+
const t = a.tokens[k];
|
|
832
|
+
if (t === undefined || t.text === "," || t.text === ")" || t.text === ";")
|
|
833
|
+
return true;
|
|
834
|
+
const engine = a.view.reading.profile.id;
|
|
835
|
+
const next = a.tokens[k + 1];
|
|
836
|
+
switch (t.kw) {
|
|
837
|
+
case "full": return engine !== "mysql";
|
|
838
|
+
case "natural":
|
|
839
|
+
case "using": return engine !== "sqlserver";
|
|
840
|
+
case "straight_join": return engine === "mysql";
|
|
841
|
+
case "with": return next?.text === "(";
|
|
842
|
+
case "force":
|
|
843
|
+
case "use":
|
|
844
|
+
case "ignore": return next?.kw === "index" || next?.kw === "key";
|
|
845
|
+
default: return ENDS_A_SOURCE.has(t.kw);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The alias of the row source ending at `i`, in the only forms the check reads one: `orders o`,
|
|
850
|
+
* `orders AS o`, `) t`, where the alias is followed by what ends the source ({@link endsSource})
|
|
851
|
+
* or by its column list. Anything else (`orders TABLESAMPLE …`, `orders NOT INDEXED`) is no alias
|
|
852
|
+
* the check recognises, so it returns undefined and the caller fails closed.
|
|
853
|
+
*/
|
|
854
|
+
function aliasAt(a, i) {
|
|
855
|
+
const as = a.tokens[i + 1]?.kw === "as";
|
|
856
|
+
if (!as && endsSource(a, i + 1))
|
|
857
|
+
return undefined;
|
|
858
|
+
const alias = as ? i + 2 : i + 1;
|
|
859
|
+
if (!isWord(a.tokens[alias]))
|
|
860
|
+
return undefined;
|
|
861
|
+
return endsSource(a, alias + 1) || a.tokens[alias + 1]?.text === "(" ? alias : undefined;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* The names the table reference at `i` goes by: its alias, or its own name when what follows ends
|
|
865
|
+
* it with no alias. Anything else after it is a form the check doesn't read, so the read gets no
|
|
866
|
+
* name and only an unqualified predicate filters it.
|
|
867
|
+
*/
|
|
868
|
+
function readNames(a, i) {
|
|
869
|
+
const end = sourceEnd(a, i);
|
|
870
|
+
if (hiddenByGroupAlias(a, end))
|
|
871
|
+
return new Set();
|
|
872
|
+
const alias = aliasAt(a, end);
|
|
873
|
+
if (alias !== undefined)
|
|
874
|
+
return a.tokens[alias + 1]?.text === "(" ? new Set() : new Set([a.tokens[alias].name]);
|
|
875
|
+
return endsSource(a, end + 1) ? new Set([a.tokens[i].name]) : new Set();
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Whether the row source ending at `end` sits inside parentheses, in a source position, that
|
|
879
|
+
* something other than the source's end follows: an alias (`("order" x) AS y`,
|
|
880
|
+
* `(orders x CROSS JOIN s) AS j`) hides the names inside, so a predicate on `x` names another
|
|
881
|
+
* read. Any other word there is a form the check doesn't read, so it fails closed the same way.
|
|
882
|
+
*/
|
|
883
|
+
function hiddenByGroupAlias(a, end) {
|
|
884
|
+
const { tokens } = a;
|
|
885
|
+
for (let open = enclosing(tokens, end, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
886
|
+
if (opensQuery(tokens, open) || !inSourcePosition(tokens, open))
|
|
887
|
+
return false;
|
|
888
|
+
const close = enclosing(tokens, open + 1, ")");
|
|
889
|
+
if (close < 0 || !endsSource(a, close + 1))
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Where the table reference at `i` ends: past the `)` of any parentheses that hold it alone
|
|
896
|
+
* (`ONLY (orders) a`, SQLite's `(orders) a`), since its alias follows them.
|
|
897
|
+
*/
|
|
898
|
+
function sourceEnd(a, i) {
|
|
899
|
+
let start = qualifiedStart(a.tokens, i);
|
|
900
|
+
let end = i;
|
|
901
|
+
// Only parentheses in a source position (after `FROM`, `JOIN`, `ONLY`, `,` or another `(`), not a call's.
|
|
902
|
+
const opensSource = (open) => {
|
|
903
|
+
const before = a.tokens[open - 1];
|
|
904
|
+
return before !== undefined && ((before.kw === "from" && opensFromClause(a.tokens, open - 1)) || ["join", "only", "lateral", "straight_join"].includes(before.kw) || before.text === "," || before.text === "(");
|
|
905
|
+
};
|
|
906
|
+
while (a.tokens[start - 1]?.text === "(" && a.tokens[end + 1]?.text === ")" && opensSource(start - 1)) {
|
|
907
|
+
start--;
|
|
908
|
+
end++;
|
|
909
|
+
}
|
|
910
|
+
return end;
|
|
694
911
|
}
|
|
695
912
|
/**
|
|
696
|
-
* Whether the
|
|
697
|
-
*
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
913
|
+
* Whether the `(` at `open` sits where a row source starts: right after `FROM`, a join word,
|
|
914
|
+
* `LATERAL`, `APPLY`, a comma of a `FROM` list, or another `(` that does.
|
|
915
|
+
*/
|
|
916
|
+
function inSourcePosition(tokens, open) {
|
|
917
|
+
const before = tokens[open - 1];
|
|
918
|
+
if (before === undefined)
|
|
919
|
+
return false;
|
|
920
|
+
if (before.kw === "from")
|
|
921
|
+
return opensFromClause(tokens, open - 1);
|
|
922
|
+
if (["join", "straight_join", "lateral", "apply"].includes(before.kw))
|
|
923
|
+
return true;
|
|
924
|
+
if (before.text === "(")
|
|
925
|
+
return !opensQuery(tokens, open - 1) && inSourcePosition(tokens, open - 1);
|
|
926
|
+
if (before.text !== ",")
|
|
927
|
+
return false;
|
|
928
|
+
for (let k = open - 2; k >= 0; k--) {
|
|
929
|
+
const t = tokens[k];
|
|
930
|
+
if (t.depth < before.depth)
|
|
931
|
+
return false;
|
|
932
|
+
if (t.depth > before.depth)
|
|
933
|
+
continue;
|
|
934
|
+
if (t.kw === "from")
|
|
935
|
+
return opensFromClause(tokens, k);
|
|
936
|
+
if (t.kw === "select" || t.kw === "where" || t.kw === "having" || (CLAUSE_END.has(t.kw) && !JOIN_WORDS.has(t.kw)))
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Whether a table read can start at `start`: right after a `SELECT`'s own `FROM`, a join word,
|
|
943
|
+
* `ONLY`, `LATERAL`, a comma of a `FROM` list, or a `(` in one of those places that opens no query.
|
|
944
|
+
* A whole-row reference in an expression (`json_agg(orders)`, `(orders).id`) starts none.
|
|
945
|
+
*/
|
|
946
|
+
function startsSource(tokens, start) {
|
|
947
|
+
const before = tokens[start - 1];
|
|
948
|
+
if (before === undefined)
|
|
949
|
+
return false;
|
|
950
|
+
if (before.kw === "from")
|
|
951
|
+
return opensFromClause(tokens, start - 1);
|
|
952
|
+
if (["join", "straight_join", "only", "lateral"].includes(before.kw))
|
|
953
|
+
return true;
|
|
954
|
+
if (before.text === "(")
|
|
955
|
+
return !opensQuery(tokens, start - 1) && startsSource(tokens, start - 1);
|
|
956
|
+
return before.text === "," && inSourcePosition(tokens, start);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Whether the `FROM` at `k` opens its `SELECT`'s own `FROM` clause: not `IS [NOT] DISTINCT FROM`,
|
|
960
|
+
* and not a `FROM` inside a call's parentheses (`EXTRACT(YEAR FROM d)`, `SUBSTRING(s FROM 2)`).
|
|
961
|
+
*/
|
|
962
|
+
function opensFromClause(tokens, k) {
|
|
963
|
+
if (tokens[k]?.kw !== "from" || tokens[k - 1]?.kw === "distinct")
|
|
964
|
+
return false;
|
|
965
|
+
const open = enclosing(tokens, k, "(");
|
|
966
|
+
return open < 0 || opensQuery(tokens, open);
|
|
967
|
+
}
|
|
968
|
+
/** Whether the alias of the row source ending at `i` has a column list (`x t (a, b)`), which renames its output. */
|
|
969
|
+
function hasColumnList(a, i) {
|
|
970
|
+
const alias = aliasAt(a, i);
|
|
971
|
+
return alias !== undefined && a.tokens[alias + 1]?.text === "(";
|
|
972
|
+
}
|
|
973
|
+
/** Whether the `(` at `open` follows `AS` (or `AS [NOT] MATERIALIZED`), as a CTE body does. */
|
|
974
|
+
function opensAfterAs(tokens, open) {
|
|
975
|
+
let k = open - 1;
|
|
976
|
+
if (tokens[k]?.kw === "materialized")
|
|
977
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
978
|
+
return tokens[k]?.kw === "as";
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* The CTE named before the `(` at `open` (`WITH x AS (`, `, x AS MATERIALIZED (`): its name and
|
|
982
|
+
* token. Undefined when it isn't one, or when a column list (`x (a, b) AS (`) renames its output.
|
|
983
|
+
*/
|
|
984
|
+
function cteAt(tokens, open) {
|
|
985
|
+
let k = open - 1;
|
|
986
|
+
if (tokens[k]?.kw === "materialized")
|
|
987
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
988
|
+
if (tokens[k]?.kw !== "as" || !isWord(tokens[k - 1]))
|
|
989
|
+
return undefined;
|
|
990
|
+
const before = tokens[k - 2];
|
|
991
|
+
if (!(before?.kw === "with" || before?.kw === "recursive" || before?.text === ","))
|
|
992
|
+
return undefined;
|
|
993
|
+
return { name: tableNameOf(tokens[k - 1]), at: k - 1 };
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* The reads of `name`: every spelling of it, quoted or not, except a word of `ORDER BY` or
|
|
997
|
+
* `GROUP BY` and a qualifier (`orders.id`), which refers to a read rather than being one. A read
|
|
998
|
+
* goes by its names ({@link readNames}): an alias only, once it has one, since the table's name
|
|
999
|
+
* then refers to another read, or to none.
|
|
1000
|
+
*/
|
|
1001
|
+
function readsOf(a, name) {
|
|
1002
|
+
const reads = [];
|
|
1003
|
+
a.tokens.forEach((t, i) => {
|
|
1004
|
+
if (tableNameOf(t) !== name || isOrderOrGroupBy(a.tokens, i) || a.tokens[i + 1]?.text === ".")
|
|
1005
|
+
return;
|
|
1006
|
+
reads.push({ at: i, scope: a.scopes.of[i], names: readNames(a, i) });
|
|
1007
|
+
});
|
|
1008
|
+
return reads;
|
|
1009
|
+
}
|
|
1010
|
+
/** The qualifier of the column at `column` (`o` in `o.agency_id`), if any, as the reading compares names. */
|
|
1011
|
+
function qualifierOf(tokens, column) {
|
|
1012
|
+
return tokens[column - 1]?.text === "." ? tokens[column - 2]?.name : undefined;
|
|
1013
|
+
}
|
|
1014
|
+
/** Every tenant predicate in the view for one of `targets`, with the `SELECT`s it filters. */
|
|
1015
|
+
function occurrences(a, targets) {
|
|
1016
|
+
const found = [];
|
|
1017
|
+
a.tokens.forEach((t, k) => {
|
|
1018
|
+
for (const target of targets) {
|
|
1019
|
+
// A quoted name spelled like the placeholder is a name, which nothing binds.
|
|
1020
|
+
if (t.text !== target.placeholder || t.quoted === true)
|
|
1021
|
+
continue;
|
|
1022
|
+
const predicate = predicateAt(a.tokens, k, target.column, a.view.reading.profile.id);
|
|
1023
|
+
if (!predicate)
|
|
1024
|
+
continue;
|
|
1025
|
+
const filters = filteredScopes(a, predicate.start, predicate.end, qualifierOf(a.tokens, predicate.column), predicate.column);
|
|
1026
|
+
if (filters.length > 0)
|
|
1027
|
+
found.push({ target, filters });
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
return found;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Whether every read of `table` is filtered, on every reading, by a predicate from `targets`
|
|
1034
|
+
* (#399): a column and placeholder compared as a conjunct of a filter clause in the read's own
|
|
1035
|
+
* `SELECT`, and tied to that read by its qualifier. Each branch of a `UNION` / `INTERSECT` /
|
|
1036
|
+
* `EXCEPT` is its own `SELECT`, and so is every CTE body, derived table and subquery. A read in a
|
|
1037
|
+
* derived table or CTE is also filtered from outside, when that source passes the column through
|
|
1038
|
+
* unrenamed and every reference to it is filtered on it. See {@link validateTenantGuardrails}.
|
|
701
1039
|
*/
|
|
702
1040
|
function hasTenantPredicate(views, table, targets) {
|
|
703
1041
|
const name = table.toLowerCase();
|
|
704
1042
|
return views.every((view) => {
|
|
705
|
-
const
|
|
706
|
-
//
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
if (tokens[i].lower === name && !isOrderOrGroupBy(tokens, i))
|
|
710
|
-
return true;
|
|
711
|
-
return false;
|
|
712
|
-
};
|
|
713
|
-
return queryBlocks(tokens)
|
|
714
|
-
.filter(([from, to]) => mentions(from, to))
|
|
715
|
-
.every(([from, to]) => {
|
|
716
|
-
for (let k = from; k < to; k++) {
|
|
717
|
-
for (const target of targets) {
|
|
718
|
-
if (tokens[k].text !== target.placeholder)
|
|
719
|
-
continue;
|
|
720
|
-
const span = predicateSpan(tokens, k, target);
|
|
721
|
-
if (!span || !isFilterConjunct(tokens, span[0], span[1], view.reading))
|
|
722
|
-
continue;
|
|
723
|
-
if (!target.discriminator || hasDiscriminator(tokens, from, to, view, target))
|
|
724
|
-
return true;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
1043
|
+
const a = analyze(view);
|
|
1044
|
+
// Read the Postgres way, a column list after a read's alias, or after a parenthesized source it
|
|
1045
|
+
// sits in, renames its columns, so the read can't be checked.
|
|
1046
|
+
if (view.reading.profile.id === "postgres" && renamesColumns(a.tokens, name))
|
|
727
1047
|
return false;
|
|
1048
|
+
const reads = readsOf(a, name);
|
|
1049
|
+
// A reading that sees the table's name but no read of it (the name only inside a longer quoted
|
|
1050
|
+
// name, or only as a qualifier) can't tell what filters it, so it fails closed.
|
|
1051
|
+
if (reads.length === 0)
|
|
1052
|
+
return !mentionsAsName(view, name);
|
|
1053
|
+
const found = occurrences(a, targets);
|
|
1054
|
+
const tableReads = reads.filter((r) => startsSource(a.tokens, qualifiedStart(a.tokens, r.at)));
|
|
1055
|
+
return reads.every((read) => {
|
|
1056
|
+
const sameSelect = tableReads.filter((r) => r.scope === read.scope);
|
|
1057
|
+
return tableReads.includes(read) && sameSelect.length > 1
|
|
1058
|
+
? selfJoinReadFiltered(a, found, targets, read, name, sameSelect)
|
|
1059
|
+
: readFiltered(a, found, targets, read, name);
|
|
728
1060
|
});
|
|
729
1061
|
});
|
|
730
1062
|
}
|
|
731
1063
|
/**
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
|
|
736
|
-
|
|
1064
|
+
* Words Postgres never takes as a bare table alias: its reserved keywords, and those it reserves
|
|
1065
|
+
* except as a function or type name. So in `orders WHERE (`, `orders ON (` or `orders USING (` the
|
|
1066
|
+
* word after the table is no alias, and the `(` no column list.
|
|
1067
|
+
*/
|
|
1068
|
+
const NOT_A_TABLE_ALIAS = new Set([
|
|
1069
|
+
"all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization", "binary", "both",
|
|
1070
|
+
"case", "cast", "check", "collate", "collation", "column", "concurrently", "constraint", "create", "cross",
|
|
1071
|
+
"current_catalog", "current_date", "current_role", "current_schema", "current_time", "current_timestamp",
|
|
1072
|
+
"current_user", "default", "deferrable", "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for",
|
|
1073
|
+
"foreign", "freeze", "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect",
|
|
1074
|
+
"into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime", "localtimestamp",
|
|
1075
|
+
"natural", "not", "notnull", "null", "offset", "on", "only", "or", "order", "outer", "overlaps", "placing",
|
|
1076
|
+
"primary", "references", "returning", "right", "select", "session_user", "similar", "some", "symmetric",
|
|
1077
|
+
"system_user", "table", "tablesample", "then", "to", "trailing", "true", "union", "unique", "user", "using",
|
|
1078
|
+
"variadic", "verbose", "when", "where", "window", "with",
|
|
1079
|
+
]);
|
|
1080
|
+
/**
|
|
1081
|
+
* Whether a read of the table `name` has its columns renamed by a column list after an alias, at any
|
|
1082
|
+
* depth: right after the table (`orders t (a, b)`, `orders AS t (a, b)`, `orders * t (a, b)`), or
|
|
1083
|
+
* after a parenthesized `FROM` item the read sits in, a join or a derived table
|
|
1084
|
+
* (`(orders o JOIN s ON true) AS j (a, b, agency_id)`). Postgres and CockroachDB read the list as
|
|
1085
|
+
* new names for the first columns, so a predicate on `agency_id` filters whatever column the list
|
|
1086
|
+
* names that, and the real one is renamed out of the way: the read can't be checked.
|
|
1087
|
+
*/
|
|
1088
|
+
function renamesColumns(tokens, name) {
|
|
1089
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1090
|
+
if (tokens[i].lower !== name)
|
|
1091
|
+
continue;
|
|
1092
|
+
for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
1093
|
+
if (subqueryUse(tokens, open) !== "table")
|
|
1094
|
+
continue;
|
|
1095
|
+
const close = enclosing(tokens, open + 1, ")");
|
|
1096
|
+
if (close >= 0 && aliasWithColumnList(tokens, close + 1))
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
if (tokens[i + 1]?.text === ".")
|
|
1100
|
+
continue;
|
|
1101
|
+
if (aliasWithColumnList(tokens, tokens[i + 1]?.text === "*" ? i + 2 : i + 1))
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
/** Whether `tokens[j]` starts an alias and a column list: `AS t (` or `t (`, where a bare `t` is no reserved word. */
|
|
1107
|
+
function aliasWithColumnList(tokens, j) {
|
|
1108
|
+
const as = tokens[j]?.kw === "as";
|
|
1109
|
+
const alias = tokens[as ? j + 1 : j];
|
|
1110
|
+
if (alias === undefined || !isIdentifier(alias) || tokens[as ? j + 2 : j + 1]?.text !== "(")
|
|
1111
|
+
return false;
|
|
1112
|
+
return as || alias.kw === "" || !NOT_A_TABLE_ALIAS.has(alias.kw);
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* A read of a table its `SELECT` reads more than once (a self-join): it counts as filtered only
|
|
1116
|
+
* when every read of the table there is canonical (`table alias` or `table AS alias`, outside
|
|
1117
|
+
* parentheses, with a plain unquoted alias no other read there shares, in any case) and a
|
|
1118
|
+
* predicate qualified with its own alias filters it. Anything less can't tell the reads apart, so
|
|
1119
|
+
* it fails closed.
|
|
1120
|
+
*/
|
|
1121
|
+
function selfJoinReadFiltered(a, found, targets, read, table, sameSelect) {
|
|
1122
|
+
const aliases = sameSelect.map((r) => canonicalAlias(a, r));
|
|
1123
|
+
if (aliases.some((alias) => alias === undefined))
|
|
1124
|
+
return false;
|
|
1125
|
+
if (new Set(aliases.map((alias) => alias.toLowerCase())).size !== aliases.length)
|
|
1126
|
+
return false;
|
|
1127
|
+
const mine = canonicalAlias(a, read);
|
|
1128
|
+
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && targets.includes(o.target) &&
|
|
1129
|
+
(!o.target.discriminator || hasDiscriminator(a, read, o.target))));
|
|
1130
|
+
}
|
|
1131
|
+
/** The plain alias of a read written `table alias` or `table AS alias`, at its `SELECT`'s own level; else undefined. */
|
|
1132
|
+
function canonicalAlias(a, read) {
|
|
1133
|
+
const { tokens } = a;
|
|
1134
|
+
const open = scopeOpen(read.scope);
|
|
1135
|
+
const depth = open < 0 ? 0 : tokens[open].depth + 1;
|
|
1136
|
+
if (tokens[read.at].depth !== depth || sourceEnd(a, read.at) !== read.at)
|
|
1137
|
+
return undefined;
|
|
1138
|
+
const at = aliasAt(a, read.at);
|
|
1139
|
+
if (at === undefined || tokens[at].quoted === true || tokens[at + 1]?.text === "(")
|
|
1140
|
+
return undefined;
|
|
1141
|
+
return tokens[at].name;
|
|
1142
|
+
}
|
|
1143
|
+
function readFiltered(a, found, targets, read, table) {
|
|
1144
|
+
for (const o of found) {
|
|
1145
|
+
for (const f of o.filters) {
|
|
1146
|
+
if (f.scope !== read.scope || !filtersRead(a, f, o.target, read, table))
|
|
1147
|
+
continue;
|
|
1148
|
+
if (o.target.discriminator && !hasDiscriminator(a, read, o.target))
|
|
1149
|
+
continue;
|
|
1150
|
+
return true;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return targets.some((target) => target.table.toLowerCase() === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Whether a predicate that filters `read`'s `SELECT` through `filter` filters `read` itself.
|
|
1157
|
+
* Unqualified, it does: the read's table has the column, so the name resolves to it or the SQL is
|
|
1158
|
+
* ambiguous. On the read's own column, its qualifier must name that read ({@link readNames});
|
|
1159
|
+
* on another table's column (a root filtered through a column
|
|
1160
|
+
* that carries its IDs), a read of that table. A qualifier that names no read the check sees,
|
|
1161
|
+
* such as a table function's alias, counts for nothing. Through `col IN (…)`, which read of
|
|
1162
|
+
* another table `col` filters is left to the join.
|
|
1163
|
+
*/
|
|
1164
|
+
function filtersRead(a, filter, target, read, table) {
|
|
1165
|
+
const q = filter.qualifier;
|
|
1166
|
+
if (q === undefined)
|
|
1167
|
+
return filter.on === undefined || seenByOn(a.tokens, filter.on, read.at);
|
|
1168
|
+
const owner = target.table.toLowerCase();
|
|
1169
|
+
if (owner === table)
|
|
1170
|
+
return read.names.has(q);
|
|
1171
|
+
return !filter.ownSelect || readsOf(a, owner).some((r) => r.names.has(q));
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Whether `read`, inside a derived table or CTE, is filtered from outside on `target`'s column:
|
|
1175
|
+
* its `SELECT` is the source's whole body, the body passes the column through unrenamed, and
|
|
1176
|
+
* every reference to the source is filtered on that column in its own `SELECT`, directly or,
|
|
1177
|
+
* through a source around it, the same way. A set operation in the body, a column list on the
|
|
1178
|
+
* source, a recursive CTE or a CTE nothing reads fails closed.
|
|
1179
|
+
*/
|
|
1180
|
+
function filteredFromOutside(a, found, read, target, depth) {
|
|
1181
|
+
// Each source's answer is worked out once per call: re-exploring it once per path is exponential
|
|
1182
|
+
// in a chain of sources that each read the one before several times.
|
|
1183
|
+
let memo = outsideMemo.get(found);
|
|
1184
|
+
if (memo === undefined)
|
|
1185
|
+
outsideMemo.set(found, (memo = new Map()));
|
|
1186
|
+
const key = `${read.scope}|${depth}|${[...read.names].join(" ")}|${target.table}|${target.column}|${target.placeholder}`;
|
|
1187
|
+
const known = memo.get(key);
|
|
1188
|
+
if (known !== undefined)
|
|
1189
|
+
return known;
|
|
1190
|
+
memo.set(key, false); // a cycle fails closed
|
|
1191
|
+
const open = scopeOpen(read.scope);
|
|
1192
|
+
let filtered = false;
|
|
1193
|
+
if (depth <= 8 && open >= 0 && !a.scopes.branched.has(open)) {
|
|
1194
|
+
const refs = sourceRefs(a, open);
|
|
1195
|
+
filtered = refs !== undefined && refs.length > 0 && passesThrough(a, open, read.names, target.column) &&
|
|
1196
|
+
refs.every((ref) => found.some((o) => o.target === target &&
|
|
1197
|
+
o.filters.some((f) => f.ownSelect && f.scope === ref.scope && (f.qualifier === undefined ? f.on === undefined || seenByOn(a.tokens, f.on, ref.at) : ref.names.has(f.qualifier)))) ||
|
|
1198
|
+
filteredFromOutside(a, found, ref, target, depth + 1));
|
|
1199
|
+
}
|
|
1200
|
+
memo.set(key, filtered);
|
|
1201
|
+
return filtered;
|
|
1202
|
+
}
|
|
1203
|
+
const outsideMemo = new WeakMap();
|
|
1204
|
+
const sourceRefsMemo = new WeakMap();
|
|
1205
|
+
/**
|
|
1206
|
+
* The references to the row source whose query opens at `open`, or undefined when it isn't a source
|
|
1207
|
+
* this check follows: a column list on the source or on a reference to it renames its output.
|
|
1208
|
+
*/
|
|
1209
|
+
function sourceRefs(a, open) {
|
|
1210
|
+
let memo = sourceRefsMemo.get(a);
|
|
1211
|
+
if (memo === undefined)
|
|
1212
|
+
sourceRefsMemo.set(a, (memo = new Map()));
|
|
1213
|
+
if (!memo.has(open))
|
|
1214
|
+
memo.set(open, readSourceRefs(a, open));
|
|
1215
|
+
return memo.get(open);
|
|
1216
|
+
}
|
|
1217
|
+
function readSourceRefs(a, open) {
|
|
1218
|
+
const { tokens, scopes } = a;
|
|
1219
|
+
const close = scopes.close.get(open);
|
|
1220
|
+
if (close === undefined || subqueryUse(tokens, open) !== "table")
|
|
1221
|
+
return undefined;
|
|
1222
|
+
// A derived table only in a source position: a subquery after `ON` or in an expression is no row source.
|
|
1223
|
+
if (!opensAfterAs(tokens, open) && !inSourcePosition(tokens, open))
|
|
1224
|
+
return undefined;
|
|
1225
|
+
if (opensAfterAs(tokens, open)) {
|
|
1226
|
+
const cte = cteAt(tokens, open);
|
|
1227
|
+
if (cte === undefined)
|
|
1228
|
+
return undefined; // a column list renames the output, or it's no CTE this check reads
|
|
1229
|
+
const refs = [];
|
|
1230
|
+
for (let j = 0; j < tokens.length; j++) {
|
|
1231
|
+
if (j === cte.at || tableNameOf(tokens[j]) !== cte.name || tokens[j + 1]?.text === "." || isOrderOrGroupBy(tokens, j))
|
|
1232
|
+
continue;
|
|
1233
|
+
if (j > open && j < close)
|
|
1234
|
+
return undefined; // recursive
|
|
1235
|
+
if (tokens[j + 1]?.text === "(" || hasColumnList(a, sourceEnd(a, j)))
|
|
1236
|
+
return undefined;
|
|
1237
|
+
refs.push({ at: j, scope: scopes.of[j], names: readNames(a, j) });
|
|
1238
|
+
}
|
|
1239
|
+
return refs;
|
|
1240
|
+
}
|
|
1241
|
+
if (hasColumnList(a, close))
|
|
1242
|
+
return undefined;
|
|
1243
|
+
const alias = aliasAt(a, close);
|
|
1244
|
+
const hidden = hiddenByGroupAlias(a, alias ?? close);
|
|
1245
|
+
return [{ at: open, scope: scopes.of[open], names: new Set(alias === undefined || hidden ? [] : [tokens[alias].name]) }];
|
|
1246
|
+
}
|
|
1247
|
+
/** Clauses that compute over a source's rows, or pick some of them, before an outside filter runs. */
|
|
1248
|
+
const COMPUTES_OVER_ROWS = new Set(["group", "having", "window", "qualify", "limit", "offset", "fetch", "top", "over"]);
|
|
1249
|
+
/**
|
|
1250
|
+
* Whether the body of the query at `open` passes `column` of the read named `names` through to
|
|
1251
|
+
* its output unrenamed, and computes nothing across rows: every select-list item is a plain column
|
|
1252
|
+
* (`col` or `q.col`, optionally aliased), `q.*` or `*`; exactly one yields that name, and it is the
|
|
1253
|
+
* column itself, the read's `q.*`, or `*` over a single source; and the body has no `GROUP BY`,
|
|
1254
|
+
* `HAVING`, window, `QUALIFY`, `LIMIT`, `OFFSET`, `FETCH` or `TOP`, which would run over every tenant's rows
|
|
1255
|
+
* before the outside filter. An item with a string or comment in it, `DISTINCT ON` or a nested
|
|
1256
|
+
* `WITH` fails closed. The column and the name it yields are compared as {@link namesColumn} reads
|
|
1257
|
+
* them, and an item that yields the name in another case fails closed too.
|
|
1258
|
+
*/
|
|
1259
|
+
function passesThrough(a, open, names, column) {
|
|
1260
|
+
const { tokens, scopes, view } = a;
|
|
1261
|
+
const reading = view.reading.profile.id;
|
|
1262
|
+
const scope = scopeKey(open, 0);
|
|
1263
|
+
const depth = tokens[open].depth + 1;
|
|
1264
|
+
const level = (i) => scopes.of[i] === scope && tokens[i].depth === depth;
|
|
1265
|
+
const select = open + 1;
|
|
1266
|
+
if (tokens[select]?.kw !== "select")
|
|
1267
|
+
return false;
|
|
1268
|
+
let from = select + 1;
|
|
1269
|
+
while (from < tokens.length && !(level(from) && tokens[from].kw === "from"))
|
|
1270
|
+
from++;
|
|
1271
|
+
if (from >= tokens.length)
|
|
1272
|
+
return false;
|
|
1273
|
+
let first = select + 1;
|
|
1274
|
+
if (tokens[first]?.kw === "all" || tokens[first]?.kw === "distinct")
|
|
1275
|
+
first++;
|
|
1276
|
+
if (tokens[first]?.kw === "on")
|
|
1277
|
+
return false;
|
|
1278
|
+
const end = scopes.close.get(open) ?? tokens.length;
|
|
1279
|
+
for (let i = select; i < end; i++)
|
|
1280
|
+
if (scopes.of[i] === scope && COMPUTES_OVER_ROWS.has(tokens[i].kw))
|
|
1281
|
+
return false;
|
|
1282
|
+
const items = [[]];
|
|
1283
|
+
for (let i = first; i < from; i++) {
|
|
1284
|
+
if (level(i) && tokens[i].text === ",")
|
|
1285
|
+
items.push([]);
|
|
1286
|
+
else
|
|
1287
|
+
items[items.length - 1].push(i);
|
|
1288
|
+
}
|
|
1289
|
+
let yields = 0;
|
|
1290
|
+
for (const item of items) {
|
|
1291
|
+
if (item.length === 0)
|
|
1292
|
+
return false;
|
|
1293
|
+
// Text the view blanked (a string, a comment) could be part of the item, so don't guess.
|
|
1294
|
+
const last = tokens[item[item.length - 1]];
|
|
1295
|
+
for (let p = tokens[item[0]].offset; p < last.offset + last.text.length; p++) {
|
|
1296
|
+
if (view.text[p] !== view.source[p] && view.text[p] !== QUOTE_MARK)
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
const ts = item.map((i) => tokens[i]);
|
|
1300
|
+
const n = ts.length;
|
|
1301
|
+
if (n === 1 && ts[0].text === "*") {
|
|
1302
|
+
if (!singleSource(a, open, scope, depth))
|
|
1303
|
+
return false;
|
|
1304
|
+
yields++;
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
if (n === 3 && ts[1].text === "." && ts[2].text === "*") {
|
|
1308
|
+
if (!names.has(ts[0].name))
|
|
1309
|
+
return false;
|
|
1310
|
+
yields++;
|
|
1311
|
+
continue;
|
|
1312
|
+
}
|
|
1313
|
+
let alias;
|
|
1314
|
+
let expr = ts;
|
|
1315
|
+
if (n >= 3 && ts[n - 2].kw === "as" && isWord(ts[n - 1])) {
|
|
1316
|
+
alias = ts[n - 1];
|
|
1317
|
+
expr = ts.slice(0, n - 2);
|
|
1318
|
+
}
|
|
1319
|
+
else if (n >= 2 && isWord(ts[n - 1]) && (isWord(ts[n - 2]) || ts[n - 2].text === ")" || /^\d/.test(ts[n - 2].text))) {
|
|
1320
|
+
alias = ts[n - 1];
|
|
1321
|
+
expr = ts.slice(0, n - 1);
|
|
1322
|
+
}
|
|
1323
|
+
const isColumn = expr.length === 1 ? isWord(expr[0]) : expr.length === 3 && isWord(expr[0]) && expr[1].text === "." && isWord(expr[2]);
|
|
1324
|
+
if (!isColumn)
|
|
1325
|
+
return false; // an expression, a call or an aggregate
|
|
1326
|
+
const name = expr[expr.length - 1];
|
|
1327
|
+
// An item that yields the name in any case is a candidate; it must name the column the way the engine reads it.
|
|
1328
|
+
if ((alias ?? name).lower !== column.toLowerCase())
|
|
1329
|
+
continue;
|
|
1330
|
+
if (!namesColumn(alias ?? name, column, reading) || !namesColumn(name, column, reading))
|
|
1331
|
+
return false;
|
|
1332
|
+
if (expr.length === 3 && !names.has(expr[0].name))
|
|
1333
|
+
return false; // another expression named like the column
|
|
1334
|
+
yields++;
|
|
1335
|
+
}
|
|
1336
|
+
return yields === 1;
|
|
1337
|
+
}
|
|
1338
|
+
/** Whether the `FROM` of the query at `open` has one source: no join and no comma before its next clause. */
|
|
1339
|
+
function singleSource(a, open, scope, depth) {
|
|
1340
|
+
const { tokens, scopes } = a;
|
|
1341
|
+
const end = scopes.close.get(open) ?? tokens.length;
|
|
1342
|
+
let i = open + 1;
|
|
1343
|
+
while (i < end && !(scopes.of[i] === scope && tokens[i].depth === depth && tokens[i].kw === "from"))
|
|
1344
|
+
i++;
|
|
1345
|
+
for (i++; i < end; i++) {
|
|
1346
|
+
const t = tokens[i];
|
|
1347
|
+
if (scopes.of[i] !== scope || t.depth !== depth)
|
|
1348
|
+
continue;
|
|
1349
|
+
if (t.text === "," || JOIN_WORDS.has(t.kw) || (t.text === "(" && !opensQuery(tokens, i)))
|
|
1350
|
+
return false;
|
|
1351
|
+
// Only a clause no engine takes as an alias ends the FROM: past `lock` or `qualify`, which
|
|
1352
|
+
// may be the first source's alias, a second source can still follow.
|
|
1353
|
+
if (ENDS_A_SOURCE.has(t.kw) && !JOIN_WORDS.has(t.kw))
|
|
1354
|
+
return true;
|
|
1355
|
+
}
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Whether `read`'s `SELECT` ANDs in `col = '<value>'` for `target.discriminator`: its column, as
|
|
1360
|
+
* {@link namesColumn} compares it (unqualified, or qualified with the read's table or alias), `=`, and a plain `'…'` literal,
|
|
1361
|
+
* read from the statement at the offsets where the view blanked it, whose value is one of the
|
|
1362
|
+
* discriminator's values and which nothing but whitespace follows up to the next token (no
|
|
1363
|
+
* `''`-escape, no adjacent literal, no comment).
|
|
737
1364
|
*/
|
|
738
|
-
function hasDiscriminator(
|
|
1365
|
+
function hasDiscriminator(a, read, target) {
|
|
739
1366
|
const { column, values } = target.discriminator;
|
|
1367
|
+
const { tokens, view } = a;
|
|
740
1368
|
const source = view.source;
|
|
741
|
-
for (let i =
|
|
1369
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
742
1370
|
const t = tokens[i];
|
|
743
1371
|
const eq = tokens[i + 1];
|
|
744
|
-
if (!isIdentifier(t) || t
|
|
1372
|
+
if (a.scopes.of[i] !== read.scope || !isIdentifier(t) || !namesColumn(t, column, view.reading.profile.id) || eq?.text !== "=")
|
|
745
1373
|
continue;
|
|
746
|
-
const qualifier = tokens
|
|
747
|
-
if (qualifier !== undefined &&
|
|
1374
|
+
const qualifier = qualifierOf(tokens, i);
|
|
1375
|
+
if (qualifier !== undefined && !read.names.has(qualifier))
|
|
748
1376
|
continue;
|
|
749
1377
|
let open = eq.offset + 1;
|
|
750
1378
|
while (/\s/.test(source[open] ?? ""))
|
|
@@ -761,38 +1389,15 @@ function hasDiscriminator(tokens, from, to, view, target) {
|
|
|
761
1389
|
const next = tokens[i + 2]?.offset ?? source.length;
|
|
762
1390
|
if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
|
|
763
1391
|
continue;
|
|
764
|
-
|
|
1392
|
+
const filters = filteredScopes(a, qualifiedStart(tokens, i), i + 1, qualifier, i);
|
|
1393
|
+
if (filters.some((f) => f.ownSelect && f.scope === read.scope && (qualifier !== undefined || f.on === undefined || seenByOn(tokens, f.on, read.at))))
|
|
765
1394
|
return true;
|
|
766
1395
|
}
|
|
767
1396
|
return false;
|
|
768
1397
|
}
|
|
769
|
-
/**
|
|
770
|
-
* Token ranges `[from, to)` of the statement's query blocks: the whole statement, and
|
|
771
|
-
* each branch of every set operation at any depth (bounded by the parentheses around it).
|
|
772
|
-
*/
|
|
773
|
-
function queryBlocks(tokens) {
|
|
774
|
-
const blocks = [[0, tokens.length]];
|
|
775
|
-
const splits = new Map(); // enclosing "(" (or -1) → set-operator positions
|
|
776
|
-
tokens.forEach((t, i) => {
|
|
777
|
-
if (!SET_OPERATORS.has(t.kw))
|
|
778
|
-
return;
|
|
779
|
-
const open = enclosing(tokens, i, "(");
|
|
780
|
-
splits.set(open, [...(splits.get(open) ?? []), i]);
|
|
781
|
-
});
|
|
782
|
-
for (const [open, positions] of splits) {
|
|
783
|
-
const end = open < 0 ? tokens.length : enclosing(tokens, open + 1, ")");
|
|
784
|
-
let from = open + 1;
|
|
785
|
-
for (const at of positions) {
|
|
786
|
-
blocks.push([from, at]);
|
|
787
|
-
from = at + 1;
|
|
788
|
-
}
|
|
789
|
-
blocks.push([from, end < 0 ? tokens.length : end]);
|
|
790
|
-
}
|
|
791
|
-
return blocks;
|
|
792
|
-
}
|
|
793
1398
|
/** An identifier token: a word that isn't one of the predicate forms' keywords. */
|
|
794
1399
|
function isIdentifier(token) {
|
|
795
|
-
return /^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw);
|
|
1400
|
+
return token.quoted === true || (/^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw));
|
|
796
1401
|
}
|
|
797
1402
|
/**
|
|
798
1403
|
* A clause keyword used as a function name (`LEFT(status, 1)`, `RIGHT(…)`) is an identifier.
|
|
@@ -809,32 +1414,28 @@ function qualifiedStart(tokens, end) {
|
|
|
809
1414
|
return start;
|
|
810
1415
|
}
|
|
811
1416
|
/**
|
|
812
|
-
* The
|
|
813
|
-
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a
|
|
814
|
-
*
|
|
815
|
-
*
|
|
1417
|
+
* The span `[start, end]` and column index of a tenant predicate around the placeholder at `k`:
|
|
1418
|
+
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a possibly
|
|
1419
|
+
* qualified name) is `column`, as {@link namesColumn} compares them under `reading`. Which read
|
|
1420
|
+
* the column belongs to is its qualifier's to say.
|
|
816
1421
|
*/
|
|
817
|
-
function
|
|
1422
|
+
function predicateAt(tokens, k, column, reading) {
|
|
818
1423
|
const at = (i) => tokens[i];
|
|
819
|
-
const column = target.column.toLowerCase();
|
|
820
1424
|
const matches = (i) => {
|
|
821
1425
|
const t = at(i);
|
|
822
|
-
|
|
823
|
-
return false;
|
|
824
|
-
const qualifier = at(i - 1)?.text === "." ? at(i - 2) : undefined;
|
|
825
|
-
return qualifier === undefined || !namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase());
|
|
1426
|
+
return t !== undefined && isIdentifier(t) && namesColumn(t, column, reading);
|
|
826
1427
|
};
|
|
827
1428
|
// col = P
|
|
828
1429
|
if (at(k - 1)?.text === "=" && matches(k - 2))
|
|
829
|
-
return
|
|
1430
|
+
return { start: qualifiedStart(tokens, k - 2), end: k, column: k - 2 };
|
|
830
1431
|
const closed = at(k + 1)?.text === ")";
|
|
831
1432
|
// col IN ( P )
|
|
832
1433
|
if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "in" && matches(k - 3)) {
|
|
833
|
-
return
|
|
1434
|
+
return { start: qualifiedStart(tokens, k - 3), end: k + 1, column: k - 3 };
|
|
834
1435
|
}
|
|
835
1436
|
// col = ANY ( P )
|
|
836
1437
|
if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "any" && at(k - 3)?.text === "=" && matches(k - 4)) {
|
|
837
|
-
return
|
|
1438
|
+
return { start: qualifiedStart(tokens, k - 4), end: k + 1, column: k - 4 };
|
|
838
1439
|
}
|
|
839
1440
|
// P = col
|
|
840
1441
|
if (at(k + 1)?.text === "=") {
|
|
@@ -842,32 +1443,34 @@ function predicateSpan(tokens, k, target) {
|
|
|
842
1443
|
while (at(end + 1)?.text === "." && at(end + 2) !== undefined && isIdentifier(at(end + 2)))
|
|
843
1444
|
end += 2;
|
|
844
1445
|
if (matches(end))
|
|
845
|
-
return
|
|
1446
|
+
return { start: k, end, column: end };
|
|
846
1447
|
}
|
|
847
1448
|
return undefined;
|
|
848
1449
|
}
|
|
849
1450
|
/**
|
|
850
|
-
* Whether the
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
1451
|
+
* Whether the written column `t` is the policy's `column` under the `reading`'s rule, compared with
|
|
1452
|
+
* the column's own spelling, which the policy writes as the schema does. A name that compares as
|
|
1453
|
+
* another column may be another source's, so it ties nothing to the policy's.
|
|
1454
|
+
* - Read the Postgres way (`postgres`, `cockroachdb`): a quoted name exactly, an unquoted one with
|
|
1455
|
+
* its ASCII letters lower-cased. So `"AGENCY_ID"` is another column than `agency_id`, and an
|
|
1456
|
+
* unquoted `agencyid` another than `"agencyId"`.
|
|
1457
|
+
* - Read the SQL Server way, the name as written: the database's collation may compare names
|
|
1458
|
+
* case-sensitively, and the check can't see it, so `agencyid` is another column than `agencyId`.
|
|
1459
|
+
* - Read the MySQL way (MySQL and MariaDB), lower-cased.
|
|
1460
|
+
* - Otherwise (SQLite, and the generic reading), with ASCII letters lower-cased, quoted or not, so
|
|
1461
|
+
* `égency_id` is another column than `Égency_id`.
|
|
1462
|
+
* A custom id or none is read every way, so the column has to match under each.
|
|
854
1463
|
*/
|
|
855
|
-
function
|
|
856
|
-
if (
|
|
857
|
-
return
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
const before = tokens[qualifiedStart(tokens, source) - 1];
|
|
866
|
-
if (before !== undefined && (before.kw === "from" || before.kw === "join" || before.text === ",")) {
|
|
867
|
-
return src.lower !== table;
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
return false;
|
|
1464
|
+
function namesColumn(t, column, reading) {
|
|
1465
|
+
if (reading === "postgres" || reading === "sqlserver")
|
|
1466
|
+
return t.name === column;
|
|
1467
|
+
if (reading === "mysql")
|
|
1468
|
+
return t.lower === column.toLowerCase();
|
|
1469
|
+
return asciiLower(t.name) === asciiLower(column);
|
|
1470
|
+
}
|
|
1471
|
+
/** `name` with its ASCII letters lower-cased, as SQLite folds a name. */
|
|
1472
|
+
function asciiLower(name) {
|
|
1473
|
+
return name.replace(/[A-Z]+/g, (s) => s.toLowerCase());
|
|
871
1474
|
}
|
|
872
1475
|
/**
|
|
873
1476
|
* Whether the subquery opened by the `(` at `open` is a row source (a derived table in
|
|
@@ -894,6 +1497,9 @@ function subqueryUse(tokens, open) {
|
|
|
894
1497
|
return tokens[open - 2] !== undefined && isIdentifier(tokens[open - 2]) ? "in" : "other";
|
|
895
1498
|
if (opener.kw === "as" || opener.kw === "lateral")
|
|
896
1499
|
return "table";
|
|
1500
|
+
// A CTE body after `AS MATERIALIZED (` or `AS NOT MATERIALIZED (`.
|
|
1501
|
+
if (opener.kw === "materialized" && (tokens[open - 2]?.kw === "as" || (tokens[open - 2]?.kw === "not" && tokens[open - 3]?.kw === "as")))
|
|
1502
|
+
return "table";
|
|
897
1503
|
const depth = tokens[open].depth;
|
|
898
1504
|
for (let i = open - 1; i >= 0; i--) {
|
|
899
1505
|
const t = tokens[i];
|
|
@@ -911,49 +1517,134 @@ function subqueryUse(tokens, open) {
|
|
|
911
1517
|
}
|
|
912
1518
|
return "other";
|
|
913
1519
|
}
|
|
914
|
-
/**
|
|
915
|
-
const
|
|
916
|
-
/**
|
|
1520
|
+
/** Words that make a join outer, wherever they stand in it: `LEFT HASH JOIN`, `LEFT STRAIGHT JOIN`, `FULL OUTER JOIN`. */
|
|
1521
|
+
const OUTER_JOIN_WORDS = new Set(["left", "right", "full", "outer"]);
|
|
1522
|
+
/** Words a join's own words start after, at its level: the source before it begins or ends at one of these. */
|
|
1523
|
+
const BEFORE_A_JOIN = new Set(["from", "join", "on", "using", "apply"]);
|
|
1524
|
+
/**
|
|
1525
|
+
* Whether the `ON` at `on` belongs to an outer join, whose `ON` doesn't filter the preserved side.
|
|
1526
|
+
* The join is outer when `LEFT`, `RIGHT`, `FULL` or `OUTER` stands anywhere among its words, between
|
|
1527
|
+
* the source before it and `JOIN`, as a keyword (not `LEFT(…)` or a qualified name), so a hint
|
|
1528
|
+
* between them (SQL Server's `HASH`, CockroachDB's `STRAIGHT`, `LOOKUP` or `INVERTED`) changes nothing.
|
|
1529
|
+
* An `ON` with another `ON` or `USING` between it and the nearest `JOIN` closes an earlier join
|
|
1530
|
+
* (`a LEFT JOIN b JOIN c ON … ON …`), which may be outer, so it counts as outer.
|
|
1531
|
+
*/
|
|
917
1532
|
function isOuterJoinOn(tokens, on) {
|
|
918
1533
|
const depth = tokens[on].depth;
|
|
919
1534
|
for (let i = on - 1; i >= 0; i--) {
|
|
920
1535
|
const t = tokens[i];
|
|
921
1536
|
if (t.depth < depth)
|
|
922
1537
|
return false;
|
|
923
|
-
if (t.depth > depth
|
|
1538
|
+
if (t.depth > depth)
|
|
1539
|
+
continue;
|
|
1540
|
+
if (t.kw === "on" || t.kw === "using")
|
|
1541
|
+
return true;
|
|
1542
|
+
if (t.kw !== "join")
|
|
1543
|
+
continue;
|
|
1544
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
1545
|
+
const w = tokens[k];
|
|
1546
|
+
if (w.depth < depth)
|
|
1547
|
+
break;
|
|
1548
|
+
if (w.depth > depth)
|
|
1549
|
+
continue;
|
|
1550
|
+
if (BEFORE_A_JOIN.has(w.kw) || w.text === ",")
|
|
1551
|
+
break;
|
|
1552
|
+
const qualified = tokens[k - 1]?.text === "." || tokens[k + 1]?.text === ".";
|
|
1553
|
+
if (OUTER_JOIN_WORDS.has(w.kw) && !isFunctionCall(tokens, k) && !qualified)
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
/** Join words that may take an `ON`: MySQL's `STRAIGHT_JOIN` may, so it counts as one. */
|
|
1561
|
+
const TAKES_ON = new Set(["join", "straight_join"]);
|
|
1562
|
+
/**
|
|
1563
|
+
* Whether the read at `at` is among the operands of the join the `ON` at `on` closes: the only reads
|
|
1564
|
+
* an unqualified column there can name. They run from the join's left operand up to the `ON`. Going
|
|
1565
|
+
* left from the join's own `JOIN`, the left operand stops at `FROM`, at the `(` around the join, at
|
|
1566
|
+
* a `FROM`-list comma (which binds looser than a join, though not on SQLite, where this only
|
|
1567
|
+
* narrows the check), or at a `JOIN` whose `ON` or `USING` hasn't come yet (`a LEFT JOIN b JOIN c
|
|
1568
|
+
* ON … ON …`). `CROSS JOIN`, `NATURAL JOIN` and `APPLY` take no `ON`, so the operand runs past them.
|
|
1569
|
+
*/
|
|
1570
|
+
function seenByOn(tokens, on, at) {
|
|
1571
|
+
if (at >= on)
|
|
1572
|
+
return false;
|
|
1573
|
+
const depth = tokens[on].depth;
|
|
1574
|
+
let join = -1;
|
|
1575
|
+
for (let i = on - 1; i >= 0 && join < 0; i--) {
|
|
1576
|
+
const t = tokens[i];
|
|
1577
|
+
if (t.depth < depth)
|
|
1578
|
+
return false;
|
|
1579
|
+
if (t.depth > depth)
|
|
1580
|
+
continue;
|
|
1581
|
+
if (t.kw === "from" || t.text === ",")
|
|
1582
|
+
return false;
|
|
1583
|
+
if (TAKES_ON.has(t.kw))
|
|
1584
|
+
join = i;
|
|
1585
|
+
}
|
|
1586
|
+
if (join < 0)
|
|
1587
|
+
return false;
|
|
1588
|
+
let pending = 0; // ON or USING seen going left, each closing a JOIN further left
|
|
1589
|
+
for (let i = join - 1; i >= 0; i--) {
|
|
1590
|
+
const t = tokens[i];
|
|
1591
|
+
if (t.depth < depth)
|
|
1592
|
+
return at > i;
|
|
1593
|
+
if (t.depth > depth)
|
|
924
1594
|
continue;
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1595
|
+
if (t.kw === "from" || t.text === ",")
|
|
1596
|
+
return at > i;
|
|
1597
|
+
if (t.kw === "on" || t.kw === "using")
|
|
1598
|
+
pending++;
|
|
1599
|
+
else if (TAKES_ON.has(t.kw) && !takesNoOn(tokens, i)) {
|
|
1600
|
+
if (pending === 0)
|
|
1601
|
+
return at > i;
|
|
1602
|
+
pending--;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return true;
|
|
1606
|
+
}
|
|
1607
|
+
/** Whether the `JOIN` at `join` is a `CROSS JOIN` or a `NATURAL … JOIN`, which take no `ON`. */
|
|
1608
|
+
function takesNoOn(tokens, join) {
|
|
1609
|
+
const depth = tokens[join].depth;
|
|
1610
|
+
for (let k = join - 1; k >= 0; k--) {
|
|
1611
|
+
const w = tokens[k];
|
|
1612
|
+
if (w.depth !== depth || BEFORE_A_JOIN.has(w.kw) || w.text === ",")
|
|
1613
|
+
return false;
|
|
1614
|
+
if ((w.kw === "cross" || w.kw === "natural") && tokens[k - 1]?.text !== "." && tokens[k + 1]?.text !== ".")
|
|
1615
|
+
return true;
|
|
931
1616
|
}
|
|
932
1617
|
return false;
|
|
933
1618
|
}
|
|
934
1619
|
/**
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
* and end), with no `OR`, `XOR` (or MySQL's `||`)
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
1620
|
+
* The `SELECT`s whose reads the term `tokens[start..end]` filters (#399), each with the qualifier
|
|
1621
|
+
* that ties it to a read there. The term must be ANDed into a `WHERE`, `ON` (of an inner join) or
|
|
1622
|
+
* `HAVING` clause: at its own level and at every enclosing parenthesized level up to that clause,
|
|
1623
|
+
* it sits between `AND`s (or the clause's start and end), with no `OR`, `XOR` (or MySQL's `||`)
|
|
1624
|
+
* at that level and no `NOT` in front of it. It then filters that clause's `SELECT`, through
|
|
1625
|
+
* `qualifier`, its column's. When that `SELECT` is the whole right side of `col IN (…)` and the
|
|
1626
|
+
* `col IN (…)` is itself such a conjunct, it filters the query around it too, through `col`'s
|
|
1627
|
+
* qualifier. A level opened by a function call or a comparison doesn't count.
|
|
942
1628
|
*/
|
|
943
|
-
function
|
|
944
|
-
const
|
|
1629
|
+
function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
1630
|
+
const { tokens, scopes } = a;
|
|
1631
|
+
const disjunction = (t) => t.kw === "or" || t.kw === "xor" || (t.text === "||" && a.view.reading.pipesAreOr);
|
|
945
1632
|
const endsClause = (i) => CLAUSE_END.has(tokens[i].kw) && !isFunctionCall(tokens, i);
|
|
1633
|
+
const filters = [];
|
|
946
1634
|
let lo = start;
|
|
947
1635
|
let hi = end;
|
|
948
1636
|
let depth = tokens[start].depth;
|
|
1637
|
+
let q = qualifier;
|
|
1638
|
+
let ownSelect = true;
|
|
1639
|
+
let column = termColumn;
|
|
949
1640
|
for (;;) {
|
|
950
1641
|
// What is directly around the term, at its level.
|
|
951
1642
|
const before = tokens[lo - 1];
|
|
952
1643
|
const after = tokens[hi + 1];
|
|
953
|
-
const leftOk = before !== undefined && (
|
|
1644
|
+
const leftOk = before !== undefined && (conjunctAnd(tokens, lo - 1) || before.text === "(" || FILTER_CLAUSE.has(before.kw));
|
|
954
1645
|
const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || endsClause(hi + 1);
|
|
955
1646
|
if (!leftOk || !rightOk)
|
|
956
|
-
return
|
|
1647
|
+
return filters;
|
|
957
1648
|
// Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
|
|
958
1649
|
let clause = -1;
|
|
959
1650
|
let open = -1;
|
|
@@ -966,13 +1657,13 @@ function isFilterConjunct(tokens, start, end, reading) {
|
|
|
966
1657
|
if (t.depth > depth)
|
|
967
1658
|
continue;
|
|
968
1659
|
if (disjunction(t) || t.kw === "not")
|
|
969
|
-
return
|
|
1660
|
+
return filters;
|
|
970
1661
|
if (FILTER_CLAUSE.has(t.kw)) {
|
|
971
1662
|
clause = i;
|
|
972
1663
|
break;
|
|
973
1664
|
}
|
|
974
1665
|
if ((endsClause(i) || NOT_A_FILTER.has(t.kw)) && !isFunctionCall(tokens, i))
|
|
975
|
-
return
|
|
1666
|
+
return filters;
|
|
976
1667
|
}
|
|
977
1668
|
let close = tokens.length;
|
|
978
1669
|
for (let j = hi + 1; j < tokens.length; j++) {
|
|
@@ -983,44 +1674,160 @@ function isFilterConjunct(tokens, start, end, reading) {
|
|
|
983
1674
|
}
|
|
984
1675
|
if (t.depth > depth)
|
|
985
1676
|
continue;
|
|
986
|
-
if (t.text === ";" ||
|
|
1677
|
+
if (t.text === ";" || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
987
1678
|
break;
|
|
988
1679
|
if (disjunction(t))
|
|
989
|
-
return
|
|
1680
|
+
return filters;
|
|
990
1681
|
}
|
|
991
1682
|
if (clause >= 0) {
|
|
992
1683
|
if (tokens[clause].kw === "on" && isOuterJoinOn(tokens, clause))
|
|
993
|
-
return
|
|
994
|
-
if (
|
|
995
|
-
return
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1684
|
+
return filters;
|
|
1685
|
+
if (tokens[clause].kw === "having" && !onGroupKey(a, clause, column))
|
|
1686
|
+
return filters;
|
|
1687
|
+
const scope = scopes.of[clause];
|
|
1688
|
+
filters.push({ scope, qualifier: q, ownSelect, ...(tokens[clause].kw === "on" ? { on: clause } : {}) });
|
|
1689
|
+
// `col IN (<this SELECT>)` limits the query around it to the rows this SELECT returns.
|
|
1690
|
+
const query = scopeOpen(scope);
|
|
1691
|
+
const queryClose = scopes.close.get(query);
|
|
1692
|
+
if (query < 0 || queryClose === undefined || scopes.branched.has(query) || subqueryUse(tokens, query) !== "in")
|
|
1693
|
+
return filters;
|
|
1694
|
+
lo = qualifiedStart(tokens, query - 2);
|
|
1695
|
+
hi = queryClose;
|
|
1696
|
+
depth = tokens[query].depth;
|
|
1697
|
+
q = qualifierOf(tokens, query - 2);
|
|
1698
|
+
column = query - 2;
|
|
1699
|
+
ownSelect = false;
|
|
1009
1700
|
continue;
|
|
1010
1701
|
}
|
|
1011
1702
|
if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
|
|
1012
|
-
return
|
|
1703
|
+
return filters;
|
|
1013
1704
|
// A parenthesized group counts only as a plain boolean conjunct: opened right after
|
|
1014
1705
|
// WHERE/ON/HAVING, AND, or another "(". Anything else (`f(`, `IN (`, `EXISTS (`,
|
|
1015
1706
|
// `NOT (`, `= (`) makes it something other than a filter conjunct.
|
|
1016
1707
|
const opener = tokens[open - 1];
|
|
1017
|
-
if (opener === undefined || !(FILTER_CLAUSE.has(opener.kw) ||
|
|
1018
|
-
return
|
|
1708
|
+
if (opener === undefined || !(FILTER_CLAUSE.has(opener.kw) || conjunctAnd(tokens, open - 1) || opener.text === "("))
|
|
1709
|
+
return filters;
|
|
1019
1710
|
lo = open;
|
|
1020
1711
|
hi = close;
|
|
1021
1712
|
depth = tokens[open].depth;
|
|
1022
1713
|
}
|
|
1023
1714
|
}
|
|
1715
|
+
/** Whether the token at `i` is an `AND` that joins two conjuncts, not the `AND` of a `BETWEEN`. */
|
|
1716
|
+
function conjunctAnd(tokens, i) {
|
|
1717
|
+
return tokens[i]?.kw === "and" && !andOfBetween(tokens, i);
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Whether the `AND` at `and` closes a `BETWEEN` (`x BETWEEN 0 AND y`), so that what follows is the
|
|
1721
|
+
* upper bound, not a conjunct: MySQL reads `0 BETWEEN 0 AND col IN (…)` with the `IN` inside the
|
|
1722
|
+
* bound. Each `BETWEEN` at the `AND`'s level, from the start of its clause or parentheses, takes the
|
|
1723
|
+
* first `AND` after it there, whatever its lower bound holds (`LEFT(…)`, a cast); an `AND` inside a
|
|
1724
|
+
* `CASE … END` belongs to the `CASE`.
|
|
1725
|
+
*/
|
|
1726
|
+
function andOfBetween(tokens, and) {
|
|
1727
|
+
const depth = tokens[and].depth;
|
|
1728
|
+
// A word beside a `.` is a name (`o.and`, `o.end`), never the keyword.
|
|
1729
|
+
const dotted = (i) => tokens[i - 1]?.text === "." || tokens[i + 1]?.text === ".";
|
|
1730
|
+
let from = and - 1;
|
|
1731
|
+
while (from >= 0) {
|
|
1732
|
+
const t = tokens[from];
|
|
1733
|
+
if (t.depth < depth || (t.depth === depth && !dotted(from) && (FILTER_CLAUSE.has(t.kw) || t.kw === "select")))
|
|
1734
|
+
break;
|
|
1735
|
+
from--;
|
|
1736
|
+
}
|
|
1737
|
+
let pending = 0;
|
|
1738
|
+
let inCase = 0;
|
|
1739
|
+
for (let i = from + 1; i < and; i++) {
|
|
1740
|
+
const t = tokens[i];
|
|
1741
|
+
if (t.depth !== depth || dotted(i))
|
|
1742
|
+
continue;
|
|
1743
|
+
if (t.kw === "case")
|
|
1744
|
+
inCase++;
|
|
1745
|
+
else if (t.kw === "end" && inCase > 0)
|
|
1746
|
+
inCase--;
|
|
1747
|
+
else if (inCase === 0 && t.kw === "between")
|
|
1748
|
+
pending++;
|
|
1749
|
+
else if (inCase === 0 && t.kw === "and" && pending > 0)
|
|
1750
|
+
pending--;
|
|
1751
|
+
}
|
|
1752
|
+
return pending > 0;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* Whether the word at `j` ends the scan for an `OR` beside a predicate: a clause start that every
|
|
1756
|
+
* engine reserves (`WHERE`, `HAVING`, `GROUP BY`, `ORDER BY`, a set operation, `JOIN`), not beside a
|
|
1757
|
+
* `.`, and MySQL's `STRAIGHT_JOIN` where the SQL is read as MySQL. Other clause words (`LIMIT`, `LOCK`,
|
|
1758
|
+
* `QUALIFY`, `NATURAL`, `USING`, `WINDOW`) are names on some engine, so the scan goes on past them, and
|
|
1759
|
+
* an `OR` after one still counts as beside the predicate.
|
|
1760
|
+
*/
|
|
1761
|
+
function endsFilterScan(tokens, j, engine) {
|
|
1762
|
+
const t = tokens[j];
|
|
1763
|
+
if (tokens[j - 1]?.text === "." || tokens[j + 1]?.text === "." || isFunctionCall(tokens, j))
|
|
1764
|
+
return false;
|
|
1765
|
+
if (t.kw === "group" || t.kw === "order")
|
|
1766
|
+
return tokens[j + 1]?.kw === "by";
|
|
1767
|
+
if (t.kw === "straight_join")
|
|
1768
|
+
return engine === "mysql";
|
|
1769
|
+
return ["where", "having", "union", "intersect", "except", "join"].includes(t.kw);
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Whether a predicate on the column at `column`, ANDed into the `HAVING` at `having`, filters rows:
|
|
1773
|
+
* only when that column is a plain key of its `SELECT`'s `GROUP BY` (`agency_id`, or `o.agency_id`
|
|
1774
|
+
* written the same way). On any other column, a group holds every row it covers, and SQLite and
|
|
1775
|
+
* MariaDB take the column's value from one of them. Read the MySQL way, an unqualified column that a
|
|
1776
|
+
* select-list item is aliased to never counts: MySQL and MariaDB resolve such a `HAVING` name to the
|
|
1777
|
+
* alias in some forms and to the `GROUP BY` column in others, so the check refuses the name either way.
|
|
1778
|
+
*/
|
|
1779
|
+
function onGroupKey(a, having, column) {
|
|
1780
|
+
const { tokens } = a;
|
|
1781
|
+
const depth = tokens[having].depth;
|
|
1782
|
+
let select = -1;
|
|
1783
|
+
let groupBy = -1;
|
|
1784
|
+
for (let i = having - 1; i >= 0 && select < 0; i--) {
|
|
1785
|
+
const t = tokens[i];
|
|
1786
|
+
if (t.depth < depth)
|
|
1787
|
+
break;
|
|
1788
|
+
if (t.depth > depth)
|
|
1789
|
+
continue;
|
|
1790
|
+
if (t.kw === "by" && tokens[i - 1]?.kw === "group" && groupBy < 0)
|
|
1791
|
+
groupBy = i;
|
|
1792
|
+
if (t.kw === "select")
|
|
1793
|
+
select = i;
|
|
1794
|
+
}
|
|
1795
|
+
if (select < 0 || groupBy < 0)
|
|
1796
|
+
return false;
|
|
1797
|
+
const name = tokens[column].lower;
|
|
1798
|
+
const qualifier = qualifierOf(tokens, column);
|
|
1799
|
+
const itemRanges = (from, to) => {
|
|
1800
|
+
const out = [];
|
|
1801
|
+
let itemStart = from;
|
|
1802
|
+
for (let i = from; i <= to; i++) {
|
|
1803
|
+
const t = tokens[i];
|
|
1804
|
+
if (i === to || (t?.depth === depth && t.text === ",")) {
|
|
1805
|
+
if (i > itemStart)
|
|
1806
|
+
out.push([itemStart, i - 1]);
|
|
1807
|
+
itemStart = i + 1;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
return out;
|
|
1811
|
+
};
|
|
1812
|
+
if (a.view.reading.profile.id === "mysql" && qualifier === undefined) {
|
|
1813
|
+
let from = select + 1;
|
|
1814
|
+
while (tokens[from]?.kw === "distinct" || tokens[from]?.kw === "all")
|
|
1815
|
+
from++;
|
|
1816
|
+
let fromKw = from;
|
|
1817
|
+
while (fromKw < having && !(tokens[fromKw].depth === depth && tokens[fromKw].kw === "from"))
|
|
1818
|
+
fromKw++;
|
|
1819
|
+
const aliased = itemRanges(from, fromKw).some(([s, e]) => e > s && tokens[e - 1]?.text !== "." && isIdentifier(tokens[e]) && tokens[e].lower === name);
|
|
1820
|
+
if (aliased)
|
|
1821
|
+
return false;
|
|
1822
|
+
}
|
|
1823
|
+
return itemRanges(groupBy + 1, having).some(([s, e]) => {
|
|
1824
|
+
const last = tokens[e];
|
|
1825
|
+
if (!isIdentifier(last) || last.lower !== name)
|
|
1826
|
+
return false;
|
|
1827
|
+
const plain = e === s || (e === s + 2 && tokens[s + 1]?.text === ".");
|
|
1828
|
+
return plain && qualifierOf(tokens, e) === qualifier;
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1024
1831
|
/** Index of the `(` or `)` that encloses the token at `i`, or -1 at the top level. */
|
|
1025
1832
|
function enclosing(tokens, i, paren) {
|
|
1026
1833
|
const depth = tokens[i].depth;
|