@askdb/core 1.0.0-beta.52 → 1.0.0-beta.54
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/dist/errors.d.ts +4 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +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 +36 -1
- 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/sql/mask-literals.d.ts +15 -0
- package/dist/sql/mask-literals.d.ts.map +1 -0
- package/dist/sql/mask-literals.js +69 -0
- package/dist/sql/mask-literals.js.map +1 -0
- package/dist/sql/readings.d.ts +16 -3
- package/dist/sql/readings.d.ts.map +1 -1
- package/dist/sql/readings.js +54 -8
- package/dist/sql/readings.js.map +1 -1
- package/dist/sql/tenant-guardrail.d.ts +9 -4
- package/dist/sql/tenant-guardrail.d.ts.map +1 -1
- package/dist/sql/tenant-guardrail.js +910 -164
- package/dist/sql/tenant-guardrail.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import { placeholderForTenantRoot, } from "../schema/v2/tenant-policy.js";
|
|
|
2
2
|
import { SQLSERVER_DIALECT } from "./dialect-spec.js";
|
|
3
3
|
import { decide, tenantFindings, throwIfDenied } from "./guardrail-decide.js";
|
|
4
4
|
import { findPostgresOnlyForms } from "./lexer.js";
|
|
5
|
-
import { mayBeSqlite, readSql, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, unmatchedNonAsciiName, } from "./readings.js";
|
|
5
|
+
import { mayBeSqlite, readSql, QUERY_START_WORDS, stringsSqliteReadsAsNames, tenantCodeReadings, tenantStatementReadings, nameEnginesFoldApart, unmatchedNonAsciiName, } from "./readings.js";
|
|
6
6
|
import { EXPRESSION_KEYWORDS, EXPRESSION_PUNCTUATION } from "./tsql-batch.js";
|
|
7
7
|
import { denylistFor } from "./validate.js";
|
|
8
8
|
/**
|
|
@@ -24,10 +24,15 @@ import { denylistFor } from "./validate.js";
|
|
|
24
24
|
* needs the same predicate on its tenant ID column. Down a join path, the predicate
|
|
25
25
|
* must be on a column that carries the IDs of the path's root or one of its ancestors
|
|
26
26
|
* in the hierarchy. A polymorphic table also needs `typeColumn = '<key>'` ANDed into
|
|
27
|
-
* the same
|
|
28
|
-
* its id column.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
31
36
|
*
|
|
32
37
|
* **This is not a security boundary.** Its purpose is to catch model mistakes
|
|
33
38
|
* (a forgotten or widened tenant filter, an unclassified table) early and
|
|
@@ -107,6 +112,15 @@ export function tenantRuleWarnings(sql, policy, scope, dialect, schemaNames) {
|
|
|
107
112
|
"SQL Server reads names through its collation, which ignores some characters and reads others as ASCII letters, so the " +
|
|
108
113
|
"tenant check can't tell which table or column it names. Spell the name exactly as the schema does."));
|
|
109
114
|
}
|
|
115
|
+
// Some engine folds a few letters into ASCII ones (CockroachDB and MySQL read U+0130 as `i`), and
|
|
116
|
+
// CockroachDB normalizes a name to NFC, so the checks below can't match such a name the engines'
|
|
117
|
+
// way: unless the schema spells it exactly, refuse it.
|
|
118
|
+
const apart = nameEnginesFoldApart(read, dialect, knownNames(policy, schemaNames));
|
|
119
|
+
if (apart !== undefined) {
|
|
120
|
+
warnings.push(warn("UNICODE_ESCAPED_IDENTIFIER", "", `The SQL uses the name ${apart}, which holds a letter some database engines read as an ASCII letter (U+0130, U+0131, U+017F or U+212A) ` +
|
|
121
|
+
"or isn't in Unicode normalization form C, and isn't spelled that way in the schema. The tenant check can't tell which table or column it names. " +
|
|
122
|
+
"Spell the name exactly as the schema does."));
|
|
123
|
+
}
|
|
110
124
|
// Past an E'…' string continued on a later line, Postgres and the checks below read different
|
|
111
125
|
// strings, so a table or call could sit in what they take for a string: refuse it (#703).
|
|
112
126
|
const continued = postgresOnly.escapeStringContinuation;
|
|
@@ -217,7 +231,7 @@ function checkPolymorphicTable(sql, pt, policy, warnings) {
|
|
|
217
231
|
return;
|
|
218
232
|
}
|
|
219
233
|
// The id predicate filters by one root's IDs, so the discriminator must pick that root's
|
|
220
|
-
// rows: `owner_type = 'agency' AND owner_id = :tenant_agency_ids`, ANDed in the same
|
|
234
|
+
// rows: `owner_type = 'agency' AND owner_id = :tenant_agency_ids`, ANDed in the same `SELECT`.
|
|
221
235
|
const paired = rootIds.map((rootId, i) => ({
|
|
222
236
|
...targets[i],
|
|
223
237
|
discriminator: {
|
|
@@ -575,26 +589,43 @@ function codeView(sql, tokens, names) {
|
|
|
575
589
|
return out.join("");
|
|
576
590
|
}
|
|
577
591
|
/**
|
|
578
|
-
* A table counts as referenced when any reading sees its name in code,
|
|
579
|
-
*
|
|
592
|
+
* A table counts as referenced when any reading sees its name in code, in any case and between
|
|
593
|
+
* characters that can't continue a name in any script, except as a word of `ORDER BY` or
|
|
594
|
+
* `GROUP BY` (#828).
|
|
580
595
|
*/
|
|
581
596
|
function mentionsTable(views, tableName) {
|
|
582
|
-
|
|
597
|
+
// A name's edges are letters, digits, `_` or `$` in any script, matched in any case.
|
|
598
|
+
const pattern = new RegExp(`(?<![\\p{L}\\p{M}\\p{N}_$])${escapeRegex(tableName)}(?![\\p{L}\\p{M}\\p{N}_$])`, "giu");
|
|
583
599
|
return views.some((view) => [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length)));
|
|
584
600
|
}
|
|
585
601
|
/**
|
|
586
602
|
* Whether `text[start, end)` in a code view is a word of `ORDER BY` or `GROUP BY`. `ORDER` and
|
|
587
603
|
* `GROUP` are reserved on every engine, so a table named after one is written quoted (`"order"`,
|
|
588
604
|
* whose closing quote mark breaks the phrase) or qualified (`billing.order`, which Postgres takes
|
|
589
|
-
* unquoted), and the `BY` after them is the keyword.
|
|
605
|
+
* unquoted), and the `BY` after them is the keyword. A `.` before the word makes it a name even with
|
|
606
|
+
* whitespace or a comment (blanked in the view) between them, as every engine reads `billing . order`.
|
|
590
607
|
*/
|
|
591
608
|
function inOrderOrGroupBy(text, start, end) {
|
|
592
|
-
|
|
609
|
+
const afterDot = (at) => /\.\s*$/.test(text.slice(0, at));
|
|
610
|
+
if (afterDot(start))
|
|
593
611
|
return false;
|
|
594
612
|
const word = text.slice(start, end).toLowerCase();
|
|
595
613
|
if (word === "order" || word === "group")
|
|
596
614
|
return /^\s+by(?![\w$\u0080-\uffff])/i.test(text.slice(end));
|
|
597
|
-
|
|
615
|
+
if (word !== "by")
|
|
616
|
+
return false;
|
|
617
|
+
const before = /(?:order|group)\s+$/i.exec(text.slice(0, start));
|
|
618
|
+
if (before === null || /[\w$\u0080-\uffff\u0001]/.test(text[before.index - 1] ?? ""))
|
|
619
|
+
return false;
|
|
620
|
+
return !afterDot(before.index);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Whether a reading sees `tableName` as a name of its own, not part of a longer one (`orders$archive`):
|
|
624
|
+
* anywhere in code, or inside a quoted name, except as a word of `ORDER BY` or `GROUP BY`.
|
|
625
|
+
*/
|
|
626
|
+
function mentionsAsName(view, tableName) {
|
|
627
|
+
const pattern = new RegExp(`(?<![\\w$\\u0080-\\uffff])${escapeRegex(tableName)}(?![\\w$\\u0080-\\uffff])`, "gi");
|
|
628
|
+
return [...view.text.matchAll(pattern)].some((m) => !inOrderOrGroupBy(view.text, m.index, m.index + m[0].length));
|
|
598
629
|
}
|
|
599
630
|
/** A tenant column counts only when every reading sees it in code. */
|
|
600
631
|
function mentionsIdentifier(views, identifier) {
|
|
@@ -606,22 +637,48 @@ const TOKEN = /:[A-Za-z0-9_]+|::|\|\||<>|!=|<=|>=|[A-Za-z_\u0080-\uffff][\w$\u00
|
|
|
606
637
|
const WORD_OR_NUMBER = /^[\w\u0080-\uffff]/;
|
|
607
638
|
/**
|
|
608
639
|
* Split a code view into tokens with their nesting depth. Strings and comments are already blanked.
|
|
609
|
-
* A
|
|
610
|
-
*
|
|
640
|
+
* A quoted identifier is one token, its whole name as the lexer reads it (escapes undone), so a
|
|
641
|
+
* space, dot or bracket inside it is never read as code, and a QUOTE_MARK character written inside
|
|
642
|
+
* it changes nothing. So is a string the reading takes as a name, as SQLite does (`FROM 'orders'`,
|
|
643
|
+
* `orders 'o'`): `'join'` there is a name, never the keyword.
|
|
611
644
|
*/
|
|
612
645
|
function tokenize(view) {
|
|
613
646
|
const tokens = [];
|
|
614
647
|
const split = lexerWordEnds(view);
|
|
615
|
-
const
|
|
648
|
+
const foldsCase = view.reading.profile.id === "postgres" || view.reading.profile.id === "generic";
|
|
649
|
+
// Postgres folds only ASCII letters in an unquoted name and CockroachDB every letter: a name with
|
|
650
|
+
// an upper-case non-ASCII letter is read apart, so it names nothing ({@link READ_APART}).
|
|
651
|
+
const readsApart = view.reading.profile.id === "postgres";
|
|
652
|
+
const quotedAt = new Map();
|
|
653
|
+
for (const t of view.tokens) {
|
|
654
|
+
if (t.kind === "quoted_identifier")
|
|
655
|
+
quotedAt.set(t.start, { end: t.end, name: t.value });
|
|
656
|
+
else if (t.kind === "string" && view.quoted[t.start] === 1 && t.text.startsWith("'") && t.unterminated !== true) {
|
|
657
|
+
quotedAt.set(t.start, { end: t.end, name: t.text.slice(1, -1).replaceAll("''", "'") });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
616
660
|
let depth = 0;
|
|
661
|
+
let skipUntil = 0;
|
|
617
662
|
for (const m of view.text.matchAll(TOKEN)) {
|
|
618
|
-
if (m
|
|
663
|
+
if (m.index < skipUntil)
|
|
619
664
|
continue;
|
|
665
|
+
if (m[0] === QUOTE_MARK) {
|
|
666
|
+
const q = quotedAt.get(m.index);
|
|
667
|
+
if (q === undefined)
|
|
668
|
+
continue;
|
|
669
|
+
const name = q.name;
|
|
670
|
+
// A name spelled like a mark (`"."`, `[,]`) keeps a prefix, so it never reads as that mark.
|
|
671
|
+
const text = /^[^\w\u0080-\uffff]{1,2}$/.test(name) ? QUOTE_MARK + name : name;
|
|
672
|
+
tokens.push({ text, lower: name.toLowerCase(), name, kw: "", depth, offset: m.index + 1, quoted: true });
|
|
673
|
+
skipUntil = q.end;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
620
676
|
for (const [t, offset] of WORD_OR_NUMBER.test(m[0]) ? split(m[0], m.index) : [[m[0], m.index]]) {
|
|
621
677
|
if (t === ")")
|
|
622
678
|
depth = Math.max(0, depth - 1);
|
|
623
679
|
const lower = t.toLowerCase();
|
|
624
|
-
|
|
680
|
+
const name = !foldsCase ? t : readsApart && lower !== asciiLower(t) ? `${READ_APART}${offset}` : asciiLower(t);
|
|
681
|
+
tokens.push({ text: t, lower, name, kw: lower, depth, offset });
|
|
625
682
|
if (t === "(")
|
|
626
683
|
depth++;
|
|
627
684
|
}
|
|
@@ -692,7 +749,7 @@ const CLAUSE_END = new Set([
|
|
|
692
749
|
]);
|
|
693
750
|
/** Words that put a predicate somewhere other than a filter conjunct (select list, `CASE`, assignment, …). */
|
|
694
751
|
const NOT_A_FILTER = new Set(["select", "from", "by", "when", "then", "else", "case", "end", "as", "set", "values", "distinct", "is"]);
|
|
695
|
-
/** Keywords that combine
|
|
752
|
+
/** Keywords that combine `SELECT`s; each branch is checked on its own. */
|
|
696
753
|
const SET_OPERATORS = new Set(["union", "intersect", "except", "minus"]);
|
|
697
754
|
/** Words other than identifiers that the predicate forms use. */
|
|
698
755
|
const PREDICATE_WORDS = new Set(["in", "any", "and", "or", "not", "xor"]);
|
|
@@ -700,53 +757,327 @@ const PREDICATE_WORDS = new Set(["in", "any", "and", "or", "not", "xor"]);
|
|
|
700
757
|
* Whether the unquoted word at `i` is part of `ORDER BY` or `GROUP BY`. `ORDER` and `GROUP` are
|
|
701
758
|
* reserved on every engine, so a table named after one is written quoted. Any other keyword may
|
|
702
759
|
* name a table, unquoted, on some engine (`user` on MySQL, `lock` on Postgres), so its spelling
|
|
703
|
-
* always counts as the table.
|
|
760
|
+
* always counts as the table. After a `.` (`billing.order by`), `order` is a table's name and
|
|
761
|
+
* `by` its alias.
|
|
704
762
|
*/
|
|
705
763
|
function isOrderOrGroupBy(tokens, i) {
|
|
706
764
|
const kw = tokens[i].kw;
|
|
707
765
|
if (kw === "order" || kw === "group")
|
|
708
|
-
return tokens[i + 1]?.kw === "by";
|
|
709
|
-
return kw === "by" && (tokens[i - 1]?.kw === "order" || tokens[i - 1]?.kw === "group");
|
|
766
|
+
return tokens[i + 1]?.kw === "by" && tokens[i - 1]?.text !== ".";
|
|
767
|
+
return kw === "by" && (tokens[i - 1]?.kw === "order" || tokens[i - 1]?.kw === "group") && tokens[i - 2]?.text !== ".";
|
|
768
|
+
}
|
|
769
|
+
/** Words that start a query right after its `(`. */
|
|
770
|
+
const QUERY_START = QUERY_START_WORDS;
|
|
771
|
+
/** Words that end a row source with no alias under every reading: no engine takes one as an alias. */
|
|
772
|
+
const ENDS_A_SOURCE = new Set([
|
|
773
|
+
"where", "group", "order", "having", "union", "intersect", "except", "join", "inner", "left", "right", "cross", "outer", "on",
|
|
774
|
+
]);
|
|
775
|
+
/** The words of a join, which put a second source in a `FROM`. */
|
|
776
|
+
const JOIN_WORDS = new Set(["join", "inner", "left", "right", "full", "cross", "outer", "natural", "straight_join", "apply", "using"]);
|
|
777
|
+
const analyses = new WeakMap();
|
|
778
|
+
/** The view's tokens and `SELECT`s, read once per view. */
|
|
779
|
+
function analyze(view) {
|
|
780
|
+
let analysis = analyses.get(view);
|
|
781
|
+
if (analysis === undefined) {
|
|
782
|
+
const tokens = tokenize(view);
|
|
783
|
+
analysis = { view, tokens, scopes: readScopes(tokens) };
|
|
784
|
+
analyses.set(view, analysis);
|
|
785
|
+
}
|
|
786
|
+
return analysis;
|
|
787
|
+
}
|
|
788
|
+
/** Whether the `(` at `open` opens a query: `SELECT`, `WITH`, `VALUES` or `TABLE` right after it, past any further `(`. */
|
|
789
|
+
function opensQuery(tokens, open) {
|
|
790
|
+
let j = open + 1;
|
|
791
|
+
while (tokens[j]?.text === "(")
|
|
792
|
+
j++;
|
|
793
|
+
return QUERY_START.has(tokens[j]?.kw ?? "");
|
|
794
|
+
}
|
|
795
|
+
function readScopes(tokens) {
|
|
796
|
+
const of = [];
|
|
797
|
+
const close = new Map();
|
|
798
|
+
const branched = new Set();
|
|
799
|
+
const frames = [{ open: -1, branch: 0, depth: 0 }];
|
|
800
|
+
tokens.forEach((t, i) => {
|
|
801
|
+
let top = frames[frames.length - 1];
|
|
802
|
+
if (t.text === ")" && top.open >= 0 && t.depth === tokens[top.open].depth) {
|
|
803
|
+
close.set(top.open, i);
|
|
804
|
+
frames.pop();
|
|
805
|
+
top = frames[frames.length - 1];
|
|
806
|
+
}
|
|
807
|
+
of.push(scopeKey(top.open, top.branch));
|
|
808
|
+
if (SET_OPERATORS.has(t.kw) && t.depth === top.depth) {
|
|
809
|
+
top.branch++;
|
|
810
|
+
branched.add(top.open);
|
|
811
|
+
}
|
|
812
|
+
if (t.text === "(" && opensQuery(tokens, i))
|
|
813
|
+
frames.push({ open: i, branch: 0, depth: t.depth + 1 });
|
|
814
|
+
});
|
|
815
|
+
return { of, close, branched };
|
|
816
|
+
}
|
|
817
|
+
/** A `SELECT`'s key: the `(` that opens its query (-1 for the statement) and its set-operation branch. */
|
|
818
|
+
function scopeKey(open, branch) {
|
|
819
|
+
return `${open}:${branch}`;
|
|
820
|
+
}
|
|
821
|
+
/** The `(` that opens a `SELECT`'s query, or -1 for the statement's (see {@link scopeKey}). */
|
|
822
|
+
function scopeOpen(scope) {
|
|
823
|
+
return Number.parseInt(scope, 10);
|
|
710
824
|
}
|
|
711
825
|
/**
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* `UNION` / `INTERSECT` / `EXCEPT`, at the top level or inside a derived table or CTE.
|
|
716
|
-
* See {@link validateTenantGuardrails} for the rule.
|
|
826
|
+
* A token's name for matching a table or CTE: lower-cased, and for a quoted name without its
|
|
827
|
+
* trailing whitespace, which SQL Server ignores (`[orders ]` reads `orders`). Matching more
|
|
828
|
+
* spellings finds more reads, so the check only gets stricter on engines that keep it.
|
|
717
829
|
*/
|
|
718
|
-
function
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
830
|
+
function tableNameOf(t) {
|
|
831
|
+
return lowerEachLetter(t.quoted === true ? t.name.trimEnd() : t.text);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* `name` with each letter lower-cased on its own, as CockroachDB lower-cases an unquoted name: a
|
|
835
|
+
* final capital sigma is `σ`, which `toLowerCase` writes `ς` at the end of a word.
|
|
836
|
+
*/
|
|
837
|
+
function lowerEachLetter(name) {
|
|
838
|
+
return Array.from(name, (c) => c.toLowerCase()).join("");
|
|
839
|
+
}
|
|
840
|
+
/** Whether `token` may be a name: a quoted name, whatever it holds, or a word that starts with a letter or `_`. */
|
|
841
|
+
function isWord(token) {
|
|
842
|
+
return token !== undefined && (token.quoted === true || /^[A-Za-z_\u0080-\uffff]/.test(token.text));
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Whether the token at `k`, right after a row source, ends that source with no alias in `view`'s
|
|
846
|
+
* reading: the end of the statement, `,`, `)`, `;`, a word in {@link ENDS_A_SOURCE}, a join word
|
|
847
|
+
* the reading's engine reserves (`FULL` but on MySQL, `NATURAL` and `USING` but on SQL Server,
|
|
848
|
+
* MySQL's `STRAIGHT_JOIN`), a SQL Server `WITH (` hint, or a MySQL `FORCE`/`USE`/`IGNORE INDEX`.
|
|
849
|
+
* Any other word may be an alias on some engine (`force`, `lock`, `qualify` are on Postgres).
|
|
850
|
+
*/
|
|
851
|
+
function endsSource(a, k) {
|
|
852
|
+
const t = a.tokens[k];
|
|
853
|
+
if (t === undefined || t.text === "," || t.text === ")" || t.text === ";")
|
|
854
|
+
return true;
|
|
855
|
+
const engine = a.view.reading.profile.id;
|
|
856
|
+
const next = a.tokens[k + 1];
|
|
857
|
+
switch (t.kw) {
|
|
858
|
+
case "full": return engine !== "mysql";
|
|
859
|
+
case "natural":
|
|
860
|
+
case "using": return engine !== "sqlserver";
|
|
861
|
+
case "straight_join": return engine === "mysql";
|
|
862
|
+
case "with": return next?.text === "(";
|
|
863
|
+
case "force":
|
|
864
|
+
case "use":
|
|
865
|
+
case "ignore": return next?.kw === "index" || next?.kw === "key";
|
|
866
|
+
default: return ENDS_A_SOURCE.has(t.kw);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* The alias of the row source ending at `i`, in the only forms the check reads one: `orders o`,
|
|
871
|
+
* `orders AS o`, `) t`, where the alias is followed by what ends the source ({@link endsSource})
|
|
872
|
+
* or by its column list. Anything else (`orders TABLESAMPLE …`, `orders NOT INDEXED`) is no alias
|
|
873
|
+
* the check recognises, so it returns undefined and the caller fails closed.
|
|
874
|
+
*/
|
|
875
|
+
function aliasAt(a, i) {
|
|
876
|
+
const as = a.tokens[i + 1]?.kw === "as";
|
|
877
|
+
if (!as && endsSource(a, i + 1))
|
|
878
|
+
return undefined;
|
|
879
|
+
const alias = as ? i + 2 : i + 1;
|
|
880
|
+
if (!isWord(a.tokens[alias]))
|
|
881
|
+
return undefined;
|
|
882
|
+
return endsSource(a, alias + 1) || a.tokens[alias + 1]?.text === "(" ? alias : undefined;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* The names the table reference at `i` goes by: its alias, or its own name when what follows ends
|
|
886
|
+
* it with no alias. Anything else after it is a form the check doesn't read, so the read gets no
|
|
887
|
+
* name and only an unqualified predicate filters it.
|
|
888
|
+
*/
|
|
889
|
+
function readNames(a, i) {
|
|
890
|
+
const end = sourceEnd(a, i);
|
|
891
|
+
if (hiddenByGroupAlias(a, end))
|
|
892
|
+
return new Set();
|
|
893
|
+
const alias = aliasAt(a, end);
|
|
894
|
+
if (alias !== undefined)
|
|
895
|
+
return a.tokens[alias + 1]?.text === "(" ? new Set() : new Set([a.tokens[alias].name]);
|
|
896
|
+
return endsSource(a, end + 1) ? new Set([a.tokens[i].name]) : new Set();
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Whether the row source ending at `end` sits inside parentheses, in a source position, that
|
|
900
|
+
* something other than the source's end follows: an alias (`("order" x) AS y`,
|
|
901
|
+
* `(orders x CROSS JOIN s) AS j`) hides the names inside, so a predicate on `x` names another
|
|
902
|
+
* read. Any other word there is a form the check doesn't read, so it fails closed the same way.
|
|
903
|
+
*/
|
|
904
|
+
function hiddenByGroupAlias(a, end) {
|
|
905
|
+
const { tokens } = a;
|
|
906
|
+
for (let open = enclosing(tokens, end, "("); open >= 0; open = enclosing(tokens, open, "(")) {
|
|
907
|
+
if (opensQuery(tokens, open) || !inSourcePosition(tokens, open))
|
|
726
908
|
return false;
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
909
|
+
const close = enclosing(tokens, open + 1, ")");
|
|
910
|
+
if (close < 0 || !endsSource(a, close + 1))
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Where the table reference at `i` ends: past the `)` of any parentheses that hold it alone
|
|
917
|
+
* (`ONLY (orders) a`, SQLite's `(orders) a`), since its alias follows them.
|
|
918
|
+
*/
|
|
919
|
+
function sourceEnd(a, i) {
|
|
920
|
+
let start = qualifiedStart(a.tokens, i);
|
|
921
|
+
let end = i;
|
|
922
|
+
// Only parentheses in a source position (after `FROM`, `JOIN`, `ONLY`, `,` or another `(`), not a call's.
|
|
923
|
+
const opensSource = (open) => {
|
|
924
|
+
const before = a.tokens[open - 1];
|
|
925
|
+
return before !== undefined && ((before.kw === "from" && opensFromClause(a.tokens, open - 1)) || ["join", "only", "lateral", "straight_join"].includes(before.kw) || before.text === "," || before.text === "(");
|
|
926
|
+
};
|
|
927
|
+
while (a.tokens[start - 1]?.text === "(" && a.tokens[end + 1]?.text === ")" && opensSource(start - 1)) {
|
|
928
|
+
start--;
|
|
929
|
+
end++;
|
|
930
|
+
}
|
|
931
|
+
return end;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Whether the `(` at `open` sits where a row source starts: right after `FROM`, a join word,
|
|
935
|
+
* `LATERAL`, `APPLY`, a comma of a `FROM` list, or another `(` that does.
|
|
936
|
+
*/
|
|
937
|
+
function inSourcePosition(tokens, open) {
|
|
938
|
+
const before = tokens[open - 1];
|
|
939
|
+
if (before === undefined)
|
|
940
|
+
return false;
|
|
941
|
+
if (before.kw === "from")
|
|
942
|
+
return opensFromClause(tokens, open - 1);
|
|
943
|
+
if (["join", "straight_join", "lateral", "apply"].includes(before.kw))
|
|
944
|
+
return true;
|
|
945
|
+
if (before.text === "(")
|
|
946
|
+
return !opensQuery(tokens, open - 1) && inSourcePosition(tokens, open - 1);
|
|
947
|
+
if (before.text !== ",")
|
|
948
|
+
return false;
|
|
949
|
+
for (let k = open - 2; k >= 0; k--) {
|
|
950
|
+
const t = tokens[k];
|
|
951
|
+
if (t.depth < before.depth)
|
|
732
952
|
return false;
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
953
|
+
if (t.depth > before.depth)
|
|
954
|
+
continue;
|
|
955
|
+
if (t.kw === "from")
|
|
956
|
+
return opensFromClause(tokens, k);
|
|
957
|
+
if (t.kw === "select" || t.kw === "where" || t.kw === "having" || (CLAUSE_END.has(t.kw) && !JOIN_WORDS.has(t.kw)))
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Whether a table read can start at `start`: right after a `SELECT`'s own `FROM`, a join word,
|
|
964
|
+
* `ONLY`, `LATERAL`, a comma of a `FROM` list, or a `(` in one of those places that opens no query.
|
|
965
|
+
* A whole-row reference in an expression (`json_agg(orders)`, `(orders).id`) starts none.
|
|
966
|
+
*/
|
|
967
|
+
function startsSource(tokens, start) {
|
|
968
|
+
const before = tokens[start - 1];
|
|
969
|
+
if (before === undefined)
|
|
970
|
+
return false;
|
|
971
|
+
if (before.kw === "from")
|
|
972
|
+
return opensFromClause(tokens, start - 1);
|
|
973
|
+
if (["join", "straight_join", "only", "lateral"].includes(before.kw))
|
|
974
|
+
return true;
|
|
975
|
+
if (before.text === "(")
|
|
976
|
+
return !opensQuery(tokens, start - 1) && startsSource(tokens, start - 1);
|
|
977
|
+
return before.text === "," && inSourcePosition(tokens, start);
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Whether the `FROM` at `k` opens its `SELECT`'s own `FROM` clause: not `IS [NOT] DISTINCT FROM`,
|
|
981
|
+
* and not a `FROM` inside a call's parentheses (`EXTRACT(YEAR FROM d)`, `SUBSTRING(s FROM 2)`).
|
|
982
|
+
*/
|
|
983
|
+
function opensFromClause(tokens, k) {
|
|
984
|
+
if (tokens[k]?.kw !== "from" || tokens[k - 1]?.kw === "distinct")
|
|
985
|
+
return false;
|
|
986
|
+
const open = enclosing(tokens, k, "(");
|
|
987
|
+
return open < 0 || opensQuery(tokens, open);
|
|
988
|
+
}
|
|
989
|
+
/** Whether the alias of the row source ending at `i` has a column list (`x t (a, b)`), which renames its output. */
|
|
990
|
+
function hasColumnList(a, i) {
|
|
991
|
+
const alias = aliasAt(a, i);
|
|
992
|
+
return alias !== undefined && a.tokens[alias + 1]?.text === "(";
|
|
993
|
+
}
|
|
994
|
+
/** Whether the `(` at `open` follows `AS` (or `AS [NOT] MATERIALIZED`), as a CTE body does. */
|
|
995
|
+
function opensAfterAs(tokens, open) {
|
|
996
|
+
let k = open - 1;
|
|
997
|
+
if (tokens[k]?.kw === "materialized")
|
|
998
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
999
|
+
return tokens[k]?.kw === "as";
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* The CTE named before the `(` at `open` (`WITH x AS (`, `, x AS MATERIALIZED (`): its name and
|
|
1003
|
+
* token. Undefined when it isn't one, or when a column list (`x (a, b) AS (`) renames its output.
|
|
1004
|
+
*/
|
|
1005
|
+
function cteAt(tokens, open) {
|
|
1006
|
+
let k = open - 1;
|
|
1007
|
+
if (tokens[k]?.kw === "materialized")
|
|
1008
|
+
k -= tokens[k - 1]?.kw === "not" ? 2 : 1;
|
|
1009
|
+
if (tokens[k]?.kw !== "as" || !isWord(tokens[k - 1]))
|
|
1010
|
+
return undefined;
|
|
1011
|
+
const before = tokens[k - 2];
|
|
1012
|
+
if (!(before?.kw === "with" || before?.kw === "recursive" || before?.text === ","))
|
|
1013
|
+
return undefined;
|
|
1014
|
+
return { name: tableNameOf(tokens[k - 1]), at: k - 1 };
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* The reads of `name`: every spelling of it, quoted or not, except a word of `ORDER BY` or
|
|
1018
|
+
* `GROUP BY` and a qualifier (`orders.id`), which refers to a read rather than being one. A read
|
|
1019
|
+
* goes by its names ({@link readNames}): an alias only, once it has one, since the table's name
|
|
1020
|
+
* then refers to another read, or to none.
|
|
1021
|
+
*/
|
|
1022
|
+
function readsOf(a, name) {
|
|
1023
|
+
const reads = [];
|
|
1024
|
+
a.tokens.forEach((t, i) => {
|
|
1025
|
+
if (tableNameOf(t) !== name || isOrderOrGroupBy(a.tokens, i) || a.tokens[i + 1]?.text === ".")
|
|
1026
|
+
return;
|
|
1027
|
+
reads.push({ at: i, scope: a.scopes.of[i], names: readNames(a, i) });
|
|
1028
|
+
});
|
|
1029
|
+
return reads;
|
|
1030
|
+
}
|
|
1031
|
+
/** The qualifier of the column at `column` (`o` in `o.agency_id`), if any, as the reading compares names. */
|
|
1032
|
+
function qualifierOf(tokens, column) {
|
|
1033
|
+
return tokens[column - 1]?.text === "." ? tokens[column - 2]?.name : undefined;
|
|
1034
|
+
}
|
|
1035
|
+
/** Every tenant predicate in the view for one of `targets`, with the `SELECT`s it filters. */
|
|
1036
|
+
function occurrences(a, targets) {
|
|
1037
|
+
const found = [];
|
|
1038
|
+
a.tokens.forEach((t, k) => {
|
|
1039
|
+
for (const target of targets) {
|
|
1040
|
+
// A quoted name spelled like the placeholder is a name, which nothing binds.
|
|
1041
|
+
if (t.text !== target.placeholder || t.quoted === true)
|
|
1042
|
+
continue;
|
|
1043
|
+
const predicate = predicateAt(a.tokens, k, target.column, a.view.reading.profile.id);
|
|
1044
|
+
if (!predicate)
|
|
1045
|
+
continue;
|
|
1046
|
+
const filters = filteredScopes(a, predicate.start, predicate.end, qualifierOf(a.tokens, predicate.column), predicate.column);
|
|
1047
|
+
if (filters.length > 0)
|
|
1048
|
+
found.push({ target, filters });
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
1051
|
+
return found;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Whether every read of `table` is filtered, on every reading, by a predicate from `targets`
|
|
1055
|
+
* (#399): a column and placeholder compared as a conjunct of a filter clause in the read's own
|
|
1056
|
+
* `SELECT`, and tied to that read by its qualifier. Each branch of a `UNION` / `INTERSECT` /
|
|
1057
|
+
* `EXCEPT` is its own `SELECT`, and so is every CTE body, derived table and subquery. A read in a
|
|
1058
|
+
* derived table or CTE is also filtered from outside, when that source passes the column through
|
|
1059
|
+
* unrenamed and every reference to it is filtered on it. See {@link validateTenantGuardrails}.
|
|
1060
|
+
*/
|
|
1061
|
+
function hasTenantPredicate(views, table, targets) {
|
|
1062
|
+
const name = lowerEachLetter(table);
|
|
1063
|
+
return views.every((view) => {
|
|
1064
|
+
const a = analyze(view);
|
|
1065
|
+
// Read the Postgres way, a column list after a read's alias, or after a parenthesized source it
|
|
1066
|
+
// sits in, renames its columns, so the read can't be checked.
|
|
1067
|
+
if (view.reading.profile.id === "postgres" && renamesColumns(a.tokens, name))
|
|
749
1068
|
return false;
|
|
1069
|
+
const reads = readsOf(a, name);
|
|
1070
|
+
// A reading that sees the table's name but no read of it (the name only inside a longer quoted
|
|
1071
|
+
// name, or only as a qualifier) can't tell what filters it, so it fails closed.
|
|
1072
|
+
if (reads.length === 0)
|
|
1073
|
+
return !mentionsAsName(view, name);
|
|
1074
|
+
const found = occurrences(a, targets);
|
|
1075
|
+
const tableReads = reads.filter((r) => startsSource(a.tokens, qualifiedStart(a.tokens, r.at)));
|
|
1076
|
+
return reads.every((read) => {
|
|
1077
|
+
const sameSelect = tableReads.filter((r) => r.scope === read.scope);
|
|
1078
|
+
return tableReads.includes(read) && sameSelect.length > 1
|
|
1079
|
+
? selfJoinReadFiltered(a, found, targets, read, name, sameSelect)
|
|
1080
|
+
: readFiltered(a, found, targets, read, name);
|
|
750
1081
|
});
|
|
751
1082
|
});
|
|
752
1083
|
}
|
|
@@ -802,22 +1133,267 @@ function aliasWithColumnList(tokens, j) {
|
|
|
802
1133
|
return as || alias.kw === "" || !NOT_A_TABLE_ALIAS.has(alias.kw);
|
|
803
1134
|
}
|
|
804
1135
|
/**
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
1136
|
+
* A read of a table its `SELECT` reads more than once (a self-join): it counts as filtered only
|
|
1137
|
+
* when every read of the table there is canonical (`table alias` or `table AS alias`, outside
|
|
1138
|
+
* parentheses, with a plain unquoted alias no other read there shares, in any case) and a
|
|
1139
|
+
* predicate qualified with its own alias filters it. Anything less can't tell the reads apart, so
|
|
1140
|
+
* it fails closed.
|
|
1141
|
+
*/
|
|
1142
|
+
function selfJoinReadFiltered(a, found, targets, read, table, sameSelect) {
|
|
1143
|
+
const aliases = sameSelect.map((r) => canonicalAlias(a, r));
|
|
1144
|
+
if (aliases.some((alias) => alias === undefined))
|
|
1145
|
+
return false;
|
|
1146
|
+
if (new Set(aliases.map((alias) => alias.toLowerCase())).size !== aliases.length)
|
|
1147
|
+
return false;
|
|
1148
|
+
const mine = canonicalAlias(a, read);
|
|
1149
|
+
return found.some((o) => o.filters.some((f) => f.scope === read.scope && f.qualifier === mine && targets.includes(o.target) &&
|
|
1150
|
+
(!o.target.discriminator || hasDiscriminator(a, read, o.target))));
|
|
1151
|
+
}
|
|
1152
|
+
/** The plain alias of a read written `table alias` or `table AS alias`, at its `SELECT`'s own level; else undefined. */
|
|
1153
|
+
function canonicalAlias(a, read) {
|
|
1154
|
+
const { tokens } = a;
|
|
1155
|
+
const open = scopeOpen(read.scope);
|
|
1156
|
+
const depth = open < 0 ? 0 : tokens[open].depth + 1;
|
|
1157
|
+
if (tokens[read.at].depth !== depth || sourceEnd(a, read.at) !== read.at)
|
|
1158
|
+
return undefined;
|
|
1159
|
+
const at = aliasAt(a, read.at);
|
|
1160
|
+
if (at === undefined || tokens[at].quoted === true || tokens[at + 1]?.text === "(")
|
|
1161
|
+
return undefined;
|
|
1162
|
+
return tokens[at].name;
|
|
1163
|
+
}
|
|
1164
|
+
function readFiltered(a, found, targets, read, table) {
|
|
1165
|
+
for (const o of found) {
|
|
1166
|
+
for (const f of o.filters) {
|
|
1167
|
+
if (f.scope !== read.scope || !filtersRead(a, f, o.target, read, table))
|
|
1168
|
+
continue;
|
|
1169
|
+
if (o.target.discriminator && !hasDiscriminator(a, read, o.target))
|
|
1170
|
+
continue;
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return targets.some((target) => lowerEachLetter(target.table) === table && !target.discriminator && filteredFromOutside(a, found, read, target, 0));
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Whether a predicate that filters `read`'s `SELECT` through `filter` filters `read` itself.
|
|
1178
|
+
* Unqualified, it does: the read's table has the column, so the name resolves to it or the SQL is
|
|
1179
|
+
* ambiguous. On the read's own column, its qualifier must name that read ({@link readNames});
|
|
1180
|
+
* on another table's column (a root filtered through a column
|
|
1181
|
+
* that carries its IDs), a read of that table. A qualifier that names no read the check sees,
|
|
1182
|
+
* such as a table function's alias, counts for nothing. Through `col IN (…)`, which read of
|
|
1183
|
+
* another table `col` filters is left to the join.
|
|
1184
|
+
*/
|
|
1185
|
+
function filtersRead(a, filter, target, read, table) {
|
|
1186
|
+
const q = filter.qualifier;
|
|
1187
|
+
if (q === undefined)
|
|
1188
|
+
return filter.on === undefined || seenByOn(a.tokens, filter.on, read.at);
|
|
1189
|
+
const owner = lowerEachLetter(target.table);
|
|
1190
|
+
if (owner === table)
|
|
1191
|
+
return read.names.has(q);
|
|
1192
|
+
return !filter.ownSelect || readsOf(a, owner).some((r) => r.names.has(q));
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Whether `read`, inside a derived table or CTE, is filtered from outside on `target`'s column:
|
|
1196
|
+
* its `SELECT` is the source's whole body, the body passes the column through unrenamed, and
|
|
1197
|
+
* every reference to the source is filtered on that column in its own `SELECT`, directly or,
|
|
1198
|
+
* through a source around it, the same way. A set operation in the body, a column list on the
|
|
1199
|
+
* source, a recursive CTE or a CTE nothing reads fails closed.
|
|
1200
|
+
*/
|
|
1201
|
+
function filteredFromOutside(a, found, read, target, depth) {
|
|
1202
|
+
// Each source's answer is worked out once per call: re-exploring it once per path is exponential
|
|
1203
|
+
// in a chain of sources that each read the one before several times.
|
|
1204
|
+
let memo = outsideMemo.get(found);
|
|
1205
|
+
if (memo === undefined)
|
|
1206
|
+
outsideMemo.set(found, (memo = new Map()));
|
|
1207
|
+
const key = `${read.scope}|${depth}|${[...read.names].join(" ")}|${target.table}|${target.column}|${target.placeholder}`;
|
|
1208
|
+
const known = memo.get(key);
|
|
1209
|
+
if (known !== undefined)
|
|
1210
|
+
return known;
|
|
1211
|
+
memo.set(key, false); // a cycle fails closed
|
|
1212
|
+
const open = scopeOpen(read.scope);
|
|
1213
|
+
let filtered = false;
|
|
1214
|
+
if (depth <= 8 && open >= 0 && !a.scopes.branched.has(open)) {
|
|
1215
|
+
const refs = sourceRefs(a, open);
|
|
1216
|
+
filtered = refs !== undefined && refs.length > 0 && passesThrough(a, open, read.names, target.column) &&
|
|
1217
|
+
refs.every((ref) => found.some((o) => o.target === target &&
|
|
1218
|
+
o.filters.some((f) => f.ownSelect && f.scope === ref.scope && (f.qualifier === undefined ? f.on === undefined || seenByOn(a.tokens, f.on, ref.at) : ref.names.has(f.qualifier)))) ||
|
|
1219
|
+
filteredFromOutside(a, found, ref, target, depth + 1));
|
|
1220
|
+
}
|
|
1221
|
+
memo.set(key, filtered);
|
|
1222
|
+
return filtered;
|
|
1223
|
+
}
|
|
1224
|
+
const outsideMemo = new WeakMap();
|
|
1225
|
+
const sourceRefsMemo = new WeakMap();
|
|
1226
|
+
/**
|
|
1227
|
+
* The references to the row source whose query opens at `open`, or undefined when it isn't a source
|
|
1228
|
+
* this check follows: a column list on the source or on a reference to it renames its output.
|
|
1229
|
+
*/
|
|
1230
|
+
function sourceRefs(a, open) {
|
|
1231
|
+
let memo = sourceRefsMemo.get(a);
|
|
1232
|
+
if (memo === undefined)
|
|
1233
|
+
sourceRefsMemo.set(a, (memo = new Map()));
|
|
1234
|
+
if (!memo.has(open))
|
|
1235
|
+
memo.set(open, readSourceRefs(a, open));
|
|
1236
|
+
return memo.get(open);
|
|
1237
|
+
}
|
|
1238
|
+
function readSourceRefs(a, open) {
|
|
1239
|
+
const { tokens, scopes } = a;
|
|
1240
|
+
const close = scopes.close.get(open);
|
|
1241
|
+
if (close === undefined || subqueryUse(tokens, open) !== "table")
|
|
1242
|
+
return undefined;
|
|
1243
|
+
// A derived table only in a source position: a subquery after `ON` or in an expression is no row source.
|
|
1244
|
+
if (!opensAfterAs(tokens, open) && !inSourcePosition(tokens, open))
|
|
1245
|
+
return undefined;
|
|
1246
|
+
if (opensAfterAs(tokens, open)) {
|
|
1247
|
+
const cte = cteAt(tokens, open);
|
|
1248
|
+
if (cte === undefined)
|
|
1249
|
+
return undefined; // a column list renames the output, or it's no CTE this check reads
|
|
1250
|
+
const refs = [];
|
|
1251
|
+
for (let j = 0; j < tokens.length; j++) {
|
|
1252
|
+
if (j === cte.at || tableNameOf(tokens[j]) !== cte.name || tokens[j + 1]?.text === "." || isOrderOrGroupBy(tokens, j))
|
|
1253
|
+
continue;
|
|
1254
|
+
if (j > open && j < close)
|
|
1255
|
+
return undefined; // recursive
|
|
1256
|
+
if (tokens[j + 1]?.text === "(" || hasColumnList(a, sourceEnd(a, j)))
|
|
1257
|
+
return undefined;
|
|
1258
|
+
refs.push({ at: j, scope: scopes.of[j], names: readNames(a, j) });
|
|
1259
|
+
}
|
|
1260
|
+
return refs;
|
|
1261
|
+
}
|
|
1262
|
+
if (hasColumnList(a, close))
|
|
1263
|
+
return undefined;
|
|
1264
|
+
const alias = aliasAt(a, close);
|
|
1265
|
+
const hidden = hiddenByGroupAlias(a, alias ?? close);
|
|
1266
|
+
return [{ at: open, scope: scopes.of[open], names: new Set(alias === undefined || hidden ? [] : [tokens[alias].name]) }];
|
|
1267
|
+
}
|
|
1268
|
+
/** Clauses that compute over a source's rows, or pick some of them, before an outside filter runs. */
|
|
1269
|
+
const COMPUTES_OVER_ROWS = new Set(["group", "having", "window", "qualify", "limit", "offset", "fetch", "top", "over"]);
|
|
1270
|
+
/**
|
|
1271
|
+
* Whether the body of the query at `open` passes `column` of the read named `names` through to
|
|
1272
|
+
* its output unrenamed, and computes nothing across rows: every select-list item is a plain column
|
|
1273
|
+
* (`col` or `q.col`, optionally aliased), `q.*` or `*`; exactly one yields that name, and it is the
|
|
1274
|
+
* column itself, the read's `q.*`, or `*` over a single source; and the body has no `GROUP BY`,
|
|
1275
|
+
* `HAVING`, window, `QUALIFY`, `LIMIT`, `OFFSET`, `FETCH` or `TOP`, which would run over every tenant's rows
|
|
1276
|
+
* before the outside filter. An item with a string or comment in it, `DISTINCT ON` or a nested
|
|
1277
|
+
* `WITH` fails closed. The column and the name it yields are compared as {@link namesColumn} reads
|
|
1278
|
+
* them, and an item that yields the name in another case fails closed too.
|
|
1279
|
+
*/
|
|
1280
|
+
function passesThrough(a, open, names, column) {
|
|
1281
|
+
const { tokens, scopes, view } = a;
|
|
1282
|
+
const reading = view.reading.profile.id;
|
|
1283
|
+
const scope = scopeKey(open, 0);
|
|
1284
|
+
const depth = tokens[open].depth + 1;
|
|
1285
|
+
const level = (i) => scopes.of[i] === scope && tokens[i].depth === depth;
|
|
1286
|
+
const select = open + 1;
|
|
1287
|
+
if (tokens[select]?.kw !== "select")
|
|
1288
|
+
return false;
|
|
1289
|
+
let from = select + 1;
|
|
1290
|
+
while (from < tokens.length && !(level(from) && tokens[from].kw === "from"))
|
|
1291
|
+
from++;
|
|
1292
|
+
if (from >= tokens.length)
|
|
1293
|
+
return false;
|
|
1294
|
+
let first = select + 1;
|
|
1295
|
+
if (tokens[first]?.kw === "all" || tokens[first]?.kw === "distinct")
|
|
1296
|
+
first++;
|
|
1297
|
+
if (tokens[first]?.kw === "on")
|
|
1298
|
+
return false;
|
|
1299
|
+
const end = scopes.close.get(open) ?? tokens.length;
|
|
1300
|
+
for (let i = select; i < end; i++)
|
|
1301
|
+
if (scopes.of[i] === scope && COMPUTES_OVER_ROWS.has(tokens[i].kw))
|
|
1302
|
+
return false;
|
|
1303
|
+
const items = [[]];
|
|
1304
|
+
for (let i = first; i < from; i++) {
|
|
1305
|
+
if (level(i) && tokens[i].text === ",")
|
|
1306
|
+
items.push([]);
|
|
1307
|
+
else
|
|
1308
|
+
items[items.length - 1].push(i);
|
|
1309
|
+
}
|
|
1310
|
+
let yields = 0;
|
|
1311
|
+
for (const item of items) {
|
|
1312
|
+
if (item.length === 0)
|
|
1313
|
+
return false;
|
|
1314
|
+
// Text the view blanked (a string, a comment) could be part of the item, so don't guess.
|
|
1315
|
+
const last = tokens[item[item.length - 1]];
|
|
1316
|
+
for (let p = tokens[item[0]].offset; p < last.offset + last.text.length; p++) {
|
|
1317
|
+
if (view.text[p] !== view.source[p] && view.text[p] !== QUOTE_MARK)
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
const ts = item.map((i) => tokens[i]);
|
|
1321
|
+
const n = ts.length;
|
|
1322
|
+
if (n === 1 && ts[0].text === "*") {
|
|
1323
|
+
if (!singleSource(a, open, scope, depth))
|
|
1324
|
+
return false;
|
|
1325
|
+
yields++;
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
if (n === 3 && ts[1].text === "." && ts[2].text === "*") {
|
|
1329
|
+
if (!names.has(ts[0].name))
|
|
1330
|
+
return false;
|
|
1331
|
+
yields++;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
let alias;
|
|
1335
|
+
let expr = ts;
|
|
1336
|
+
if (n >= 3 && ts[n - 2].kw === "as" && isWord(ts[n - 1])) {
|
|
1337
|
+
alias = ts[n - 1];
|
|
1338
|
+
expr = ts.slice(0, n - 2);
|
|
1339
|
+
}
|
|
1340
|
+
else if (n >= 2 && isWord(ts[n - 1]) && (isWord(ts[n - 2]) || ts[n - 2].text === ")" || /^\d/.test(ts[n - 2].text))) {
|
|
1341
|
+
alias = ts[n - 1];
|
|
1342
|
+
expr = ts.slice(0, n - 1);
|
|
1343
|
+
}
|
|
1344
|
+
const isColumn = expr.length === 1 ? isWord(expr[0]) : expr.length === 3 && isWord(expr[0]) && expr[1].text === "." && isWord(expr[2]);
|
|
1345
|
+
if (!isColumn)
|
|
1346
|
+
return false; // an expression, a call or an aggregate
|
|
1347
|
+
const name = expr[expr.length - 1];
|
|
1348
|
+
// An item that yields the name in any case is a candidate; it must name the column the way the engine reads it.
|
|
1349
|
+
if ((alias ?? name).lower !== column.toLowerCase())
|
|
1350
|
+
continue;
|
|
1351
|
+
if (!namesColumn(alias ?? name, column, reading) || !namesColumn(name, column, reading))
|
|
1352
|
+
return false;
|
|
1353
|
+
if (expr.length === 3 && !names.has(expr[0].name))
|
|
1354
|
+
return false; // another expression named like the column
|
|
1355
|
+
yields++;
|
|
1356
|
+
}
|
|
1357
|
+
return yields === 1;
|
|
1358
|
+
}
|
|
1359
|
+
/** Whether the `FROM` of the query at `open` has one source: no join and no comma before its next clause. */
|
|
1360
|
+
function singleSource(a, open, scope, depth) {
|
|
1361
|
+
const { tokens, scopes } = a;
|
|
1362
|
+
const end = scopes.close.get(open) ?? tokens.length;
|
|
1363
|
+
let i = open + 1;
|
|
1364
|
+
while (i < end && !(scopes.of[i] === scope && tokens[i].depth === depth && tokens[i].kw === "from"))
|
|
1365
|
+
i++;
|
|
1366
|
+
for (i++; i < end; i++) {
|
|
1367
|
+
const t = tokens[i];
|
|
1368
|
+
if (scopes.of[i] !== scope || t.depth !== depth)
|
|
1369
|
+
continue;
|
|
1370
|
+
if (t.text === "," || JOIN_WORDS.has(t.kw) || (t.text === "(" && !opensQuery(tokens, i)))
|
|
1371
|
+
return false;
|
|
1372
|
+
// Only a clause no engine takes as an alias ends the FROM: past `lock` or `qualify`, which
|
|
1373
|
+
// may be the first source's alias, a second source can still follow.
|
|
1374
|
+
if (ENDS_A_SOURCE.has(t.kw) && !JOIN_WORDS.has(t.kw))
|
|
1375
|
+
return true;
|
|
1376
|
+
}
|
|
1377
|
+
return true;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Whether `read`'s `SELECT` ANDs in `col = '<value>'` for `target.discriminator`: its column, as
|
|
1381
|
+
* {@link namesColumn} compares it (unqualified, or qualified with the read's table or alias), `=`, and a plain `'…'` literal,
|
|
1382
|
+
* read from the statement at the offsets where the view blanked it, whose value is one of the
|
|
1383
|
+
* discriminator's values and which nothing but whitespace follows up to the next token (no
|
|
1384
|
+
* `''`-escape, no adjacent literal, no comment).
|
|
810
1385
|
*/
|
|
811
|
-
function hasDiscriminator(
|
|
1386
|
+
function hasDiscriminator(a, read, target) {
|
|
812
1387
|
const { column, values } = target.discriminator;
|
|
1388
|
+
const { tokens, view } = a;
|
|
813
1389
|
const source = view.source;
|
|
814
|
-
for (let i =
|
|
1390
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
815
1391
|
const t = tokens[i];
|
|
816
1392
|
const eq = tokens[i + 1];
|
|
817
|
-
if (!isIdentifier(t) || t
|
|
1393
|
+
if (a.scopes.of[i] !== read.scope || !isIdentifier(t) || !namesColumn(t, column, view.reading.profile.id) || eq?.text !== "=")
|
|
818
1394
|
continue;
|
|
819
|
-
const qualifier = tokens
|
|
820
|
-
if (qualifier !== undefined &&
|
|
1395
|
+
const qualifier = qualifierOf(tokens, i);
|
|
1396
|
+
if (qualifier !== undefined && !read.names.has(qualifier))
|
|
821
1397
|
continue;
|
|
822
1398
|
let open = eq.offset + 1;
|
|
823
1399
|
while (/\s/.test(source[open] ?? ""))
|
|
@@ -834,38 +1410,15 @@ function hasDiscriminator(tokens, from, to, view, target) {
|
|
|
834
1410
|
const next = tokens[i + 2]?.offset ?? source.length;
|
|
835
1411
|
if (view.text.slice(open, close + 1).trim() !== "" || source.slice(close + 1, next).trim() !== "")
|
|
836
1412
|
continue;
|
|
837
|
-
|
|
1413
|
+
const filters = filteredScopes(a, qualifiedStart(tokens, i), i + 1, qualifier, i);
|
|
1414
|
+
if (filters.some((f) => f.ownSelect && f.scope === read.scope && (qualifier !== undefined || f.on === undefined || seenByOn(tokens, f.on, read.at))))
|
|
838
1415
|
return true;
|
|
839
1416
|
}
|
|
840
1417
|
return false;
|
|
841
1418
|
}
|
|
842
|
-
/**
|
|
843
|
-
* Token ranges `[from, to)` of the statement's query blocks: the whole statement, and
|
|
844
|
-
* each branch of every set operation at any depth (bounded by the parentheses around it).
|
|
845
|
-
*/
|
|
846
|
-
function queryBlocks(tokens) {
|
|
847
|
-
const blocks = [[0, tokens.length]];
|
|
848
|
-
const splits = new Map(); // enclosing "(" (or -1) → set-operator positions
|
|
849
|
-
tokens.forEach((t, i) => {
|
|
850
|
-
if (!SET_OPERATORS.has(t.kw))
|
|
851
|
-
return;
|
|
852
|
-
const open = enclosing(tokens, i, "(");
|
|
853
|
-
splits.set(open, [...(splits.get(open) ?? []), i]);
|
|
854
|
-
});
|
|
855
|
-
for (const [open, positions] of splits) {
|
|
856
|
-
const end = open < 0 ? tokens.length : enclosing(tokens, open + 1, ")");
|
|
857
|
-
let from = open + 1;
|
|
858
|
-
for (const at of positions) {
|
|
859
|
-
blocks.push([from, at]);
|
|
860
|
-
from = at + 1;
|
|
861
|
-
}
|
|
862
|
-
blocks.push([from, end < 0 ? tokens.length : end]);
|
|
863
|
-
}
|
|
864
|
-
return blocks;
|
|
865
|
-
}
|
|
866
1419
|
/** An identifier token: a word that isn't one of the predicate forms' keywords. */
|
|
867
1420
|
function isIdentifier(token) {
|
|
868
|
-
return /^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw);
|
|
1421
|
+
return token.quoted === true || (/^[A-Za-z_\u0080-\uffff]/.test(token.text) && !PREDICATE_WORDS.has(token.kw));
|
|
869
1422
|
}
|
|
870
1423
|
/**
|
|
871
1424
|
* A clause keyword used as a function name (`LEFT(status, 1)`, `RIGHT(…)`) is an identifier.
|
|
@@ -882,37 +1435,28 @@ function qualifiedStart(tokens, end) {
|
|
|
882
1435
|
return start;
|
|
883
1436
|
}
|
|
884
1437
|
/**
|
|
885
|
-
* The
|
|
886
|
-
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a
|
|
887
|
-
*
|
|
888
|
-
*
|
|
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.
|
|
1438
|
+
* The span `[start, end]` and column index of a tenant predicate around the placeholder at `k`:
|
|
1439
|
+
* `col = P`, `P = col`, `col IN (P)` or `col = ANY (P)`, where `col` (the last part of a possibly
|
|
1440
|
+
* qualified name) is `column`, as {@link namesColumn} compares them under `reading`. Which read
|
|
1441
|
+
* the column belongs to is its qualifier's to say.
|
|
892
1442
|
*/
|
|
893
|
-
function
|
|
1443
|
+
function predicateAt(tokens, k, column, reading) {
|
|
894
1444
|
const at = (i) => tokens[i];
|
|
895
|
-
const column = target.column.toLowerCase();
|
|
896
1445
|
const matches = (i) => {
|
|
897
1446
|
const t = at(i);
|
|
898
|
-
|
|
899
|
-
return false;
|
|
900
|
-
if (exactQuoted && t.kw === "" && t.text !== target.column)
|
|
901
|
-
return false;
|
|
902
|
-
const qualifier = at(i - 1)?.text === "." ? at(i - 2) : undefined;
|
|
903
|
-
return qualifier === undefined || !namesOtherTable(tokens, qualifier.lower, target.table.toLowerCase());
|
|
1447
|
+
return t !== undefined && isIdentifier(t) && namesColumn(t, column, reading);
|
|
904
1448
|
};
|
|
905
1449
|
// col = P
|
|
906
1450
|
if (at(k - 1)?.text === "=" && matches(k - 2))
|
|
907
|
-
return
|
|
1451
|
+
return { start: qualifiedStart(tokens, k - 2), end: k, column: k - 2 };
|
|
908
1452
|
const closed = at(k + 1)?.text === ")";
|
|
909
1453
|
// col IN ( P )
|
|
910
1454
|
if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "in" && matches(k - 3)) {
|
|
911
|
-
return
|
|
1455
|
+
return { start: qualifiedStart(tokens, k - 3), end: k + 1, column: k - 3 };
|
|
912
1456
|
}
|
|
913
1457
|
// col = ANY ( P )
|
|
914
1458
|
if (closed && at(k - 1)?.text === "(" && at(k - 2)?.kw === "any" && at(k - 3)?.text === "=" && matches(k - 4)) {
|
|
915
|
-
return
|
|
1459
|
+
return { start: qualifiedStart(tokens, k - 4), end: k + 1, column: k - 4 };
|
|
916
1460
|
}
|
|
917
1461
|
// P = col
|
|
918
1462
|
if (at(k + 1)?.text === "=") {
|
|
@@ -920,32 +1464,50 @@ function predicateSpan(tokens, k, target, exactQuoted) {
|
|
|
920
1464
|
while (at(end + 1)?.text === "." && at(end + 2) !== undefined && isIdentifier(at(end + 2)))
|
|
921
1465
|
end += 2;
|
|
922
1466
|
if (matches(end))
|
|
923
|
-
return
|
|
1467
|
+
return { start: k, end, column: end };
|
|
924
1468
|
}
|
|
925
1469
|
return undefined;
|
|
926
1470
|
}
|
|
927
1471
|
/**
|
|
928
|
-
*
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
1472
|
+
* The name every engine that shares the `reading` reads for the written name `t`, to compare with a
|
|
1473
|
+
* column's spelling ({@link namesColumn}) or with another written name ({@link sameName}); undefined
|
|
1474
|
+
* when those engines read it apart, so it names no column. A name read as another column may be
|
|
1475
|
+
* another source's, so it ties nothing to the policy's.
|
|
1476
|
+
* - Read the Postgres way (`postgres`, `cockroachdb`): a quoted name exactly; an unquoted one with
|
|
1477
|
+
* its ASCII letters lower-cased, as Postgres folds it. CockroachDB lower-cases every letter, so an
|
|
1478
|
+
* unquoted name with an upper-case non-ASCII letter (`Égency_id`) is read apart.
|
|
1479
|
+
* - Read the SQL Server way, as written: the database's collation may compare names
|
|
1480
|
+
* case-sensitively, and the check can't see it, so `agencyid` is another column than `agencyId`.
|
|
1481
|
+
* - Read the MySQL way (MySQL and MariaDB) and otherwise (SQLite, the generic reading), with only
|
|
1482
|
+
* ASCII letters lower-cased, quoted or not: SQLite folds only those, and MariaDB keeps apart
|
|
1483
|
+
* letters Unicode lower-casing merges (U+212A KELVIN SIGN and `k`).
|
|
1484
|
+
* A custom id or none is read every way, so a name has to match under each.
|
|
932
1485
|
*/
|
|
933
|
-
function
|
|
934
|
-
if (name
|
|
935
|
-
return
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
return
|
|
1486
|
+
function engineName(t, reading) {
|
|
1487
|
+
if (t.name.startsWith(READ_APART))
|
|
1488
|
+
return undefined;
|
|
1489
|
+
return reading === "sqlserver" || reading === "postgres" ? t.name : asciiLower(t.name);
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* The start of the name a token gets when the engines that share its reading read it apart (an
|
|
1493
|
+
* unquoted name with an upper-case non-ASCII letter, read the Postgres way), followed by its
|
|
1494
|
+
* offset: no other name equals it, so an alias, a qualifier or a column spelled that way names
|
|
1495
|
+
* nothing ({@link engineName}).
|
|
1496
|
+
*/
|
|
1497
|
+
const READ_APART = "\u0000";
|
|
1498
|
+
/** Whether the written column `t` is the policy's `column`, whose spelling the policy writes as the schema does ({@link engineName}). */
|
|
1499
|
+
function namesColumn(t, column, reading) {
|
|
1500
|
+
const name = engineName(t, reading);
|
|
1501
|
+
return name !== undefined && name === (reading === "postgres" || reading === "sqlserver" ? column : asciiLower(column));
|
|
1502
|
+
}
|
|
1503
|
+
/** Whether the written names `t` and `u` are the same name for every engine that shares the `reading` ({@link engineName}). */
|
|
1504
|
+
function sameName(t, u, reading) {
|
|
1505
|
+
const name = engineName(t, reading);
|
|
1506
|
+
return name !== undefined && name === engineName(u, reading);
|
|
1507
|
+
}
|
|
1508
|
+
/** `name` with its ASCII letters lower-cased, as SQLite folds a name. */
|
|
1509
|
+
function asciiLower(name) {
|
|
1510
|
+
return name.replace(/[A-Z]+/g, (s) => s.toLowerCase());
|
|
949
1511
|
}
|
|
950
1512
|
/**
|
|
951
1513
|
* Whether the subquery opened by the `(` at `open` is a row source (a derived table in
|
|
@@ -972,6 +1534,9 @@ function subqueryUse(tokens, open) {
|
|
|
972
1534
|
return tokens[open - 2] !== undefined && isIdentifier(tokens[open - 2]) ? "in" : "other";
|
|
973
1535
|
if (opener.kw === "as" || opener.kw === "lateral")
|
|
974
1536
|
return "table";
|
|
1537
|
+
// A CTE body after `AS MATERIALIZED (` or `AS NOT MATERIALIZED (`.
|
|
1538
|
+
if (opener.kw === "materialized" && (tokens[open - 2]?.kw === "as" || (tokens[open - 2]?.kw === "not" && tokens[open - 3]?.kw === "as")))
|
|
1539
|
+
return "table";
|
|
975
1540
|
const depth = tokens[open].depth;
|
|
976
1541
|
for (let i = open - 1; i >= 0; i--) {
|
|
977
1542
|
const t = tokens[i];
|
|
@@ -1029,29 +1594,94 @@ function isOuterJoinOn(tokens, on) {
|
|
|
1029
1594
|
}
|
|
1030
1595
|
return false;
|
|
1031
1596
|
}
|
|
1597
|
+
/** Join words that may take an `ON`: MySQL's `STRAIGHT_JOIN` may, so it counts as one. */
|
|
1598
|
+
const TAKES_ON = new Set(["join", "straight_join"]);
|
|
1032
1599
|
/**
|
|
1033
|
-
* Whether the
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
* conjunct; not inside `EXISTS (…)`, a scalar subquery, or a comparison. A level opened by a function call or a comparison doesn't count.
|
|
1600
|
+
* Whether the read at `at` is among the operands of the join the `ON` at `on` closes: the only reads
|
|
1601
|
+
* an unqualified column there can name. They run from the join's left operand up to the `ON`. Going
|
|
1602
|
+
* left from the join's own `JOIN`, the left operand stops at `FROM`, at the `(` around the join, at
|
|
1603
|
+
* a `FROM`-list comma (which binds looser than a join, though not on SQLite, where this only
|
|
1604
|
+
* narrows the check), or at a `JOIN` whose `ON` or `USING` hasn't come yet (`a LEFT JOIN b JOIN c
|
|
1605
|
+
* ON … ON …`). `CROSS JOIN`, `NATURAL JOIN` and `APPLY` take no `ON`, so the operand runs past them.
|
|
1040
1606
|
*/
|
|
1041
|
-
function
|
|
1042
|
-
|
|
1607
|
+
function seenByOn(tokens, on, at) {
|
|
1608
|
+
if (at >= on)
|
|
1609
|
+
return false;
|
|
1610
|
+
const depth = tokens[on].depth;
|
|
1611
|
+
let join = -1;
|
|
1612
|
+
for (let i = on - 1; i >= 0 && join < 0; i--) {
|
|
1613
|
+
const t = tokens[i];
|
|
1614
|
+
if (t.depth < depth)
|
|
1615
|
+
return false;
|
|
1616
|
+
if (t.depth > depth)
|
|
1617
|
+
continue;
|
|
1618
|
+
if (t.kw === "from" || t.text === ",")
|
|
1619
|
+
return false;
|
|
1620
|
+
if (TAKES_ON.has(t.kw))
|
|
1621
|
+
join = i;
|
|
1622
|
+
}
|
|
1623
|
+
if (join < 0)
|
|
1624
|
+
return false;
|
|
1625
|
+
let pending = 0; // ON or USING seen going left, each closing a JOIN further left
|
|
1626
|
+
for (let i = join - 1; i >= 0; i--) {
|
|
1627
|
+
const t = tokens[i];
|
|
1628
|
+
if (t.depth < depth)
|
|
1629
|
+
return at > i;
|
|
1630
|
+
if (t.depth > depth)
|
|
1631
|
+
continue;
|
|
1632
|
+
if (t.kw === "from" || t.text === ",")
|
|
1633
|
+
return at > i;
|
|
1634
|
+
if (t.kw === "on" || t.kw === "using")
|
|
1635
|
+
pending++;
|
|
1636
|
+
else if (TAKES_ON.has(t.kw) && !takesNoOn(tokens, i)) {
|
|
1637
|
+
if (pending === 0)
|
|
1638
|
+
return at > i;
|
|
1639
|
+
pending--;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
return true;
|
|
1643
|
+
}
|
|
1644
|
+
/** Whether the `JOIN` at `join` is a `CROSS JOIN` or a `NATURAL … JOIN`, which take no `ON`. */
|
|
1645
|
+
function takesNoOn(tokens, join) {
|
|
1646
|
+
const depth = tokens[join].depth;
|
|
1647
|
+
for (let k = join - 1; k >= 0; k--) {
|
|
1648
|
+
const w = tokens[k];
|
|
1649
|
+
if (w.depth !== depth || BEFORE_A_JOIN.has(w.kw) || w.text === ",")
|
|
1650
|
+
return false;
|
|
1651
|
+
if ((w.kw === "cross" || w.kw === "natural") && tokens[k - 1]?.text !== "." && tokens[k + 1]?.text !== ".")
|
|
1652
|
+
return true;
|
|
1653
|
+
}
|
|
1654
|
+
return false;
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* The `SELECT`s whose reads the term `tokens[start..end]` filters (#399), each with the qualifier
|
|
1658
|
+
* that ties it to a read there. The term must be ANDed into a `WHERE`, `ON` (of an inner join) or
|
|
1659
|
+
* `HAVING` clause: at its own level and at every enclosing parenthesized level up to that clause,
|
|
1660
|
+
* it sits between `AND`s (or the clause's start and end), with no `OR`, `XOR` (or MySQL's `||`)
|
|
1661
|
+
* at that level and no `NOT` in front of it. It then filters that clause's `SELECT`, through
|
|
1662
|
+
* `qualifier`, its column's. When that `SELECT` is the whole right side of `col IN (…)` and the
|
|
1663
|
+
* `col IN (…)` is itself such a conjunct, it filters the query around it too, through `col`'s
|
|
1664
|
+
* qualifier. A level opened by a function call or a comparison doesn't count.
|
|
1665
|
+
*/
|
|
1666
|
+
function filteredScopes(a, start, end, qualifier, termColumn) {
|
|
1667
|
+
const { tokens, scopes } = a;
|
|
1668
|
+
const disjunction = (t) => t.kw === "or" || t.kw === "xor" || (t.text === "||" && a.view.reading.pipesAreOr);
|
|
1043
1669
|
const endsClause = (i) => CLAUSE_END.has(tokens[i].kw) && !isFunctionCall(tokens, i);
|
|
1670
|
+
const filters = [];
|
|
1044
1671
|
let lo = start;
|
|
1045
1672
|
let hi = end;
|
|
1046
1673
|
let depth = tokens[start].depth;
|
|
1674
|
+
let q = qualifier;
|
|
1675
|
+
let ownSelect = true;
|
|
1676
|
+
let column = termColumn;
|
|
1047
1677
|
for (;;) {
|
|
1048
1678
|
// What is directly around the term, at its level.
|
|
1049
1679
|
const before = tokens[lo - 1];
|
|
1050
1680
|
const after = tokens[hi + 1];
|
|
1051
|
-
const leftOk = before !== undefined && (
|
|
1681
|
+
const leftOk = before !== undefined && (conjunctAnd(tokens, lo - 1) || before.text === "(" || FILTER_CLAUSE.has(before.kw));
|
|
1052
1682
|
const rightOk = after === undefined || after.kw === "and" || after.text === ")" || after.text === ";" || endsClause(hi + 1);
|
|
1053
1683
|
if (!leftOk || !rightOk)
|
|
1054
|
-
return
|
|
1684
|
+
return filters;
|
|
1055
1685
|
// Scan the rest of this level on both sides for a disjunction, a NOT, or the clause keyword.
|
|
1056
1686
|
let clause = -1;
|
|
1057
1687
|
let open = -1;
|
|
@@ -1064,13 +1694,13 @@ function isFilterConjunct(tokens, start, end, reading) {
|
|
|
1064
1694
|
if (t.depth > depth)
|
|
1065
1695
|
continue;
|
|
1066
1696
|
if (disjunction(t) || t.kw === "not")
|
|
1067
|
-
return
|
|
1697
|
+
return filters;
|
|
1068
1698
|
if (FILTER_CLAUSE.has(t.kw)) {
|
|
1069
1699
|
clause = i;
|
|
1070
1700
|
break;
|
|
1071
1701
|
}
|
|
1072
1702
|
if ((endsClause(i) || NOT_A_FILTER.has(t.kw)) && !isFunctionCall(tokens, i))
|
|
1073
|
-
return
|
|
1703
|
+
return filters;
|
|
1074
1704
|
}
|
|
1075
1705
|
let close = tokens.length;
|
|
1076
1706
|
for (let j = hi + 1; j < tokens.length; j++) {
|
|
@@ -1081,44 +1711,160 @@ function isFilterConjunct(tokens, start, end, reading) {
|
|
|
1081
1711
|
}
|
|
1082
1712
|
if (t.depth > depth)
|
|
1083
1713
|
continue;
|
|
1084
|
-
if (t.text === ";" ||
|
|
1714
|
+
if (t.text === ";" || endsFilterScan(tokens, j, a.view.reading.profile.id))
|
|
1085
1715
|
break;
|
|
1086
1716
|
if (disjunction(t))
|
|
1087
|
-
return
|
|
1717
|
+
return filters;
|
|
1088
1718
|
}
|
|
1089
1719
|
if (clause >= 0) {
|
|
1090
1720
|
if (tokens[clause].kw === "on" && isOuterJoinOn(tokens, clause))
|
|
1091
|
-
return
|
|
1092
|
-
if (
|
|
1093
|
-
return
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1721
|
+
return filters;
|
|
1722
|
+
if (tokens[clause].kw === "having" && !onGroupKey(a, clause, column))
|
|
1723
|
+
return filters;
|
|
1724
|
+
const scope = scopes.of[clause];
|
|
1725
|
+
filters.push({ scope, qualifier: q, ownSelect, ...(tokens[clause].kw === "on" ? { on: clause } : {}) });
|
|
1726
|
+
// `col IN (<this SELECT>)` limits the query around it to the rows this SELECT returns.
|
|
1727
|
+
const query = scopeOpen(scope);
|
|
1728
|
+
const queryClose = scopes.close.get(query);
|
|
1729
|
+
if (query < 0 || queryClose === undefined || scopes.branched.has(query) || subqueryUse(tokens, query) !== "in")
|
|
1730
|
+
return filters;
|
|
1731
|
+
lo = qualifiedStart(tokens, query - 2);
|
|
1732
|
+
hi = queryClose;
|
|
1733
|
+
depth = tokens[query].depth;
|
|
1734
|
+
q = qualifierOf(tokens, query - 2);
|
|
1735
|
+
column = query - 2;
|
|
1736
|
+
ownSelect = false;
|
|
1107
1737
|
continue;
|
|
1108
1738
|
}
|
|
1109
1739
|
if (open < 0 || tokens[open].text !== "(" || close >= tokens.length || tokens[close].text !== ")")
|
|
1110
|
-
return
|
|
1740
|
+
return filters;
|
|
1111
1741
|
// A parenthesized group counts only as a plain boolean conjunct: opened right after
|
|
1112
1742
|
// WHERE/ON/HAVING, AND, or another "(". Anything else (`f(`, `IN (`, `EXISTS (`,
|
|
1113
1743
|
// `NOT (`, `= (`) makes it something other than a filter conjunct.
|
|
1114
1744
|
const opener = tokens[open - 1];
|
|
1115
|
-
if (opener === undefined || !(FILTER_CLAUSE.has(opener.kw) ||
|
|
1116
|
-
return
|
|
1745
|
+
if (opener === undefined || !(FILTER_CLAUSE.has(opener.kw) || conjunctAnd(tokens, open - 1) || opener.text === "("))
|
|
1746
|
+
return filters;
|
|
1117
1747
|
lo = open;
|
|
1118
1748
|
hi = close;
|
|
1119
1749
|
depth = tokens[open].depth;
|
|
1120
1750
|
}
|
|
1121
1751
|
}
|
|
1752
|
+
/** Whether the token at `i` is an `AND` that joins two conjuncts, not the `AND` of a `BETWEEN`. */
|
|
1753
|
+
function conjunctAnd(tokens, i) {
|
|
1754
|
+
return tokens[i]?.kw === "and" && !andOfBetween(tokens, i);
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Whether the `AND` at `and` closes a `BETWEEN` (`x BETWEEN 0 AND y`), so that what follows is the
|
|
1758
|
+
* upper bound, not a conjunct: MySQL reads `0 BETWEEN 0 AND col IN (…)` with the `IN` inside the
|
|
1759
|
+
* bound. Each `BETWEEN` at the `AND`'s level, from the start of its clause or parentheses, takes the
|
|
1760
|
+
* first `AND` after it there, whatever its lower bound holds (`LEFT(…)`, a cast); an `AND` inside a
|
|
1761
|
+
* `CASE … END` belongs to the `CASE`.
|
|
1762
|
+
*/
|
|
1763
|
+
function andOfBetween(tokens, and) {
|
|
1764
|
+
const depth = tokens[and].depth;
|
|
1765
|
+
// A word beside a `.` is a name (`o.and`, `o.end`), never the keyword.
|
|
1766
|
+
const dotted = (i) => tokens[i - 1]?.text === "." || tokens[i + 1]?.text === ".";
|
|
1767
|
+
let from = and - 1;
|
|
1768
|
+
while (from >= 0) {
|
|
1769
|
+
const t = tokens[from];
|
|
1770
|
+
if (t.depth < depth || (t.depth === depth && !dotted(from) && (FILTER_CLAUSE.has(t.kw) || t.kw === "select")))
|
|
1771
|
+
break;
|
|
1772
|
+
from--;
|
|
1773
|
+
}
|
|
1774
|
+
let pending = 0;
|
|
1775
|
+
let inCase = 0;
|
|
1776
|
+
for (let i = from + 1; i < and; i++) {
|
|
1777
|
+
const t = tokens[i];
|
|
1778
|
+
if (t.depth !== depth || dotted(i))
|
|
1779
|
+
continue;
|
|
1780
|
+
if (t.kw === "case")
|
|
1781
|
+
inCase++;
|
|
1782
|
+
else if (t.kw === "end" && inCase > 0)
|
|
1783
|
+
inCase--;
|
|
1784
|
+
else if (inCase === 0 && t.kw === "between")
|
|
1785
|
+
pending++;
|
|
1786
|
+
else if (inCase === 0 && t.kw === "and" && pending > 0)
|
|
1787
|
+
pending--;
|
|
1788
|
+
}
|
|
1789
|
+
return pending > 0;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Whether the word at `j` ends the scan for an `OR` beside a predicate: a clause start that every
|
|
1793
|
+
* engine reserves (`WHERE`, `HAVING`, `GROUP BY`, `ORDER BY`, a set operation, `JOIN`), not beside a
|
|
1794
|
+
* `.`, and MySQL's `STRAIGHT_JOIN` where the SQL is read as MySQL. Other clause words (`LIMIT`, `LOCK`,
|
|
1795
|
+
* `QUALIFY`, `NATURAL`, `USING`, `WINDOW`) are names on some engine, so the scan goes on past them, and
|
|
1796
|
+
* an `OR` after one still counts as beside the predicate.
|
|
1797
|
+
*/
|
|
1798
|
+
function endsFilterScan(tokens, j, engine) {
|
|
1799
|
+
const t = tokens[j];
|
|
1800
|
+
if (tokens[j - 1]?.text === "." || tokens[j + 1]?.text === "." || isFunctionCall(tokens, j))
|
|
1801
|
+
return false;
|
|
1802
|
+
if (t.kw === "group" || t.kw === "order")
|
|
1803
|
+
return tokens[j + 1]?.kw === "by";
|
|
1804
|
+
if (t.kw === "straight_join")
|
|
1805
|
+
return engine === "mysql";
|
|
1806
|
+
return ["where", "having", "union", "intersect", "except", "join"].includes(t.kw);
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Whether a predicate on the column at `column`, ANDed into the `HAVING` at `having`, filters rows:
|
|
1810
|
+
* only when that column is a plain key of its `SELECT`'s `GROUP BY` (`agency_id`, or `o.agency_id`
|
|
1811
|
+
* written the same way, the name compared by {@link sameName}). On any other column, a group holds every row it covers, and SQLite and
|
|
1812
|
+
* MariaDB take the column's value from one of them. Read the MySQL way, an unqualified column that a
|
|
1813
|
+
* select-list item is aliased to never counts: MySQL and MariaDB resolve such a `HAVING` name to the
|
|
1814
|
+
* alias in some forms and to the `GROUP BY` column in others, so the check refuses the name either way.
|
|
1815
|
+
*/
|
|
1816
|
+
function onGroupKey(a, having, column) {
|
|
1817
|
+
const { tokens } = a;
|
|
1818
|
+
const depth = tokens[having].depth;
|
|
1819
|
+
let select = -1;
|
|
1820
|
+
let groupBy = -1;
|
|
1821
|
+
for (let i = having - 1; i >= 0 && select < 0; i--) {
|
|
1822
|
+
const t = tokens[i];
|
|
1823
|
+
if (t.depth < depth)
|
|
1824
|
+
break;
|
|
1825
|
+
if (t.depth > depth)
|
|
1826
|
+
continue;
|
|
1827
|
+
if (t.kw === "by" && tokens[i - 1]?.kw === "group" && groupBy < 0)
|
|
1828
|
+
groupBy = i;
|
|
1829
|
+
if (t.kw === "select")
|
|
1830
|
+
select = i;
|
|
1831
|
+
}
|
|
1832
|
+
if (select < 0 || groupBy < 0)
|
|
1833
|
+
return false;
|
|
1834
|
+
const name = tokens[column].lower;
|
|
1835
|
+
const qualifier = qualifierOf(tokens, column);
|
|
1836
|
+
const itemRanges = (from, to) => {
|
|
1837
|
+
const out = [];
|
|
1838
|
+
let itemStart = from;
|
|
1839
|
+
for (let i = from; i <= to; i++) {
|
|
1840
|
+
const t = tokens[i];
|
|
1841
|
+
if (i === to || (t?.depth === depth && t.text === ",")) {
|
|
1842
|
+
if (i > itemStart)
|
|
1843
|
+
out.push([itemStart, i - 1]);
|
|
1844
|
+
itemStart = i + 1;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return out;
|
|
1848
|
+
};
|
|
1849
|
+
if (a.view.reading.profile.id === "mysql" && qualifier === undefined) {
|
|
1850
|
+
let from = select + 1;
|
|
1851
|
+
while (tokens[from]?.kw === "distinct" || tokens[from]?.kw === "all")
|
|
1852
|
+
from++;
|
|
1853
|
+
let fromKw = from;
|
|
1854
|
+
while (fromKw < having && !(tokens[fromKw].depth === depth && tokens[fromKw].kw === "from"))
|
|
1855
|
+
fromKw++;
|
|
1856
|
+
const aliased = itemRanges(from, fromKw).some(([s, e]) => e > s && tokens[e - 1]?.text !== "." && isIdentifier(tokens[e]) && tokens[e].lower === name);
|
|
1857
|
+
if (aliased)
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1860
|
+
return itemRanges(groupBy + 1, having).some(([s, e]) => {
|
|
1861
|
+
const last = tokens[e];
|
|
1862
|
+
if (!isIdentifier(last) || !sameName(last, tokens[column], a.view.reading.profile.id))
|
|
1863
|
+
return false;
|
|
1864
|
+
const plain = e === s || (e === s + 2 && tokens[s + 1]?.text === ".");
|
|
1865
|
+
return plain && qualifierOf(tokens, e) === qualifier;
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1122
1868
|
/** Index of the `(` or `)` that encloses the token at `i`, or -1 at the top level. */
|
|
1123
1869
|
function enclosing(tokens, i, paren) {
|
|
1124
1870
|
const depth = tokens[i].depth;
|