@mason-data/cli 0.8.0 → 0.9.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 +13 -4
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +9 -7
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +24 -9
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT A GENERATED TABLE'S SQL IS, ASKED OF THE CATALOG POSTGRES (issue 124; src/duck/generated.ts keeps the table): the
|
|
3
|
+
* file's `sql:` planned as any statement is — analysed by Postgres, rewritten, held to the verified list, read back — and
|
|
4
|
+
* then held to what a table with no session can store. Everything here refuses, by file, key, reason and way out; none
|
|
5
|
+
* of it moves a table from one `kind:` to another.
|
|
6
|
+
*
|
|
7
|
+
* A TABLE HAS NO SESSION. So the plan is made in UTC under `public` (src/duck/generated.ts sets the connection it runs on
|
|
8
|
+
* to the same), and anything whose answer a session's settings decide is refused rather than given a default: a default
|
|
9
|
+
* zone would give wrong days with no error. Postgres already says which those are (`sessionReads`): a function, an
|
|
10
|
+
* operator or a text cast it marks IMMUTABLE reads no session, by its own rule for what an index may hold. The two
|
|
11
|
+
* exceptions are named: `age(timestamptz, timestamptz)` is marked immutable and reads the zone, `to_char(timestamp, …)`
|
|
12
|
+
* is marked stable and reads none of it, the zone being in the SQL by then. `now()` and its kin are instants, the run's.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS STORED IS WHAT A COPY OF THAT TYPE HOLDS (`STORED_TYPES`): the column's own Postgres type, so a statement over
|
|
15
|
+
* the table is answered by the very path that answers one over a copy. A `numeric`, an enum and a citext are stored as
|
|
16
|
+
* the engine answers them (`duck: null`), which is what a copy of that column holds: a numeric's DECIMAL, whose display
|
|
17
|
+
* scale the catalog's typmod pads on read; an enum's ENUM, in its declared order; a citext's text (stage 3, issue 175). A
|
|
18
|
+
* numeric whose digits a statement computes at the result's edge (`DuckPlan.exact`) is stored where Postgres's types fix
|
|
19
|
+
* its scale, written as the engine's DECIMAL arithmetic (`storedNumeric`); one they don't is refused with its own reason
|
|
20
|
+
* (`notStoredRefusal`). Any other type is refused by name.
|
|
21
|
+
*/
|
|
22
|
+
import { deparseSync } from "pgsql-deparser";
|
|
23
|
+
import { parseSql, PostgresSyntaxError } from "../api/postgres/parser.js";
|
|
24
|
+
import { describeKeys } from "./resolved.js";
|
|
25
|
+
import { treeDifference } from "./rewrite.js";
|
|
26
|
+
import { StatementError } from "./statement.js";
|
|
27
|
+
import { exprType } from "./pgtree.js";
|
|
28
|
+
/** the Postgres type of an output column, by OID, as a copy stores it (src/duck/types.ts writes each back as Postgres's) */
|
|
29
|
+
const STORED_TYPES = new Map([
|
|
30
|
+
[16, "BOOLEAN"], [17, "BLOB"], [20, "BIGINT"], [21, "SMALLINT"], [23, "INTEGER"], [25, "VARCHAR"], [114, "JSON"], [700, "FLOAT"], [701, "DOUBLE"], [1043, "VARCHAR"],
|
|
31
|
+
[1082, "DATE"], [1083, "TIME"], [1114, "TIMESTAMP"], [1184, "TIMESTAMPTZ"], [1186, "INTERVAL"], [2950, "UUID"], [3802, "JSON"],
|
|
32
|
+
]);
|
|
33
|
+
const NUMERIC_OID = 1700;
|
|
34
|
+
/** the most decimals a stored DECIMAL(38, s) may keep: past it a sum could overflow its 38 digits and fail the write */
|
|
35
|
+
const MAX_STORED_SCALE = 18;
|
|
36
|
+
/** the Postgres name `?column?` is an expression no one named, and a table's column needs one */
|
|
37
|
+
const UNNAMED = "?column?";
|
|
38
|
+
/** what the subquery of a definition with an `order_by:` is called: its output's columns are all it can read */
|
|
39
|
+
const SHELL_ALIAS = "__mason_generated";
|
|
40
|
+
const SHELL_KEYS = new Set(["targetList", "fromClause", "sortClause", "limitOption", "op"]);
|
|
41
|
+
const PER_VALUE = new Set(["sqrt", "exp", "ln", "log", "log10", "power", "pow"]);
|
|
42
|
+
const FIXES = new Set(["round", "trunc", "floor", "ceil", "ceiling"]);
|
|
43
|
+
const DOUBLES = new Set([700, 701]);
|
|
44
|
+
const TEXTS = new Set([25, 1042, 1043]);
|
|
45
|
+
const JSONS = new Set([114, 3802]);
|
|
46
|
+
const rec = (v) => typeof v === "object" && v !== null ? v : {};
|
|
47
|
+
const lastName = (names) => {
|
|
48
|
+
const list = Array.isArray(names) ? names : [];
|
|
49
|
+
const s = rec(rec(list[list.length - 1]).String).sval;
|
|
50
|
+
return typeof s === "string" ? s.toLowerCase() : "";
|
|
51
|
+
};
|
|
52
|
+
const isForm = (s) => s !== null && s !== "double";
|
|
53
|
+
const isTree = (v) => v !== undefined && v !== null && typeof v === "object" && !Array.isArray(v);
|
|
54
|
+
const int = (v) => typeof v === "string" && /^-?\d+$/.test(v) ? Number(v) : -1;
|
|
55
|
+
/** the nodes whose values an analysed node computes from: a call's or an operator's arguments, an aggregate's, the
|
|
56
|
+
* results of a CASE (never its conditions), a coercion's argument, a scalar sublink's subselect's column, what a VAR
|
|
57
|
+
* reads (`followed`: a subquery's, a CTE's or each set operation arm's column) */
|
|
58
|
+
function inputsOf(n) {
|
|
59
|
+
if (n._node === "AGGREF")
|
|
60
|
+
return (Array.isArray(n.args) ? n.args : []).map((e) => isTree(e) && isTree(e.expr) ? e.expr : null).filter(isTree);
|
|
61
|
+
if (n._node === "CASEEXPR")
|
|
62
|
+
return [...(Array.isArray(n.args) ? n.args : []).map((w) => isTree(w) ? w.result : null), n.defresult].filter(isTree);
|
|
63
|
+
if (n._node === "SUBLINK") {
|
|
64
|
+
const q = n.subselect, first = isTree(q) && Array.isArray(q.targetList) ? q.targetList.find((e) => isTree(e) && e.resjunk !== "true") : undefined;
|
|
65
|
+
return isTree(first) && isTree(first.expr) ? [first.expr] : [];
|
|
66
|
+
}
|
|
67
|
+
if (n._node === "VAR")
|
|
68
|
+
return Array.isArray(n.followed) ? n.followed.filter(isTree) : [];
|
|
69
|
+
if (Array.isArray(n.args))
|
|
70
|
+
return n.args.filter(isTree);
|
|
71
|
+
return isTree(n.arg) ? [n.arg] : [];
|
|
72
|
+
}
|
|
73
|
+
/** a form born at a node: its kind, the user's function, and the node's offset as its one site */
|
|
74
|
+
const bornAt = (why, fn, at, from = null) => ({ why, fn, fixed: false, from, sites: [{ at, vars: [] }], way: null, scaled: null });
|
|
75
|
+
/**
|
|
76
|
+
* WHAT THE TEXT A NUMERIC IS READ FROM WAS MADE FROM (PR 202 round 12): a value's form survives every conversion, so the
|
|
77
|
+
* walk goes down through every text and JSON node over it (a cast, `||`, a jsonb build and `->>`, a WITH or subquery
|
|
78
|
+
* column, any other) to the values it was made from. A numeric there keeps its own form (`seen`: a variance, sqrt and
|
|
79
|
+
* its kin, a division, a cast from a double, a text read as a numeric), and a double the statement computes is "a cast
|
|
80
|
+
* from a double" at its node: either is the text's form, so a typmod over the text never stores the engine's double. Where
|
|
81
|
+
* none is, `from` says what the text was, as the analysis typed it: a JSON value's (an operator or a function over json or
|
|
82
|
+
* jsonb: `->>`, `json_extract_path_text`), a stored double's (a column or a constant), or any other text (a stored or
|
|
83
|
+
* exactly computed numeric's among them). Any other type stops the walk: an integer's, a date's or a boolean's text is
|
|
84
|
+
* exact whatever made it.
|
|
85
|
+
*/
|
|
86
|
+
function textFrom(n, w) {
|
|
87
|
+
const made = madeOf(n, w);
|
|
88
|
+
return { form: made.form, from: made.from ?? "text" };
|
|
89
|
+
}
|
|
90
|
+
function madeOf(x, w) {
|
|
91
|
+
const done = w.made.get(x);
|
|
92
|
+
if (done !== undefined)
|
|
93
|
+
return done;
|
|
94
|
+
const made = madeAt(x, w);
|
|
95
|
+
w.made.set(x, made);
|
|
96
|
+
return made;
|
|
97
|
+
}
|
|
98
|
+
function madeAt(x, w) {
|
|
99
|
+
const type = exprType(x)?.type ?? -1, at = int(x.location);
|
|
100
|
+
if (x._node === "VAR" && inputsOf(x).length > 0) {
|
|
101
|
+
// a column read from a subquery, a CTE or a set operation: the first arm's form, every arm of it a site, through this VAR
|
|
102
|
+
const made = inputsOf(x).map((i) => madeOf(i, w)), from = made.find((m) => m.from !== null)?.from ?? null;
|
|
103
|
+
return { form: throughVar(made.map((m) => m.form), at), from };
|
|
104
|
+
}
|
|
105
|
+
if (DOUBLES.has(type)) {
|
|
106
|
+
if (x._node === "VAR" || x._node === "CONST")
|
|
107
|
+
return { form: null, from: "double" };
|
|
108
|
+
return { form: bornAt("double", "", at), from: null };
|
|
109
|
+
}
|
|
110
|
+
if (type === NUMERIC_OID) {
|
|
111
|
+
const s = seen(x, w);
|
|
112
|
+
return { form: isForm(s) ? s : null, from: null };
|
|
113
|
+
}
|
|
114
|
+
if (!TEXTS.has(type) && !JSONS.has(type))
|
|
115
|
+
return { form: null, from: null };
|
|
116
|
+
const args = Array.isArray(x.args) ? x.args.filter(isTree) : [];
|
|
117
|
+
let from = (x._node === "OPEXPR" || x._node === "FUNCEXPR") && args[0] !== undefined && JSONS.has(exprType(args[0])?.type ?? -1) ? "json" : null;
|
|
118
|
+
for (const i of inputsOf(x)) {
|
|
119
|
+
const m = madeOf(i, w);
|
|
120
|
+
from ??= m.from;
|
|
121
|
+
if (m.form !== null)
|
|
122
|
+
return { form: m.form, from };
|
|
123
|
+
}
|
|
124
|
+
return { form: null, from };
|
|
125
|
+
}
|
|
126
|
+
/** the most sites a form's way out is written at: a statement whose set operations each read the level below in two
|
|
127
|
+
* arms doubles them at every level, so past this the reason stands and the way out is the column's whole (round 14) */
|
|
128
|
+
const MAX_SITES = 64;
|
|
129
|
+
/** a column read from a subquery, a CTE or a set operation (a VAR at `at`): the first arm's form says the reason, and
|
|
130
|
+
* every arm of that form is a site of the way out, reached through this VAR; at most one past `MAX_SITES` kept */
|
|
131
|
+
function throughVar(below, at) {
|
|
132
|
+
const forms = below.filter(isForm), first = forms[0];
|
|
133
|
+
if (first === undefined)
|
|
134
|
+
return null;
|
|
135
|
+
const sites = forms.filter((f) => f.why === first.why).flatMap((f) => f.sites).slice(0, MAX_SITES + 1)
|
|
136
|
+
.map((s) => ({ at: s.at, vars: at >= 0 ? [at, ...s.vars] : s.vars }));
|
|
137
|
+
return { ...first, sites };
|
|
138
|
+
}
|
|
139
|
+
/** the walk: each node's type first (a double is a double, anything but a numeric carries nothing), then the form below
|
|
140
|
+
* it passed up, marked by what is over it; `names` the functions' and operators' names, by `f<oid>` and `o<oid>` */
|
|
141
|
+
function seen(n, w) {
|
|
142
|
+
const done = w.seen.get(n);
|
|
143
|
+
if (done !== undefined)
|
|
144
|
+
return done;
|
|
145
|
+
const s = seenAt(n, w);
|
|
146
|
+
w.seen.set(n, s);
|
|
147
|
+
return s;
|
|
148
|
+
}
|
|
149
|
+
function seenAt(n, w) {
|
|
150
|
+
const names = w.names, t = exprType(n);
|
|
151
|
+
if (t !== null && DOUBLES.has(t.type))
|
|
152
|
+
return "double";
|
|
153
|
+
if (t === null || t.type !== NUMERIC_OID)
|
|
154
|
+
return null;
|
|
155
|
+
const inputs = inputsOf(n), below = inputs.map((x) => seen(x, w));
|
|
156
|
+
const inner = below.find(isForm) ?? null, at = int(n.location);
|
|
157
|
+
const born = (why, fn, from = null) => bornAt(why, fn, at, from);
|
|
158
|
+
// a text read as a numeric is the one form a node over it outranks: one scale for the text would not store a division,
|
|
159
|
+
// a variance or sqrt and its kin over it, so that node is the reason
|
|
160
|
+
const under = (outranks) => inner !== null && !(outranks && inner.why === "text") ? inner : null;
|
|
161
|
+
switch (n._node) {
|
|
162
|
+
case "FUNCEXPR": {
|
|
163
|
+
const cast = int(n.funcformat) === 1 || int(n.funcformat) === 2, typmod = t.typmod >= 0;
|
|
164
|
+
if (cast) {
|
|
165
|
+
// a numeric cast from a double; a typmod over a text read as a numeric gives it its one scale
|
|
166
|
+
if (below[0] === "double")
|
|
167
|
+
return { ...born("double", ""), fixed: typmod };
|
|
168
|
+
return inner === null || (inner.why === "text" && typmod) ? null : { ...inner, fixed: inner.fixed || typmod };
|
|
169
|
+
}
|
|
170
|
+
const name = names.get(`f${int(n.funcid)}`) ?? "", kept = under(PER_VALUE.has(name) || name === "div");
|
|
171
|
+
if (kept !== null)
|
|
172
|
+
return { ...kept, fixed: kept.fixed || FIXES.has(name) };
|
|
173
|
+
return PER_VALUE.has(name) ? born("per-value", name) : name === "div" ? born("divides", name) : null;
|
|
174
|
+
}
|
|
175
|
+
case "AGGREF": {
|
|
176
|
+
const name = names.get(`f${int(n.aggfnoid)}`) ?? "";
|
|
177
|
+
return under(VARIANCES.has(name) || name === "avg") ?? (VARIANCES.has(name) ? born("variance", name) : name === "avg" ? born("divides", name) : null);
|
|
178
|
+
}
|
|
179
|
+
case "OPEXPR": {
|
|
180
|
+
const divides = names.get(`o${int(n.opno)}`) === "/";
|
|
181
|
+
return under(divides) ?? (divides ? born("divides", "/") : null);
|
|
182
|
+
}
|
|
183
|
+
// a text read as a numeric keeps the form of what the text was made from (round 12: a variance's text under a typmod
|
|
184
|
+
// stored the engine's double); where nothing beneath has one, it keeps the decimals each value's text has
|
|
185
|
+
case "COERCEVIAIO": {
|
|
186
|
+
if (inputs[0] === undefined || !TEXTS.has(exprType(inputs[0])?.type ?? -1))
|
|
187
|
+
return inner;
|
|
188
|
+
const made = textFrom(inputs[0], w);
|
|
189
|
+
return made.form ?? born("text", "", made.from);
|
|
190
|
+
}
|
|
191
|
+
// a column read from a subquery, a CTE or a set operation: the first arm's form says the reason, and every arm of
|
|
192
|
+
// that form is a site of the way out, reached through this VAR
|
|
193
|
+
case "VAR": return throughVar(below, at);
|
|
194
|
+
// a coalesce, a greatest or least, a CASE's results, a relabel, a sublink: the value it takes
|
|
195
|
+
default: return inner;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** the functions and operators of analysed expressions, as `f<oid>` and `o<oid>` */
|
|
199
|
+
function oidsOf(v, out, met = new Set()) {
|
|
200
|
+
if (Array.isArray(v)) {
|
|
201
|
+
for (const x of v)
|
|
202
|
+
oidsOf(x, out, met);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!isTree(v) || met.has(v))
|
|
206
|
+
return;
|
|
207
|
+
met.add(v);
|
|
208
|
+
if (v._node === "FUNCEXPR" && int(v.funcid) > 0)
|
|
209
|
+
out.f.add(int(v.funcid));
|
|
210
|
+
if (v._node === "AGGREF" && int(v.aggfnoid) > 0)
|
|
211
|
+
out.f.add(int(v.aggfnoid));
|
|
212
|
+
if (v._node === "OPEXPR" && int(v.opno) > 0)
|
|
213
|
+
out.o.add(int(v.opno));
|
|
214
|
+
for (const [k, x] of Object.entries(v))
|
|
215
|
+
if (k !== "_node")
|
|
216
|
+
oidsOf(x, out, met);
|
|
217
|
+
}
|
|
218
|
+
/** the user's own top SELECT's body (under an `order_by:` shell, the shell's subquery's) */
|
|
219
|
+
function userTop(stmt) {
|
|
220
|
+
const body = rec(rec(stmt).SelectStmt), targets = body.targetList;
|
|
221
|
+
const from = Array.isArray(body.fromClause) ? body.fromClause : [];
|
|
222
|
+
const sub = rec(rec(from[0]).RangeSubselect);
|
|
223
|
+
const star = Array.isArray(targets) ? rec(rec(rec(rec(targets[0]).ResTarget).val).ColumnRef).fields : undefined;
|
|
224
|
+
if (Array.isArray(targets) && targets.length === 1 && from.length === 1 && rec(sub.alias).aliasname === SHELL_ALIAS && Array.isArray(star) && "A_Star" in rec(star[0]))
|
|
225
|
+
return userTop(sub.subquery);
|
|
226
|
+
return body;
|
|
227
|
+
}
|
|
228
|
+
/** the user's own expression of each output column (the raw tree's targets), null for a set operation's */
|
|
229
|
+
function rawTargets(stmt) {
|
|
230
|
+
const targets = userTop(stmt).targetList;
|
|
231
|
+
return Array.isArray(targets) ? targets.map((t) => rec(rec(t).ResTarget).val) : null;
|
|
232
|
+
}
|
|
233
|
+
const typeName = (name, typmods = []) => ({ names: [{ String: { sval: "pg_catalog" } }, { String: { sval: name } }],
|
|
234
|
+
...(typmods.length === 0 ? {} : { typmods: typmods.map((m) => ({ A_Const: { ival: { ival: m } } })) }), typemod: -1 });
|
|
235
|
+
const DOUBLE_TYPE = typeName("float8");
|
|
236
|
+
/** the one scale a way out gives a text read as a numeric */
|
|
237
|
+
const ONE_SCALE = typeName("numeric", [20, 4]);
|
|
238
|
+
const isNumericCast = (n) => "TypeCast" in rec(n) && lastName(rec(rec(rec(n).TypeCast).typeName).names) === "numeric";
|
|
239
|
+
const isFixer = (n) => isNumericCast(n) || ("FuncCall" in rec(n) && FIXES.has(lastName(rec(rec(n).FuncCall).funcname)));
|
|
240
|
+
const castTo = (n, type) => ({ TypeCast: { arg: n, typeName: type } });
|
|
241
|
+
/** a cast made a cast to `type` (a call such as `numeric(x)`: its argument cast) */
|
|
242
|
+
const retyped = (n, type) => "TypeCast" in rec(n) ? { TypeCast: { ...rec(rec(n).TypeCast), typeName: type } }
|
|
243
|
+
: castTo(rec(rec(n).FuncCall).args?.[0], type);
|
|
244
|
+
/** an expression of the raw tree, as the deparser writes it */
|
|
245
|
+
const exprText = (val) => deparseSync({ SelectStmt: { targetList: [{ ResTarget: { val } }], limitOption: "LIMIT_OPTION_DEFAULT", op: "SETOP_NONE" } }, { pretty: false }).replace(/^SELECT /, "");
|
|
246
|
+
function pathTo(root, hit) {
|
|
247
|
+
const path = [];
|
|
248
|
+
const find = (v, key) => {
|
|
249
|
+
if (Array.isArray(v))
|
|
250
|
+
return v.some((x) => find(x, key));
|
|
251
|
+
if (typeof v !== "object" || v === null)
|
|
252
|
+
return false;
|
|
253
|
+
const node = v;
|
|
254
|
+
path.push({ key, node });
|
|
255
|
+
if (hit(node) || Object.entries(node).some(([k, x]) => find(x, k)))
|
|
256
|
+
return true;
|
|
257
|
+
path.pop();
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
return find(root, "") ? path : null;
|
|
261
|
+
}
|
|
262
|
+
const located = (kinds, at) => (o) => kinds.some((k) => k in o && rec(o[k]).location === at);
|
|
263
|
+
/**
|
|
264
|
+
* WHERE A WAY OUT IS WRITTEN, for a path to a node of the raw tree: the output column's own expression (its ResTarget,
|
|
265
|
+
* `name` null; a scalar sublink is inside it), or the query of the statement the node is in, named in the user's words
|
|
266
|
+
* (`the WITH query t`, `the subquery s`, `the UNION ALL`: an arm of a set operation is written as the whole of it).
|
|
267
|
+
* Undefined where that query has no name the user wrote.
|
|
268
|
+
*/
|
|
269
|
+
function placeOf(path, top) {
|
|
270
|
+
const i = path.findIndex((s) => "ResTarget" in s.node);
|
|
271
|
+
if (i < 1)
|
|
272
|
+
return undefined;
|
|
273
|
+
if (path[i - 1].node === top)
|
|
274
|
+
return { key: path[i].node, name: null };
|
|
275
|
+
let r = i - 1;
|
|
276
|
+
while (r > 0 && (path[r].key === "larg" || path[r].key === "rarg"))
|
|
277
|
+
r--;
|
|
278
|
+
const body = path[r].node;
|
|
279
|
+
if (body === top)
|
|
280
|
+
return { key: body, name: `the ${String(body.op).replace(/^SETOP_/, "")}${body.all === true ? " ALL" : ""}` };
|
|
281
|
+
const wrapper = path[r - 1], parent = path[r - 2], alias = rec(parent?.node.alias).aliasname;
|
|
282
|
+
if (wrapper?.key === "ctequery" && parent?.key === "CommonTableExpr")
|
|
283
|
+
return { key: body, name: `the WITH query ${String(parent.node.ctename)}` };
|
|
284
|
+
if (wrapper?.key === "subquery" && parent?.key === "RangeSubselect" && typeof alias === "string")
|
|
285
|
+
return { key: body, name: `the subquery ${alias}` };
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* THE WAY OUT IN THE USER'S OWN WORDS (`Form.way`, `Form.scaled`): their SQL with only the cast moved, every aggregate and
|
|
290
|
+
* wrapper kept, at each of the form's sites. As a double (`double`), the value is cast to double precision at the node
|
|
291
|
+
* the engine can't store, or above the outermost round, trunc, floor, ceil or numeric cast over it, through every query
|
|
292
|
+
* the walk followed it across (a round of a double takes no decimals, and a numeric cast would make it a numeric again):
|
|
293
|
+
* a numeric cast there is made the cast to double, a numeric cast from a double with nothing over it is taken away, and a
|
|
294
|
+
* text's cast to numeric is made the cast to double. Given one scale (`scale`, a text read as a numeric), the text's cast
|
|
295
|
+
* is given `numeric(20,4)`. Written where the cast lands (`placeOf`); null where a node is not found in the user's text.
|
|
296
|
+
*/
|
|
297
|
+
function wayOf(stmt, f, mode) {
|
|
298
|
+
const top = userTop(stmt), swaps = new Map(), places = new Map();
|
|
299
|
+
for (const site of f.sites) {
|
|
300
|
+
const born = pathTo(stmt, located(["FuncCall", "TypeCast", "A_Expr"], site.at));
|
|
301
|
+
if (born === null)
|
|
302
|
+
return null;
|
|
303
|
+
const target = born[born.length - 1].node;
|
|
304
|
+
// the expressions over the node within each query, the column's own first, through each VAR the walk followed
|
|
305
|
+
const segments = [...site.vars.map((v) => pathTo(stmt, located(["ColumnRef"], v))).filter((p) => p !== null), born];
|
|
306
|
+
const over = (p) => p.slice(p.findIndex((s) => "ResTarget" in s.node) + 1, -1).find((s) => isFixer(s.node));
|
|
307
|
+
const fixed = mode === "scale" ? undefined : segments.map((p) => ({ p, s: over(p) })).find((x) => x.s !== undefined);
|
|
308
|
+
const [node, path] = fixed?.s !== undefined ? [fixed.s.node, fixed.p] : [target, born];
|
|
309
|
+
swaps.set(node, mode === "scale" ? retyped(target, ONE_SCALE)
|
|
310
|
+
: fixed !== undefined ? (isNumericCast(node) ? retyped(node, DOUBLE_TYPE) : castTo(node, DOUBLE_TYPE))
|
|
311
|
+
: !isNumericCast(target) ? castTo(target, DOUBLE_TYPE) : f.why === "double" ? rec(rec(target).TypeCast).arg : retyped(target, DOUBLE_TYPE));
|
|
312
|
+
const place = placeOf(path, top);
|
|
313
|
+
if (place === undefined)
|
|
314
|
+
return null;
|
|
315
|
+
places.set(place.key, place.name);
|
|
316
|
+
}
|
|
317
|
+
const swap = (v) => swaps.has(v) ? swaps.get(v) : Array.isArray(v) ? v.map(swap)
|
|
318
|
+
: typeof v === "object" && v !== null ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, swap(x)])) : v;
|
|
319
|
+
const all = [...places];
|
|
320
|
+
if (all.length === 1 && all[0][1] === null)
|
|
321
|
+
return { expr: exprText(swap(rec(rec(all[0][0]).ResTarget).val)) };
|
|
322
|
+
if (all.some(([, name]) => name === null))
|
|
323
|
+
return null;
|
|
324
|
+
return { places: all.map(([body, name]) => ({ place: name, select: deparseSync({ SelectStmt: swap(body) }, { pretty: false }) })) };
|
|
325
|
+
}
|
|
326
|
+
/** each output column's form (`DefinitionPlan.forms`), read from its analysed expression, with its ways out written over
|
|
327
|
+
* the user's own SQL: null where it has none */
|
|
328
|
+
async function formsOf(rows, outputs, stmt, n) {
|
|
329
|
+
if (outputs === undefined || outputs.length !== n)
|
|
330
|
+
return Array(n).fill(null);
|
|
331
|
+
const oids = { f: new Set(), o: new Set() };
|
|
332
|
+
oidsOf(outputs, oids);
|
|
333
|
+
const names = new Map(oids.f.size + oids.o.size === 0 ? [] : (await rows(`SELECT 'f' || oid::text, proname::text FROM pg_proc WHERE oid IN (${[0, ...oids.f].join(", ")}) UNION ALL SELECT 'o' || oid::text, oprname::text FROM pg_operator WHERE oid IN (${[0, ...oids.o].join(", ")})`))
|
|
334
|
+
.map(([k, name]) => [k, name]));
|
|
335
|
+
const w = { names, seen: new Map(), made: new Map() };
|
|
336
|
+
return outputs.map((e) => {
|
|
337
|
+
const s = e === null ? null : seen(e, w);
|
|
338
|
+
if (!isForm(s))
|
|
339
|
+
return null;
|
|
340
|
+
const found = s.sites.length <= MAX_SITES && s.sites.every((x) => x.at >= 0);
|
|
341
|
+
return { ...s, way: found ? wayOf(stmt, s, "double") : null, scaled: found && s.why === "text" ? wayOf(stmt, s, "scale") : null };
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
const asRecord = (v) => v;
|
|
345
|
+
const WAY_OUT_DOCS = "write it as a SELECT: one statement, reading the tables the sources file syncs";
|
|
346
|
+
/** the file's `sql:` parsed: one SELECT, or the refusal */
|
|
347
|
+
async function selectOf(sql) {
|
|
348
|
+
let stmts;
|
|
349
|
+
try {
|
|
350
|
+
stmts = (await parseSql(sql)).stmts ?? [];
|
|
351
|
+
}
|
|
352
|
+
catch (e) {
|
|
353
|
+
if (!(e instanceof PostgresSyntaxError))
|
|
354
|
+
throw e;
|
|
355
|
+
return { refused: { key: "sql", message: `it is not SQL Postgres reads: ${e.message}`, why: null, options: [WAY_OUT_DOCS] } };
|
|
356
|
+
}
|
|
357
|
+
const only = stmts[0]?.stmt;
|
|
358
|
+
if (stmts.length !== 1 || only === undefined)
|
|
359
|
+
return { refused: { key: "sql", message: `it holds ${stmts.length} statements`, why: "A generated table is what one SELECT gives.", options: [WAY_OUT_DOCS] } };
|
|
360
|
+
const body = asRecord(only).SelectStmt;
|
|
361
|
+
if (body === undefined)
|
|
362
|
+
return { refused: { key: "sql", message: "it is not a SELECT", why: "Mason reads and stores: a generated table is what a SELECT gives.", options: [WAY_OUT_DOCS] } };
|
|
363
|
+
if (body.intoClause !== undefined || body.lockingClause !== undefined)
|
|
364
|
+
return { refused: { key: "sql", message: "it is a SELECT with an INTO or a locking clause", why: "Neither means anything in a table Mason keeps.", options: ["remove the clause"] } };
|
|
365
|
+
return { stmt: only };
|
|
366
|
+
}
|
|
367
|
+
/** a top-level ORDER BY with no LIMIT: it can't change which rows the table holds, so nothing is refused for it */
|
|
368
|
+
const UNUSED_ORDER = "sql: its ORDER BY doesn't change what the table holds. Set order_by: for how its rows are laid out, and sort in the statements that read it.";
|
|
369
|
+
/**
|
|
370
|
+
* THE STATEMENT A DEFINITION RUNS: its `sql:`, and with an `order_by:` that SELECT read by a shell sorted by it, so Postgres
|
|
371
|
+
* resolves the order over the output's columns and the rewrite sorts as a statement sorts. The shell is held to its own
|
|
372
|
+
* shape and the `sql:` put in as a tree, never pasted into text: `order_by:` is one clause, so `x LIMIT 1` or `x; DROP`
|
|
373
|
+
* is refused.
|
|
374
|
+
*/
|
|
375
|
+
export async function definitionStatement(sql, orderBy) {
|
|
376
|
+
const select = await selectOf(sql);
|
|
377
|
+
if ("refused" in select)
|
|
378
|
+
return select;
|
|
379
|
+
const top = asRecord(select.stmt).SelectStmt;
|
|
380
|
+
const warnings = top.sortClause !== undefined && top.limitCount === undefined && top.limitOffset === undefined ? [UNUSED_ORDER] : [];
|
|
381
|
+
if (orderBy === null)
|
|
382
|
+
return { text: sql, stmt: select.stmt, warnings };
|
|
383
|
+
const bad = (why) => ({ refused: { key: "order_by", message: `is not what follows ORDER BY over the table's columns: ${why}`, why: null, options: ["write one SQL string, like order_by: day, tenant_id"] } });
|
|
384
|
+
let stmts;
|
|
385
|
+
try {
|
|
386
|
+
stmts = (await parseSql(`SELECT * FROM (SELECT 1) AS ${SHELL_ALIAS} ORDER BY ${orderBy}`)).stmts ?? [];
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
if (!(e instanceof PostgresSyntaxError))
|
|
390
|
+
throw e;
|
|
391
|
+
return bad(e.message);
|
|
392
|
+
}
|
|
393
|
+
const shell = stmts[0]?.stmt, body = asRecord(shell ?? {}).SelectStmt;
|
|
394
|
+
if (stmts.length !== 1 || shell === undefined || body === undefined)
|
|
395
|
+
return bad("it ends the statement");
|
|
396
|
+
const stray = Object.keys(body).filter((k) => !SHELL_KEYS.has(k));
|
|
397
|
+
const from = body.fromClause ?? [];
|
|
398
|
+
if (stray.length > 0 || from.length !== 1 || (body.op !== undefined && body.op !== "SETOP_NONE") || (body.limitOption !== undefined && body.limitOption !== "LIMIT_OPTION_DEFAULT"))
|
|
399
|
+
return bad("it says more than an order");
|
|
400
|
+
asRecord(from[0]).RangeSubselect.subquery = select.stmt;
|
|
401
|
+
const text = deparseSync(shell, { pretty: false });
|
|
402
|
+
const back = (await parseSql(text)).stmts?.[0]?.stmt;
|
|
403
|
+
const difference = back === undefined ? "nothing" : treeDifference(back, shell);
|
|
404
|
+
if (back === undefined || difference !== null)
|
|
405
|
+
throw new Error(`a generated table's statement does not read back as itself (Mason bug: report it): ${difference}`);
|
|
406
|
+
return { text, stmt: back, warnings };
|
|
407
|
+
}
|
|
408
|
+
const THE_SESSION_WHY = "A generated table has no session, so nothing may depend on one's time zone or settings: a default would give wrong days with no error.";
|
|
409
|
+
const THE_SESSION_WAYS = [
|
|
410
|
+
"name the zone in the SQL: t AT TIME ZONE 'UTC', or date_trunc('day', t, 'UTC')",
|
|
411
|
+
"for today, (now() AT TIME ZONE 'UTC')::date",
|
|
412
|
+
];
|
|
413
|
+
/** the SQLVALUEFUNCTION kinds that are instants, the run's (`field:SQLVALUEFUNCTION.op=`): CURRENT_TIMESTAMP and (n) */
|
|
414
|
+
const INSTANTS = new Set(["3", "4"]);
|
|
415
|
+
/** Marked stable or volatile, and reading no session here: the clock, as of the run (`DuckPlan.clock`), and a timestamp's
|
|
416
|
+
* text, whose zone the SQL has already said */
|
|
417
|
+
const NOT_THE_SESSION = ["now()", "transaction_timestamp()", "statement_timestamp()", "clock_timestamp()", "random()", "to_char(timestamp without time zone, text)"];
|
|
418
|
+
/** Marked immutable, and reading the session's zone (timestamptz_age splits both instants in it) */
|
|
419
|
+
const READS_ZONE_ANYWAY = ["age(timestamp with time zone, timestamp with time zone)"];
|
|
420
|
+
/**
|
|
421
|
+
* What the plan's keys read of a session, as a person reads each (none: nothing). The keys are what Postgres resolved the
|
|
422
|
+
* statement to (src/duck/resolved.ts): a function by its OID, an operator by its, a cast through text by its two types'
|
|
423
|
+
* output and input functions, read back from the catalog so nothing here is a list of names to keep current.
|
|
424
|
+
*/
|
|
425
|
+
export async function sessionReads(rows, keys) {
|
|
426
|
+
// the listener's own current_setting (src/api/postgres/catalog.ts), which reads a session's settings by construction
|
|
427
|
+
const out = keys.filter((k) => /^function:ext:current_setting\(/.test(k));
|
|
428
|
+
const values = (kind) => keys.filter((k) => k.startsWith(`${kind}:`)).map((k) => k.slice(kind.length + 1));
|
|
429
|
+
for (const k of keys) {
|
|
430
|
+
const m = /^field:SQLVALUEFUNCTION\.op=(\d+)$/.exec(k);
|
|
431
|
+
if (m !== null && !INSTANTS.has(m[1]))
|
|
432
|
+
out.push(k);
|
|
433
|
+
}
|
|
434
|
+
const procs = [...values("function"), ...values("aggregate"), ...values("window")].filter((v) => /^\d+$/.test(v));
|
|
435
|
+
const allowed = NOT_THE_SESSION.map((s) => `'${s}'::regprocedure::oid`).join(", ");
|
|
436
|
+
const denied = READS_ZONE_ANYWAY.map((s) => `'${s}'::regprocedure::oid`).join(", ");
|
|
437
|
+
if (procs.length > 0) {
|
|
438
|
+
// a stable function over "any" or a polymorphic type is stable for its arguments' text alone: `textReads` judges it
|
|
439
|
+
const hit = await rows(`SELECT p.oid::text FROM pg_proc p WHERE p.oid IN (${procs.join(", ")}) AND (p.oid IN (${denied}) OR (p.provolatile <> 'i' AND p.oid NOT IN (${allowed}) ` +
|
|
440
|
+
`AND NOT (p.provolatile = 's' AND ${FOR_ITS_ARGUMENTS})))`);
|
|
441
|
+
const set = new Set(hit.map(([o]) => o));
|
|
442
|
+
for (const k of keys) {
|
|
443
|
+
const v = k.slice(k.indexOf(":") + 1);
|
|
444
|
+
if (/^(function|aggregate|window):/.test(k) && set.has(v))
|
|
445
|
+
out.push(k);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const ops = values("operator").filter((v) => /^\d+$/.test(v));
|
|
449
|
+
if (ops.length > 0) {
|
|
450
|
+
const hit = new Set((await rows(`SELECT o.oid::text FROM pg_operator o JOIN pg_proc p ON p.oid = o.oprcode WHERE o.oid IN (${ops.join(", ")}) AND (p.oid IN (${denied}) OR (p.provolatile <> 'i' AND p.oid NOT IN (${allowed})))`)).map(([o]) => o));
|
|
451
|
+
for (const v of ops)
|
|
452
|
+
if (hit.has(v))
|
|
453
|
+
out.push(`operator:${v}`);
|
|
454
|
+
}
|
|
455
|
+
// a cast through text calls its type's output function, and the next type's input function (`date::text` reads DateStyle)
|
|
456
|
+
const casts = values("cast").map((v) => v.split(">")).filter((p) => p.length === 2 && p.every((x) => /^\d+$/.test(x)));
|
|
457
|
+
if (keys.includes("node:COERCEVIAIO") && casts.length > 0) {
|
|
458
|
+
const list = casts.map(([from, to]) => `(${from}, ${to})`).join(", ");
|
|
459
|
+
const hit = await rows(`SELECT c.a::text, c.b::text FROM (VALUES ${list}) AS c(a, b) JOIN pg_type f ON f.oid = c.a JOIN pg_type t ON t.oid = c.b ` +
|
|
460
|
+
`JOIN pg_proc o ON o.oid = f.typoutput::oid JOIN pg_proc i ON i.oid = t.typinput::oid WHERE o.provolatile <> 'i' OR i.provolatile <> 'i'`);
|
|
461
|
+
for (const [a, b] of hit)
|
|
462
|
+
out.push(`cast:${a}>${b}`);
|
|
463
|
+
}
|
|
464
|
+
if (out.length === 0)
|
|
465
|
+
return [];
|
|
466
|
+
const names = await describeKeys(rows, out);
|
|
467
|
+
return [...new Set(out.map((k) => names.get(k) ?? k))];
|
|
468
|
+
}
|
|
469
|
+
/** a definition that reads a session, `session` what it reads as a person reads each */
|
|
470
|
+
const sessionRefusal = (session) => ({ key: "sql", message: `it reads a session: ${session.slice(0, 5).join("; ")}`, why: THE_SESSION_WHY, options: THE_SESSION_WAYS });
|
|
471
|
+
/** a function (`p`) taking "any" or a polymorphic type: Postgres marks one stable when an argument's text may read a
|
|
472
|
+
* setting (`jsonb_build_object`, `concat`, `format`, `to_jsonb`, `jsonb_agg`) */
|
|
473
|
+
const FOR_ITS_ARGUMENTS = "EXISTS (SELECT 1 FROM unnest(p.proargtypes::oid[] || p.provariadic) AS a(t) JOIN pg_type y ON y.oid = a.t WHERE y.typtype = 'p')";
|
|
474
|
+
/**
|
|
475
|
+
* What a stable function over "any" or a polymorphic type reads of a session through its arguments' text, as a person
|
|
476
|
+
* reads each (none: nothing): a function given an argument whose type's text Postgres writes by a setting (a timestamptz,
|
|
477
|
+
* an interval, a date, an array or row of one). Judged by each argument the statement gives it (`argTypes`, src/duck/resolved.ts
|
|
478
|
+
* `argumentTypes`), from the type's own output function, as `sessionReads` judges a cast through text.
|
|
479
|
+
*/
|
|
480
|
+
export async function textReads(rows, keys, argTypes) {
|
|
481
|
+
const procs = keys.filter((k) => /^(function|aggregate|window):\d+$/.test(k)).map((k) => k.slice(k.indexOf(":") + 1));
|
|
482
|
+
if (procs.length === 0)
|
|
483
|
+
return [];
|
|
484
|
+
const flagged = new Set((await rows(`SELECT p.oid::text FROM pg_proc p WHERE p.oid IN (${procs.join(", ")}) AND p.provolatile = 's' AND ${FOR_ITS_ARGUMENTS}`)).map(([o]) => o));
|
|
485
|
+
if (flagged.size === 0)
|
|
486
|
+
return [];
|
|
487
|
+
const pairs = argTypes.map((a) => a.split(":")).filter((x) => x.length === 2 && flagged.has(x[0]) && /^\d+$/.test(x[1]));
|
|
488
|
+
const out = [];
|
|
489
|
+
// a function the statement gives no argument of a known type: nothing shows what it reads, so it is refused
|
|
490
|
+
const unknown = [...flagged].filter((f) => !pairs.some(([g]) => g === f));
|
|
491
|
+
if (unknown.length > 0)
|
|
492
|
+
for (const [n] of await rows(`SELECT oid::regprocedure::text FROM pg_proc WHERE oid IN (${unknown.join(", ")})`))
|
|
493
|
+
out.push(n);
|
|
494
|
+
if (pairs.length > 0) {
|
|
495
|
+
const hit = await rows(`SELECT DISTINCT format('%s over %s', (SELECT proname FROM pg_proc WHERE oid = a.f), format_type(a.t, NULL)) FROM (VALUES ${pairs.map(([f, t]) => `(${f}::oid, ${t}::oid)`).join(", ")}) AS a(f, t) ` +
|
|
496
|
+
`JOIN pg_type y ON y.oid = a.t JOIN pg_proc o ON o.oid = y.typoutput LEFT JOIN pg_type el ON y.typelem <> 0 AND el.oid = y.typelem LEFT JOIN pg_proc eo ON eo.oid = el.typoutput ` +
|
|
497
|
+
`WHERE CASE WHEN y.typcategory = 'A' THEN coalesce(eo.provolatile, 'v') <> 'i' ELSE o.provolatile <> 'i' END`);
|
|
498
|
+
for (const [n] of hit)
|
|
499
|
+
out.push(n);
|
|
500
|
+
}
|
|
501
|
+
return out;
|
|
502
|
+
}
|
|
503
|
+
/** a definition whose function reads a session through an argument's text */
|
|
504
|
+
const textRefusal = (reads) => ({
|
|
505
|
+
key: "sql", message: `it calls ${reads.slice(0, 3).join("; ")}, a type whose text Postgres writes by the session's settings`,
|
|
506
|
+
why: "Postgres writes that value's text by the session's DateStyle, TimeZone or IntervalStyle, and a generated table has no session.",
|
|
507
|
+
options: ["give it that value as text you write, with the zone named: to_char(t AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')", "or call it in the statement that reads the table, over the column this one stores"],
|
|
508
|
+
});
|
|
509
|
+
/** the reason a table's column cannot be stored, with its ways out; null: it can */
|
|
510
|
+
function columnProblem(f, typeName, kind) {
|
|
511
|
+
const it = `column ${f.name}`;
|
|
512
|
+
if (f.name === UNNAMED)
|
|
513
|
+
return { key: "sql", message: "an output column has no name", why: "A table's columns are named, and Postgres has no name for an expression.", options: ["give it one: count(*) + 1 AS total"] };
|
|
514
|
+
if (f.name.startsWith("__mason"))
|
|
515
|
+
return { key: "sql", message: `${it} has a name Mason keeps for itself`, why: null, options: ["rename it with AS"] };
|
|
516
|
+
if (STORED_TYPES.has(f.oid) || asAnswered(f.oid, kind))
|
|
517
|
+
return null;
|
|
518
|
+
if (kind === "d")
|
|
519
|
+
return { key: "sql", message: `${it} is of type ${typeName}, a domain, which a generated table can't store yet`, why: null, options: [`cast it to the domain's own type, or to text: ${f.name}::text`] };
|
|
520
|
+
return { key: "sql", message: `${it} is of type ${typeName}, which a generated table can't store yet`, why: null, options: [`cast it to a type it can store: ${f.name}::text`] };
|
|
521
|
+
}
|
|
522
|
+
/** a column stored as the engine answers it (`GeneratedColumn.duck` null): a numeric, an enum (`typtype` e), a citext */
|
|
523
|
+
const asAnswered = (oid, kind) => oid === NUMERIC_OID || kind === "e" || kind === "citext";
|
|
524
|
+
/** the forms whose value the engine computes as a double, where Postgres computes it exactly (a variance; sqrt, exp, ln,
|
|
525
|
+
* log or power; avg or `/`) or from a double's digits (a numeric cast from one): never stored, whatever the program */
|
|
526
|
+
const ENGINE_DOUBLES = new Set(["variance", "per-value", "divides", "double"]);
|
|
527
|
+
/** the variance family: Postgres computes each of a numeric exactly, at decimals it picks by the value's size */
|
|
528
|
+
const VARIANCES = new Set(["variance", "var_samp", "var_pop", "stddev", "stddev_samp", "stddev_pop"]);
|
|
529
|
+
const INTEGER_TYPES = new Set(["TINYINT", "SMALLINT", "INTEGER", "BIGINT", "HUGEINT", "UTINYINT", "USMALLINT", "UINTEGER", "UBIGINT", "UHUGEINT"]);
|
|
530
|
+
const DECIMAL_TYPE = /^DECIMAL\((\d+),(\d+)\)$/;
|
|
531
|
+
/** the scale of values combined: what decides it per row, by precedence, else `numbers` of the numbers */
|
|
532
|
+
const scaleOf = (xs, numbers) => {
|
|
533
|
+
for (const k of ["text", "value", "rows", "picks"])
|
|
534
|
+
if (xs.some((x) => x.scale === k))
|
|
535
|
+
return k;
|
|
536
|
+
return numbers(xs.map((x) => x.scale));
|
|
537
|
+
};
|
|
538
|
+
const made = (sql, held, scale, from) => ({ sql, held, scale, peak: Math.max(held, ...from.map((x) => x.peak)), engine: from.some((x) => x.engine) });
|
|
539
|
+
/**
|
|
540
|
+
* A NUMERIC COMPUTED AT THE RESULT'S EDGE (`ExactColumn`), WRITTEN AS THE ENGINE'S DECIMAL ARITHMETIC over the plan's cells
|
|
541
|
+
* (`__mason_output."__mason_cell_<key>"`, src/duck/rewrite.ts). Each cell's ENGINE type is what DuckDB says the plan's SQL
|
|
542
|
+
* computes (`types`, never assumed): a DECIMAL is used at its own scale, an integer (a sum of int8 is a HUGEINT) is cast
|
|
543
|
+
* to DECIMAL(38,0) first, since DuckDB's floor, ceil and round of an integer are a double's; a text cell (sqrt, exp, ln,
|
|
544
|
+
* power, computed by the catalog's Postgres) is a value at its own decimals, and a double cell is a double: each judged
|
|
545
|
+
* at the top (`verdict`), where what is around it is known. DuckDB's DECIMAL keeps the scale Postgres's numeric does for
|
|
546
|
+
* `+ - *` (the larger; the sum), a negation, `abs` and `mod` (the larger), and an explicit one for `round(x, n)` and
|
|
547
|
+
* `trunc(x, n)` (n < 0: none), a cast to numeric(p,s), `floor` and `ceil`, each rounded half away from zero as Postgres
|
|
548
|
+
* rounds. A GREATEST, LEAST, COALESCE or CASE is the value it takes, each argument cast to the most decimals any holds so
|
|
549
|
+
* none loses a digit. Not stored here: a division (the engine divides a numeric as a double; an average is one).
|
|
550
|
+
* One node at a time: its children are `sub`'s (`storedProgram`, which walks each distinct node once).
|
|
551
|
+
*/
|
|
552
|
+
function storedNumeric(p, types, cells, sub) {
|
|
553
|
+
// a numeric read from text is its text until a typmod gives it a scale: nothing else computes over it
|
|
554
|
+
const all = (args) => {
|
|
555
|
+
const out = [];
|
|
556
|
+
for (const a of args) {
|
|
557
|
+
const x = sub(a);
|
|
558
|
+
if ("why" in x)
|
|
559
|
+
return x;
|
|
560
|
+
if (x.scale === "text")
|
|
561
|
+
return { why: "text" };
|
|
562
|
+
out.push(x);
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
};
|
|
566
|
+
const one = (arg, make) => {
|
|
567
|
+
const x = sub(arg);
|
|
568
|
+
return "why" in x ? x : x.scale === "text" ? { why: "text" } : make(x);
|
|
569
|
+
};
|
|
570
|
+
// a scale the types fix again: the value cast to it
|
|
571
|
+
const fixed = (x, sql, scale, precision = 38) => made(`CAST(${sql} AS DECIMAL(${precision},${scale}))`, scale, scale, [x]);
|
|
572
|
+
// round or trunc to n decimals; n < 0 rounds left of the point, at no decimals
|
|
573
|
+
const toPlace = (fn, arg, n) => one(arg, (x) => fixed(x, `${fn}(${x.sql}, ${n})`, Math.max(0, n)));
|
|
574
|
+
switch (p.op) {
|
|
575
|
+
case "cell": {
|
|
576
|
+
const name = `__mason_cell_${p.key}`, type = types.get(name);
|
|
577
|
+
const sql = `__mason_output.${JSON.stringify(name)}`;
|
|
578
|
+
const d = type === undefined ? null : DECIMAL_TYPE.exec(type);
|
|
579
|
+
if (d !== null)
|
|
580
|
+
return { sql, held: Number(d[2]), peak: Number(d[2]), scale: Number(d[2]), engine: false };
|
|
581
|
+
if (type !== undefined && INTEGER_TYPES.has(type))
|
|
582
|
+
return { sql: `CAST(${sql} AS DECIMAL(38,0))`, held: 0, peak: 0, scale: 0, engine: false };
|
|
583
|
+
// a text cell: a numeric read from text (stored under a typmod, which rounds it as numeric_in does, half away from
|
|
584
|
+
// zero), or a function the catalog's Postgres computes; a double cell: the engine's. Neither of the last two is
|
|
585
|
+
// stored, and why is the column's form's
|
|
586
|
+
if (type === "VARCHAR" && cells[Number(p.key)] === "parsed")
|
|
587
|
+
return { sql, held: 0, peak: 0, scale: "text", engine: false };
|
|
588
|
+
if (type === "VARCHAR" || type === "DOUBLE" || type === "FLOAT")
|
|
589
|
+
return { sql: "NULL", held: 0, peak: 0, scale: "value", engine: true };
|
|
590
|
+
return { why: "other", what: `a value the engine holds as ${type ?? "nothing it names"}` };
|
|
591
|
+
}
|
|
592
|
+
case "literal": {
|
|
593
|
+
const m = /^-?(\d+)(?:\.(\d*))?$/.exec(p.text);
|
|
594
|
+
if (m === null)
|
|
595
|
+
return { why: "other", what: `the literal ${p.text}` };
|
|
596
|
+
const scale = m[2]?.length ?? 0;
|
|
597
|
+
return { sql: `CAST('${p.text}' AS DECIMAL(${Math.min(38, Math.max(1, m[1].length + scale))},${scale}))`, held: scale, peak: scale, scale, engine: false };
|
|
598
|
+
}
|
|
599
|
+
case "add":
|
|
600
|
+
case "sub":
|
|
601
|
+
case "mul":
|
|
602
|
+
case "mod": {
|
|
603
|
+
const xs = all(p.args);
|
|
604
|
+
if (!Array.isArray(xs))
|
|
605
|
+
return xs;
|
|
606
|
+
const [a, b] = xs;
|
|
607
|
+
if (p.op === "mul")
|
|
608
|
+
return made(`(${a.sql} * ${b.sql})`, a.held + b.held, scaleOf(xs, (n) => n[0] + n[1]), xs);
|
|
609
|
+
// a mod by zero raises, as Postgres's does: the engine's would be NULL
|
|
610
|
+
const sql = p.op === "mod" ? `(CASE WHEN ${b.sql} = 0 THEN error('division by zero') ELSE ${a.sql} % ${b.sql} END)` : `(${a.sql} ${p.op === "add" ? "+" : "-"} ${b.sql})`;
|
|
611
|
+
return made(sql, Math.max(a.held, b.held), scaleOf(xs, (n) => Math.max(...n)), xs);
|
|
612
|
+
}
|
|
613
|
+
case "neg": return one(p.arg, (x) => ({ ...x, sql: `(-${x.sql})` }));
|
|
614
|
+
case "abs": return one(p.arg, (x) => ({ ...x, sql: `abs(${x.sql})` }));
|
|
615
|
+
case "floor":
|
|
616
|
+
case "ceil": return one(p.arg, (x) => fixed(x, `${p.op}(${x.sql})`, 0));
|
|
617
|
+
case "round": return toPlace("round", p.arg, p.scale);
|
|
618
|
+
case "trunc": return toPlace("trunc", p.arg, p.scale);
|
|
619
|
+
// numeric(p, s) past 38 digits holds what DECIMAL(38, s) does, for a value the engine computed
|
|
620
|
+
case "typmod": {
|
|
621
|
+
const x = sub(p.arg);
|
|
622
|
+
return "why" in x ? x : fixed(x, x.sql, p.scale, Math.min(38, p.precision));
|
|
623
|
+
}
|
|
624
|
+
// past MAX_STORED_SCALE the cast could overflow: the value as held, its scale the types' (refused at the top, unless
|
|
625
|
+
// a round or a cast above fixes a smaller one); a scale decided per row stays so (a pad is at least, never at)
|
|
626
|
+
case "pad": return one(p.arg, (x) => typeof x.scale !== "number" ? x : p.scale <= MAX_STORED_SCALE ? fixed(x, x.sql, p.scale) : { ...x, scale: p.scale });
|
|
627
|
+
case "greatest":
|
|
628
|
+
case "least":
|
|
629
|
+
case "coalesce":
|
|
630
|
+
case "pick": {
|
|
631
|
+
// a sum of numbers read from text (src/duck/rewrite.ts `summed`): its exact sum, or the double's NaN or Infinity,
|
|
632
|
+
// which no stored numeric holds; the exact sum's refusal is the form's true reason
|
|
633
|
+
if (p.op === "pick" && p.args[0]?.op === "rescale")
|
|
634
|
+
return sub(p.args[0]);
|
|
635
|
+
const xs = all(p.args);
|
|
636
|
+
if (!Array.isArray(xs))
|
|
637
|
+
return xs;
|
|
638
|
+
const held = Math.max(0, ...xs.map((x) => x.held));
|
|
639
|
+
const args = xs.map((x) => `CAST(${x.sql} AS DECIMAL(38,${held}))`);
|
|
640
|
+
const scales = new Set(xs.map((x) => x.scale));
|
|
641
|
+
const scale = scales.size === 1 ? [...scales][0] : scaleOf(xs, () => -1) === -1 ? "picks" : scaleOf(xs, () => -1);
|
|
642
|
+
// (differing numbers: the value taken decides, "picks"; a scale decided per row stays so)
|
|
643
|
+
if (p.op !== "pick")
|
|
644
|
+
return made(`${p.op}(${args.join(", ")})`, held, scale, xs);
|
|
645
|
+
const index = sub(p.index);
|
|
646
|
+
if ("why" in index)
|
|
647
|
+
return index;
|
|
648
|
+
return made(`(CASE CAST(${index.sql} AS BIGINT) ${args.map((a, i) => `WHEN ${i} THEN ${a}`).join(" ")} END)`, held, scale, [...xs, index]);
|
|
649
|
+
}
|
|
650
|
+
case "div": return { why: "divides" };
|
|
651
|
+
// a variance's digits: a square root Postgres takes at decimals it picks by the value's size
|
|
652
|
+
case "stddev": return { sql: "NULL", held: 0, peak: 0, scale: "value", engine: true };
|
|
653
|
+
case "rescale": return one(p.arg, (x) => ({ ...x, scale: typeof x.scale === "number" ? "rows" : x.scale }));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* A PROGRAM AS STORED, LINEAR IN ITS DISTINCT NODES (issue 244, #484): a program's node may be shared by several parents
|
|
658
|
+
* (src/lib/pg-numeric.ts; a FROM item's column read k times a level, `c + c - c` through every level), so each node is
|
|
659
|
+
* judged ONCE (`storedNumeric`, memoised), and a node several parents read is written ONCE: its DECIMAL expression is a
|
|
660
|
+
* column of its own (`layers`, `__mason_output."__mason_s<column>_<k>"`, computed below the column's SELECT, each layer
|
|
661
|
+
* over the one before: src/duck/generated.ts), and every parent reads that column.
|
|
662
|
+
* A LAYER IS COMPUTED ONLY FOR THE ROWS THE INLINE EXPRESSION COMPUTES IT FOR (PR 520 review r1, the class
|
|
663
|
+
* shared-node-hoisted-out-of-its-condition): what reaches a node is read from the SQL `storedNumeric` writes (`edgesOf`):
|
|
664
|
+
* an operand of `+ - *`, a cast, a CASE's index, GREATEST's, COALESCE's first argument is computed whenever its parent
|
|
665
|
+
* is; a CASE's arm only where the index takes it, a later COALESCE argument only where every one before it is NULL, a
|
|
666
|
+
* `mod`'s dividend only where its divisor is not zero. A node every row computes (`always`) is a plain layer; any other
|
|
667
|
+
* is a layer under its GUARD, `CASE WHEN <guard> THEN … END`, the guard the OR, over each way the node is reached, of
|
|
668
|
+
* its parent's guard AND that way's condition (a boolean column `__mason_g<column>_<k>`, or one condition written in
|
|
669
|
+
* place) — so the engine computes it for no row it would not have computed it for written in place (whatever the
|
|
670
|
+
* engine may raise on: an overflow, a division). The columns are written ONE SELECT A LEVEL (PR 520 review r2): the
|
|
671
|
+
* nesting is the longest chain of them, never their number, which the engine's expression depth bounds.
|
|
672
|
+
* A condition is written over leaves only (a cell, a literal), so every guard is computed before any value;
|
|
673
|
+
* a node reached under a condition over a computed value is written in its parents, as before this PR. A leaf is short,
|
|
674
|
+
* and written where it is read.
|
|
675
|
+
*/
|
|
676
|
+
function storedProgram(p, types, cells, column) {
|
|
677
|
+
const leaf = (x) => x.op === "cell" || x.op === "literal";
|
|
678
|
+
const judged = new Map();
|
|
679
|
+
// a leaf's SQL, or null where it has none to compare (a value not stored): its condition cannot be written
|
|
680
|
+
const leafSql = (x) => {
|
|
681
|
+
if (!leaf(x))
|
|
682
|
+
return null;
|
|
683
|
+
const s = sub(x);
|
|
684
|
+
return "why" in s || s.sql === "NULL" ? null : s.sql;
|
|
685
|
+
};
|
|
686
|
+
// HOW `storedNumeric` READS EACH CHILD: the edges of its SQL, by op (a CASE of a rescale is its first arm alone, and a
|
|
687
|
+
// division, a variance and a rescale's scale write none)
|
|
688
|
+
const edgesOf = (x) => {
|
|
689
|
+
const always = (c) => ({ child: c, when: "always" });
|
|
690
|
+
switch (x.op) {
|
|
691
|
+
case "cell":
|
|
692
|
+
case "literal":
|
|
693
|
+
case "div":
|
|
694
|
+
case "stddev": return [];
|
|
695
|
+
case "add":
|
|
696
|
+
case "sub":
|
|
697
|
+
case "mul":
|
|
698
|
+
case "greatest":
|
|
699
|
+
case "least": return x.args.map(always);
|
|
700
|
+
case "neg":
|
|
701
|
+
case "abs":
|
|
702
|
+
case "floor":
|
|
703
|
+
case "ceil":
|
|
704
|
+
case "round":
|
|
705
|
+
case "trunc":
|
|
706
|
+
case "typmod":
|
|
707
|
+
case "pad":
|
|
708
|
+
case "rescale": return [always(x.arg)];
|
|
709
|
+
// CASE WHEN b = 0 THEN error(…) ELSE a % b END: the dividend only where the divisor is not zero
|
|
710
|
+
case "mod": return [always(x.args[1]), { child: x.args[0], when: (g) => { const b = leafSql(x.args[1]); return b === null ? null : `(${g} AND (${b} = 0) IS NOT TRUE)`; } }];
|
|
711
|
+
case "coalesce": return x.args.map((c, i) => i === 0 ? always(c) : { child: c, when: (g) => {
|
|
712
|
+
const before = x.args.slice(0, i).map(leafSql);
|
|
713
|
+
return before.some((b) => b === null) ? null : `(${g} AND ${before.map((b) => `${b} IS NULL`).join(" AND ")})`;
|
|
714
|
+
} });
|
|
715
|
+
case "pick": {
|
|
716
|
+
if (x.args[0]?.op === "rescale")
|
|
717
|
+
return [always(x.args[0])];
|
|
718
|
+
return [always(x.index), ...x.args.map((c, k) => ({ child: c, when: (g) => { const i = leafSql(x.index); return i === null ? null : `(${g} AND CAST(${i} AS BIGINT) = ${k})`; } }))];
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
// each distinct node's ways in, from the top (a node read twice by one parent, `c + c`, is read twice)
|
|
723
|
+
const into = new Map();
|
|
724
|
+
const walk = (x) => {
|
|
725
|
+
for (const edge of edgesOf(x)) {
|
|
726
|
+
const known = into.get(edge.child);
|
|
727
|
+
if (known === undefined)
|
|
728
|
+
into.set(edge.child, [{ parent: x, edge }]);
|
|
729
|
+
else
|
|
730
|
+
known.push({ parent: x, edge });
|
|
731
|
+
if (known === undefined)
|
|
732
|
+
walk(edge.child);
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
walk(p);
|
|
736
|
+
// the nodes computed for every row: reached from the top along "always" edges alone
|
|
737
|
+
const always = new Set();
|
|
738
|
+
const reach = (x) => {
|
|
739
|
+
if (always.has(x))
|
|
740
|
+
return;
|
|
741
|
+
always.add(x);
|
|
742
|
+
for (const e of edgesOf(x))
|
|
743
|
+
if (e.when === "always")
|
|
744
|
+
reach(e.child);
|
|
745
|
+
};
|
|
746
|
+
reach(p);
|
|
747
|
+
const guardLevels = [], guarded = new Map();
|
|
748
|
+
let guardCount = 0;
|
|
749
|
+
const guardOf = (x) => {
|
|
750
|
+
if (always.has(x))
|
|
751
|
+
return { sql: "TRUE", level: -1, plain: true };
|
|
752
|
+
if (guarded.has(x))
|
|
753
|
+
return guarded.get(x);
|
|
754
|
+
guarded.set(x, null);
|
|
755
|
+
const ways = [];
|
|
756
|
+
let level = -1, plain = true;
|
|
757
|
+
for (const { parent, edge } of into.get(x) ?? []) {
|
|
758
|
+
const g = guardOf(parent);
|
|
759
|
+
const way = g === null ? null : edge.when === "always" ? g.sql : edge.when(g.sql);
|
|
760
|
+
if (g === null || way === null)
|
|
761
|
+
return null;
|
|
762
|
+
level = Math.max(level, g.level);
|
|
763
|
+
plain &&= g.plain;
|
|
764
|
+
if (!ways.includes(way))
|
|
765
|
+
ways.push(way);
|
|
766
|
+
}
|
|
767
|
+
let guard;
|
|
768
|
+
if (ways.length === 1 && plain)
|
|
769
|
+
guard = { sql: ways[0], level, plain: false };
|
|
770
|
+
else {
|
|
771
|
+
const name = `__mason_g${column}_${guardCount++}`;
|
|
772
|
+
(guardLevels[level + 1] ??= []).push(`(${ways.join(" OR ")}) AS ${JSON.stringify(name)}`);
|
|
773
|
+
guard = { sql: `__mason_output.${JSON.stringify(name)}`, level: level + 1, plain: true };
|
|
774
|
+
}
|
|
775
|
+
guarded.set(x, guard);
|
|
776
|
+
return guard;
|
|
777
|
+
};
|
|
778
|
+
// the highest value LEVEL a node's SQL reads (-1: none): a laid node's own, another's its children's highest
|
|
779
|
+
const valueLevels = [], laid = new Map(), reads = new Map();
|
|
780
|
+
let valueCount = 0;
|
|
781
|
+
const readsLevel = (x) => {
|
|
782
|
+
const own = laid.get(x) ?? reads.get(x);
|
|
783
|
+
if (own !== undefined)
|
|
784
|
+
return own;
|
|
785
|
+
const level = edgesOf(x).reduce((m, e) => Math.max(m, readsLevel(e.child)), -1);
|
|
786
|
+
reads.set(x, level);
|
|
787
|
+
return level;
|
|
788
|
+
};
|
|
789
|
+
const sub = (x) => {
|
|
790
|
+
const known = judged.get(x);
|
|
791
|
+
if (known !== undefined)
|
|
792
|
+
return known;
|
|
793
|
+
let s = storedNumeric(x, types, cells, sub);
|
|
794
|
+
if (!("why" in s) && (into.get(x)?.length ?? 0) > 1 && !leaf(x) && s.sql !== "NULL") {
|
|
795
|
+
const guard = guardOf(x);
|
|
796
|
+
if (guard !== null) {
|
|
797
|
+
const name = `__mason_s${column}_${valueCount++}`, level = 1 + edgesOf(x).reduce((m, e) => Math.max(m, readsLevel(e.child)), -1);
|
|
798
|
+
(valueLevels[level] ??= []).push(`${guard.sql === "TRUE" ? s.sql : `CASE WHEN ${guard.sql} THEN ${s.sql} END`} AS ${JSON.stringify(name)}`);
|
|
799
|
+
laid.set(x, level);
|
|
800
|
+
s = { ...s, sql: `__mason_output.${JSON.stringify(name)}` };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
judged.set(x, s);
|
|
804
|
+
return s;
|
|
805
|
+
};
|
|
806
|
+
const stored = sub(p);
|
|
807
|
+
// ONE LAYER A LEVEL (PR 520 review r2, the class guard-layers-multiply-the-nesting): a column of a level reads only the
|
|
808
|
+
// levels below it, so a level is one SELECT; the guards' levels first (they read leaves and guards alone), then the
|
|
809
|
+
// values' (each reads its guard and the values below it). The nesting is the longest chain of shared nodes, never
|
|
810
|
+
// their count, and a guard with one way in is no column at all
|
|
811
|
+
return { stored, guards: guardLevels, values: valueLevels };
|
|
812
|
+
}
|
|
813
|
+
/** a stored numeric's verdict, where all that is around it is known: null where it is stored (a picked value's `scale`:
|
|
814
|
+
* the most decimals any value it picks from holds, which one scale for it keeps) */
|
|
815
|
+
function verdict(s) {
|
|
816
|
+
if (s.scale === "text")
|
|
817
|
+
return { why: "text" };
|
|
818
|
+
if (s.engine || s.scale === "value")
|
|
819
|
+
return { why: "engine" };
|
|
820
|
+
if (s.scale === "rows")
|
|
821
|
+
return { why: "rows" };
|
|
822
|
+
if (s.scale === "picks")
|
|
823
|
+
return { why: "picks", scale: s.held };
|
|
824
|
+
if (s.scale > MAX_STORED_SCALE)
|
|
825
|
+
return { why: "scale", scale: s.scale };
|
|
826
|
+
if (s.peak > MAX_STORED_SCALE)
|
|
827
|
+
return { why: "held", scale: s.peak };
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
/** what a text read as a numeric was, in the words of its refusal */
|
|
831
|
+
const TEXT_WORDS = { json: "a JSON number's ->> cast to numeric", double: "the text of a double precision value, cast to numeric", text: "a text value cast to numeric" };
|
|
832
|
+
/**
|
|
833
|
+
* A numeric not stored, said by its own reason, with ways out that hold. Where the engine computes the value
|
|
834
|
+
* ("engine"), and for a numeric read from text, the reason and the way out are its FORM's (`formsOf`), in the user's own
|
|
835
|
+
* function's name; the scale's reasons are the program's walk's (`verdict`). Every way out is the user's own SQL
|
|
836
|
+
* (`Form.way`, `Form.scaled`, or the column's own expression, `root`, cast whole), never a placeholder, and never the
|
|
837
|
+
* output column named as if its source had it. Nothing here reads a reason from an engine type, the outermost node or a
|
|
838
|
+
* constant.
|
|
839
|
+
*/
|
|
840
|
+
function notStoredRefusal(name, n, f, root) {
|
|
841
|
+
const atRead = "or compute it in the statement that reads the synced table";
|
|
842
|
+
const written = (w) => "expr" in w ? `${w.expr} AS ${name}` : w.places.map((p) => `write ${p.place} as ${p.select}`).join("; and ");
|
|
843
|
+
// the column's own expression cast whole, where the user wrote one (a set operation's column has none)
|
|
844
|
+
const whole = (type) => root === null || root === undefined ? null : { expr: exprText(castTo(root, type)) };
|
|
845
|
+
const doubleWay = f?.way ?? whole(DOUBLE_TYPE);
|
|
846
|
+
const asDouble = (or) => `${or ? "or " : ""}store it as a double if it need not be exact: ${doubleWay === null ? "cast it to double precision where it is computed" : written(doubleWay)}`;
|
|
847
|
+
const scaled = (words, fallback) => `${words}: ${f?.why === "text" && f.scaled !== null ? written(f.scaled) : fallback}`;
|
|
848
|
+
// a value the engine computes (a variance, sqrt and its kin, a double) is its form's reason, whatever the program saw
|
|
849
|
+
// around it (a sum of sqrt's texts is "the rows decide" to the program, and no scale given to its rows would store
|
|
850
|
+
// it); a division over a text read as a numeric is the division's, since one scale for the text would not store it
|
|
851
|
+
const strong = f !== null && (f.why === "variance" || f.why === "per-value" || f.why === "double");
|
|
852
|
+
const why = n.why === "engine" ? f?.why ?? "engine" : strong ? f.why : n.why === "text" ? (f?.why === "divides" ? "divides" : "text") : null;
|
|
853
|
+
switch (why) {
|
|
854
|
+
case "variance": return { key: "sql",
|
|
855
|
+
message: `column ${name} is a ${f.fn}, which Postgres computes as an exact numeric at decimals it picks by the size of each value, and the engine only as a double`,
|
|
856
|
+
why: "A stored numeric keeps one scale for every row, and a stored double holds neither Postgres's digits nor its type.",
|
|
857
|
+
options: [asDouble(false), atRead] };
|
|
858
|
+
case "per-value": return f.fixed
|
|
859
|
+
? { key: "sql", message: `column ${name} rounds or casts a numeric Postgres computes for each row at decimals it picks (${f.fn}), which the engine computes as a double`, why: "Postgres's digits for it are worked out when a statement is answered, and a stored column skips that step.",
|
|
860
|
+
options: [asDouble(false), atRead] }
|
|
861
|
+
: { key: "sql", message: `column ${name} is a numeric whose decimals Postgres picks by the size of each value (${f.fn})`, why: "A stored numeric keeps one scale for every row.",
|
|
862
|
+
options: [asDouble(false), atRead] };
|
|
863
|
+
case "text": return { key: "sql", message: `column ${name} is a numeric read from text (${TEXT_WORDS[f?.why === "text" && f.from !== null ? f.from : "text"]}), which keeps the decimals each value's text has`,
|
|
864
|
+
why: "A stored numeric keeps one scale for every row.",
|
|
865
|
+
options: [scaled("give it one scale", "cast the text to numeric(20,4) where it is read"), asDouble(true)] };
|
|
866
|
+
case "divides": return divides(name, asDouble(false));
|
|
867
|
+
case "double": return { key: "sql", message: `column ${name} is a numeric cast from a double precision value (x::float8::numeric)`, why: "Postgres writes such a numeric from the double's digits when a statement is answered, a step a stored column skips.",
|
|
868
|
+
options: [`store the double it is: ${f?.way === null || f?.way === undefined ? "take its cast to numeric away" : written(f.way)}`, atRead] };
|
|
869
|
+
case "engine": return { key: "sql", message: `column ${name} is a numeric the engine computes as a double, where Postgres computes it exactly`, why: "A stored double would hold neither Postgres's digits nor its type.",
|
|
870
|
+
options: [asDouble(false), atRead] };
|
|
871
|
+
case null: break;
|
|
872
|
+
}
|
|
873
|
+
switch (n.why) {
|
|
874
|
+
case "divides": return divides(name, asDouble(false));
|
|
875
|
+
case "scale": return { key: "sql", message: `column ${name} has ${n.scale} decimals, more than the ${MAX_STORED_SCALE} a stored numeric keeps`, why: "A sum at that scale could overflow the 38 digits a stored numeric has.",
|
|
876
|
+
options: [`round it to fewer: round(sum(x), 2) AS ${name}`, asDouble(true)] };
|
|
877
|
+
case "held": return { key: "sql", message: `column ${name} is computed at ${n.scale} decimals on the way to its own, more than the ${MAX_STORED_SCALE} a stored numeric keeps (a sum of numbers read from text is added at 36)`, why: "A value at that scale could overflow the 38 digits a stored numeric has before it is rounded.",
|
|
878
|
+
options: [scaled("give the values fewer decimals first", "cast each value to numeric(20,4) before it is summed or rounded"), asDouble(true)] };
|
|
879
|
+
case "rows": return { key: "sql", message: `column ${name} sums values Postgres keeps at differing scales, so the rows decide its decimals`, why: "A stored numeric keeps one scale for every row.",
|
|
880
|
+
options: [scaled("give the values one scale first", "cast each value to numeric(20,4) before it is summed"), asDouble(true)] };
|
|
881
|
+
case "picks": {
|
|
882
|
+
const one = whole(typeName("numeric", [38, n.scale]));
|
|
883
|
+
return { key: "sql", message: `column ${name} is whichever value a GREATEST, LEAST, COALESCE or CASE takes, at that value's own decimals, and its values have differing ones`, why: "A stored numeric keeps one scale for every row.",
|
|
884
|
+
options: [`give it one scale: ${one === null ? `cast it to numeric(38,${n.scale}) where it is computed` : written(one)}`, asDouble(true)] };
|
|
885
|
+
}
|
|
886
|
+
case "other": return { key: "sql", message: `column ${name} computes a numeric from ${n.what}, which Mason can't store exactly yet`, why: null,
|
|
887
|
+
options: [`compute it in the statement that reads the table`, asDouble(true)] };
|
|
888
|
+
default: throw new Error(`a numeric not stored with no reason said: ${n.why}`);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const divides = (name, double) => ({ key: "sql", message: `column ${name} divides a numeric, which the engine computes as a double`,
|
|
892
|
+
why: "Postgres divides a numeric exactly (an average is a division), so a stored double would not hold its digits.",
|
|
893
|
+
options: [double, "or store what it divides, and divide in the statement that reads the table"] });
|
|
894
|
+
/** a numeric output column, as the catalog spells its type */
|
|
895
|
+
export const isNumeric = (c) => /^numeric\b/.test(c.type);
|
|
896
|
+
/**
|
|
897
|
+
* EVERY NUMERIC OF A DEFINITION, AS STORED, held to the engine type DuckDB says the plan's SQL computes (`types`), or refused
|
|
898
|
+
* by its own reason. A computed one (`DefinitionPlan.numerics`) is written as DECIMAL arithmetic over its cells; one the
|
|
899
|
+
* engine answers itself (no program, "as the engine answers it", a passthrough's among them) is stored as the engine's
|
|
900
|
+
* DECIMAL, an integer as HUGEINT (one type for a numeric of no decimals: a sum of int8, a sign), and a double refused.
|
|
901
|
+
* Before either, the ONE authority: a column whose analysed form the engine computes as a double (`ENGINE_DOUBLES`) is
|
|
902
|
+
* refused by that form, whether the rewrite made a program for it or not.
|
|
903
|
+
* At the check (`planDefinition` with a `describe`) and again at the write, over the SQL that runs. `layers`: the
|
|
904
|
+
* columns a computed numeric's shared nodes are written once in (`storedProgram`), each `<expression> AS "<name>"`
|
|
905
|
+
* over `__mason_output` as the layer before it left it, in order: the write computes each below the table's SELECT.
|
|
906
|
+
*/
|
|
907
|
+
export function storedColumns(d, types) {
|
|
908
|
+
const refused = [], exprs = new Map(), guards = [], values = [];
|
|
909
|
+
// a level of each column joins that level of every other: no column reads another's (each names its own)
|
|
910
|
+
const merge = (all, own) => own.forEach((level, i) => { (all[i] ??= []).push(...level); });
|
|
911
|
+
// what the analysis says the engine computes as a double is refused by its form, whether or not the rewrite made a
|
|
912
|
+
// program for it: no exact program carries one, and the rewrite's silence (src/duck/rewrite.ts `programNeeded`, the
|
|
913
|
+
// wire's rule) stored `stddev(x)::numeric(p,s)` from the engine's double (PR 202 round 11)
|
|
914
|
+
const engineDouble = (i) => { const f = d.forms[i]; return f !== null && f !== undefined && ENGINE_DOUBLES.has(f.why); };
|
|
915
|
+
for (const { column, program, cells } of d.numerics) {
|
|
916
|
+
const name = d.columns[column].name, { stored, guards: ownGuards, values: ownValues } = storedProgram(program, types, cells, column);
|
|
917
|
+
const no = engineDouble(column) ? { why: "engine" } : "why" in stored ? stored : verdict(stored);
|
|
918
|
+
if (no !== null)
|
|
919
|
+
refused.push(notStoredRefusal(name, no, d.forms[column] ?? null, d.roots[column]));
|
|
920
|
+
else {
|
|
921
|
+
exprs.set(column, stored.sql);
|
|
922
|
+
merge(guards, ownGuards);
|
|
923
|
+
merge(values, ownValues);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
d.columns.forEach((c, i) => {
|
|
927
|
+
if (!isNumeric(c) || d.numerics.some((n) => n.column === i))
|
|
928
|
+
return;
|
|
929
|
+
if (engineDouble(i)) {
|
|
930
|
+
refused.push(notStoredRefusal(c.name, { why: "engine" }, d.forms[i] ?? null, d.roots[i]));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const type = types.get(`#${i}`);
|
|
934
|
+
if (type !== undefined && (DECIMAL_TYPE.test(type) || type === "HUGEINT"))
|
|
935
|
+
return;
|
|
936
|
+
if (type !== undefined && INTEGER_TYPES.has(type))
|
|
937
|
+
exprs.set(i, `CAST(__mason_output.__mason_c${i} AS HUGEINT)`);
|
|
938
|
+
else
|
|
939
|
+
refused.push(notStoredRefusal(c.name, type === "DOUBLE" || type === "FLOAT" ? { why: "engine" } : { why: "other", what: `a value the engine holds as ${type ?? "nothing it names"}` }, d.forms[i] ?? null, d.roots[i]));
|
|
940
|
+
});
|
|
941
|
+
if (refused.length > 0)
|
|
942
|
+
return { refused };
|
|
943
|
+
const layers = [...guards, ...values].filter((level) => level !== undefined && level.length > 0).map((level) => level.join(", "));
|
|
944
|
+
return { columns: d.columns.map((c, i) => { const expr = exprs.get(i); return expr === undefined ? c : { ...c, duck: null, expr }; }), layers };
|
|
945
|
+
}
|
|
946
|
+
/** the tables a definition's SQL names, by name alone (`schema.table`, a bare name `public`'s): what a cycle is told
|
|
947
|
+
* from before anything is planned. A bare name some WITH of the statement holds is a CTE's and left out. Nothing,
|
|
948
|
+
* where the SQL does not parse: that is refused at its check. */
|
|
949
|
+
export async function readsByName(sql) {
|
|
950
|
+
const select = await selectOf(sql);
|
|
951
|
+
if ("refused" in select)
|
|
952
|
+
return [];
|
|
953
|
+
const ctes = new Set(), qualified = new Set(), bare = new Set();
|
|
954
|
+
const walk = (v) => {
|
|
955
|
+
if (Array.isArray(v)) {
|
|
956
|
+
v.forEach(walk);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (typeof v !== "object" || v === null)
|
|
960
|
+
return;
|
|
961
|
+
const o = v;
|
|
962
|
+
const cte = o.CommonTableExpr;
|
|
963
|
+
if (cte?.ctename !== undefined)
|
|
964
|
+
ctes.add(cte.ctename);
|
|
965
|
+
const r = o.RangeVar;
|
|
966
|
+
if (r !== undefined)
|
|
967
|
+
(r.schemaname === undefined ? bare : qualified).add(r.schemaname === undefined ? r.relname : `${r.schemaname}.${r.relname}`);
|
|
968
|
+
Object.values(o).forEach(walk);
|
|
969
|
+
};
|
|
970
|
+
walk(select.stmt);
|
|
971
|
+
return [...new Set([...qualified, ...[...bare].filter((n) => !ctes.has(n)).map((n) => `public.${n}`)])].sort();
|
|
972
|
+
}
|
|
973
|
+
/** a definition in a cycle of generated tables (`cycle`: the tables, from this one round to it again) */
|
|
974
|
+
export function cycleRefusal(cycle) {
|
|
975
|
+
return { key: "sql", message: cycle.length <= 2 ? `it reads ${cycle[0]}, itself` : `it reads itself through other generated tables: ${cycle.join(" -> ")}`,
|
|
976
|
+
why: "A generated table is built from the tables built before it, so none can read itself, through others or not.",
|
|
977
|
+
options: ["read the tables the cycle is generated from instead", "or write the other table's SQL into this one, as a subquery"] };
|
|
978
|
+
}
|
|
979
|
+
/** a definition whose check failed on what Mason did not expect (src/duck/generated.ts `check`): a verdict all the same,
|
|
980
|
+
* in a user's words, never the error's, which names the engine (its text goes to the log, for the report) */
|
|
981
|
+
export function uncheckedRefusal() {
|
|
982
|
+
return { key: "sql", message: "Mason could not check this definition (a Mason bug: report it)",
|
|
983
|
+
why: "Checking it stopped on something Mason did not expect, so the table is not built; Mason's log says what, for the report.",
|
|
984
|
+
options: ["report it, with the file's SQL and the log's line for this file", "or write the SQL another way: a CTE or a subquery as a join, or each UNION ALL arm as its own generated table"] };
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* A DEFINITION HELD TO WHAT A TABLE CAN STORE, its reads named. `relations`: for a run of a chain, each parent built in
|
|
988
|
+
* the same run (`schema.table`) and the side table read in its place (src/duck/plan.ts swapRelations). Throws what `Planner.plan` throws: a statement Postgres or the rewrite refuses is the
|
|
989
|
+
* caller's to say at its key, and a table not there yet its to wait for.
|
|
990
|
+
*/
|
|
991
|
+
export async function planDefinition(p, sql, orderBy, relations, describe) {
|
|
992
|
+
const statement = await definitionStatement(sql, orderBy);
|
|
993
|
+
if ("refused" in statement)
|
|
994
|
+
return { refused: [statement.refused] };
|
|
995
|
+
let plan;
|
|
996
|
+
try {
|
|
997
|
+
plan = await p.plan(statement.text, statement.stmt, relations);
|
|
998
|
+
}
|
|
999
|
+
catch (e) {
|
|
1000
|
+
// a statement refused for what Mason has not verified (`current_time`, a timetz) that also reads a session is refused
|
|
1001
|
+
// for the session, with the line to write: verifying it would not make it a table's to hold
|
|
1002
|
+
if (!(e instanceof StatementError) || e.gap !== "unverified")
|
|
1003
|
+
throw e;
|
|
1004
|
+
const keys = (/: ([^]*)$/.exec(e.fields.detail ?? "")?.[1] ?? "").split(", ").filter((k) => k !== "");
|
|
1005
|
+
const session = await sessionReads(p.rows, keys);
|
|
1006
|
+
if (session.length === 0)
|
|
1007
|
+
throw e;
|
|
1008
|
+
return { refused: [sessionRefusal(session)] };
|
|
1009
|
+
}
|
|
1010
|
+
const bases = [...new Set((plan.reads ?? []).map((r) => r.table))].sort();
|
|
1011
|
+
const refused = [];
|
|
1012
|
+
const session = await sessionReads(p.rows, plan.resolved);
|
|
1013
|
+
if (session.length > 0)
|
|
1014
|
+
refused.push(sessionRefusal(session));
|
|
1015
|
+
const text = await textReads(p.rows, plan.resolved, plan.argumentTypes ?? []);
|
|
1016
|
+
if (text.length > 0)
|
|
1017
|
+
refused.push(textRefusal(text));
|
|
1018
|
+
if (plan.clock?.parse !== undefined)
|
|
1019
|
+
refused.push({ key: "sql", message: "it reads a relative literal ('now', 'today')", why: THE_SESSION_WHY, options: ["use now(), as of the run, with the zone named if it reads a day: (now() AT TIME ZONE 'UTC')::date"] });
|
|
1020
|
+
if (plan.withTies)
|
|
1021
|
+
refused.push({ key: "sql", message: "it ends in FETCH … WITH TIES", why: "That row count isn't a table's to keep.", options: ["use LIMIT, with an ORDER BY that decides every row"] });
|
|
1022
|
+
const fields = plan.fields ?? [];
|
|
1023
|
+
const twice = fields.find((f, i) => f.name !== UNNAMED && fields.findIndex((x) => x.name === f.name) !== i);
|
|
1024
|
+
if (twice !== undefined)
|
|
1025
|
+
refused.push({ key: "sql", message: `it names column ${twice.name} twice`, why: "A table's columns have one name each.", options: ["rename one with AS"] });
|
|
1026
|
+
const typeNames = new Map((await p.rows(fields.length === 0 ? "SELECT 0, ''" : `SELECT v.i::text, format_type(v.oid, v.typmod) FROM (VALUES ${fields.map((f, i) => `(${i}, ${Math.trunc(f.oid)}::oid, ${Math.trunc(f.typmod)})`).join(", ")}) AS v(i, oid, typmod)`))
|
|
1027
|
+
.map(([i, name]) => [Number(i), name]));
|
|
1028
|
+
// a type a user or an extension made: an enum (`e`) and a citext are stored, a domain and a composite are not. Judged
|
|
1029
|
+
// by the column's INDEX and the catalog's type of the name the wire gives its OID: a field carries the SOURCE's OID
|
|
1030
|
+
// for a made type (src/duck/plan.ts), which the catalog's pg_type does not have
|
|
1031
|
+
const made = fields.map((f, i) => [f, i]).filter(([f]) => !STORED_TYPES.has(f.oid) && f.oid !== NUMERIC_OID);
|
|
1032
|
+
const kinds = new Map(made.length === 0 ? [] : (await p.rows(`SELECT v.i::text, CASE WHEN t.typname = 'citext' AND t.typtype = 'b' THEN 'citext' ELSE t.typtype::text END ` +
|
|
1033
|
+
`FROM (VALUES ${made.map(([f, i]) => `(${i}, ${Math.trunc(f.oid)}::oid)`).join(", ")}) AS v(i, oid) JOIN pg_type t ON t.oid = coalesce(to_regtype(public.mason_wire_type(v.oid, -1)), v.oid)`))
|
|
1034
|
+
.map(([i, kind]) => [Number(i), kind]));
|
|
1035
|
+
fields.forEach((f, i) => { const bad = columnProblem(f, typeNames.get(i) ?? String(f.oid), kinds.get(i)); if (bad !== null)
|
|
1036
|
+
refused.push(bad); });
|
|
1037
|
+
// a numeric computed after the engine's answer: stored as DECIMAL arithmetic where the types fix its scale, over the
|
|
1038
|
+
// engine types DuckDB says the plan computes (`storedColumns`)
|
|
1039
|
+
const numerics = [];
|
|
1040
|
+
for (const e of refused.length === 0 ? plan.exact ?? [] : []) {
|
|
1041
|
+
const f = fields[e.column];
|
|
1042
|
+
if (e.text) {
|
|
1043
|
+
refused.push({ key: "sql", message: `column ${f.name} is the text of a numeric computed when a statement is answered, which a generated table can't store yet`, why: "Postgres's digits for it are worked out when a statement is answered, and a stored column skips that step.", options: [`store the numeric, and write its text in the statement that reads the table`] });
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
numerics.push({ column: e.column, program: e.program, cells: e.cells });
|
|
1047
|
+
}
|
|
1048
|
+
if (refused.length > 0)
|
|
1049
|
+
return { refused };
|
|
1050
|
+
const definition = { plan, bases, warnings: statement.warnings, numerics, forms: await formsOf(p.rows, plan.outputs, statement.stmt, fields.length),
|
|
1051
|
+
roots: ((raw) => fields.map((_, i) => raw !== null && raw.length === fields.length ? raw[i] : null))(rawTargets(statement.stmt)),
|
|
1052
|
+
columns: fields.map((f, i) => ({ name: f.name, type: typeNames.get(i), duck: numerics.some((n) => n.column === i) ? null : STORED_TYPES.get(f.oid) ?? null })) };
|
|
1053
|
+
if (describe === undefined || !definition.columns.some(isNumeric))
|
|
1054
|
+
return { definition };
|
|
1055
|
+
const stored = storedColumns(definition, await describe(plan.sql.text));
|
|
1056
|
+
return "refused" in stored ? stored : { definition: { ...definition, columns: stored.columns } };
|
|
1057
|
+
}
|
|
1058
|
+
/** A refusal said to a person: the file, the key, then the reason and the ways out a line each (issue 124's shape). */
|
|
1059
|
+
export function refusalText(file, r) {
|
|
1060
|
+
return [`${file}: ${r.key === null ? "" : `${r.key}: `}${r.message}.`, ...(r.why === null ? [] : [` ${r.why}`]),
|
|
1061
|
+
...(r.options.length === 0 ? [] : [" Your options:", ...r.options.map((o) => ` - ${o}`)])].join("\n");
|
|
1062
|
+
}
|
|
1063
|
+
/** A statement's refusal as a table's: Postgres's or a gap's message, at `sql:`, with its hint as the way out. */
|
|
1064
|
+
export function refusalOf(e) {
|
|
1065
|
+
const options = e.fields.hint === undefined ? [] : [e.fields.hint];
|
|
1066
|
+
// a column the database has and the copy doesn't is the likely reason a generated table meets this
|
|
1067
|
+
if (e.fields.code === "42703")
|
|
1068
|
+
options.push("if the table has that column, sync it: add it to the table's columns:, or take it out of exclude:, in the sources file");
|
|
1069
|
+
return { key: "sql", message: e.fields.message, why: e.fields.detail ?? null, options };
|
|
1070
|
+
}
|