@mason-data/cli 0.6.2 → 0.8.0
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 +147 -69
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog.js +258 -14
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/server.js +1290 -223
- package/dist/src/api/postgres/translate.js +81 -9
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +55 -8
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +5 -3
- package/dist/src/cli/commands.js +843 -272
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/demo.js +158 -0
- package/dist/src/cli/dev.js +458 -125
- package/dist/src/cli/doctor.js +25 -12
- package/dist/src/cli/guides.js +103 -31
- package/dist/src/cli/init.js +159 -58
- package/dist/src/cli/machine.js +18 -12
- package/dist/src/cli/main.js +5 -5
- package/dist/src/cli/mcp.js +735 -0
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/pg-tls.js +13 -3
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +13 -12
- package/dist/src/cli/preflight.js +24 -14
- package/dist/src/cli/project.js +48 -7
- package/dist/src/cli/readiness.js +38 -4
- package/dist/src/cli/registry.js +57 -35
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +584 -0
- package/dist/src/cli/run-server.js +498 -133
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +10 -8
- package/dist/src/cli/serve.js +147 -40
- package/dist/src/cli/share-run.js +286 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +168 -0
- package/dist/src/cli/sources.js +290 -39
- package/dist/src/cli/supervise.js +12 -1
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui.js +9 -6
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user.js +496 -40
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +240 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/events.js +134 -0
- package/dist/src/duck/executor-worker.js +25 -11
- package/dist/src/duck/executor.js +107 -53
- package/dist/src/duck/explain.js +158 -29
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +73 -19
- package/dist/src/duck/gaps.js +25 -11
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/history.js +123 -0
- package/dist/src/duck/jsonb-order.js +175 -0
- package/dist/src/duck/jsonb.js +10 -8
- package/dist/src/duck/kept-route.js +82 -2
- package/dist/src/duck/kept.js +211 -44
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +120 -30
- package/dist/src/duck/publication.js +6 -6
- package/dist/src/duck/requests.js +265 -93
- package/dist/src/duck/rewrite.js +1193 -73
- package/dist/src/duck/row-filter.js +649 -0
- package/dist/src/duck/store.js +271 -159
- package/dist/src/duck/stream.js +10 -5
- package/dist/src/duck/sync.js +907 -275
- package/dist/src/duck/types.js +6 -5
- package/dist/src/duck/usage.js +324 -0
- package/dist/src/duck/users.js +64 -15
- package/dist/src/duck/verified.js +9 -0
- package/dist/src/duck/views.js +2 -3
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/log-buffer.js +68 -0
- package/dist/src/lib/pg-numeric.js +5 -2
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/table-sync.js +267 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +706 -55
- package/dist/src/web/app.js +330 -258
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +84 -15
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +446 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +434 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +218 -0
- package/dist/src/web/shared.js +568 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +10 -3
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROW FILTERS: WHICH ROWS OF A TABLE A ROLE READS (GitHub issue 30, stage 2). A table of a role in the roles file
|
|
3
|
+
* (src/cli/roles.ts) may carry `where: "<a Postgres boolean expression>"` over the table's columns and the role's
|
|
4
|
+
* attributes (`:tenant`), or `through: <table>` — a row is visible when the row of that other table it references by its
|
|
5
|
+
* foreign key is visible. A user carries the attributes' values (`mason user add … --set tenant=42`, src/duck/users.ts).
|
|
6
|
+
*
|
|
7
|
+
* ANSWERED AS POSTGRES'S ROW SECURITY ANSWERS IT: Postgres's rewriter adds a policy's USING expression to the range-table
|
|
8
|
+
* entry of every read of the table, wherever the read is (a view, a CTE, a subquery, a join's side, a UNION's branch), with
|
|
9
|
+
* `current_setting('mason.attr.<name>')::<type>` read as the session's value. Mason does what it does to a view
|
|
10
|
+
* (src/duck/views.ts): every reference to a filtered table, in the statement as Postgres analysed it, becomes
|
|
11
|
+
* `(SELECT * FROM <table> WHERE <the predicate, the user's values bound as literals of their type>) AS <its name>`, and
|
|
12
|
+
* THAT statement is planned from the start — analysed by Postgres, rewritten for DuckDB by the same rewrite as every
|
|
13
|
+
* statement (one grammar), verified as any other. A literal's type is the type Postgres infers for the parameter where
|
|
14
|
+
* the predicate uses it (`tenant_id = :tenant`: the column's), so `'42'` binds as `CAST('42' AS integer)` — what
|
|
15
|
+
* `current_setting(…)::integer` is — and a list as `CAST(ARRAY['12', '41'] AS integer[])`.
|
|
16
|
+
*
|
|
17
|
+
* `:name` is read by ONE lexer (`whereParts`), for the file's check, the catalog's policy and the binding alike: outside a
|
|
18
|
+
* string, a quoted name, a dollar-quoted body and a comment, never in a `::` cast.
|
|
19
|
+
*/
|
|
20
|
+
import { deparseSync } from "pgsql-deparser";
|
|
21
|
+
import { gapRefusal, StatementError } from "../api/postgres/server.js";
|
|
22
|
+
import { parseSql } from "../api/postgres/parser.js";
|
|
23
|
+
import { analyze, catalogRows } from "./analyze.js";
|
|
24
|
+
import { treeDifference } from "./rewrite.js";
|
|
25
|
+
/** an attribute's name: what `:name` reads and `--set name=value` sets */
|
|
26
|
+
export const ATTRIBUTE_NAME_RE = /^[a-z_][a-z0-9_]{0,62}$/;
|
|
27
|
+
export const ATTRIBUTE_NAME_RULE = "1 to 63 lower case letters, digits or \"_\", starting with a letter or \"_\" (e.g. tenant)";
|
|
28
|
+
/**
|
|
29
|
+
* A `where:` read: its text in parts, each `:name` one of them; or what is wrong with it (an unterminated string or
|
|
30
|
+
* comment). A name that is no attribute is the caller's to judge (src/cli/roles.ts).
|
|
31
|
+
*/
|
|
32
|
+
export function whereParts(sql) {
|
|
33
|
+
const out = [];
|
|
34
|
+
let text = "";
|
|
35
|
+
let i = 0;
|
|
36
|
+
const n = sql.length;
|
|
37
|
+
const until = (end, from, what) => {
|
|
38
|
+
const at = sql.indexOf(end, from);
|
|
39
|
+
return at < 0 ? { problem: `${what} is not closed` } : at + end.length;
|
|
40
|
+
};
|
|
41
|
+
while (i < n) {
|
|
42
|
+
const c = sql[i];
|
|
43
|
+
const next = sql[i + 1];
|
|
44
|
+
let to = null;
|
|
45
|
+
if (c === "'" || ((c === "e" || c === "E") && next === "'" && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? ""))) {
|
|
46
|
+
// a string: '' is a quote inside it; E'…' takes a backslash's escape too
|
|
47
|
+
const escapes = c !== "'";
|
|
48
|
+
let j = escapes ? i + 2 : i + 1;
|
|
49
|
+
for (;;) {
|
|
50
|
+
if (j >= n)
|
|
51
|
+
return { problem: "a string ('…') is not closed" };
|
|
52
|
+
if (escapes && sql[j] === "\\") {
|
|
53
|
+
j += 2;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (sql[j] === "'") {
|
|
57
|
+
if (sql[j + 1] === "'") {
|
|
58
|
+
j += 2;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
j++;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
j++;
|
|
65
|
+
}
|
|
66
|
+
to = j;
|
|
67
|
+
}
|
|
68
|
+
else if (c === '"') {
|
|
69
|
+
let j = i + 1;
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (j >= n)
|
|
72
|
+
return { problem: "a quoted name (\"…\") is not closed" };
|
|
73
|
+
if (sql[j] === '"') {
|
|
74
|
+
if (sql[j + 1] === '"') {
|
|
75
|
+
j += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
j++;
|
|
82
|
+
}
|
|
83
|
+
to = j;
|
|
84
|
+
}
|
|
85
|
+
else if (c === "-" && next === "-") {
|
|
86
|
+
const eol = sql.indexOf("\n", i);
|
|
87
|
+
to = eol < 0 ? n : eol;
|
|
88
|
+
}
|
|
89
|
+
else if (c === "/" && next === "*") {
|
|
90
|
+
// comments nest in Postgres
|
|
91
|
+
let depth = 0, j = i;
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (j >= n)
|
|
94
|
+
return { problem: "a comment (/* … */) is not closed" };
|
|
95
|
+
if (sql[j] === "/" && sql[j + 1] === "*") {
|
|
96
|
+
depth++;
|
|
97
|
+
j += 2;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (sql[j] === "*" && sql[j + 1] === "/") {
|
|
101
|
+
depth--;
|
|
102
|
+
j += 2;
|
|
103
|
+
if (depth === 0)
|
|
104
|
+
break;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
to = j;
|
|
110
|
+
}
|
|
111
|
+
else if (c === "$" && !/[A-Za-z0-9_]/.test(sql[i - 1] ?? "")) {
|
|
112
|
+
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
|
|
113
|
+
if (tag !== null)
|
|
114
|
+
to = until(tag[0], i + tag[0].length, `a dollar-quoted string (${tag[0]}…${tag[0]})`);
|
|
115
|
+
// $1 is a statement's parameter: bound here it would read the STATEMENT's first value, never the role's
|
|
116
|
+
else if (/[0-9]/.test(next ?? ""))
|
|
117
|
+
return { problem: `${/^\$[0-9]+/.exec(sql.slice(i))[0]} is a statement's parameter, which a where: cannot read — name an attribute of the role: :tenant` };
|
|
118
|
+
}
|
|
119
|
+
else if (c === ":" && next === ":") {
|
|
120
|
+
to = i + 2;
|
|
121
|
+
}
|
|
122
|
+
else if (c === ":" && /[A-Za-z_]/.test(next ?? "")) {
|
|
123
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(sql.slice(i + 1))[0];
|
|
124
|
+
if (text !== "") {
|
|
125
|
+
out.push(text);
|
|
126
|
+
text = "";
|
|
127
|
+
}
|
|
128
|
+
out.push({ attr: name });
|
|
129
|
+
i += 1 + name.length;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (to !== null) {
|
|
133
|
+
if (typeof to !== "number")
|
|
134
|
+
return to;
|
|
135
|
+
text += sql.slice(i, to);
|
|
136
|
+
i = to;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
text += c;
|
|
140
|
+
i++;
|
|
141
|
+
}
|
|
142
|
+
if (text !== "")
|
|
143
|
+
out.push(text);
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** the attributes a `where:` reads, in the order it first reads each */
|
|
147
|
+
export const whereAttributes = (parts) => [...new Set(parts.flatMap((p) => typeof p === "string" ? [] : [p.attr]))];
|
|
148
|
+
/** the `where:` written again, each `:name` as `render` says (the same text between them) */
|
|
149
|
+
export const renderWhere = (parts, render) => parts.map((p) => typeof p === "string" ? p : render(p.attr)).join("");
|
|
150
|
+
/**
|
|
151
|
+
* A user's attribute as a session setting, `mason.attr.<name>` (a list's values joined by commas), or null: what
|
|
152
|
+
* `SHOW` and `current_setting` answer, and what the catalog's row security policies read (src/api/postgres/catalog.ts
|
|
153
|
+
* catalogPolicies), as a Postgres role's own `ALTER ROLE … SET mason.attr.tenant` would. Never the session's to SET.
|
|
154
|
+
*/
|
|
155
|
+
export function attributeSetting(name, values) {
|
|
156
|
+
if (values === undefined || !name.startsWith("mason.attr."))
|
|
157
|
+
return null;
|
|
158
|
+
const v = values[name.slice("mason.attr.".length)];
|
|
159
|
+
return v === undefined ? null : typeof v === "string" ? v : v.join(",");
|
|
160
|
+
}
|
|
161
|
+
const quoteIdent = (name) => `"${name.replace(/"/g, '""')}"`;
|
|
162
|
+
/** `schema.table` as SQL: each part quoted */
|
|
163
|
+
export const tableSql = (table) => { const dot = table.indexOf("."); return `${quoteIdent(table.slice(0, dot))}.${quoteIdent(table.slice(dot + 1))}`; };
|
|
164
|
+
export const literal = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
165
|
+
/**
|
|
166
|
+
* THE TYPE OF EACH ATTRIBUTE A `where:` READS, as Postgres infers it — the predicate analysed as `SELECT 1 FROM <table>
|
|
167
|
+
* WHERE <it>` with each `:name` a parameter `$n` — or Postgres's own refusal, said at the key (`at`) with `:name` for
|
|
168
|
+
* `$n`. A list must be read as an array (`= ANY(:tenants)`) and a scalar as a value: the other way round, the predicate
|
|
169
|
+
* is analysed again with the parameter DECLARED of the shape the file says, so the refusal is Postgres's own
|
|
170
|
+
* (`operator does not exist: integer = integer[]`). A `where:` that reads another table (a subquery) is refused:
|
|
171
|
+
* `through:` is how a filter reads another table. The ONE derivation of the types: the catalog's policies and every
|
|
172
|
+
* statement's binding read what this returns (src/api/postgres/server.ts `checkedRoles`).
|
|
173
|
+
*/
|
|
174
|
+
export async function filterTypes(run, f, shapes) {
|
|
175
|
+
if (f.where === undefined)
|
|
176
|
+
return { types: new Map() };
|
|
177
|
+
const names = whereAttributes(f.where.parts);
|
|
178
|
+
const sqlOf = () => `SELECT 1 FROM ${tableSql(f.table)} WHERE ${renderWhere(f.where.parts, (a) => `$${names.indexOf(a) + 1}`)}`;
|
|
179
|
+
const sql = sqlOf();
|
|
180
|
+
const prefix = `SELECT 1 FROM ${tableSql(f.table)} WHERE `;
|
|
181
|
+
const said = (r) => {
|
|
182
|
+
const at = r.P !== undefined && Number(r.P) > prefix.length ? ` (at character ${Number(r.P) - prefix.length} of the where:)` : "";
|
|
183
|
+
const message = (r.M ?? "Postgres refused it").replace(/\$(\d+)/g, (m, k) => names[Number(k) - 1] !== undefined ? `:${names[Number(k) - 1]}` : m);
|
|
184
|
+
return { problem: `${f.at}.where: ${message}${at} — the predicate is checked by Postgres as SELECT 1 FROM ${f.table} WHERE ${f.where.text}` };
|
|
185
|
+
};
|
|
186
|
+
const first = await analyze(run, sql, names.map(() => 0), "public");
|
|
187
|
+
if ("refused" in first)
|
|
188
|
+
return said(first.refused);
|
|
189
|
+
if (hasSubquery(first.tree))
|
|
190
|
+
return { problem: `${f.at}.where: reads another table (a subquery) — a where: reads its own table's columns; through: <table> makes a row visible through another table's` };
|
|
191
|
+
const oids = first.params;
|
|
192
|
+
// each type named as it reads under any search_path (pg_catalog's own bare, every other qualified)
|
|
193
|
+
const rows = oids.length === 0 ? [] : await catalogRows(run, `SET LOCAL search_path = pg_catalog; SELECT t.oid::text, format_type(t.oid, NULL), (t.typcategory = 'A')::text, t.typelem::text, ` +
|
|
194
|
+
`t.typarray::text FROM pg_type t WHERE t.oid IN (${oids.map((o) => Math.trunc(o)).join(", ")})`);
|
|
195
|
+
const byOid = new Map(rows.map(([oid, name, isArray, elem, array]) => [Number(oid), { name: name, isArray: isArray === "true", elem: Number(elem), array: Number(array) }]));
|
|
196
|
+
const types = new Map();
|
|
197
|
+
const declared = [];
|
|
198
|
+
let mismatch = null;
|
|
199
|
+
for (const [i, a] of names.entries()) {
|
|
200
|
+
const t = byOid.get(oids[i]);
|
|
201
|
+
if (t === undefined)
|
|
202
|
+
return { problem: `${f.at}.where: Postgres inferred no type for :${a} — write a cast: :${a}::integer` };
|
|
203
|
+
const list = (shapes.get(a) ?? "scalar") === "list";
|
|
204
|
+
if (list && !t.isArray) {
|
|
205
|
+
mismatch ??= `:${a} is a list, read here as one ${t.name} — write = ANY(:${a})`;
|
|
206
|
+
declared.push(t.array);
|
|
207
|
+
}
|
|
208
|
+
else if (!list && t.isArray) {
|
|
209
|
+
mismatch ??= `:${a} is a scalar, read here as a list (${t.name}) — declare it attributes: { ${a}: list }, or compare it as a value`;
|
|
210
|
+
declared.push(t.elem);
|
|
211
|
+
}
|
|
212
|
+
else
|
|
213
|
+
declared.push(oids[i]);
|
|
214
|
+
types.set(a, t.name);
|
|
215
|
+
}
|
|
216
|
+
if (mismatch !== null) {
|
|
217
|
+
// Postgres's own refusal, the parameter declared of the shape the file says
|
|
218
|
+
const again = await analyze(run, sql, declared, "public");
|
|
219
|
+
if ("refused" in again)
|
|
220
|
+
return { problem: `${said(again.refused).problem} (${mismatch})` };
|
|
221
|
+
return { problem: `${f.at}.where: ${mismatch}` };
|
|
222
|
+
}
|
|
223
|
+
return { types };
|
|
224
|
+
}
|
|
225
|
+
/** whether an analysed tree holds a subquery (a SubLink: IN, EXISTS, a scalar subquery …) */
|
|
226
|
+
function hasSubquery(tree) {
|
|
227
|
+
let found = false;
|
|
228
|
+
const visit = (v) => {
|
|
229
|
+
if (found)
|
|
230
|
+
return;
|
|
231
|
+
if (Array.isArray(v)) {
|
|
232
|
+
for (const x of v)
|
|
233
|
+
visit(x);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (v === null || typeof v !== "object")
|
|
237
|
+
return;
|
|
238
|
+
if (v._node === "SUBLINK") {
|
|
239
|
+
found = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
for (const [k, x] of Object.entries(v))
|
|
243
|
+
if (k !== "_node")
|
|
244
|
+
visit(x);
|
|
245
|
+
};
|
|
246
|
+
visit(tree);
|
|
247
|
+
return found;
|
|
248
|
+
}
|
|
249
|
+
/** a value as EXPLAIN says it: a number bare, anything else quoted */
|
|
250
|
+
const shownValue = (v) => /^-?\d+(\.\d+)?$/.test(v) ? v : literal(v);
|
|
251
|
+
/** a list as EXPLAIN says it: its first values, then how many more */
|
|
252
|
+
const SHOWN_VALUES = 5;
|
|
253
|
+
export const shownList = (vs) => vs.length <= SHOWN_VALUES ? vs.map(shownValue).join(", ") : `${vs.slice(0, SHOWN_VALUES).map(shownValue).join(", ")}, … and ${vs.length - SHOWN_VALUES} more`;
|
|
254
|
+
/** The filter bound to a user's values (every attribute it reads is set: `missingAttributes` refused the user first). */
|
|
255
|
+
export function bindFilter(f, types, values) {
|
|
256
|
+
if (f.through !== undefined) {
|
|
257
|
+
const parent = f.through.parent.replace(/^public\./, "");
|
|
258
|
+
return { table: f.table, through: f.through, words: `a row of ${parent} it references by ${f.through.column} is visible` };
|
|
259
|
+
}
|
|
260
|
+
const parts = f.where.parts;
|
|
261
|
+
const valueOf = (a) => values[a] ?? "";
|
|
262
|
+
const bound = (a) => {
|
|
263
|
+
const v = valueOf(a);
|
|
264
|
+
const type = types.get(a) ?? "text";
|
|
265
|
+
return typeof v === "string" ? `CAST(${literal(v)} AS ${type})` : `CAST(ARRAY[${v.map(literal).join(", ")}] AS ${type})`;
|
|
266
|
+
};
|
|
267
|
+
const words = renderWhere(parts, (a) => { const v = valueOf(a); return typeof v === "string" ? shownValue(v) : shownList(v); }).replace(/\s+/g, " ").trim();
|
|
268
|
+
const attributes = whereAttributes(parts).map((a) => ({ name: a, type: types.get(a) ?? "text", sql: bound(a), at: `${f.at}.where`, where: f.where.text }));
|
|
269
|
+
return { table: f.table, predicate: renderWhere(parts, bound), words, attributes };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* THE ATTRIBUTE WHOSE VALUE ITS TYPE REFUSES, read when a filtered statement's analysis raised a data exception: only a
|
|
273
|
+
* bound value can be `--set tenant=abc` on an integer, and only here is the attribute still known. `tables`: the filters
|
|
274
|
+
* this statement reads, for a value it never binds is no cause of its error. Null: every value is good, so the exception
|
|
275
|
+
* is the statement's own. `mason user add` cannot refuse it at the door: the type is the catalog's
|
|
276
|
+
* inference, and a role changed since is met here anyway.
|
|
277
|
+
*/
|
|
278
|
+
export async function attributeRefused(run, rows, tables) {
|
|
279
|
+
for (const table of tables) {
|
|
280
|
+
for (const a of rows.get(table)?.attributes ?? []) {
|
|
281
|
+
const r = await analyze(run, `SELECT ${a.sql}`, [], "public");
|
|
282
|
+
if ("refused" in r)
|
|
283
|
+
return { name: a.name, type: a.type, at: a.at, where: a.where, problem: r.refused.M ?? "Postgres refused it" };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* THE FILTERED READ `checkRoles` PLANS, so a `where:` Mason's rewrite cannot send is the file's problem at its key, never
|
|
290
|
+
* a refusal at every statement of its users: the one read a statement meets (`filteredSql`), every attribute NULL, the
|
|
291
|
+
* one value of every type.
|
|
292
|
+
*/
|
|
293
|
+
export function filterProbe(f, types, shapes) {
|
|
294
|
+
const predicate = renderWhere(f.where.parts, (a) => `CAST(${shapes.get(a) === "list" ? "ARRAY[NULL]" : "NULL"} AS ${types.get(a) ?? "text"})`);
|
|
295
|
+
return filteredSql(f.table, new Map([[f.table, { table: f.table, predicate, words: "" }]]));
|
|
296
|
+
}
|
|
297
|
+
/** whether a user holds a value of an attribute: an empty list is none, never a filter that keeps no row (a user
|
|
298
|
+
* written by hand: `mason user add` refuses one), which would be the silent zero rows this refuses */
|
|
299
|
+
const hasValue = (v) => v !== undefined && (typeof v === "string" || v.length > 0);
|
|
300
|
+
/** the attributes a role reads (its filters' `where:`s) that a user's values lack, each with the filter that reads it */
|
|
301
|
+
export function missingAttributes(filters, values) {
|
|
302
|
+
const out = [];
|
|
303
|
+
for (const f of filters) {
|
|
304
|
+
if (f.where === undefined)
|
|
305
|
+
continue;
|
|
306
|
+
for (const a of whereAttributes(f.where.parts))
|
|
307
|
+
if (!hasValue(values[a]) && !out.some((x) => x.attr === a))
|
|
308
|
+
out.push({ attr: a, at: `${f.at}.where`, where: f.where.text });
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
// ------------------------------------------------------------------ the statement with its filters in place
|
|
313
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
314
|
+
const sval = (n) => isObject(n) && isObject(n.String) && typeof n.String.sval === "string" ? n.String.sval : null;
|
|
315
|
+
/** every `{ Kind: body }` node of the raw tree, with the object and key holding it (to be replaced in place) */
|
|
316
|
+
function nodes(root) {
|
|
317
|
+
const out = [];
|
|
318
|
+
const visit = (holder, key, v, parent) => {
|
|
319
|
+
if (Array.isArray(v)) {
|
|
320
|
+
v.forEach((x, i) => visit(v, i, x, parent));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!isObject(v))
|
|
324
|
+
return;
|
|
325
|
+
const keys = Object.keys(v);
|
|
326
|
+
let kind = parent;
|
|
327
|
+
if (keys.length === 1 && /^[A-Z]/.test(keys[0]) && isObject(v[keys[0]])) {
|
|
328
|
+
out.push({ holder, key, kind: keys[0], body: v[keys[0]], parent });
|
|
329
|
+
kind = keys[0];
|
|
330
|
+
}
|
|
331
|
+
for (const k of keys)
|
|
332
|
+
visit(v, k, v[k], kind);
|
|
333
|
+
};
|
|
334
|
+
visit({ root }, "root", root, null);
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/** the copied tables Postgres's analysis read, by OID (range-table entries of a relation, relkind r) */
|
|
338
|
+
function relationsRead(tree) {
|
|
339
|
+
const out = new Set();
|
|
340
|
+
const visit = (v) => {
|
|
341
|
+
if (Array.isArray(v)) {
|
|
342
|
+
for (const x of v)
|
|
343
|
+
visit(x);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (v === null || typeof v !== "object")
|
|
347
|
+
return;
|
|
348
|
+
if (v._node === "RANGETBLENTRY" && v.rtekind === "0" && v.relkind === "r" && typeof v.relid === "string")
|
|
349
|
+
out.add(Number(v.relid));
|
|
350
|
+
for (const [k, x] of Object.entries(v))
|
|
351
|
+
if (k !== "_node")
|
|
352
|
+
visit(x);
|
|
353
|
+
};
|
|
354
|
+
visit(tree);
|
|
355
|
+
return out;
|
|
356
|
+
}
|
|
357
|
+
function refused(message, hint) {
|
|
358
|
+
return gapRefusal("roles", message, hint);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* THE SECURITY BARRIER: what Postgres's row security gets by marking a policy's subquery a barrier, written in the one
|
|
362
|
+
* form the engine's optimiser cannot see through. It pushes a filter through a subquery, a projection and a join's side,
|
|
363
|
+
* so a statement's own condition would run on the rows the filter is there to hide — below a SEMI join, on every row of
|
|
364
|
+
* the table — and an expression that raises on one (`1/(CASE WHEN qty = 99 THEN 0 ELSE 1 END)`) would answer "does a
|
|
365
|
+
* hidden row satisfy P?" for any P. It never pushes a filter through a LIMIT, which would change what the limit keeps,
|
|
366
|
+
* so a limit that keeps every row is a wall: everything of the statement runs on what comes out of it. Measured
|
|
367
|
+
* (docs/DESIGN.md "Roles: rows per user"): `LIMIT <the largest bigint>` beside `OFFSET 0`, which runs a serial operator
|
|
368
|
+
* under the default settings, and with no gate: every filtered read has it. A filter's own reads (the parents of a
|
|
369
|
+
* `through:`) hold nothing of the statement's, so only the outermost read needs it.
|
|
370
|
+
*
|
|
371
|
+
* THE LIMIT STOPS A FILTER, NOT A FACT: the engine carries each column's table-wide minimum, maximum and null count up
|
|
372
|
+
* through a limit, and proves a condition false from them. `WHERE 1/(id-id) > 0 AND secret > N` then raises for N below
|
|
373
|
+
* the table's maximum and answers nothing above it, a binary search on a hidden row with no EXPLAIN in it. So the read
|
|
374
|
+
* ends in a projection the engine carries no statistics through, `try(col)` of every column (`STATISTICS_WALL`; a
|
|
375
|
+
* column's name and type pass through it): what the statement meets knows nothing of the table. Disabling the
|
|
376
|
+
* optimiser pass per statement is not open: the engine's setting is global, so it would race the other sessions.
|
|
377
|
+
*/
|
|
378
|
+
export const BARRIER = "LIMIT 9223372036854775807";
|
|
379
|
+
/** the name of the select around the barrier, unless the statement holds it (`wallsOf`) */
|
|
380
|
+
const WALL = "__mason_barrier";
|
|
381
|
+
/** the select list's call of `try(col)` on every column, as the engine reads it; `AS "\0"` keeps each column's name */
|
|
382
|
+
export const STATISTICS_WALL = "try(COLUMNS(*))";
|
|
383
|
+
/** FOR TESTS ALONE (tests/duck-roles-barrier-wire.test.ts): `statistics` false leaves `try` out, so the LIMIT is shown to
|
|
384
|
+
* hold the barrier's shapes by itself. Nothing in src/ sets it */
|
|
385
|
+
export const wallSwitch = { statistics: true };
|
|
386
|
+
/** a name for the walls that no string of the statement holds (its names, its literals), so nothing of the user's can be
|
|
387
|
+
* taken for Mason's: a name is no proof of whose a subquery is unless it is chosen after reading the statement. The
|
|
388
|
+
* same statement chooses the same name, so what EXPLAIN says of it does not vary */
|
|
389
|
+
function wallName(tree) {
|
|
390
|
+
const strings = new Set();
|
|
391
|
+
const collect = (v) => {
|
|
392
|
+
if (typeof v === "string")
|
|
393
|
+
strings.add(v);
|
|
394
|
+
else if (Array.isArray(v))
|
|
395
|
+
v.forEach(collect);
|
|
396
|
+
else if (isObject(v))
|
|
397
|
+
Object.values(v).forEach(collect);
|
|
398
|
+
};
|
|
399
|
+
collect(tree);
|
|
400
|
+
let name = WALL;
|
|
401
|
+
for (let i = 1; strings.has(name); i++)
|
|
402
|
+
name = `${WALL}_${i}`;
|
|
403
|
+
return name;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* THE WALL'S SELECT LIST, in place of the `*` `filteredSql` wrote around the barrier, once the rewrite is done: Postgres
|
|
407
|
+
* has no `COLUMNS(*)`, so it is the engine's text, never the analysed one. Exactly the reads Mason wrote, or the statement
|
|
408
|
+
* is refused: a read with no wall would answer a statement from the statistics of a whole table.
|
|
409
|
+
*/
|
|
410
|
+
export function wallStatistics(tree, walls) {
|
|
411
|
+
let placed = 0;
|
|
412
|
+
const visit = (v) => {
|
|
413
|
+
if (Array.isArray(v)) {
|
|
414
|
+
v.forEach(visit);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!isObject(v))
|
|
418
|
+
return;
|
|
419
|
+
const from = v.fromClause;
|
|
420
|
+
if (from?.length === 1 && from[0]?.RangeSubselect?.alias?.aliasname === walls.alias) {
|
|
421
|
+
const list = v.targetList;
|
|
422
|
+
const fields = list?.length === 1 ? list[0]?.ResTarget?.val?.ColumnRef?.fields : undefined;
|
|
423
|
+
if (fields?.length === 1 && fields[0]?.A_Star !== undefined) {
|
|
424
|
+
const columns = { FuncCall: { funcname: [{ String: { sval: "columns" } }], agg_star: true } };
|
|
425
|
+
v.targetList = [{ ResTarget: { name: "\\0", val: { FuncCall: { funcname: [{ String: { sval: "try" } }], args: [columns] } } } }];
|
|
426
|
+
placed++;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
Object.values(v).forEach(visit);
|
|
430
|
+
};
|
|
431
|
+
visit(tree);
|
|
432
|
+
if (placed !== walls.reads) {
|
|
433
|
+
throw refused(`Mason cannot yet answer this statement over the tables its role filters: it holds ${placed} of the ${walls.reads} filtered reads Mason wrote (a Mason gap: report it, with the statement)`, "read the filtered table in a simpler form");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** the filtered read of `table` as SQL: its rows the filter keeps, `through:` read through its parent's filtered read;
|
|
437
|
+
* behind the barrier at its outermost (`depth` 1), where the statement meets it */
|
|
438
|
+
export function filteredSql(table, filters, depth = 1, wall = WALL) {
|
|
439
|
+
const f = filters.get(table);
|
|
440
|
+
if (f === undefined)
|
|
441
|
+
return `SELECT * FROM ${tableSql(table)}`;
|
|
442
|
+
// the select around the barrier, for the wall (`wallStatistics`)
|
|
443
|
+
const walled = (read) => depth === 1 ? `SELECT * FROM (${read} ${BARRIER}) AS ${wall}` : read;
|
|
444
|
+
if (f.predicate !== undefined)
|
|
445
|
+
return walled(`SELECT * FROM ${tableSql(table)} WHERE (${f.predicate})`);
|
|
446
|
+
const t = f.through;
|
|
447
|
+
const p = `__mason_parent${depth}`;
|
|
448
|
+
return walled(`SELECT * FROM ${tableSql(table)} WHERE EXISTS (SELECT 1 FROM (${filteredSql(t.parent, filters, depth + 1)}) AS ${p} WHERE ${p}.${quoteIdent(t.parentColumn)} = ${tableSql(table)}.${quoteIdent(t.column)})`);
|
|
449
|
+
}
|
|
450
|
+
/** the items a select's FROM reads itself: never a subquery's, through its JOINs */
|
|
451
|
+
function fromItems(from) {
|
|
452
|
+
if (Array.isArray(from))
|
|
453
|
+
return from.flatMap(fromItems);
|
|
454
|
+
if (!isObject(from))
|
|
455
|
+
return [];
|
|
456
|
+
const j = isObject(from.JoinExpr) ? from.JoinExpr : null;
|
|
457
|
+
if (j !== null)
|
|
458
|
+
return [...fromItems(j.larg), ...fromItems(j.rarg)];
|
|
459
|
+
return isObject(from.RangeVar) ? [from.RangeVar] : [];
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* A GROUP BY OF A FILTERED TABLE'S PRIMARY KEY KEEPS WHAT THE KEY DECIDES. Postgres accepts `SELECT o.id, o.status … GROUP
|
|
463
|
+
* BY o.id` by functional dependency on the table's key, and a table turned into a subquery has no key, so the filtered
|
|
464
|
+
* text alone would be refused 42803. So each column of the table the select reads outside an aggregate joins its GROUP
|
|
465
|
+
* BY. The key is grouped whole, so a group holds one row of the table and no column added can change a group; the gate
|
|
466
|
+
* is the key, never Postgres's acceptance, since `GROUP BY lower(status)` also reads a column ungrouped. A grouping set
|
|
467
|
+
* is left alone and refused by name (plan.ts), as is a column no type's equality can group.
|
|
468
|
+
*/
|
|
469
|
+
async function carryKeyDependency(catalog, tree, reads, analysis, oids) {
|
|
470
|
+
const selects = nodes(tree).filter((n) => n.kind === "SelectStmt" && Array.isArray(n.body.groupClause) && n.body.groupClause.length > 0);
|
|
471
|
+
if (selects.length === 0)
|
|
472
|
+
return;
|
|
473
|
+
const aliasOf = (r) => (r.alias?.aliasname ?? r.relname);
|
|
474
|
+
const keys = new Map();
|
|
475
|
+
const shape = async (table) => {
|
|
476
|
+
const known = keys.get(table);
|
|
477
|
+
if (known !== undefined)
|
|
478
|
+
return known;
|
|
479
|
+
const rows = await catalogRows(catalog, `SELECT a.attname, COALESCE(a.attnum = ANY(i.indkey), false)::text FROM pg_attribute a LEFT JOIN pg_index i ON i.indrelid = a.attrelid AND i.indisprimary ` +
|
|
480
|
+
`WHERE a.attrelid = ${Math.trunc(oids.get(table))} AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`);
|
|
481
|
+
const got = { key: rows.filter(([, k]) => k === "true").map(([c]) => c), columns: rows.map(([c]) => c) };
|
|
482
|
+
keys.set(table, got);
|
|
483
|
+
return got;
|
|
484
|
+
};
|
|
485
|
+
for (const { body: s } of selects) {
|
|
486
|
+
const groups = s.groupClause;
|
|
487
|
+
if (groups.some((g) => isObject(g) && "GroupingSet" in g))
|
|
488
|
+
continue;
|
|
489
|
+
const own = new Set(fromItems(s.fromClause));
|
|
490
|
+
const here = reads.filter((r) => own.has(r.range));
|
|
491
|
+
if (here.length === 0)
|
|
492
|
+
continue;
|
|
493
|
+
const targets = (s.targetList ?? []);
|
|
494
|
+
// what the GROUP BY groups, by name: a column, an output name of one, or a position of one
|
|
495
|
+
const columnOf = (n) => isObject(n) && isObject(n.ColumnRef) ? n.ColumnRef.fields.map(sval).filter((x) => x !== null) : null;
|
|
496
|
+
const target = (t) => isObject(t?.ResTarget) ? t.ResTarget.val : undefined;
|
|
497
|
+
const grouped = groups.map((g) => {
|
|
498
|
+
const c = columnOf(g);
|
|
499
|
+
if (c?.length === 1) {
|
|
500
|
+
const named = targets.find((t) => isObject(t.ResTarget) && t.ResTarget.name === c[0]);
|
|
501
|
+
if (named !== undefined)
|
|
502
|
+
return columnOf(target(named));
|
|
503
|
+
return c;
|
|
504
|
+
}
|
|
505
|
+
const at = isObject(g) && isObject(g.A_Const) && isObject(g.A_Const.ival) ? Number(g.A_Const.ival.ival) : null;
|
|
506
|
+
return at !== null ? columnOf(target(targets[at - 1])) : c;
|
|
507
|
+
}).filter((c) => c !== null);
|
|
508
|
+
// what the select reads outside an aggregate, of itself or from a subquery's correlation
|
|
509
|
+
const read = [];
|
|
510
|
+
const walk = (v) => {
|
|
511
|
+
if (Array.isArray(v)) {
|
|
512
|
+
v.forEach(walk);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!isObject(v))
|
|
516
|
+
return;
|
|
517
|
+
const call = isObject(v.FuncCall) ? v.FuncCall : null;
|
|
518
|
+
if (call !== null && call.over === undefined && analysis.types.get(call.location)?.some((t) => t.node === "AGGREF"))
|
|
519
|
+
return;
|
|
520
|
+
if (isObject(v.ColumnRef)) {
|
|
521
|
+
const fields = v.ColumnRef.fields;
|
|
522
|
+
const star = isObject(fields[fields.length - 1]) && "A_Star" in fields[fields.length - 1];
|
|
523
|
+
const names = fields.map(sval);
|
|
524
|
+
read.push({ of: fields.length >= 2 ? names[fields.length - 2] : null, name: star ? null : names[fields.length - 1] });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
Object.values(v).forEach(walk);
|
|
528
|
+
};
|
|
529
|
+
walk([s.targetList, s.havingClause, s.sortClause, s.windowClause]);
|
|
530
|
+
for (const { range, table } of here) {
|
|
531
|
+
const alias = aliasOf(range);
|
|
532
|
+
const { key, columns } = await shape(table);
|
|
533
|
+
const groupedHere = grouped.filter((c) => c.length === 1 || c[c.length - 2] === alias).map((c) => c[c.length - 1]);
|
|
534
|
+
if (key.length === 0 || !key.every((k) => groupedHere.includes(k)))
|
|
535
|
+
continue;
|
|
536
|
+
const add = columns.filter((c) => !groupedHere.includes(c) && read.some((r) => (r.of === null || r.of === alias) && (r.name === null || r.name === c)));
|
|
537
|
+
groups.push(...add.map((c) => ({ ColumnRef: { fields: [{ String: { sval: alias } }, { String: { sval: c } }] } })));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* The statement with every read of a filtered table replaced by its filtered read, printed; null when it reads no
|
|
543
|
+
* filtered table. Throws a StatementError (gap roles) where the replacement could read otherwise than Postgres's row
|
|
544
|
+
* security: a filtered table's whole row (a subquery's is a record), a name both a WITH query and a filtered table, a
|
|
545
|
+
* TABLESAMPLE of one, a column qualified by its schema beside another item of the table's name.
|
|
546
|
+
*/
|
|
547
|
+
export async function expandRowFilters(catalog, stmt, analysis, searchPathSql, filters) {
|
|
548
|
+
const read = relationsRead(analysis.tree);
|
|
549
|
+
if (read.size === 0 || filters.size === 0)
|
|
550
|
+
return null;
|
|
551
|
+
const names = await catalogRows(catalog, `SELECT c.oid::text, n.nspname || '.' || c.relname, c.reltype::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid IN (${[...read].join(", ")})`);
|
|
552
|
+
const filteredRead = new Map(names.filter(([, name]) => filters.has(name)).map(([oid, name, rowType]) => [Number(oid), { table: name, rowType: Number(rowType) }]));
|
|
553
|
+
if (filteredRead.size === 0)
|
|
554
|
+
return null;
|
|
555
|
+
// a filtered table's whole row is the table's row type; a subquery's is a record
|
|
556
|
+
const rowTypes = new Set([...filteredRead.values()].map((v) => v.rowType));
|
|
557
|
+
if ([...analysis.types.values()].flat().some((t) => rowTypes.has(t.type))) {
|
|
558
|
+
throw refused("Mason cannot yet answer a whole row of a table its role filters", "name the table's columns, or write t.*");
|
|
559
|
+
}
|
|
560
|
+
const tree = structuredClone(stmt);
|
|
561
|
+
const all = nodes(tree);
|
|
562
|
+
const ranges = all.filter((n) => n.kind === "RangeVar");
|
|
563
|
+
const ctes = new Set(all.filter((n) => n.kind === "CommonTableExpr").map((n) => n.body.ctename));
|
|
564
|
+
const spelled = ranges.map((r) => [r.body.catalogname, r.body.schemaname, r.body.relname].filter((x) => typeof x === "string").map(quoteIdent).join("."));
|
|
565
|
+
const resolved = spelled.length === 0 ? [] : await catalogRows(catalog, `SET LOCAL search_path = ${searchPathSql}, pg_catalog; SELECT v.i::text, c.oid::text FROM (VALUES ${spelled.map((s, i) => `(${i}, ${literal(s)})`).join(", ")}) AS v(i, name) ` +
|
|
566
|
+
`JOIN pg_class c ON c.oid = to_regclass(v.name) WHERE c.relkind = 'r'`);
|
|
567
|
+
const asFiltered = new Map();
|
|
568
|
+
for (const [i, oid] of resolved) {
|
|
569
|
+
const hit = filteredRead.get(Number(oid));
|
|
570
|
+
if (hit === undefined)
|
|
571
|
+
continue;
|
|
572
|
+
const r = ranges[Number(i)];
|
|
573
|
+
if (r.body.schemaname === undefined && ctes.has(r.body.relname)) {
|
|
574
|
+
throw refused(`Mason cannot yet answer a statement whose WITH query is named as a table its role filters (${r.body.relname})`, "rename the WITH query");
|
|
575
|
+
}
|
|
576
|
+
if (r.parent === "RangeTableSample")
|
|
577
|
+
throw refused(`Mason cannot yet answer a TABLESAMPLE of a table its role filters (${hit.table})`, "read the table without TABLESAMPLE");
|
|
578
|
+
asFiltered.set(Number(i), hit.table);
|
|
579
|
+
}
|
|
580
|
+
// `row(c.*)`, `ARRAY[c.*]`, `VALUES (c.*)`: a star expanded where it is written, which only a SELECT's own list does
|
|
581
|
+
// on the copy; a whole row by another spelling (above), refused by the same name rather than the engine's own words
|
|
582
|
+
const filteredNames = new Set([...asFiltered.keys()].map((i) => { const b = ranges[i].body; return (b.alias?.aliasname ?? b.relname); }));
|
|
583
|
+
for (const c of all.filter((n) => n.kind === "ColumnRef" && n.parent !== "ResTarget")) {
|
|
584
|
+
const fields = c.body.fields;
|
|
585
|
+
const star = isObject(fields[fields.length - 1]) && "A_Star" in fields[fields.length - 1];
|
|
586
|
+
const of = fields.length >= 2 ? sval(fields[fields.length - 2]) : null;
|
|
587
|
+
if (star && of !== null && filteredNames.has(of))
|
|
588
|
+
throw refused("Mason cannot yet answer a whole row of a table its role filters", "name the table's columns, or write t.*");
|
|
589
|
+
}
|
|
590
|
+
const found = new Set(asFiltered.values());
|
|
591
|
+
if ([...filteredRead.values()].some((v) => !found.has(v.table))) {
|
|
592
|
+
throw refused("Mason could not tell which of the statement's names are the tables its role filters (a Mason gap: report it, with the statement)", "write each table's name qualified by its schema");
|
|
593
|
+
}
|
|
594
|
+
// a column qualified by its table's schema (`public.orders.id`, Metabase writes every column so) names the subquery by
|
|
595
|
+
// its alias alone: sound only where no other item of the statement has the table's name
|
|
596
|
+
const named = all.flatMap((n) => {
|
|
597
|
+
const alias = n.body.alias?.aliasname;
|
|
598
|
+
if (n.kind === "RangeVar")
|
|
599
|
+
return [typeof alias === "string" ? alias : n.body.relname];
|
|
600
|
+
if (n.kind === "CommonTableExpr")
|
|
601
|
+
return [n.body.ctename];
|
|
602
|
+
return typeof alias === "string" && ["RangeSubselect", "RangeFunction", "RangeTableFunc", "JoinExpr"].includes(n.kind) ? [alias] : [];
|
|
603
|
+
});
|
|
604
|
+
await carryKeyDependency(catalog, tree, [...asFiltered].map(([i, table]) => ({ range: ranges[i].body, table })), analysis, new Map([...filteredRead].map(([oid, v]) => [v.table, oid])));
|
|
605
|
+
const qualified = new Set();
|
|
606
|
+
const parsedOf = new Map();
|
|
607
|
+
const wall = wallName(tree);
|
|
608
|
+
for (const [i, table] of asFiltered) {
|
|
609
|
+
const r = ranges[i];
|
|
610
|
+
let sub = parsedOf.get(table);
|
|
611
|
+
if (sub === undefined) {
|
|
612
|
+
const s = (await parseSql(filteredSql(table, filters, 1, wall))).stmts?.[0]?.stmt;
|
|
613
|
+
if (s === undefined)
|
|
614
|
+
throw new Error(`the filtered read of ${table} did not parse: report it`);
|
|
615
|
+
parsedOf.set(table, s);
|
|
616
|
+
sub = s;
|
|
617
|
+
}
|
|
618
|
+
if (r.body.alias === undefined)
|
|
619
|
+
qualified.add(`${table.slice(0, table.indexOf("."))}.${r.body.relname}`);
|
|
620
|
+
r.holder[r.key] = { RangeSubselect: { subquery: structuredClone(sub), alias: r.body.alias ?? { aliasname: r.body.relname } } };
|
|
621
|
+
}
|
|
622
|
+
for (const c of all.filter((n) => n.kind === "ColumnRef")) {
|
|
623
|
+
const fields = c.body.fields;
|
|
624
|
+
if (fields.length < 3)
|
|
625
|
+
continue;
|
|
626
|
+
const [schema, rel] = [sval(fields[fields.length - 3]), sval(fields[fields.length - 2])];
|
|
627
|
+
if (schema === null || rel === null || !qualified.has(`${schema}.${rel}`))
|
|
628
|
+
continue;
|
|
629
|
+
if (named.filter((n) => n === rel).length > 1) {
|
|
630
|
+
throw refused(`Mason cannot yet answer a column qualified by its schema (${schema}.${rel}) beside another item named ${rel}, where its role filters ${rel}`, "give the table an alias, and qualify its columns by it");
|
|
631
|
+
}
|
|
632
|
+
c.body.fields = fields.slice(-2);
|
|
633
|
+
}
|
|
634
|
+
const text = deparseSync(tree, { pretty: false });
|
|
635
|
+
const back = (await parseSql(text)).stmts?.[0]?.stmt;
|
|
636
|
+
const difference = back === undefined ? "nothing" : treeDifference(back, tree);
|
|
637
|
+
if (difference !== null) {
|
|
638
|
+
throw refused("Mason cannot answer this statement's filtered tables: the statement with their filters does not read back as itself (a Mason gap: report it, with the statement)", `read back: ${difference}`);
|
|
639
|
+
}
|
|
640
|
+
// every table whose filter the statement now reads: the filtered ones, and the parents they are read through
|
|
641
|
+
const tables = new Set();
|
|
642
|
+
const reach = (t) => { if (tables.has(t))
|
|
643
|
+
return; const f = filters.get(t); if (f === undefined)
|
|
644
|
+
return; tables.add(t); if (f.through)
|
|
645
|
+
reach(f.through.parent); };
|
|
646
|
+
for (const t of found)
|
|
647
|
+
reach(t);
|
|
648
|
+
return { text, stmt: back, walls: { alias: wall, reads: asFiltered.size }, tables: [...tables] };
|
|
649
|
+
}
|