@askdb/core 1.0.0-beta.51 → 1.0.0-beta.52
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/schema/v2/loader.js +1 -1
- package/dist/schema/v2/loader.js.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 +109 -0
- package/dist/sql/readings.d.ts.map +1 -0
- package/dist/sql/readings.js +287 -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 +3 -1
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +337 -239
- 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, 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
|
/**
|
|
@@ -58,17 +59,20 @@ export function validateTenantGuardrails(sql, policy, scope, options) {
|
|
|
58
59
|
/**
|
|
59
60
|
* The tenant rule alone: what {@link validateTenantGuardrails} finds in `sql`, with no
|
|
60
61
|
* mode. Internal to `@askdb/core`; the guardrail checks and the validator share it.
|
|
62
|
+
* `schemaNames` are the schema's table, namespace and column names, when the caller has the
|
|
63
|
+
* schema: a name outside printable ASCII counts when it is one of them or a name in the policy.
|
|
61
64
|
*/
|
|
62
|
-
export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
65
|
+
export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
63
66
|
// Global scope bypasses tenant guardrails
|
|
64
67
|
if (scope.access.kind === "global")
|
|
65
68
|
return [];
|
|
66
69
|
const warnings = [];
|
|
70
|
+
const read = readSql(sql);
|
|
67
71
|
// The per-table checks below read queries. A statement that isn't one (a `DO` block, `CALL`,
|
|
68
72
|
// `EXPLAIN`, `SHOW`) can touch tables they never see, a `DO` body as a dollar-quoted string,
|
|
69
73
|
// so it is refused here, under every dialect: read-only validation may be a custom
|
|
70
74
|
// AskDialect's job (ADR 0010), tenant enforcement isn't (#698).
|
|
71
|
-
const keyword = nonQueryKeyword(
|
|
75
|
+
const keyword = nonQueryKeyword(read, dialect);
|
|
72
76
|
if (keyword !== undefined) {
|
|
73
77
|
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
78
|
"tenant predicates, so under a tenant policy it refuses any other statement."));
|
|
@@ -78,7 +82,7 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
78
82
|
// reads. The read-only check allows some of these as reads, and a custom AskDialect skips
|
|
79
83
|
// it, so the tenant check refuses them under every dialect (#702). A listed name counts in any
|
|
80
84
|
// code position, since an engine can reach a function without `(` right after its name.
|
|
81
|
-
const name = uncheckableName(
|
|
85
|
+
const name = uncheckableName(read, dialect);
|
|
82
86
|
if (name !== undefined) {
|
|
83
87
|
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
88
|
"tenant policy, query the tables directly (pivot with CASE or FILTER, not crosstab), and don't " +
|
|
@@ -94,6 +98,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
94
98
|
warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the Unicode-escaped name ${escaped}, which the tenant check can't read. ` +
|
|
95
99
|
'Write the name in plain double quotes ("orders") or unquoted.'));
|
|
96
100
|
}
|
|
101
|
+
// The checks below match names by spelling. SQL Server reads a name through the database
|
|
102
|
+
// collation, which ignores some characters and reads others as ASCII letters, so a name outside
|
|
103
|
+
// printable ASCII may reach a table they never see: unless the schema spells it exactly, refuse it.
|
|
104
|
+
const folded = unmatchedNonAsciiName(read, dialect, knownNames(policy, schemaNames));
|
|
105
|
+
if (folded !== undefined) {
|
|
106
|
+
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. ` +
|
|
107
|
+
"SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
|
|
108
|
+
"tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
|
|
109
|
+
}
|
|
97
110
|
// Past an E'…' string continued on a later line, Postgres and the checks below read different
|
|
98
111
|
// strings, so a table or call could sit in what they take for a string: refuse it (#703).
|
|
99
112
|
const continued = postgresOnly.escapeStringContinuation;
|
|
@@ -108,7 +121,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect) {
|
|
|
108
121
|
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
122
|
"CockroachDB reads with backslash escapes and the tenant check doesn't. Write the bytes as a hex string cast to bytea ('\\x00'::bytea)."));
|
|
110
123
|
}
|
|
111
|
-
|
|
124
|
+
// On a Postgres reading the lexer reads `$1OR` as one parameter, while PostgreSQL 14 and earlier
|
|
125
|
+
// and CockroachDB end the parameter at its last digit and read the word as a keyword the checks
|
|
126
|
+
// below would miss. Refuse it rather than guess how the server splits it (GHSA-rhwf-7742-8hwj).
|
|
127
|
+
const glued = parameterAgainstWord(read, dialect);
|
|
128
|
+
if (glued !== undefined) {
|
|
129
|
+
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 ` +
|
|
130
|
+
"keyword, so the tenant check can't tell which words are keywords. Put a space after the parameter."));
|
|
131
|
+
}
|
|
132
|
+
const views = codeViews(read, dialect);
|
|
112
133
|
// Check the roots this scope covers: a query on the root table itself is scoped by its tenant ID column.
|
|
113
134
|
for (const rootId of scopeRootIds(scope)) {
|
|
114
135
|
const root = policy.roots.find((r) => r.id === rootId);
|
|
@@ -255,6 +276,57 @@ function idCarriers(policy, rootId) {
|
|
|
255
276
|
}
|
|
256
277
|
return carriers;
|
|
257
278
|
}
|
|
279
|
+
/**
|
|
280
|
+
* The names a statement may spell outside printable ASCII: every table the policy covers and its
|
|
281
|
+
* namespace, every column the policy names, and `schemaNames` when the caller has the schema.
|
|
282
|
+
*/
|
|
283
|
+
function knownNames(policy, schemaNames) {
|
|
284
|
+
const names = new Set(schemaNames);
|
|
285
|
+
const table = (id) => {
|
|
286
|
+
const path = id.replace(/^table:/, "");
|
|
287
|
+
const dot = path.lastIndexOf(".");
|
|
288
|
+
names.add(path.slice(dot + 1));
|
|
289
|
+
if (dot !== -1)
|
|
290
|
+
names.add(path.slice(0, dot));
|
|
291
|
+
};
|
|
292
|
+
const column = (id) => {
|
|
293
|
+
const hash = id.lastIndexOf("#");
|
|
294
|
+
if (hash !== -1)
|
|
295
|
+
table(id.slice(0, hash));
|
|
296
|
+
names.add(extractColumnName(id));
|
|
297
|
+
};
|
|
298
|
+
for (const entry of policy.coverage)
|
|
299
|
+
table(entry.tableId);
|
|
300
|
+
for (const id of policy.globalTables)
|
|
301
|
+
table(id);
|
|
302
|
+
for (const root of policy.roots) {
|
|
303
|
+
table(root.id);
|
|
304
|
+
column(root.tenantIdColumn);
|
|
305
|
+
if (root.parent)
|
|
306
|
+
column(root.parent.foreignKey);
|
|
307
|
+
}
|
|
308
|
+
for (const edge of policy.hierarchy)
|
|
309
|
+
column(edge.foreignKey);
|
|
310
|
+
for (const st of policy.scopedTables) {
|
|
311
|
+
table(st.id);
|
|
312
|
+
for (const path of st.scopeThrough) {
|
|
313
|
+
if ("column" in path)
|
|
314
|
+
column(path.column);
|
|
315
|
+
else {
|
|
316
|
+
for (const step of path.join) {
|
|
317
|
+
column(step.from);
|
|
318
|
+
column(step.to);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const pt of policy.polymorphicTables) {
|
|
324
|
+
table(pt.id);
|
|
325
|
+
column(pt.typeColumn);
|
|
326
|
+
column(pt.idColumn);
|
|
327
|
+
}
|
|
328
|
+
return names;
|
|
329
|
+
}
|
|
258
330
|
/** `rootId` and every root above it in the policy's hierarchy (`hierarchy[]`, `roots[].parent`). */
|
|
259
331
|
function selfAndAncestors(policy, rootId) {
|
|
260
332
|
const chain = [rootId];
|
|
@@ -303,21 +375,6 @@ function continuesExpression(prev) {
|
|
|
303
375
|
return prev.text !== ")" && EXPRESSION_PUNCTUATION.has(prev.text);
|
|
304
376
|
return prev.kind === "word" && (EXPRESSION_KEYWORDS.has(prev.lower) || prev.lower === "as" || prev.lower === "for");
|
|
305
377
|
}
|
|
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
378
|
/**
|
|
322
379
|
* The leading token of the first statement in `sql` that isn't a query, upper-cased (a
|
|
323
380
|
* keyword, or the token's own text when it isn't a word), or undefined when every statement
|
|
@@ -325,15 +382,15 @@ function statementReadings(dialect) {
|
|
|
325
382
|
* reading also at a top-level word from {@link TSQL_STATEMENT_WORDS} that doesn't
|
|
326
383
|
* {@link continuesExpression continue an expression}. Its first token is read
|
|
327
384
|
* past comments and `(` with the shared lexer, under every reading from
|
|
328
|
-
*
|
|
385
|
+
* `tenantStatementReadings`, so a statement any of them reads as a non-query counts.
|
|
329
386
|
*/
|
|
330
|
-
function nonQueryKeyword(
|
|
331
|
-
for (const profile of
|
|
387
|
+
function nonQueryKeyword(read, dialect) {
|
|
388
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
332
389
|
const tsql = profile.id === "sqlserver";
|
|
333
390
|
let statementStart = true;
|
|
334
391
|
let depth = 0;
|
|
335
392
|
let prev;
|
|
336
|
-
for (const token of
|
|
393
|
+
for (const token of read.tokens(profile)) {
|
|
337
394
|
if (token.kind === "comment")
|
|
338
395
|
continue;
|
|
339
396
|
const before = prev;
|
|
@@ -362,6 +419,20 @@ function nonQueryKeyword(sql, dialect) {
|
|
|
362
419
|
}
|
|
363
420
|
return undefined;
|
|
364
421
|
}
|
|
422
|
+
/**
|
|
423
|
+
* The first `$N` parameter with a word written right after its digits (`$1OR`) under a reading
|
|
424
|
+
* from `tenantStatementReadings` on the Postgres lexer, where the lexer reads both as one token.
|
|
425
|
+
*/
|
|
426
|
+
function parameterAgainstWord(read, dialect) {
|
|
427
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
428
|
+
if (profile.id !== "postgres")
|
|
429
|
+
continue;
|
|
430
|
+
const token = read.tokens(profile).find((t) => t.kind === "parameter" && /^\$\d+\D/.test(t.text));
|
|
431
|
+
if (token)
|
|
432
|
+
return token.text.slice(0, 40);
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
365
436
|
/**
|
|
366
437
|
* Functions whose reads the tenant check can't see, lowercase (#702). A function belongs here when
|
|
367
438
|
* it is built into Postgres or SQL Server, or comes from an extension that ships with Postgres
|
|
@@ -392,11 +463,11 @@ const UNCHECKABLE_FUNCTIONS = new Set([
|
|
|
392
463
|
* in any code position, in any case, bare, quoted or qualified, not only right before `(`: an
|
|
393
464
|
* engine can reach a function in forms with no `(` after its name, so a column, an alias or a
|
|
394
465
|
* placeholder named like a listed function counts too. Text in comments and single-quoted strings
|
|
395
|
-
* doesn't. Read under every reading from
|
|
466
|
+
* doesn't. Read under every reading from `tenantStatementReadings`, so a name any of them sees counts.
|
|
396
467
|
*/
|
|
397
|
-
function uncheckableName(
|
|
398
|
-
for (const profile of
|
|
399
|
-
for (const token of
|
|
468
|
+
function uncheckableName(read, dialect) {
|
|
469
|
+
for (const profile of tenantStatementReadings(dialect)) {
|
|
470
|
+
for (const token of read.tokens(profile)) {
|
|
400
471
|
const name = nameOf(token);
|
|
401
472
|
if (name !== undefined && UNCHECKABLE_FUNCTIONS.has(name))
|
|
402
473
|
return name;
|
|
@@ -436,209 +507,94 @@ function extractColumnName(columnId) {
|
|
|
436
507
|
const hash = columnId.lastIndexOf("#");
|
|
437
508
|
return hash !== -1 ? columnId.slice(hash + 1) : columnId;
|
|
438
509
|
}
|
|
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
510
|
/**
|
|
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.
|
|
511
|
+
* The statement's code views. A built-in engine gets its family's reading; any other id, or none,
|
|
512
|
+
* gets every reading (standard SQL, Postgres with `E'…'` escape strings, MySQL): the statement is
|
|
513
|
+
* ambiguous exactly where they differ, and the matchers below resolve that ambiguity toward a
|
|
514
|
+
* warning (tables: any view; predicates: every view). A MySQL server running with `ANSI_QUOTES`
|
|
515
|
+
* reads `"…"` as an identifier; the MySQL reading still treats it as a string, so a predicate
|
|
516
|
+
* written only as `"agency_id"` is flagged there, never passed. A MySQL reading comes twice, with
|
|
517
|
+
* the bodies of `/*! … *\/` executable comments read as code and as comments, since whether the
|
|
518
|
+
* server runs one depends on its version.
|
|
474
519
|
*/
|
|
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 };
|
|
520
|
+
function codeViews(read, dialect) {
|
|
521
|
+
return tenantCodeReadings(dialect).map((reading) => {
|
|
522
|
+
const tokens = read.tokens(reading.profile);
|
|
523
|
+
// A reading that may be SQLite's reads a '…' in a table's place as the table's name, and one right
|
|
524
|
+
// after a table or CTE reference as its alias.
|
|
525
|
+
const names = mayBeSqlite(reading.profile) ? stringsSqliteReadsAsNames(tokens, true) : new Set();
|
|
526
|
+
const quoted = new Uint8Array(read.sql.length);
|
|
527
|
+
tokens.forEach((token, i) => {
|
|
528
|
+
if (token.kind === "quoted_identifier" || names.has(i))
|
|
529
|
+
quoted.fill(1, token.start, token.end);
|
|
530
|
+
});
|
|
531
|
+
return { text: codeView(read.sql, tokens, names), source: read.sql, reading, tokens, quoted };
|
|
532
|
+
});
|
|
499
533
|
}
|
|
500
534
|
/**
|
|
501
535
|
* 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
|
|
536
|
+
* identifier matching still sees `"agency_id"` as `agency_id`. The tokenizer skips it, and
|
|
537
|
+
* tells a quoted `"where"` (an identifier) from the keyword `WHERE` by the lexer's spans.
|
|
504
538
|
*/
|
|
505
539
|
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
540
|
/**
|
|
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
|
|
541
|
+
* `sql` with everything that is not code blanked, from the shared lexer's `tokens`, so identifier
|
|
542
|
+
* checks only see code. Strings and comments become spaces (a newline stays); the body of an
|
|
543
|
+
* executable comment the reading runs is code, since the lexer reads it as tokens. The output has
|
|
544
|
+
* the same length and keeps the original case (placeholders are case-sensitive), so word
|
|
515
545
|
* boundaries at the seams are unchanged.
|
|
516
546
|
*
|
|
517
|
-
* Quoted identifiers keep their contents
|
|
518
|
-
* `"agency_id"` *is* the identifier
|
|
519
|
-
* would hide `FROM "orders"` from the table check
|
|
547
|
+
* Quoted identifiers keep their contents, and each of their delimiters becomes
|
|
548
|
+
* {@link QUOTE_MARK}, a doubled one inside them too: `"agency_id"` *is* the identifier
|
|
549
|
+
* `agency_id` on Postgres, and blanking it would hide `FROM "orders"` from the table check. The
|
|
550
|
+
* strings at the indices in `names` are read the same way, as SQLite reads them (`FROM 'orders'`).
|
|
520
551
|
*/
|
|
521
|
-
function codeView(sql,
|
|
552
|
+
function codeView(sql, tokens, names) {
|
|
522
553
|
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
|
-
}
|
|
554
|
+
for (const [index, token] of tokens.entries()) {
|
|
555
|
+
if (names.has(index)) {
|
|
556
|
+
for (let k = token.start; k < token.end; k++)
|
|
557
|
+
if (sql[k] === "'")
|
|
558
|
+
out[k] = QUOTE_MARK;
|
|
582
559
|
}
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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;
|
|
560
|
+
else if (token.kind === "string" || token.kind === "comment") {
|
|
561
|
+
// A string's prefix letter (`E'…'`, `N'…'`) stays code, as the check has always read it.
|
|
562
|
+
const from = token.kind === "string" && /^[A-Za-z]/.test(token.text) ? token.start + 1 : token.start;
|
|
563
|
+
for (let k = from; k < token.end; k++)
|
|
564
|
+
if (out[k] !== "\n")
|
|
565
|
+
out[k] = " ";
|
|
596
566
|
}
|
|
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;
|
|
610
|
-
}
|
|
611
|
-
if (ch === '"' || ch === "`" || ch === "[") {
|
|
612
|
-
const close = sql.indexOf(ch === "[" ? "]" : ch, i + 1);
|
|
613
|
-
out[i] = QUOTE_MARK;
|
|
614
|
-
if (close === -1)
|
|
615
|
-
break;
|
|
616
|
-
out[close] = QUOTE_MARK;
|
|
617
|
-
i = close + 1;
|
|
618
|
-
continue;
|
|
567
|
+
else if (token.kind === "quoted_identifier") {
|
|
568
|
+
const close = token.quote === "[" ? "]" : token.quote;
|
|
569
|
+
out[token.start] = QUOTE_MARK;
|
|
570
|
+
for (let k = token.start + 1; k < token.end; k++)
|
|
571
|
+
if (sql[k] === close)
|
|
572
|
+
out[k] = QUOTE_MARK;
|
|
619
573
|
}
|
|
620
|
-
i++;
|
|
621
574
|
}
|
|
622
575
|
return out.join("");
|
|
623
576
|
}
|
|
624
577
|
/**
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
* identifier such as `date'…'`). `out` is the view built so far, where blanked
|
|
628
|
-
* strings, comments, and quoted-identifier delimiters are spaces.
|
|
578
|
+
* A table counts as referenced when any reading sees its name in code, except as a word of
|
|
579
|
+
* `ORDER BY` or `GROUP BY` (#828).
|
|
629
580
|
*/
|
|
630
|
-
function isEStringPrefix(out, quote, reading) {
|
|
631
|
-
if (!reading.eStrings || quote === 0)
|
|
632
|
-
return false;
|
|
633
|
-
const prefix = out[quote - 1];
|
|
634
|
-
if (prefix !== "E" && prefix !== "e")
|
|
635
|
-
return false;
|
|
636
|
-
return quote < 2 || !IDENTIFIER_CONTINUE.test(out[quote - 2]);
|
|
637
|
-
}
|
|
638
|
-
/** A table counts as referenced when any reading sees it. */
|
|
639
581
|
function mentionsTable(views, tableName) {
|
|
640
|
-
const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "
|
|
641
|
-
return views.some((view) => pattern.
|
|
582
|
+
const pattern = new RegExp(`\\b${escapeRegex(tableName)}\\b`, "gi");
|
|
583
|
+
return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Whether `text[start, end)` in a code view is a word of `ORDER BY` or `GROUP BY`. `ORDER` and
|
|
587
|
+
* `GROUP` are reserved on every engine, so a table named after one is written quoted (`"order"`,
|
|
588
|
+
* whose closing quote mark breaks the phrase) or qualified (`billing.order`, which Postgres takes
|
|
589
|
+
* unquoted), and the `BY` after them is the keyword.
|
|
590
|
+
*/
|
|
591
|
+
function inOrderOrGroupBy(text, start, end) {
|
|
592
|
+
if (text[start - 1] === ".")
|
|
593
|
+
return false;
|
|
594
|
+
const word = text.slice(start, end).toLowerCase();
|
|
595
|
+
if (word === "order" || word === "group")
|
|
596
|
+
return /^\s+by(?![\w$\u0080-\uffff])/i.test(text.slice(end));
|
|
597
|
+
return word === "by" && /(?:^|[^\w$\u0080-\uffff.\u0001])(?:order|group)\s+$/i.test(text.slice(0, start));
|
|
642
598
|
}
|
|
643
599
|
/** A tenant column counts only when every reading sees it in code. */
|
|
644
600
|
function mentionsIdentifier(views, identifier) {
|
|
@@ -646,26 +602,86 @@ function mentionsIdentifier(views, identifier) {
|
|
|
646
602
|
return views.every((view) => pattern.test(view.text));
|
|
647
603
|
}
|
|
648
604
|
const TOKEN = /:[A-Za-z0-9_]+|::|\|\||<>|!=|<=|>=|[A-Za-z_\u0080-\uffff][\w$\u0080-\uffff]*|\d[\w.]*|\S/g;
|
|
649
|
-
/**
|
|
650
|
-
|
|
605
|
+
/** A {@link TOKEN} match that is a word or a number. */
|
|
606
|
+
const WORD_OR_NUMBER = /^[\w\u0080-\uffff]/;
|
|
607
|
+
/**
|
|
608
|
+
* Split a code view into tokens with their nesting depth. Strings and comments are already blanked.
|
|
609
|
+
* A token is quoted when it lies inside a name the view reads as quoted, as the reading's lexer
|
|
610
|
+
* spans it, so a {@link QUOTE_MARK} character written inside a quoted name changes nothing.
|
|
611
|
+
*/
|
|
612
|
+
function tokenize(view) {
|
|
651
613
|
const tokens = [];
|
|
614
|
+
const split = lexerWordEnds(view);
|
|
615
|
+
const quoted = view.quoted;
|
|
652
616
|
let depth = 0;
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
const t = m[0];
|
|
656
|
-
if (t === QUOTE_MARK) {
|
|
657
|
-
quoted = !quoted;
|
|
617
|
+
for (const m of view.text.matchAll(TOKEN)) {
|
|
618
|
+
if (m[0] === QUOTE_MARK)
|
|
658
619
|
continue;
|
|
620
|
+
for (const [t, offset] of WORD_OR_NUMBER.test(m[0]) ? split(m[0], m.index) : [[m[0], m.index]]) {
|
|
621
|
+
if (t === ")")
|
|
622
|
+
depth = Math.max(0, depth - 1);
|
|
623
|
+
const lower = t.toLowerCase();
|
|
624
|
+
tokens.push({ text: t, lower, kw: quoted[offset] === 1 ? "" : lower, depth, offset });
|
|
625
|
+
if (t === "(")
|
|
626
|
+
depth++;
|
|
659
627
|
}
|
|
660
|
-
if (t === ")")
|
|
661
|
-
depth = Math.max(0, depth - 1);
|
|
662
|
-
const lower = t.toLowerCase();
|
|
663
|
-
tokens.push({ text: t, lower, kw: quoted ? "" : lower, depth, offset: m.index });
|
|
664
|
-
if (t === "(")
|
|
665
|
-
depth++;
|
|
666
628
|
}
|
|
667
629
|
return tokens;
|
|
668
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Where a word or number of the view ends under its reading (#623, GHSA-rhwf-7742-8hwj), so a
|
|
633
|
+
* keyword written right after one is a word of its own, as the engine reads it:
|
|
634
|
+
*
|
|
635
|
+
* - SQL Server: wherever the shared lexer ends a token. That is after `0x` or `1E`, after a number
|
|
636
|
+
* before a word, and at whitespace such as U+00A0.
|
|
637
|
+
* - PostgreSQL and CockroachDB: after a number, before a word written right after it (`1OR`,
|
|
638
|
+
* `1.0OR`), as PostgreSQL 14 and earlier read it. A `$N` parameter written against a word
|
|
639
|
+
* (`$1OR`) is refused instead ({@link parameterAgainstWord}).
|
|
640
|
+
* - MySQL and MariaDB: after a number with a decimal point or an exponent (`1.0OR`, `1e1OR`). A
|
|
641
|
+
* name that starts with digits (`3d_models`, `1OR`) is one name there.
|
|
642
|
+
*
|
|
643
|
+
* Any other reading keeps the check's own word ends. Returns a function that cuts a word or number
|
|
644
|
+
* of the view at those ends; under SQL Server it also drops the characters the lexer reads as
|
|
645
|
+
* whitespace. A quoted name is one lexer token, so it is never cut.
|
|
646
|
+
*/
|
|
647
|
+
function lexerWordEnds(view) {
|
|
648
|
+
const { profile } = view.reading;
|
|
649
|
+
const owner = new Int32Array(view.text.length).fill(-1);
|
|
650
|
+
if (profile.tsqlLiterals) {
|
|
651
|
+
view.tokens.forEach((token, index) => owner.fill(index, token.start, token.end));
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Every character is code to this reading; a new piece starts only at a number's end.
|
|
655
|
+
owner.fill(0);
|
|
656
|
+
let piece = 0;
|
|
657
|
+
view.tokens.forEach((token, index) => {
|
|
658
|
+
const next = view.tokens[index + 1];
|
|
659
|
+
const wordAfter = next !== undefined && next.start === token.end && next.kind === "word";
|
|
660
|
+
const numberEnds = token.kind === "number" &&
|
|
661
|
+
wordAfter &&
|
|
662
|
+
(profile.id === "postgres" || (profile.id === "mysql" && /^(?!0x)[\d.]*[.eE]/i.test(token.text)));
|
|
663
|
+
if (numberEnds)
|
|
664
|
+
owner.fill(++piece, next.start);
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return (text, offset) => {
|
|
668
|
+
const pieces = [];
|
|
669
|
+
let start = -1;
|
|
670
|
+
const cut = (at) => {
|
|
671
|
+
if (start >= 0)
|
|
672
|
+
pieces.push([view.text.slice(start, at), start]);
|
|
673
|
+
start = -1;
|
|
674
|
+
};
|
|
675
|
+
for (let k = offset; k < offset + text.length; k++) {
|
|
676
|
+
if (owner[k] === -1 || (start >= 0 && owner[k] !== owner[start]))
|
|
677
|
+
cut(k);
|
|
678
|
+
if (owner[k] !== -1 && start < 0)
|
|
679
|
+
start = k;
|
|
680
|
+
}
|
|
681
|
+
cut(offset + text.length);
|
|
682
|
+
return pieces;
|
|
683
|
+
};
|
|
684
|
+
}
|
|
669
685
|
/** Keywords that open a clause whose conjuncts filter rows. */
|
|
670
686
|
const FILTER_CLAUSE = new Set(["where", "on", "having"]);
|
|
671
687
|
/** Keywords that end a filter clause at its own nesting level. */
|
|
@@ -702,7 +718,12 @@ function isOrderOrGroupBy(tokens, i) {
|
|
|
702
718
|
function hasTenantPredicate(views, table, targets) {
|
|
703
719
|
const name = table.toLowerCase();
|
|
704
720
|
return views.every((view) => {
|
|
705
|
-
const tokens = tokenize(view
|
|
721
|
+
const tokens = tokenize(view);
|
|
722
|
+
// Read the Postgres way, a column list after the table's alias renames its columns, and a quoted
|
|
723
|
+
// name is compared exactly.
|
|
724
|
+
const postgres = view.reading.profile.id === "postgres";
|
|
725
|
+
if (postgres && renamesColumns(tokens, name))
|
|
726
|
+
return false;
|
|
706
727
|
// Every spelling of the name counts as the table, quoted or not, except the words of `ORDER BY`.
|
|
707
728
|
const mentions = (from, to) => {
|
|
708
729
|
for (let i = from; i < to; i++)
|
|
@@ -715,9 +736,10 @@ function hasTenantPredicate(views, table, targets) {
|
|
|
715
736
|
.every(([from, to]) => {
|
|
716
737
|
for (let k = from; k < to; k++) {
|
|
717
738
|
for (const target of targets) {
|
|
718
|
-
|
|
739
|
+
// A quoted name spelled like the placeholder is a name, which nothing binds.
|
|
740
|
+
if (tokens[k].text !== target.placeholder || tokens[k].kw === "")
|
|
719
741
|
continue;
|
|
720
|
-
const span = predicateSpan(tokens, k, target);
|
|
742
|
+
const span = predicateSpan(tokens, k, target, postgres);
|
|
721
743
|
if (!span || !isFilterConjunct(tokens, span[0], span[1], view.reading))
|
|
722
744
|
continue;
|
|
723
745
|
if (!target.discriminator || hasDiscriminator(tokens, from, to, view, target))
|
|
@@ -728,6 +750,57 @@ function hasTenantPredicate(views, table, targets) {
|
|
|
728
750
|
});
|
|
729
751
|
});
|
|
730
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* Words Postgres never takes as a bare table alias: its reserved keywords, and those it reserves
|
|
755
|
+
* except as a function or type name. So in `orders WHERE (`, `orders ON (` or `orders USING (` the
|
|
756
|
+
* word after the table is no alias, and the `(` no column list.
|
|
757
|
+
*/
|
|
758
|
+
const NOT_A_TABLE_ALIAS = new Set([
|
|
759
|
+
"all", "analyse", "analyze", "and", "any", "array", "as", "asc", "asymmetric", "authorization", "binary", "both",
|
|
760
|
+
"case", "cast", "check", "collate", "collation", "column", "concurrently", "constraint", "create", "cross",
|
|
761
|
+
"current_catalog", "current_date", "current_role", "current_schema", "current_time", "current_timestamp",
|
|
762
|
+
"current_user", "default", "deferrable", "desc", "distinct", "do", "else", "end", "except", "false", "fetch", "for",
|
|
763
|
+
"foreign", "freeze", "from", "full", "grant", "group", "having", "ilike", "in", "initially", "inner", "intersect",
|
|
764
|
+
"into", "is", "isnull", "join", "lateral", "leading", "left", "like", "limit", "localtime", "localtimestamp",
|
|
765
|
+
"natural", "not", "notnull", "null", "offset", "on", "only", "or", "order", "outer", "overlaps", "placing",
|
|
766
|
+
"primary", "references", "returning", "right", "select", "session_user", "similar", "some", "symmetric",
|
|
767
|
+
"system_user", "table", "tablesample", "then", "to", "trailing", "true", "union", "unique", "user", "using",
|
|
768
|
+
"variadic", "verbose", "when", "where", "window", "with",
|
|
769
|
+
]);
|
|
770
|
+
/**
|
|
771
|
+
* Whether a read of the table `name` has its columns renamed by a column list after an alias, at any
|
|
772
|
+
* depth: right after the table (`orders t (a, b)`, `orders AS t (a, b)`, `orders * t (a, b)`), or
|
|
773
|
+
* after a parenthesized `FROM` item the read sits in, a join or a derived table
|
|
774
|
+
* (`(orders o JOIN s ON true) AS j (a, b, agency_id)`). Postgres and CockroachDB read the list as
|
|
775
|
+
* new names for the first columns, so a predicate on `agency_id` filters whatever column the list
|
|
776
|
+
* names that, and the real one is renamed out of the way: the read can't be checked.
|
|
777
|
+
*/
|
|
778
|
+
function renamesColumns(tokens, name) {
|
|
779
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
780
|
+
if (tokens[i].lower !== name)
|
|
781
|
+
continue;
|
|
782
|
+
for (let open = enclosing(tokens, i, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
783
|
+
if (subqueryUse(tokens, open) !== "table")
|
|
784
|
+
continue;
|
|
785
|
+
const close = enclosing(tokens, open + 1, ")");
|
|
786
|
+
if (close >= 0 && aliasWithColumnList(tokens, close + 1))
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
if (tokens[i + 1]?.text === ".")
|
|
790
|
+
continue;
|
|
791
|
+
if (aliasWithColumnList(tokens, tokens[i + 1]?.text === "*" ? i + 2 : i + 1))
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
/** Whether `tokens[j]` starts an alias and a column list: `AS t (` or `t (`, where a bare `t` is no reserved word. */
|
|
797
|
+
function aliasWithColumnList(tokens, j) {
|
|
798
|
+
const as = tokens[j]?.kw === "as";
|
|
799
|
+
const alias = tokens[as ? j + 1 : j];
|
|
800
|
+
if (alias === undefined || !isIdentifier(alias) || tokens[as ? j + 2 : j + 1]?.text !== "(")
|
|
801
|
+
return false;
|
|
802
|
+
return as || alias.kw === "" || !NOT_A_TABLE_ALIAS.has(alias.kw);
|
|
803
|
+
}
|
|
731
804
|
/**
|
|
732
805
|
* Whether the block `tokens[from, to)` ANDs in `col = '<value>'` for `target.discriminator`:
|
|
733
806
|
* its column (optionally qualified with the target's table or alias), `=`, and a plain
|
|
@@ -812,15 +885,20 @@ function qualifiedStart(tokens, end) {
|
|
|
812
885
|
* The token span `[start, end]` of a tenant predicate around the placeholder at `k`:
|
|
813
886
|
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a
|
|
814
887
|
* possibly qualified name) is the target's column. A qualifier that names, or is the
|
|
815
|
-
* alias of, a table other than the target's makes it no predicate for that table.
|
|
888
|
+
* alias of, a table other than the target's makes it no predicate for that table. With
|
|
889
|
+
* `exactQuoted` (read the Postgres way, where a quoted name is compared exactly), a quoted
|
|
890
|
+
* `col` counts only when spelled as the schema spells the column: `"AGENCY_ID"` is another
|
|
891
|
+
* column there, which another source in the query may hold.
|
|
816
892
|
*/
|
|
817
|
-
function predicateSpan(tokens, k, target) {
|
|
893
|
+
function predicateSpan(tokens, k, target, exactQuoted) {
|
|
818
894
|
const at = (i) => tokens[i];
|
|
819
895
|
const column = target.column.toLowerCase();
|
|
820
896
|
const matches = (i) => {
|
|
821
897
|
const t = at(i);
|
|
822
898
|
if (t === undefined || !isIdentifier(t) || t.lower !== column)
|
|
823
899
|
return false;
|
|
900
|
+
if (exactQuoted && t.kw === "" && t.text !== target.column)
|
|
901
|
+
return false;
|
|
824
902
|
const qualifier = at(i - 1)?.text === "." ? at(i - 2) : undefined;
|
|
825
903
|
return qualifier === undefined || !namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase());
|
|
826
904
|
};
|
|
@@ -911,23 +989,43 @@ function subqueryUse(tokens, open) {
|
|
|
911
989
|
}
|
|
912
990
|
return "other";
|
|
913
991
|
}
|
|
914
|
-
/**
|
|
915
|
-
const
|
|
916
|
-
/**
|
|
992
|
+
/** Words that make a join outer, wherever they stand in it: `LEFT HASH JOIN`, `LEFT STRAIGHT JOIN`, `FULL OUTER JOIN`. */
|
|
993
|
+
const OUTER_JOIN_WORDS = new Set(["left", "right", "full", "outer"]);
|
|
994
|
+
/** Words a join's own words start after, at its level: the source before it begins or ends at one of these. */
|
|
995
|
+
const BEFORE_A_JOIN = new Set(["from", "join", "on", "using", "apply"]);
|
|
996
|
+
/**
|
|
997
|
+
* Whether the `ON` at `on` belongs to an outer join, whose `ON` doesn't filter the preserved side.
|
|
998
|
+
* The join is outer when `LEFT`, `RIGHT`, `FULL` or `OUTER` stands anywhere among its words, between
|
|
999
|
+
* the source before it and `JOIN`, as a keyword (not `LEFT(…)` or a qualified name), so a hint
|
|
1000
|
+
* between them (SQL Server's `HASH`, CockroachDB's `STRAIGHT`, `LOOKUP` or `INVERTED`) changes nothing.
|
|
1001
|
+
* An `ON` with another `ON` or `USING` between it and the nearest `JOIN` closes an earlier join
|
|
1002
|
+
* (`a LEFT JOIN b JOIN c ON … ON …`), which may be outer, so it counts as outer.
|
|
1003
|
+
*/
|
|
917
1004
|
function isOuterJoinOn(tokens, on) {
|
|
918
1005
|
const depth = tokens[on].depth;
|
|
919
1006
|
for (let i = on - 1; i >= 0; i--) {
|
|
920
1007
|
const t = tokens[i];
|
|
921
1008
|
if (t.depth < depth)
|
|
922
1009
|
return false;
|
|
923
|
-
if (t.depth > depth
|
|
1010
|
+
if (t.depth > depth)
|
|
1011
|
+
continue;
|
|
1012
|
+
if (t.kw === "on" || t.kw === "using")
|
|
1013
|
+
return true;
|
|
1014
|
+
if (t.kw !== "join")
|
|
924
1015
|
continue;
|
|
925
|
-
let
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1016
|
+
for (let k = i - 1; k >= 0; k--) {
|
|
1017
|
+
const w = tokens[k];
|
|
1018
|
+
if (w.depth < depth)
|
|
1019
|
+
break;
|
|
1020
|
+
if (w.depth > depth)
|
|
1021
|
+
continue;
|
|
1022
|
+
if (BEFORE_A_JOIN.has(w.kw) || w.text === ",")
|
|
1023
|
+
break;
|
|
1024
|
+
const qualified = tokens[k - 1]?.text === "." || tokens[k + 1]?.text === ".";
|
|
1025
|
+
if (OUTER_JOIN_WORDS.has(w.kw) && !isFunctionCall(tokens, k) && !qualified)
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
return false;
|
|
931
1029
|
}
|
|
932
1030
|
return false;
|
|
933
1031
|
}
|