@mason-data/cli 0.6.2 → 0.7.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 +71 -12
- package/dist/src/api/postgres/catalog.js +36 -1
- package/dist/src/api/postgres/server.js +133 -32
- package/dist/src/cdc/pg-wire.js +144 -38
- package/dist/src/cli/background.js +29 -5
- package/dist/src/cli/command.js +4 -3
- package/dist/src/cli/commands.js +259 -127
- package/dist/src/cli/demo.js +149 -0
- package/dist/src/cli/dev.js +113 -20
- package/dist/src/cli/doctor.js +6 -3
- package/dist/src/cli/guides.js +32 -8
- package/dist/src/cli/init.js +58 -18
- package/dist/src/cli/machine.js +8 -3
- package/dist/src/cli/main.js +2 -2
- package/dist/src/cli/mcp.js +726 -0
- package/dist/src/cli/preflight.js +16 -7
- package/dist/src/cli/project.js +5 -3
- package/dist/src/cli/registry.js +8 -5
- package/dist/src/cli/run-server.js +29 -106
- package/dist/src/cli/serve.js +6 -1
- package/dist/src/cli/source-edit.js +116 -0
- package/dist/src/cli/sources.js +6 -4
- package/dist/src/demo/seed.js +237 -0
- package/dist/src/demo/source.js +655 -0
- package/dist/src/duck/distinct-key.js +228 -0
- package/dist/src/duck/executor-worker.js +7 -2
- package/dist/src/duck/executor.js +67 -26
- package/dist/src/duck/explain.js +128 -9
- package/dist/src/duck/functions.js +60 -5
- package/dist/src/duck/gaps.js +9 -4
- 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 +206 -39
- package/dist/src/duck/pgtree.js +40 -0
- package/dist/src/duck/plan.js +21 -8
- package/dist/src/duck/rewrite.js +1168 -68
- package/dist/src/duck/store.js +132 -56
- package/dist/src/duck/sync.js +90 -25
- package/dist/src/duck/verified.js +6 -0
- package/dist/src/lib/timezone.js +3 -2
- package/dist/src/web/app.css +11 -0
- package/dist/src/web/app.js +167 -12
- package/dist/src/web/index.html +6 -1
- package/package.json +1 -1
package/dist/src/duck/kept.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { MASON_SCHEMA } from "./store.js";
|
|
2
|
-
import {
|
|
1
|
+
import { DUCKDB_VERSION, MASON_SCHEMA } from "./store.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { canonicalRead, EQUAL_STEPS, HIDDEN_PREFIX, HIDDEN_RE, hiddenName, isEqualRead, isKeyRead, isOrderRead, KEY_NULL_HEX, KEY_RECOMPUTE_HEX, knownSteps, ORDER_STEPS, pathKey, pathText, readOfKey, VISIBLE_CATALOG } from "./kept-route.js";
|
|
4
|
+
import { JSONB_KEY_FUNCTIONS, KEY_VERSION, libcUncovered, STORED_SUFFIX } from "./jsonb-order.js";
|
|
5
|
+
import { encodeLibcOrder } from "./libc-order.js";
|
|
6
|
+
import { icuNameOf } from "./rewrite.js";
|
|
3
7
|
/** a path read by this many statements in the request log is stored (a larger table's also needs `KEEP_FILL_SHARE`) */
|
|
4
8
|
export const KEEP_AFTER_STATEMENTS = 3;
|
|
5
9
|
/** a path of a SMALL table (`SMALL_FILL_SECONDS`) read by one statement that took this long (ms, the request log's
|
|
@@ -57,6 +61,8 @@ const MEASURE_AGAIN_MS = 10 * 60_000;
|
|
|
57
61
|
export const BACKFILL_ROWS = 50_000;
|
|
58
62
|
/** how often the request log is read for new paths, and backfills continued */
|
|
59
63
|
export const REVIEW_MS = 10_000;
|
|
64
|
+
/** the marked rows whose documents are read to say what they are (`markedText`) */
|
|
65
|
+
const MARKS_READ = 10_000;
|
|
60
66
|
/** every this many reviews, each READY column's unset rows are counted (the detector) */
|
|
61
67
|
const CHECK_EVERY = 6;
|
|
62
68
|
export const PATHS_TABLE = `${MASON_SCHEMA}.json_paths`;
|
|
@@ -93,6 +99,50 @@ const tableOf = (source) => { const at = source.indexOf("."); return `${ident(so
|
|
|
93
99
|
export function keptValueSql(p, value) {
|
|
94
100
|
return `coalesce(__mason_json_kept(NULL, ${value}, ${literal(p.steps)}, ${p.either}, ${p.canonical}, false), ${NULL_SQL})`;
|
|
95
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* A JSONB'S STORED KEY, from its column's `value` (src/duck/kept-route.ts `ORDER_STEPS`): the key the statement would
|
|
104
|
+
* compute (src/duck/rewrite.ts `jsonbKeyCall`), by the same functions (src/duck/jsonb-order.ts `JSONB_KEY_FUNCTIONS`,
|
|
105
|
+
* their stored twins), never NULL — `KEY_NULL_HEX` for a NULL document (the column's NULL is a row not stored yet), and
|
|
106
|
+
* `KEY_RECOMPUTE_HEX` where the key raised, which its read computes (and raises) again.
|
|
107
|
+
*/
|
|
108
|
+
export function orderKeySql(c, value) {
|
|
109
|
+
const call = (name, ...args) => `${name}${STORED_SUFFIX}(NULL, ${args.join(", ")})`;
|
|
110
|
+
const key = c.kind === "bytes" ? call(JSONB_KEY_FUNCTIONS.bytes.key, value)
|
|
111
|
+
: call(JSONB_KEY_FUNCTIONS[c.kind].key, value, `list_transform(${call(JSONB_KEY_FUNCTIONS[c.kind].strings, value)}, __mason_s -> icu_sort_key(__mason_s, ${literal(icuNameOf(c))}))`);
|
|
112
|
+
return `CASE WHEN (${value}) IS NULL THEN unhex('${KEY_NULL_HEX}') ELSE coalesce(${key}, unhex('${KEY_RECOMPUTE_HEX}')) END`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A STORED KEY'S ID in its `steps` (`ORDER_STEPS:<id>`): what its bytes depend on, so a key is read only where the
|
|
116
|
+
* statement's own key would be the same bytes — its collation (a libc one by its measured order's content: a glibc
|
|
117
|
+
* whose order moved is another collation), the key's encoding (`KEY_VERSION`) and the DuckDB whose ICU made its strings'
|
|
118
|
+
* keys (`DUCKDB_VERSION`: an upgrade may bring another ICU). Another id is another column, dropped at `orderBy`.
|
|
119
|
+
*/
|
|
120
|
+
export function orderId(c, libc, versions = `k${KEY_VERSION}:${DUCKDB_VERSION}`) {
|
|
121
|
+
if (c.kind === "bytes")
|
|
122
|
+
return `c:${versions}`;
|
|
123
|
+
if (c.kind === "icu")
|
|
124
|
+
return `icu:${c.name}:${versions}`;
|
|
125
|
+
if (libc === undefined)
|
|
126
|
+
throw new Error("a libc collation's order was not read (Mason bug: report it)");
|
|
127
|
+
return `libc:${c.icu}:${createHash("sha256").update(encodeLibcOrder(libc)).digest("hex").slice(0, 16)}:${versions}`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* THE C KEY'S ID, stored under a libc collation for equality (`EQUAL_STEPS:<id>`, src/duck/kept-route.ts): the C
|
|
131
|
+
* collation's order id (`orderId`), so with the key's encoding (`KEY_VERSION`) and the DuckDB (`DUCKDB_VERSION`) in it,
|
|
132
|
+
* as an order key's — a release that changes either stores it again. The libc order is not in it: the C key reads no
|
|
133
|
+
* collation, so a glibc whose order moved keeps it.
|
|
134
|
+
*/
|
|
135
|
+
export function equalId(versions) {
|
|
136
|
+
return versions === undefined ? orderId({ kind: "bytes" }, undefined) : orderId({ kind: "bytes" }, undefined, versions);
|
|
137
|
+
}
|
|
138
|
+
/** a stored column's type, and its mark of a value computed at read time, as SQL */
|
|
139
|
+
const columnType = (p) => isKeyRead(p) ? "BLOB" : "VARCHAR";
|
|
140
|
+
const recomputeSql = (p) => isKeyRead(p) ? `unhex('${KEY_RECOMPUTE_HEX}')` : RECOMPUTE_SQL;
|
|
141
|
+
/** what a fill's measured rate is kept by (`estimate`, `lastRate`): the JSON column, and whether the fill computes a path,
|
|
142
|
+
* a jsonb's order key or its C key — a key's function is not a path's (a list of strings, their ICU keys, the key's
|
|
143
|
+
* bytes), nor a libc order's key the C key's (4.7 µs a row against 1.4, docs/DESIGN.md "jsonb's order"), so one's
|
|
144
|
+
* rate is no evidence for another's */
|
|
145
|
+
const measuredKey = (r) => `${r.source}\u0000${r.column}\u0000${isOrderRead(r) ? "order" : isEqualRead(r) ? "equal" : "path"}`;
|
|
96
146
|
/** a read as its stored path's `->` read (src/duck/kept-route.ts pathKey): what the ledger holds */
|
|
97
147
|
export const pathOf = (r) => ({ ...canonicalRead(r), asText: false });
|
|
98
148
|
/** a span of seconds as a person reads it: `4 s`, `9 min 40 s`, `1 h 28 min` */
|
|
@@ -180,14 +230,73 @@ export class KeptJsonPaths {
|
|
|
180
230
|
}
|
|
181
231
|
keepNow;
|
|
182
232
|
// --------------------------------------------------------------------------------------------- what a plan reads
|
|
183
|
-
ready(
|
|
233
|
+
ready(asked) {
|
|
184
234
|
if (!this.enabled)
|
|
185
235
|
return null;
|
|
236
|
+
const read = this.own(asked);
|
|
186
237
|
const p = this.paths.get(hiddenName(read));
|
|
187
238
|
return p !== undefined && p.state === "ready" && pathKey(p) === pathKey(read) ? { name: p.name, exact: p.marked === 0 } : null;
|
|
188
239
|
}
|
|
189
240
|
visible() { return this.views; }
|
|
190
241
|
generation() { return this.gen; }
|
|
242
|
+
// --------------------------------------------------------------------------------------------- a jsonb's order
|
|
243
|
+
/** the collation a jsonb's key is stored under (`orderBy`), and its id (`orderId`); null until told */
|
|
244
|
+
order = null;
|
|
245
|
+
/**
|
|
246
|
+
* A READ AS THIS KEEPER STORES IT: a jsonb's order (`ORDER_STEPS`) under the collation it was told; its equality
|
|
247
|
+
* (`EQUAL_STEPS`) as the C key (`equalId`) under a libc collation, and under C or ICU as the ORDER's read — the order's
|
|
248
|
+
* key equates there, so one column serves both and no second is ever made; any other read as it is.
|
|
249
|
+
*/
|
|
250
|
+
own(r) {
|
|
251
|
+
if (isEqualRead(r) && this.order !== null && this.order.collation.kind !== "libc")
|
|
252
|
+
return { ...r, steps: `${ORDER_STEPS}:${this.order.id}` };
|
|
253
|
+
if (isEqualRead(r))
|
|
254
|
+
return { ...r, steps: `${EQUAL_STEPS}:${this.order === null ? "?" : equalId()}` };
|
|
255
|
+
return isOrderRead(r) ? { ...r, steps: `${ORDER_STEPS}:${this.order?.id ?? "?"}` } : r;
|
|
256
|
+
}
|
|
257
|
+
/** the steps a stored key would have now (`orderBy`): the order's under the collation told, the C key's under a libc
|
|
258
|
+
* one (none under C or ICU: the order's key serves); null until told */
|
|
259
|
+
nowSteps(p) {
|
|
260
|
+
if (this.order === null)
|
|
261
|
+
return null;
|
|
262
|
+
if (isEqualRead(p))
|
|
263
|
+
return this.order.collation.kind === "libc" ? `${EQUAL_STEPS}:${equalId()}` : null;
|
|
264
|
+
return `${ORDER_STEPS}:${this.order.id}`;
|
|
265
|
+
}
|
|
266
|
+
/** a stored key of the collation statements sort and equate by now (`orderBy`): computed by writes and read by plans */
|
|
267
|
+
current(p) { const now = this.nowSteps(p); return now !== null && p.steps === now; }
|
|
268
|
+
/**
|
|
269
|
+
* THE COLLATION, TOLD (src/duck/store.ts `listener`, once the source's is known): a jsonb's stored key is that
|
|
270
|
+
* collation's. Until then a stored key is written unset and read by no plan (`open`); now the keys of another
|
|
271
|
+
* collation (the source's changed, or its glibc's order moved) are dropped, and those of this one filled where a
|
|
272
|
+
* write left them unset — the backfill's sweep — and ready again.
|
|
273
|
+
*/
|
|
274
|
+
async orderBy(collation, libc) {
|
|
275
|
+
if (!this.enabled)
|
|
276
|
+
return;
|
|
277
|
+
const id = orderId(collation, libc);
|
|
278
|
+
await this.host.onWriter(async () => {
|
|
279
|
+
this.order = { id, collation, libc };
|
|
280
|
+
for (const p of [...this.paths.values()].filter((x) => isKeyRead(x) && !this.current(x) && (x.state === "backfilling" || x.state === "ready"))) {
|
|
281
|
+
const was = p.steps.slice(p.steps.indexOf(":") + 1), now = this.nowSteps(p);
|
|
282
|
+
await this.drop(this.store.writer, p, now === null
|
|
283
|
+
? `the database's collation is no longer a libc one (now ${id}): its order's key equates jsonb, so equality reads that one`
|
|
284
|
+
: `stored under another collation or version (${was}, now ${now.slice(now.indexOf(":") + 1)}); stored again when its reads ask for it`);
|
|
285
|
+
this.host.log(`${p.source}: ${pathText(p)} is no longer stored: ${p.why}`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
void this.fills();
|
|
289
|
+
}
|
|
290
|
+
/** what a stored column takes from its JSON column's `value`: a path's `->` answer (`keptValueSql`), a jsonb's order
|
|
291
|
+
* key (`orderKeySql` under the collation told), or its C key (`orderKeySql` under C) — unset (NULL) while the key is
|
|
292
|
+
* not the one told (`orderBy`): the sweep fills it */
|
|
293
|
+
valueSql(p, value) {
|
|
294
|
+
if (!isKeyRead(p))
|
|
295
|
+
return keptValueSql(p, value);
|
|
296
|
+
if (!this.current(p))
|
|
297
|
+
return "NULL";
|
|
298
|
+
return orderKeySql(isEqualRead(p) ? { kind: "bytes" } : this.order.collation, value);
|
|
299
|
+
}
|
|
191
300
|
/** every path with a row, for `mason status` */
|
|
192
301
|
status() {
|
|
193
302
|
return [...this.paths.values()].sort((a, b) => a.source.localeCompare(b.source) || pathText(a).localeCompare(pathText(b)))
|
|
@@ -198,11 +307,11 @@ export class KeptJsonPaths {
|
|
|
198
307
|
* write of whole rows names after the copy's own columns */
|
|
199
308
|
stored(source, valueOf) {
|
|
200
309
|
const kept = this.physical(source);
|
|
201
|
-
return { names: kept.map((p) => p.name), values: kept.map((p) =>
|
|
310
|
+
return { names: kept.map((p) => p.name), values: kept.map((p) => this.valueSql(p, valueOf(p.column))) };
|
|
202
311
|
}
|
|
203
312
|
/** what an UPDATE that sets `carried` sets too: each stored column read from one of them, from its new value */
|
|
204
313
|
storedSet(source, carried, valueOf) {
|
|
205
|
-
return this.physical(source).filter((p) => carried.includes(p.column)).map((p) => `${ident(p.name)} = ${
|
|
314
|
+
return this.physical(source).filter((p) => carried.includes(p.column)).map((p) => `${ident(p.name)} = ${this.valueSql(p, valueOf(p.column))}`);
|
|
206
315
|
}
|
|
207
316
|
physical(source) {
|
|
208
317
|
return [...this.paths.values()].filter((p) => p.source === source && (p.state === "backfilling" || p.state === "ready")).sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -230,6 +339,14 @@ export class KeptJsonPaths {
|
|
|
230
339
|
basis: isBasis(fillBasis) ? fillBasis : "text", rowsPerSecond: fillRate === null || fillRate === undefined ? null : Number(fillRate) },
|
|
231
340
|
filled: filledRows === null || filledRows === undefined || filledSeconds === null || filledSeconds === undefined ? null : { rows: Number(filledRows), seconds: Number(filledSeconds) } });
|
|
232
341
|
}
|
|
342
|
+
// a row of a kind this Mason does not know (another release's: `knownSteps`) is dropped, its column with it (no
|
|
343
|
+
// write here could compute it, and none names it once dropped), and said with its steps: inert, never fatal
|
|
344
|
+
for (const p of this.paths.values()) {
|
|
345
|
+
if (p.state === "dropped" || knownSteps(p.steps))
|
|
346
|
+
continue;
|
|
347
|
+
await this.drop(w, p, `stored by another release as ${p.steps}, a kind this Mason does not know`);
|
|
348
|
+
this.host.log(`${p.source}: dropped the stored ${p.column} of a kind this Mason does not know (${p.steps}); its column goes at this start`);
|
|
349
|
+
}
|
|
233
350
|
// a path an earlier Mason stored in another form (a column per spelling or per `->>`, its rows' marks other): its
|
|
234
351
|
// name is not its path's (`hiddenName`), so it is dropped with its column, and stored again as the policy says
|
|
235
352
|
const earlier = "stored by an earlier Mason in another form; stored again when its reads ask for it";
|
|
@@ -266,6 +383,14 @@ export class KeptJsonPaths {
|
|
|
266
383
|
continue;
|
|
267
384
|
await this.drop(w, p, "its column is not in the copy");
|
|
268
385
|
}
|
|
386
|
+
// a jsonb's stored key is its collation's, told after this (`orderBy`): until then a write leaves it unset and no
|
|
387
|
+
// plan reads it, so a ready one is filled again where unset first — the backfill's sweep alone (its ranges are done)
|
|
388
|
+
for (const p of this.paths.values()) {
|
|
389
|
+
if (!isKeyRead(p) || p.state !== "ready")
|
|
390
|
+
continue;
|
|
391
|
+
p.state = "backfilling";
|
|
392
|
+
this.cursors.set(p.name, Number.MAX_SAFE_INTEGER);
|
|
393
|
+
}
|
|
269
394
|
await w.run(`ATTACH IF NOT EXISTS ':memory:' AS ${VISIBLE_CATALOG}`);
|
|
270
395
|
for (const source of this.host.served().keys())
|
|
271
396
|
await this.view(source);
|
|
@@ -286,7 +411,9 @@ export class KeptJsonPaths {
|
|
|
286
411
|
this.reader?.closeSync();
|
|
287
412
|
this.reader = null;
|
|
288
413
|
}
|
|
289
|
-
/** a path's row marked dropped, with why (on the caller's connection and turn)
|
|
414
|
+
/** a path's row marked dropped, with why (on the caller's connection and turn). Its COLUMN stays in the table until
|
|
415
|
+
* the next start, whose reconciling of the file with the ledger (`open`, "a column with no row") drops it: every
|
|
416
|
+
* write names only the stored columns (`stored`), and DuckDB's DROP COLUMN rewrites the table */
|
|
290
417
|
async drop(c, p, why) {
|
|
291
418
|
await c.run(`UPDATE ${PATHS_TABLE} SET state = 'dropped', why = ${literal(why)} WHERE name = ${literal(p.name)}`);
|
|
292
419
|
if (p.state === "ready")
|
|
@@ -335,14 +462,14 @@ export class KeptJsonPaths {
|
|
|
335
462
|
return;
|
|
336
463
|
for (const p of this.physical(source).filter((x) => columns.includes(x.column))) {
|
|
337
464
|
// no default: its NULL is "not stored yet", and an ADD COLUMN writes nothing into the rows (`keep`)
|
|
338
|
-
await c.run(`ALTER TABLE ${side} ADD COLUMN IF NOT EXISTS ${ident(p.name)}
|
|
465
|
+
await c.run(`ALTER TABLE ${side} ADD COLUMN IF NOT EXISTS ${ident(p.name)} ${columnType(p)}`);
|
|
339
466
|
const end = Number((await c.runAndReadAll(`SELECT coalesce(max(rowid), -1) + 1 FROM ${side}`)).getRowsJS()[0][0]);
|
|
340
467
|
for (let lo = 0; lo < end; lo += this.backfillRows) {
|
|
341
|
-
await run(`UPDATE ${side} SET ${ident(p.name)} = ${
|
|
468
|
+
await run(`UPDATE ${side} SET ${ident(p.name)} = ${this.valueSql(p, ident(p.column))} WHERE rowid >= ${lo} AND rowid < ${lo + this.backfillRows} AND ${ident(p.name)} IS NULL`);
|
|
342
469
|
}
|
|
343
470
|
// rows the column cannot answer alone (KEPT_RECOMPUTE) turn its reads to the CASE before the side table serves;
|
|
344
471
|
// counted again once it does (`swapped`)
|
|
345
|
-
const marked = Number((await c.runAndReadAll(`SELECT count(*) FROM ${side} WHERE ${ident(p.name)} = ${
|
|
472
|
+
const marked = Number((await c.runAndReadAll(`SELECT count(*) FROM ${side} WHERE ${ident(p.name)} = ${recomputeSql(p)}`)).getRowsJS()[0][0]);
|
|
346
473
|
if (marked > p.marked) {
|
|
347
474
|
if (p.marked === 0 && p.state === "ready")
|
|
348
475
|
this.gen++;
|
|
@@ -415,7 +542,7 @@ export class KeptJsonPaths {
|
|
|
415
542
|
const read = readOfKey(key);
|
|
416
543
|
if (read === null)
|
|
417
544
|
continue;
|
|
418
|
-
const path = pathOf(read), k = pathKey(path);
|
|
545
|
+
const path = this.own(pathOf(read)), k = pathKey(path);
|
|
419
546
|
let e = by.get(k);
|
|
420
547
|
if (e === undefined)
|
|
421
548
|
by.set(k, e = { read: path, statements: new Map() });
|
|
@@ -436,14 +563,14 @@ export class KeptJsonPaths {
|
|
|
436
563
|
async estimate(read) {
|
|
437
564
|
if (this.estimateFor !== undefined)
|
|
438
565
|
return this.estimateFor(read);
|
|
439
|
-
const key =
|
|
566
|
+
const key = measuredKey(read);
|
|
440
567
|
const was = this.measured.get(key);
|
|
441
568
|
if (was !== undefined && Date.now() - was.at < MEASURE_AGAIN_MS)
|
|
442
569
|
return was.fill;
|
|
443
570
|
this.reader ??= await this.store.instance.connect();
|
|
444
571
|
const target = tableOf(read.source);
|
|
445
572
|
const rows = Number((await this.reader.runAndReadAll(`SELECT count(*) FROM ${target}`)).getRowsJS()[0][0]);
|
|
446
|
-
const rate = this.lastRate(read
|
|
573
|
+
const rate = this.lastRate(read, rows);
|
|
447
574
|
let fill;
|
|
448
575
|
if (rate !== null)
|
|
449
576
|
fill = measuredEstimate(rows, rate);
|
|
@@ -460,12 +587,12 @@ export class KeptJsonPaths {
|
|
|
460
587
|
this.measured.set(key, { at: Date.now(), fill });
|
|
461
588
|
return fill;
|
|
462
589
|
}
|
|
463
|
-
/** the rows a second of the last fill of a JSON column (any path of it
|
|
464
|
-
* (`MEASURED_MIN_ROWS`, `MEASURED_SPAN`), or null */
|
|
465
|
-
lastRate(
|
|
590
|
+
/** the rows a second of the last fill of a JSON column (any path of it; a jsonb's key by a key's fill alone) that is
|
|
591
|
+
* evidence for a table of `rows` now (`MEASURED_MIN_ROWS`, `MEASURED_SPAN`), or null */
|
|
592
|
+
lastRate(read, rows) {
|
|
466
593
|
let last = null;
|
|
467
594
|
for (const p of this.paths.values()) {
|
|
468
|
-
if (p
|
|
595
|
+
if (measuredKey(p) !== measuredKey(read) || p.filled === null || p.filled.seconds <= 0 || p.filled.rows < MEASURED_MIN_ROWS)
|
|
469
596
|
continue;
|
|
470
597
|
if (rows > MEASURED_SPAN * p.filled.rows)
|
|
471
598
|
continue;
|
|
@@ -593,11 +720,16 @@ export class KeptJsonPaths {
|
|
|
593
720
|
const cols = this.host.served().get(read.source);
|
|
594
721
|
if (cols === undefined || !this.views.has(read.source) || this.host.rebuilding(read.source))
|
|
595
722
|
return false;
|
|
723
|
+
// a jsonb's key waits for its collation (`orderBy`, at the listener's start), and its C key is stored under a libc
|
|
724
|
+
// one alone (`own` folds it into the order's elsewhere): nothing to say
|
|
725
|
+
if (isKeyRead(read) && !this.current(read))
|
|
726
|
+
return false;
|
|
596
727
|
const name = hiddenName(read);
|
|
597
728
|
const col = cols.find((c) => c.name === read.column);
|
|
598
729
|
const why = col === undefined ? `${read.column} is not a column of the copy`
|
|
599
730
|
: !/^jsonb?$/.test(col.type) ? `${read.column} is a ${col.type}, not a json or jsonb`
|
|
600
|
-
:
|
|
731
|
+
: isKeyRead(read) && col.type !== "jsonb" ? `${read.column} is a ${col.type}, which has no order or equality (a jsonb's is stored)`
|
|
732
|
+
: cols.some((c) => c.name.toLowerCase() === name) ? `the copy has a column named ${name} of its own` : null;
|
|
601
733
|
if (why === null)
|
|
602
734
|
return true;
|
|
603
735
|
const key = pathKey(read);
|
|
@@ -659,7 +791,7 @@ export class KeptJsonPaths {
|
|
|
659
791
|
const target = tableOf(read.source);
|
|
660
792
|
await w.run("BEGIN TRANSACTION");
|
|
661
793
|
try {
|
|
662
|
-
await w.run(`ALTER TABLE ${target} ADD COLUMN ${ident(name)}
|
|
794
|
+
await w.run(`ALTER TABLE ${target} ADD COLUMN ${ident(name)} ${columnType(read)}`);
|
|
663
795
|
const rows = Number((await w.runAndReadAll(`SELECT count(*) FROM ${target}`)).getRowsJS()[0][0]);
|
|
664
796
|
const p = { ...pathOf(read), name, state: "backfilling", rowsTotal: rows, rowsDone: 0, statements: c.statements, slowestMs: c.slowestMs, totalMs: c.totalMs,
|
|
665
797
|
fill, filled: null, marked: 0, keptAt: new Date().toISOString(), readyAt: null, why };
|
|
@@ -682,6 +814,9 @@ export class KeptJsonPaths {
|
|
|
682
814
|
/** a backfill continued to its end, a chunk a writer's turn (paused while its table is rebuilt: the next review goes
|
|
683
815
|
* on), then the path made ready once no row of it is unset — and read by its column alone once none is marked */
|
|
684
816
|
async backfill(p) {
|
|
817
|
+
// a jsonb's key waits for its collation (`orderBy`): its rows would be filled unset
|
|
818
|
+
if (isKeyRead(p) && !this.current(p))
|
|
819
|
+
return;
|
|
685
820
|
const target = tableOf(p.source);
|
|
686
821
|
const end = await this.host.onWriter(async () => Number((await this.store.writer.runAndReadAll(`SELECT coalesce(max(rowid), -1) + 1 FROM ${target}`)).getRowsJS()[0][0]));
|
|
687
822
|
// what this fill measures: the rows its chunks filled and the time they held the writer (never the waits for its
|
|
@@ -700,28 +835,58 @@ export class KeptJsonPaths {
|
|
|
700
835
|
if (!done)
|
|
701
836
|
return;
|
|
702
837
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
838
|
+
// THE SWEEP: what the ranges did not reach (a row whose rowid moved, a row a write left unset while a jsonb's
|
|
839
|
+
// collation was not told), by the mark itself — a chunk a writer's turn, as the ranges are; the turn whose chunk
|
|
840
|
+
// finds none unset makes the path ready (every write computes it from then on, so none is left unset after it)
|
|
841
|
+
for (;;) {
|
|
842
|
+
const swept = await this.host.onWriter(async () => {
|
|
843
|
+
if (this.stopped || this.host.rebuilding(p.source) || p.state !== "backfilling")
|
|
844
|
+
return "paused";
|
|
845
|
+
if (await this.fillChunk(p, `rowid IN (SELECT rowid FROM ${target} WHERE ${ident(p.name)} IS NULL LIMIT ${this.backfillRows}) AND `) > 0)
|
|
846
|
+
return "more";
|
|
847
|
+
p.marked = await this.markedRows(p);
|
|
848
|
+
Object.assign(p, { state: "ready", readyAt: new Date().toISOString(), filled: rows > 0 && ms > 0 ? { rows, seconds: ms / 1000 } : p.filled });
|
|
849
|
+
await this.record(this.store.writer, p);
|
|
850
|
+
this.cursors.delete(p.name);
|
|
851
|
+
this.measured.delete(measuredKey(p));
|
|
852
|
+
this.gen++;
|
|
853
|
+
return "ready";
|
|
854
|
+
});
|
|
855
|
+
if (swept !== "more")
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
716
858
|
if (p.state === "ready") {
|
|
717
859
|
const took = rows > 0 && ms > 0 ? `, ${rows.toLocaleString("en-US")} by this fill in ${spanText(ms / 1000)}${p.fill === null ? "" : `, estimated at ${spanText(p.fill.seconds)} (${p.fill.basis === "measured" ? "from the last fill" : "modelled"})`}` : "";
|
|
718
860
|
this.host.log(`${p.source}: ${pathText(p)} is stored: statements read it from its column (${p.rowsDone.toLocaleString("en-US")} row(s) filled${took})` +
|
|
719
|
-
(p.marked > 0 ? `; ${p.marked
|
|
861
|
+
(p.marked > 0 ? `; ${await this.markedText(p, p.marked)}` : ""));
|
|
720
862
|
}
|
|
721
863
|
}
|
|
864
|
+
/**
|
|
865
|
+
* WHAT A PATH'S MARKED ROWS ARE, in words (the fill's end, the detector): a jsonb's key under a libc collation is
|
|
866
|
+
* marked where a string holds a character the measured order does not cover — the gap its sort is refused by
|
|
867
|
+
* (`libc-uncovered`), told apart by reading the marked rows' documents (`libcUncovered`, at most `MARKS_READ` of
|
|
868
|
+
* them); any other mark is a row its function could not read, a Mason bug.
|
|
869
|
+
*/
|
|
870
|
+
async markedText(p, marked) {
|
|
871
|
+
let uncovered = 0, read = marked;
|
|
872
|
+
const libc = this.order?.libc;
|
|
873
|
+
if (isOrderRead(p) && this.current(p) && libc !== undefined) {
|
|
874
|
+
const docs = await this.host.onWriter(async () => (await this.store.writer.runAndReadAll(`SELECT ${ident(p.column)}::VARCHAR FROM ${tableOf(p.source)} ` +
|
|
875
|
+
`WHERE ${ident(p.name)} = ${recomputeSql(p)} LIMIT ${MARKS_READ}`)).getRowsJS());
|
|
876
|
+
read = docs.length;
|
|
877
|
+
uncovered = docs.filter(([d]) => d !== null && libcUncovered(libc, d)).length;
|
|
878
|
+
}
|
|
879
|
+
const of = read < marked ? ` (of the first ${read.toLocaleString("en-US")} read, of ${marked.toLocaleString("en-US")})` : "";
|
|
880
|
+
const said = [];
|
|
881
|
+
if (uncovered > 0)
|
|
882
|
+
said.push(`${uncovered.toLocaleString("en-US")} row(s) hold a character the database's libc order is not measured for, so a sort of them is refused by name (Mason gap libc-uncovered)`);
|
|
883
|
+
if (read > uncovered)
|
|
884
|
+
said.push(`${(read - uncovered).toLocaleString("en-US")} row(s) its function could not read are computed from their document when read (a Mason bug: report it, with mason logs)`);
|
|
885
|
+
return said.join("; ") + of;
|
|
886
|
+
}
|
|
722
887
|
/** a column's rows marked to be computed at read time (KEPT_RECOMPUTE), on the caller's turn */
|
|
723
888
|
async markedRows(p) {
|
|
724
|
-
return Number((await this.store.writer.runAndReadAll(`SELECT count(*) FROM ${tableOf(p.source)} WHERE ${ident(p.name)} = ${
|
|
889
|
+
return Number((await this.store.writer.runAndReadAll(`SELECT count(*) FROM ${tableOf(p.source)} WHERE ${ident(p.name)} = ${recomputeSql(p)}`)).getRowsJS()[0][0]);
|
|
725
890
|
}
|
|
726
891
|
/** a READY path's marked rows counted again (its table swapped in, the detector): a change of exactness moves the
|
|
727
892
|
* plans' generation, so a plan that read the column alone is made again as the CASE, and the other way */
|
|
@@ -742,7 +907,7 @@ export class KeptJsonPaths {
|
|
|
742
907
|
const w = this.store.writer;
|
|
743
908
|
await w.run("BEGIN TRANSACTION");
|
|
744
909
|
try {
|
|
745
|
-
const n = Number((await w.run(`UPDATE ${tableOf(p.source)} SET ${ident(p.name)} = ${
|
|
910
|
+
const n = Number((await w.run(`UPDATE ${tableOf(p.source)} SET ${ident(p.name)} = ${this.valueSql(p, ident(p.column))} WHERE ${where}${ident(p.name)} IS NULL`)).rowsChanged);
|
|
746
911
|
await w.run(`UPDATE ${PATHS_TABLE} SET rows_done = rows_done + ${n} WHERE name = ${literal(p.name)}`);
|
|
747
912
|
await w.run("COMMIT");
|
|
748
913
|
p.rowsDone += n;
|
|
@@ -756,9 +921,9 @@ export class KeptJsonPaths {
|
|
|
756
921
|
/** THE DETECTOR: a READY column's rows still unset (a write that did not compute it: a Mason bug), filled and said;
|
|
757
922
|
* and its marked rows counted again (`settle`) */
|
|
758
923
|
async check(p) {
|
|
759
|
-
await this.host.onWriter(async () => {
|
|
924
|
+
const marked = await this.host.onWriter(async () => {
|
|
760
925
|
if (this.stopped || this.host.rebuilding(p.source) || p.state !== "ready")
|
|
761
|
-
return;
|
|
926
|
+
return 0;
|
|
762
927
|
const unset = Number((await this.store.writer.runAndReadAll(`SELECT count(*) FROM ${tableOf(p.source)} WHERE ${ident(p.name)} IS NULL`)).getRowsJS()[0][0]);
|
|
763
928
|
if (unset > 0) {
|
|
764
929
|
this.host.log(`${p.source}: ${unset} row(s) were written without the stored value of ${pathText(p)} (a Mason bug: report it, with mason logs); they are filled now`);
|
|
@@ -767,14 +932,16 @@ export class KeptJsonPaths {
|
|
|
767
932
|
}
|
|
768
933
|
const was = p.marked;
|
|
769
934
|
await this.settle(p);
|
|
770
|
-
|
|
771
|
-
this.host.log(`${p.source}: ${p.marked} row(s) of ${pathText(p)} could not be read by its function (a Mason bug: report it, with mason logs); its reads compute them from their document`);
|
|
935
|
+
return p.marked > was ? p.marked : 0;
|
|
772
936
|
});
|
|
937
|
+
// (the words read the table again on a writer's turn of their own, never inside the detector's)
|
|
938
|
+
if (marked > 0)
|
|
939
|
+
this.host.log(`${p.source}: ${pathText(p)} has marked rows, read through their document from now on: ${await this.markedText(p, marked)}`);
|
|
773
940
|
}
|
|
774
941
|
/** THE ORACLE'S (`eager`): every read's path stored and filled now, whatever the request log says */
|
|
775
942
|
async keepAll(reads) {
|
|
776
943
|
for (const r of reads) {
|
|
777
|
-
const read = pathOf(r);
|
|
944
|
+
const read = this.own(pathOf(r));
|
|
778
945
|
const name = hiddenName(read);
|
|
779
946
|
if (this.paths.get(name)?.state === "ready" || !this.storable(read))
|
|
780
947
|
continue;
|
package/dist/src/duck/pgtree.js
CHANGED
|
@@ -233,6 +233,9 @@ export function typesByLocation(tree) {
|
|
|
233
233
|
const relation = v._node === "VAR" ? relationOf(v, rtables) : null;
|
|
234
234
|
if (relation !== null)
|
|
235
235
|
typed.relation = relation;
|
|
236
|
+
const fromFunction = v._node === "VAR" ? functionColumnOf(v, rtables) : null;
|
|
237
|
+
if (fromFunction !== null)
|
|
238
|
+
typed.functionColumn = fromFunction;
|
|
236
239
|
const list = out.get(loc);
|
|
237
240
|
if (list)
|
|
238
241
|
list.push(typed);
|
|
@@ -255,6 +258,17 @@ function relationOf(v, rtables) {
|
|
|
255
258
|
const relid = rte === undefined ? null : num(rte.relid ?? null);
|
|
256
259
|
return rte === undefined || attno === null || attno < 1 || num(rte.rtekind ?? null) !== RTE_RELATION || relid === null || relid === 0 ? null : { relid, attno };
|
|
257
260
|
}
|
|
261
|
+
const RTE_FUNCTION = 3;
|
|
262
|
+
/** Typed.functionColumn */
|
|
263
|
+
function functionColumnOf(v, rtables) {
|
|
264
|
+
const up = num(v.varlevelsup ?? null) ?? 0, varno = num(v.varno ?? null), attno = num(v.varattno ?? null);
|
|
265
|
+
const rte = varno === null ? undefined : rtables[rtables.length - 1 - up]?.[varno - 1];
|
|
266
|
+
if (rte === undefined || attno === null || attno < 1 || num(rte.rtekind ?? null) !== RTE_FUNCTION || !isNode(rte.eref ?? null))
|
|
267
|
+
return null;
|
|
268
|
+
const eref = rte.eref;
|
|
269
|
+
const column = strings(eref.colnames)[attno - 1];
|
|
270
|
+
return typeof eref.aliasname === "string" && column !== undefined ? { alias: eref.aliasname, column } : null;
|
|
271
|
+
}
|
|
258
272
|
const strings = (v) => Array.isArray(v) ? v.map((x) => typeof x === "string" ? x : "") : [];
|
|
259
273
|
/** Typed.qualifier: the name of a VAR's FROM item when another FROM item of its query has the column in another case,
|
|
260
274
|
* or when the VAR is an outer query's (a correlated subquery's bare `i4`, which DuckDB binds to the subquery's own
|
|
@@ -327,6 +341,32 @@ export function caseTwins(tree) {
|
|
|
327
341
|
visit(tree);
|
|
328
342
|
return out;
|
|
329
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* Whether a JOIN … USING or a NATURAL JOIN of the analysed tree joins on a column of one of `types` (jsonb, jsonb[]):
|
|
346
|
+
* Postgres equates the columns by that type's `=`, which DuckDB's USING would not (it compares the text). The join's
|
|
347
|
+
* condition is Postgres's own (its `quals`), at no offset of the statement, so it is read here, not by the rewrite.
|
|
348
|
+
*/
|
|
349
|
+
export function joinsUsing(tree, types) {
|
|
350
|
+
const compares = (v) => {
|
|
351
|
+
if (Array.isArray(v))
|
|
352
|
+
return v.some(compares);
|
|
353
|
+
if (v === undefined || !isNode(v))
|
|
354
|
+
return false;
|
|
355
|
+
if (v._node === "OPEXPR" && Array.isArray(v.args) && v.args.some((a) => isNode(a) && types.has(exprType(a)?.type ?? 0)))
|
|
356
|
+
return true;
|
|
357
|
+
return Object.entries(v).some(([k, x]) => k !== "_node" && compares(x));
|
|
358
|
+
};
|
|
359
|
+
const visit = (v) => {
|
|
360
|
+
if (Array.isArray(v))
|
|
361
|
+
return v.some(visit);
|
|
362
|
+
if (v === undefined || !isNode(v))
|
|
363
|
+
return false;
|
|
364
|
+
if (v._node === "JOINEXPR" && Array.isArray(v.usingClause) && v.usingClause.length > 0 && compares(v.quals))
|
|
365
|
+
return true;
|
|
366
|
+
return Object.entries(v).some(([k, x]) => k !== "_node" && visit(x));
|
|
367
|
+
};
|
|
368
|
+
return visit(tree);
|
|
369
|
+
}
|
|
330
370
|
/** The analysed query's output columns, in order (resjunk ones, which a sort may add, left out): their types. */
|
|
331
371
|
export function outputTypes(tree) {
|
|
332
372
|
if (!isNode(tree) || tree._node !== "QUERY" || !Array.isArray(tree.targetList))
|
package/dist/src/duck/plan.js
CHANGED
|
@@ -16,6 +16,7 @@ import { selectListAfterLimit } from "./late.js";
|
|
|
16
16
|
import { pgTyplen } from "./types.js";
|
|
17
17
|
import { datumText, masonCalls, rewrite, treeDifference, Unrewritable } from "./rewrite.js";
|
|
18
18
|
import { exposes, hideKept, readKey, searchSchemas } from "./kept-route.js";
|
|
19
|
+
import { distinctCountTables, distinctKeyCounts, uniqueColumnsOf, uniqueColumnsSql } from "./distinct-key.js";
|
|
19
20
|
const JSON_OID = 114, JSONB_OID = 3802;
|
|
20
21
|
/** what the rewrite asks of the stored JSON paths (src/duck/kept-route.ts KeptLookup): the copied table and column of
|
|
21
22
|
* each json or jsonb column the statement reads from a table (Typed.relation), by the catalog's OIDs; null when it
|
|
@@ -33,6 +34,15 @@ async function keptLookup(catalog, types, kept) {
|
|
|
33
34
|
const at = new Map(rows.map(([relid, attno, schema, table, column]) => [`${relid}.${attno}`, { source: `${schema}.${table}`, column: column }]));
|
|
34
35
|
return { at: (relid, attno) => at.get(`${relid}.${attno}`) ?? null, ready: (read) => kept.ready(read) };
|
|
35
36
|
}
|
|
37
|
+
/** the count(DISTINCT x) of the statement that are count(x) (src/duck/distinct-key.ts), by their offsets into the
|
|
38
|
+
* analysed text: x unique by the CATALOG's own pg_index — the source's keys as the catalog declares them — read only
|
|
39
|
+
* for a statement that counts DISTINCT values at all */
|
|
40
|
+
async function distinctKeys(catalog, tree) {
|
|
41
|
+
const relids = distinctCountTables(tree);
|
|
42
|
+
if (relids.length === 0)
|
|
43
|
+
return new Set();
|
|
44
|
+
return distinctKeyCounts(tree, uniqueColumnsOf(await catalogRows(catalog, uniqueColumnsSql(relids))));
|
|
45
|
+
}
|
|
36
46
|
/**
|
|
37
47
|
* DuckDB's keywords that Postgres's are not: a column named `at` (DuckDB's AT TIME ZONE) or `qualify` is an identifier
|
|
38
48
|
* the deparser writes bare, as Postgres reads it, and DuckDB refuses as a syntax error. The deparser's one quoting
|
|
@@ -119,7 +129,9 @@ export function missingRelationHint(relation, copies) {
|
|
|
119
129
|
case "copied": return `${c.source} was just copied and joins the catalog as it is rebuilt: run the statement again in a moment (if it stays missing, mason logs says why)`;
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
|
-
|
|
132
|
+
/** A statement's plan. `wireBound`: its rows are bounded by a row count an Execute sets on the wire, which its SQL
|
|
133
|
+
* does not say (src/duck/rewrite.ts markBounds; the listener asks for it where PlannedSql.unboundedLateral says so) */
|
|
134
|
+
export async function planDuck(catalog, text, stmt, base, paramOids, searchPath, timeZone, collation, verified, userTypes = new Map(), copies = () => [], depth = 0, kept = null, wireBound = false) {
|
|
123
135
|
const started = performance.now();
|
|
124
136
|
const analysis = await analyze(catalog, text, paramOids, pathSql(searchPath), timeZone);
|
|
125
137
|
if ("refused" in analysis) {
|
|
@@ -138,7 +150,7 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
138
150
|
if (depth >= MAX_VIEW_DEPTH)
|
|
139
151
|
throw viewRefused(`Mason cannot yet answer views read through more than ${MAX_VIEW_DEPTH} other views`, "read the tables the views read");
|
|
140
152
|
try {
|
|
141
|
-
return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept);
|
|
153
|
+
return await planDuck(catalog, expanded.text, expanded.stmt, 0, analysis.params, searchPath, timeZone, collation, verified, userTypes, copies, depth + 1, kept, wireBound);
|
|
142
154
|
}
|
|
143
155
|
catch (e) {
|
|
144
156
|
if (!(e instanceof StatementError))
|
|
@@ -183,24 +195,25 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
183
195
|
f.oid = source;
|
|
184
196
|
}
|
|
185
197
|
}
|
|
186
|
-
let tree, dropLast, exact, clock, jsonReads;
|
|
198
|
+
let tree, dropLast, exact, clock, jsonReads, unboundedLateral;
|
|
187
199
|
// a table's row the source has otherwise (copied in part: no source OID for it) is not the source's row
|
|
188
200
|
const sourceRows = new Map([...rowTypes].filter(([oid]) => userTypes.has(catalogTypes.get(oid) ?? "")));
|
|
189
201
|
// the stored JSON paths the statement's JSON reads may be answered from (src/duck/kept-route.ts)
|
|
190
202
|
const lookup = kept === null ? null : await keptLookup(catalog, analysis.types, kept);
|
|
203
|
+
const keyCounts = await distinctKeys(catalog, analysis.tree);
|
|
191
204
|
const analysed = performance.now();
|
|
192
|
-
const rewritten = (l = lookup) => rewrite(stmt, base, analysis, collation, catalogTypes, sourceRows, timeZone, enums, l);
|
|
205
|
+
const rewritten = (l = lookup) => rewrite(stmt, base, analysis, collation, catalogTypes, sourceRows, timeZone, enums, l, wireBound, keyCounts);
|
|
193
206
|
try {
|
|
194
|
-
({ tree, dropLast, exact, clock, jsonReads } = rewritten());
|
|
207
|
+
({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
|
|
195
208
|
// the oracle's (`eager`): every read stored before the plan is made, and the plan made again over them
|
|
196
209
|
if (kept?.keepNow != null && lookup !== null && jsonReads.some((r) => kept.ready(r) === null)) {
|
|
197
210
|
await kept.keepNow(jsonReads);
|
|
198
|
-
({ tree, dropLast, exact, clock, jsonReads } = rewritten());
|
|
211
|
+
({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten());
|
|
199
212
|
}
|
|
200
213
|
// a statement that lists a copy's columns without naming them reads none from a stored column (hideKept): made
|
|
201
214
|
// again with every read its call, the reads still recorded
|
|
202
215
|
if (lookup !== null && exposes(tree))
|
|
203
|
-
({ tree, dropLast, exact, clock, jsonReads } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
|
|
216
|
+
({ tree, dropLast, exact, clock, jsonReads, unboundedLateral } = rewritten({ at: (relid, attno) => lookup.at(relid, attno), ready: () => null }));
|
|
204
217
|
}
|
|
205
218
|
catch (e) {
|
|
206
219
|
// a gap says its id (docs/SQL-SUPPORT.md); Postgres's own refusal is its SQLSTATE, and no gap
|
|
@@ -240,7 +253,7 @@ export async function planDuck(catalog, text, stmt, base, paramOids, searchPath,
|
|
|
240
253
|
throw new StatementError({ code: "0A000", message: "Mason cannot run this statement on DuckDB exactly: its rewritten form does not read back as itself (a Mason gap: report it, with the statement)",
|
|
241
254
|
detail: `read back: ${difference}; DuckDB SQL: ${sql}` }, text);
|
|
242
255
|
}
|
|
243
|
-
const planned = { text: sql, functions: masonCalls(tree) };
|
|
256
|
+
const planned = { text: sql, functions: masonCalls(tree), ...(unboundedLateral ? { unboundedLateral: true } : {}) };
|
|
244
257
|
return { sql: planned, params: analysis.params, fields: fields?.map((f) => ({ ...f, size: pgTyplen(f.oid) })) ?? null, dropLast, ...(exact.length > 0 ? { exact } : {}), resolved: keys,
|
|
245
258
|
...(Object.keys(clock).length > 0 ? { clock } : {}), ...(jsonReads.length > 0 ? { jsonReads: jsonReads.map(readKey) } : {}), timing: { analysisMs: analysed - started, rewriteMs: performance.now() - analysed } };
|
|
246
259
|
}
|