@mason-data/cli 0.0.0-stage → 0.6.2
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/LICENSE +202 -0
- package/README.md +209 -2
- package/dist/src/api/error.js +18 -0
- package/dist/src/api/postgres/catalog-worker.js +92 -0
- package/dist/src/api/postgres/catalog.js +442 -0
- package/dist/src/api/postgres/encode.js +541 -0
- package/dist/src/api/postgres/explain.js +143 -0
- package/dist/src/api/postgres/listener.js +60 -0
- package/dist/src/api/postgres/parser-worker.js +58 -0
- package/dist/src/api/postgres/parser.js +121 -0
- package/dist/src/api/postgres/plain.js +33 -0
- package/dist/src/api/postgres/protocol.js +404 -0
- package/dist/src/api/postgres/schema.js +54 -0
- package/dist/src/api/postgres/server.js +1670 -0
- package/dist/src/api/postgres/translate.js +248 -0
- package/dist/src/api/request-log.js +50 -0
- package/dist/src/cdc/pg-wire.js +482 -0
- package/dist/src/cdc/pgoutput.js +107 -0
- package/dist/src/cdc/poll.js +67 -0
- package/dist/src/cdc/source.js +65 -0
- package/dist/src/cli/background.js +174 -0
- package/dist/src/cli/command.js +172 -0
- package/dist/src/cli/commands.js +1145 -0
- package/dist/src/cli/control.js +36 -0
- package/dist/src/cli/dev.js +576 -0
- package/dist/src/cli/doctor.js +257 -0
- package/dist/src/cli/guides.js +134 -0
- package/dist/src/cli/init.js +326 -0
- package/dist/src/cli/machine.js +157 -0
- package/dist/src/cli/main.js +83 -0
- package/dist/src/cli/pg-tls.js +100 -0
- package/dist/src/cli/pg-url.js +52 -0
- package/dist/src/cli/pin.js +36 -0
- package/dist/src/cli/ports.js +118 -0
- package/dist/src/cli/preflight.js +338 -0
- package/dist/src/cli/project.js +127 -0
- package/dist/src/cli/readiness.js +76 -0
- package/dist/src/cli/registry.js +217 -0
- package/dist/src/cli/replication-checks.js +120 -0
- package/dist/src/cli/replication-mode.js +148 -0
- package/dist/src/cli/run-server.js +388 -0
- package/dist/src/cli/runs.js +120 -0
- package/dist/src/cli/security.js +86 -0
- package/dist/src/cli/self-signed-cert.js +64 -0
- package/dist/src/cli/serve.js +296 -0
- package/dist/src/cli/sources.js +562 -0
- package/dist/src/cli/state.js +58 -0
- package/dist/src/cli/style.js +187 -0
- package/dist/src/cli/supervise.js +248 -0
- package/dist/src/cli/sync.js +94 -0
- package/dist/src/cli/ui.js +107 -0
- package/dist/src/cli/user.js +229 -0
- package/dist/src/cli/version.js +23 -0
- package/dist/src/duck/analyze.js +113 -0
- package/dist/src/duck/cancel.js +49 -0
- package/dist/src/duck/executor-worker.js +263 -0
- package/dist/src/duck/executor.js +526 -0
- package/dist/src/duck/explain.js +119 -0
- package/dist/src/duck/functions.js +764 -0
- package/dist/src/duck/gaps.js +89 -0
- package/dist/src/duck/jsonb.js +186 -0
- package/dist/src/duck/kept-route.js +219 -0
- package/dist/src/duck/kept.js +797 -0
- package/dist/src/duck/late.js +221 -0
- package/dist/src/duck/libc-order.js +190 -0
- package/dist/src/duck/pg-eval-worker.js +34 -0
- package/dist/src/duck/pg-eval.js +59 -0
- package/dist/src/duck/pgtree.js +338 -0
- package/dist/src/duck/plan.js +246 -0
- package/dist/src/duck/publication.js +207 -0
- package/dist/src/duck/requests.js +209 -0
- package/dist/src/duck/resolved.js +248 -0
- package/dist/src/duck/rewrite.js +4001 -0
- package/dist/src/duck/store.js +831 -0
- package/dist/src/duck/stream.js +518 -0
- package/dist/src/duck/sync.js +2864 -0
- package/dist/src/duck/to-char.js +482 -0
- package/dist/src/duck/token.js +31 -0
- package/dist/src/duck/types.js +427 -0
- package/dist/src/duck/users.js +231 -0
- package/dist/src/duck/verified.js +995 -0
- package/dist/src/duck/views.js +150 -0
- package/dist/src/lib/database-url.js +74 -0
- package/dist/src/lib/did-you-mean.js +43 -0
- package/dist/src/lib/dotenv.js +72 -0
- package/dist/src/lib/exit-when-written.js +20 -0
- package/dist/src/lib/float-equal.js +69 -0
- package/dist/src/lib/http-json.js +22 -0
- package/dist/src/lib/log.js +161 -0
- package/dist/src/lib/pg-numeric.js +489 -0
- package/dist/src/lib/port.js +22 -0
- package/dist/src/lib/project-file.js +49 -0
- package/dist/src/lib/project-files.js +43 -0
- package/dist/src/lib/project-version.js +76 -0
- package/dist/src/lib/runnable.js +14 -0
- package/dist/src/lib/shell-word.js +4 -0
- package/dist/src/lib/timezone.js +236 -0
- package/dist/src/lib/zone-names.js +114 -0
- package/dist/src/web/app.css +65 -0
- package/dist/src/web/app.js +336 -0
- package/dist/src/web/index.html +39 -0
- package/package.json +22 -4
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT THE DUCKDB ENDPOINT KNOWS IT DOES NOT DO AS POSTGRES DOES: a list of what is known to be unsupported, and the
|
|
3
|
+
* steps that keep the list true. Every entry is one of:
|
|
4
|
+
* - `refused`: a statement using it is refused by name — SQLSTATE 0A000, the gap's id in the error's detail (or, for a
|
|
5
|
+
* value only the data shows, in the message), and the way out in the hint;
|
|
6
|
+
* - `differs`: answered, but not as Postgres answers (a decided difference: the oracle's KNOWN, which must still differ);
|
|
7
|
+
* - `limit`: not about a statement — what the prototype cannot do yet (reach, access, the poll's cost).
|
|
8
|
+
*
|
|
9
|
+
* HOW IT IS KEPT TRUE (tests/duck-gaps.test.ts, tests/integration/duck-oracle.itest.ts):
|
|
10
|
+
* - every refusal names an entry: `Unrewritable` takes a `GapId` (or Postgres's own refusal, which is not a gap), and a
|
|
11
|
+
* runtime refusal's message says `Mason gap <id>` of an id this file has;
|
|
12
|
+
* - the oracle accepts a refusal where Postgres answers ONLY when it names a `refused` entry, and fails when a
|
|
13
|
+
* `refused` or `differs` entry is met by none of its statements (a dead entry is removed, or its test named in
|
|
14
|
+
* `heldBy` instead, a test that names the id);
|
|
15
|
+
* - docs/SQL-SUPPORT.md is rendered from this file and the verified list (src/duck/verified.ts), and must be current.
|
|
16
|
+
* Anything not in src/duck/verified.ts is refused as `unverified`: this list is what is known BEYOND that.
|
|
17
|
+
*/
|
|
18
|
+
export const GAPS = {
|
|
19
|
+
// ---------------------------------------------------------------------------------------------- refused
|
|
20
|
+
unverified: { kind: "refused", what: "a function, operator, cast, type or construct the oracle has not held to Postgres", postgres: "answers", mason: "refused, naming each piece", wayOut: "write the statement without it; report it to have it verified", heldBy: "tests/duck-wire.test.ts" },
|
|
21
|
+
"explain-option": { kind: "refused", what: "EXPLAIN (FORMAT XML), (FORMAT YAML), (GENERIC_PLAN)", postgres: "the plan in XML or YAML; a plan for unbound parameters", mason: "refused by name: EXPLAIN is DuckDB's plan, in Postgres's text or JSON (docs/DESIGN.md)", wayOut: "FORMAT TEXT or FORMAT JSON; bind the parameters' values and EXPLAIN that" },
|
|
22
|
+
"setop-ordinal-star": { kind: "refused", what: "ORDER BY a column number of a UNION / INTERSECT / EXCEPT whose first SELECT lists *", postgres: "sorts by that column", mason: "refused", wayOut: "ORDER BY the column's name" },
|
|
23
|
+
"with-ties-distinct": { kind: "refused", what: "FETCH FIRST … WITH TIES over SELECT DISTINCT", postgres: "answers", mason: "refused (DuckDB has no WITH TIES; Mason's rank over DISTINCT is not Postgres's)", wayOut: "select the DISTINCT rows in a subquery and FETCH … WITH TIES from it" },
|
|
24
|
+
"collation-unknown": { kind: "refused", what: "COLLATE with a collation other than C, POSIX, default or an ICU locale DuckDB has", postgres: "sorts and compares under it", mason: "refused", wayOut: "COLLATE \"C\" for byte order, or no COLLATE for the database's" },
|
|
25
|
+
"like-any-array": { kind: "refused", what: "LIKE / ILIKE ANY (or ALL) over an array that is not written out", postgres: "answers", mason: "refused", wayOut: "write the patterns as ARRAY['…', '…'], or OR the LIKEs" },
|
|
26
|
+
"similar-to-forms": { kind: "refused", what: "SIMILAR TO with a pattern or ESCAPE that is not a literal, its substring markers, or substring(… SIMILAR … ESCAPE …)", postgres: "answers", mason: "refused", wayOut: "a literal pattern, or ~ with a POSIX regular expression" },
|
|
27
|
+
"array-remove-type": { kind: "refused", what: "array_remove of an array of a type other than text, integers, floats, booleans, dates or timestamps", postgres: "answers", mason: "refused", wayOut: "array_remove an array of one of those types" },
|
|
28
|
+
"json-agg-distinct": { kind: "refused", what: "json_agg / jsonb_agg (DISTINCT …)", postgres: "answers", mason: "refused", wayOut: "select the DISTINCT values in a subquery and aggregate them" },
|
|
29
|
+
"json-rows-in-select": { kind: "refused", what: "json(b)_array_elements / json(b)_each in the SELECT list", postgres: "a row per element", mason: "refused", wayOut: "read it in FROM: SELECT … FROM t, jsonb_array_elements(t.j) AS e" },
|
|
30
|
+
"regexp-flags": { kind: "refused", what: "regexp_replace with a start position or a count (its five- and six-argument forms)", postgres: "answers", mason: "refused", wayOut: "regexp_replace(text, pattern, replacement[, flags]): flags g for every match" },
|
|
31
|
+
"regexp-functions": { kind: "refused", what: "regexp_split_to_table / regexp_split_to_array / regexp_count / regexp_instr / regexp_substr / regexp_like", postgres: "answers", mason: "refused", wayOut: "~ to test, substring(text from pattern) to take a part, regexp_replace to replace" },
|
|
32
|
+
"regexp-matches-forms": { kind: "refused", what: "regexp_matches in FROM", postgres: "answers", mason: "refused", wayOut: "regexp_matches in the SELECT list" },
|
|
33
|
+
"json-of-type": { kind: "refused", what: "to_json / array_to_json / json_build_object of a value (or an array of elements) of a type Mason does not write as json", postgres: "answers", mason: "refused", wayOut: "cast the value to text, a number, a boolean, a timestamp or json first" },
|
|
34
|
+
"extract-julian": { kind: "refused", what: "extract(julian FROM …)", postgres: "answers", mason: "refused", wayOut: "extract(epoch FROM …) / 86400 + 2440587.5" },
|
|
35
|
+
"with-ordinality": { kind: "refused", what: "a function in FROM … WITH ORDINALITY", postgres: "numbers the rows", mason: "refused", wayOut: "row_number() OVER () instead" },
|
|
36
|
+
"multidim-array": { kind: "refused", what: "a multi-dimensional array literal", postgres: "answers", mason: "refused", wayOut: "a one-dimensional array" },
|
|
37
|
+
"format-specifiers": { kind: "refused", what: "format() with a specifier other than %s (%I, %L, widths, positions)", postgres: "answers", mason: "refused", wayOut: "concatenate with ||, or %s only" },
|
|
38
|
+
"to-char-patterns": { kind: "refused", what: "to_char of an interval or a time; its TZ, SP, EEEE, RN, V, B, L patterns; a day or month name padded to 9 characters in a parse", postgres: "answers", mason: "refused", wayOut: "the patterns listed in the hint; extract(… FROM interval) for an interval's parts" },
|
|
39
|
+
"to-timestamp-format": { kind: "refused", what: "to_timestamp / to_date with a format that is not a literal", postgres: "answers", mason: "refused", wayOut: "write the format as a string literal" },
|
|
40
|
+
"libc-uncovered": { kind: "refused", what: "ORDER BY, <, >, min / max of text holding a combining mark (e + U+0301), a Thai or Lao vowel written before its consonant, a private-use or unassigned code point, under a libc collation", postgres: "answers, in glibc's order", mason: "refused when a value holding one is ordered (the order is read from the database, and glibc orders these otherwise than Mason can follow)", wayOut: "normalize the text (normalize(x, NFC)), or ORDER BY x COLLATE \"C\" for byte order" },
|
|
41
|
+
"libc-symbol-punctuation": { kind: "differs", what: "text ordered under a libc collation where punctuation or a space follows a digit or a symbol and a later letter differs only in case ('1-X' beside '1x')", postgres: "the punctuation outranks the case: 1-X < 1x", mason: "the case outranks the punctuation: 1x < 1-X", wayOut: "ORDER BY lower(x), x when the case must not decide first" },
|
|
42
|
+
"percentile-text-libc": { kind: "refused", what: "percentile_disc / mode() WITHIN GROUP (ORDER BY text) under a libc collation", postgres: "answers", mason: "refused", wayOut: "order by a number, or ORDER BY … LIMIT in a subquery" },
|
|
43
|
+
"infinite-extract": { kind: "refused", what: "extract(year, decade, century, millennium, isoyear or epoch FROM) an infinite date or timestamp", postgres: "Infinity (a numeric)", mason: "refused when a row holds one (DuckDB's DECIMAL has no Infinity)", wayOut: "filter the infinite values out (isfinite(x)), or date_part(…), which is a double" },
|
|
44
|
+
"time-date-trunc": { kind: "refused", what: "date_trunc of a time (Postgres truncates it as an interval)", postgres: "an interval", mason: "refused", wayOut: "date_trunc of a timestamp, or extract the parts" },
|
|
45
|
+
"quote-ident": { kind: "refused", what: "quote_ident()", postgres: "quotes an identifier when it must be", mason: "refused (it needs Postgres's keyword list)", wayOut: "quote it in the statement, or format('%s', …)" },
|
|
46
|
+
"div-numeric": { kind: "refused", what: "div() of a value that is not an integer", postgres: "the integer quotient of two numerics", mason: "refused", wayOut: "trunc(a / b)" },
|
|
47
|
+
"jsonb-set": { kind: "refused", what: "jsonb_set, jsonb_insert, the #- operator, jsonb - of a text[] or an index", postgres: "answers", mason: "refused", wayOut: "build the value with jsonb_build_object" },
|
|
48
|
+
"jsonb-compare": { kind: "refused", what: "ORDER BY, GROUP BY, DISTINCT or <, >, = over jsonb (Postgres orders and equates jsonb by its own rules: numbers by value, objects by size first)", postgres: "answers", mason: "refused", wayOut: "compare a member: j ->> 'k', or j::text for the canonical text" },
|
|
49
|
+
"text-array-order": { kind: "refused", what: "ORDER BY, GROUP BY, DISTINCT, min, max or <, > over a text[] under the database's collation", postgres: "compares the elements under the collation", mason: "refused", wayOut: "sort by an element (a[1]), or by array_to_string(a, ',')" },
|
|
50
|
+
"sum-real": { kind: "refused", what: "sum() of a real (float4), as an aggregate or a window", postgres: "accumulates in single precision", mason: "refused (DuckDB accumulates in double precision)", wayOut: "sum(x::double precision)" },
|
|
51
|
+
"interval-aggregates": { kind: "refused", what: "sum() or avg() of an interval", postgres: "adds months, days and time apart", mason: "refused", wayOut: "sum(extract(epoch FROM x)) for the seconds" },
|
|
52
|
+
"bc-timestamptz-zone": { kind: "refused", what: "a timestamptz before year 1 written in a zone other than UTC", postgres: "the zone's local mean time", mason: "refused when a row holds one", wayOut: "SET TimeZone = 'UTC', or filter those rows out", heldBy: "tests/duck-wire.test.ts" },
|
|
53
|
+
"width-bucket-forms": { kind: "refused", what: "width_bucket with its bounds reversed, or of an array of thresholds", postgres: "answers", mason: "refused", wayOut: "low bound first" },
|
|
54
|
+
"inet-order": { kind: "refused", what: "ORDER BY, <, >, min or max over an inet", postgres: "orders addresses by network, then host", mason: "refused (equality, IS NULL and the value itself are answered)", wayOut: "sort or compare another column" },
|
|
55
|
+
"citext-forms": { kind: "refused", what: "GROUP BY, DISTINCT, min, max, a set operation, or = ANY (an array) over a citext", postgres: "groups and sorts without regard to case, answering one spelling", mason: "refused (=, <>, <, >, IN (…), LIKE and ORDER BY are answered, case-insensitively)", wayOut: "GROUP BY lower(x), or x::text where case matters" },
|
|
56
|
+
"bytea-forms": { kind: "refused", what: "encode / decode to a format other than hex, base64 or escape, or one not written as a literal; convert_to / convert_from of an encoding other than UTF8", postgres: "answers", mason: "refused", wayOut: "encode(x, 'hex'), 'base64' or 'escape'; the UTF8 encoding" },
|
|
57
|
+
"row-value": { kind: "refused", what: "a row as a value outside the SELECT list, a cast to text and the json functions (compared, tested with IS NULL, grouped, sorted); a row of a row; a row of a table Mason copied in part", postgres: "compares, tests and groups a row by its columns", mason: "refused", wayOut: "compare, test or sort its columns" },
|
|
58
|
+
"zone-abbreviation": { kind: "refused", what: "a time zone written as an abbreviation or a POSIX zone (`CST`, `IST`, `UTC+3`, `+05`) in AT TIME ZONE or date_trunc", postgres: "a fixed offset (`CST` is -06 all year; `UTC+3` is three hours WEST)", mason: "refused, written or held by a row (DuckDB reads several as another zone: `CST` as Chicago's, `IST` as India's, `UTC+3` as east)", wayOut: "an IANA zone name (America/Chicago, Asia/Kolkata), in any case; UT, Z and UTC are UTC", heldBy: "tests/duck-wire.test.ts" },
|
|
59
|
+
"volatile-window": { kind: "refused", what: "a window ordered or partitioned by random() or another volatile function (a random row per group)", postgres: "a new value per row", mason: "refused (DuckDB refuses a volatile expression there, and computes a deterministic one once for every row)", wayOut: "SELECT …, row_number() OVER (ORDER BY r) FROM (SELECT *, random() AS r FROM t) AS s" },
|
|
60
|
+
"numeric-special": { kind: "refused", what: "arithmetic on a numeric NaN or Infinity read from a text", postgres: "NaN and ±Infinity by its own rules (NaN + 1 is NaN)", mason: "refused when a row holds one", wayOut: "filter the special values out first (x <> 'NaN')" },
|
|
61
|
+
"numeric-exact-sum": { kind: "refused", what: "sum() or avg() of a numeric Postgres's own function computes here — a text read as an unconstrained numeric (Metabase's JSON number, `(j ->> 'k')::numeric`), ln, exp, sqrt, power — holding a value of more than 20 integer digits or 36 decimals besides trailing zeros, or summing past 20 integer digits", postgres: "adds every digit", mason: "refused when a row or the sum does (each value is summed exactly, in two DECIMAL(38,18) parts)", wayOut: "cast to numeric(p, s), or round(x, 18) first" },
|
|
62
|
+
"numeric-math": { kind: "refused", what: "ln, log, exp, sqrt or power of a numeric expression whose scale its types do not decide (a division, an unconstrained numeric's arithmetic)", postgres: "digits at a scale from the argument's own", mason: "refused (a column, an integer, a literal and a numeric(p, s) are answered by Postgres's own function)", wayOut: "cast the argument to numeric(p, s)" },
|
|
63
|
+
"view-forms": { kind: "refused", what: "a view's whole row as a value (`SELECT v FROM v`), a WITH query named as a view the statement reads, a column qualified by its view's schema beside another item of the view's name, views read through more than 16 others", postgres: "the view's row type; the WITH query or the view as each is in scope", mason: "refused (a view is answered by its definition in its place, as Postgres's rewriter answers it; its columns, `v.*` and `public.v.col` are answered)", wayOut: "name the view's columns; give the view or the WITH query another name" },
|
|
64
|
+
"enum-forms": { kind: "refused", what: "an array of an enum read from a text (`t::mood[]`)", postgres: "reads each element as a label", mason: "refused (a column, a label, an array literal and a text read as one label are answered)", wayOut: "write the array as a literal, or ARRAY[…] of its labels" },
|
|
65
|
+
"infinite-interval": { kind: "refused", what: "an infinite interval read from a text (Postgres 17+)", postgres: "infinity, -infinity", mason: "refused when a row holds one (DuckDB's interval has no infinity)", wayOut: "filter it out first, or read it as text" },
|
|
66
|
+
"relative-time-literal": { kind: "refused", what: "a relative time combined with anything else ('today 10:00', 'now'::date + … written as one literal), or read from a row as a date or a time", postgres: "the transaction's start (a row's value: when the row is read)", mason: "refused (a bare 'now', 'today', 'tomorrow', 'yesterday' literal is answered: the transaction's start at the Parse)", wayOut: "current_date + time '10:00', now(), current_date + 1" },
|
|
67
|
+
// ---------------------------------------------------------------------------------------------- differs
|
|
68
|
+
"limit-errors": { kind: "differs", what: "a select-list value that raises (`'x'::int`, a bad JSON number) in a row a LIMIT leaves out, where no index gives the ORDER BY", postgres: "raises: its plan sorts every row, its select list computed for each (through an index it stops at the limit and answers)", mason: "answers: a sorted, limited select list is computed for the rows the limit keeps (src/duck/late.ts), as Postgres's index scan does", wayOut: "none needed for an answer; filter the bad rows out to have neither raise" },
|
|
69
|
+
"mixed-scale-numeric": { kind: "differs", what: "a numeric column whose values have several scales: an integer UNION ALL a numeric, lead(x, 1, 0) beside numeric(2,1) values, an unconstrained column holding 1 and 1.50", postgres: "each value its own scale: 1, 1.5", mason: "one scale per column: 1.0, 1.5", wayOut: "cast to numeric(p, s), or round(x, s)" },
|
|
70
|
+
"unicode-16-case": { kind: "differs", what: "upper / lower of letters added in Unicode 16", postgres: "maps them (its own Unicode 16 tables)", mason: "leaves them as they are (DuckDB's ICU predates Unicode 16)", wayOut: "none needed for letters before Unicode 16" },
|
|
71
|
+
"float-last-digit": { kind: "differs", what: "stddev / variance of a float, and ln, log, exp, power of a double: the last digit", postgres: "its algorithm and libm's", mason: "DuckDB's: equal to about 1e-15", wayOut: "round(x::numeric, 12)" },
|
|
72
|
+
"negative-zero": { kind: "differs", what: "-0 beside 0 in greatest / least / percentile_disc", postgres: "keeps the one it met first", mason: "may answer the other", wayOut: "x + 0 makes -0 into 0" },
|
|
73
|
+
"float-overflow": { kind: "differs", what: "exp / power of a double past its range, or so small it underflows", postgres: "an error (22003)", mason: "Infinity, or 0", wayOut: "bound the argument" },
|
|
74
|
+
"min-max-scale-tie": { kind: "differs", what: "min / max of a numeric expression (a quotient) where equal values have different scales (0 / 7 and 0 / 1000000)", postgres: "the last of them it reads, so the scan order decides the trailing zeros", mason: "the one whose operands are least (min) or greatest (max)", wayOut: "round(min(a / b), n)" },
|
|
75
|
+
"numeric-stddev": { kind: "differs", what: "stddev / variance of DISTINCT integers or numerics, or of a numeric declared without a precision or with more than 18 digits: the last digits", postgres: "exact numeric arithmetic, to its own scale", mason: "a double's 15 digits", wayOut: "round(stddev(x), 10)" },
|
|
76
|
+
"char-n-minmax": { kind: "differs", what: "greatest / least of a char(n) beside a text", postgres: "compares without the padding, answers the first of equal values", mason: "compares the padded text", wayOut: "cast the char(n) to text first" },
|
|
77
|
+
"nested-exact-numeric": { kind: "differs", what: "an expression over a numeric a subquery computed exactly (a division, an avg, a round), outside that subquery: SELECT x * 2 FROM (SELECT a / 3 AS x …)", postgres: "exact digits at every step", mason: "the subquery's value as DuckDB computed it (a double's 15 digits) where the outer query computes with it; passed through as it is (SELECT *, s.x), it is exact", wayOut: "compute it in the outermost query, or round(…, n) inside" },
|
|
78
|
+
// ---------------------------------------------------------------------------------------------- limits
|
|
79
|
+
"one-token": { kind: "limit", what: "access", postgres: "roles, grants, row-level security", mason: "every user (mason user) and token reads every copied table: no per-user or per-tenant scope", wayOut: "copy only the tables every user may read" },
|
|
80
|
+
"poll-full-scan": { kind: "limit", what: "keeping current by the poll (mason start without --replication)", postgres: "-", mason: "the xmin poll scans every added table whole, every poll (no index covers xmin): ~13–17 s for 13 GiB", wayOut: "logical replication: mason start --replication" },
|
|
81
|
+
"writes": { kind: "limit", what: "INSERT, UPDATE, DELETE, DDL, a read-write transaction", postgres: "answers", mason: "refused by name: the copy is read-only (a read-write transaction: 25006)", wayOut: "write to the source database; the copy follows" },
|
|
82
|
+
"numeric-over-38-digits": { kind: "limit", what: "a numeric of more than 38 digits, or holding NaN or Infinity: a column, or a constant ('1e400'::numeric) inside DuckDB's arithmetic or a WHERE", postgres: "exact", mason: "a double (15 digits), said in the log for a column; a constant the outermost SELECT answers is exact", wayOut: "numeric(p, s) with p ≤ 38" },
|
|
83
|
+
"numeric-array": { kind: "limit", what: "an unconstrained numeric[] column", postgres: "exact elements", mason: "double precision elements (15 digits), said in the log", wayOut: "numeric(p, s)[]" },
|
|
84
|
+
"numeric-parameter": { kind: "limit", what: "a numeric parameter ($1) compared in a WHERE", postgres: "exact", mason: "a double (exact to 15 digits)", wayOut: "a literal" },
|
|
85
|
+
"log-literals": { kind: "limit", what: ".mason/mason.log's statement lines", postgres: "-", mason: "each statement DuckDB ran, whole, literals included", wayOut: "keep the log private" },
|
|
86
|
+
};
|
|
87
|
+
/** the words a runtime refusal carries for the oracle and a reader: `Mason gap <id>` */
|
|
88
|
+
export const gapMark = (id) => `Mason gap ${id}`;
|
|
89
|
+
export const GAP_MARK = /\bMason gap ([a-z0-9-]+)/;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `jsonb` AS POSTGRES WRITES IT (`jsonb_out`): DuckDB's JSON is the text it was given, Postgres's jsonb is a parsed
|
|
3
|
+
* value written back canonically — an object's keys sorted (shorter first, then by bytes, as `lengthCompareJsonbString`
|
|
4
|
+
* orders them) with a duplicate key's LAST value kept, `", "` between members and `": "` after a key, a number written
|
|
5
|
+
* as `numeric_out` writes it (`1e2` is `100`, `2.50` stays `2.50`, `-0` is `0`), a string's escapes as `escape_json`
|
|
6
|
+
* writes them (`é` becomes the character; `"`, `\`, and control characters escaped). The copy stores a jsonb
|
|
7
|
+
* column's text as the source sent it — already canonical — so this matters for a value a statement BUILDS
|
|
8
|
+
* (`'{"b":1,"a":2}'::jsonb`, `jsonb_build_object`, `->` of a literal).
|
|
9
|
+
*/
|
|
10
|
+
import { numericText, parseNumeric } from "../lib/pg-numeric.js";
|
|
11
|
+
/** Parses JSON keeping every number's text (JSON.parse would round `0.1000000000000000055511` and drop `2.50`'s zero). */
|
|
12
|
+
export function parseJson(text) {
|
|
13
|
+
let i = 0;
|
|
14
|
+
const ws = () => { while (i < text.length && " \t\n\r".includes(text[i]))
|
|
15
|
+
i++; };
|
|
16
|
+
const fail = (what) => { throw new Error(`invalid input syntax for type json: ${what} at offset ${i}`); };
|
|
17
|
+
const str = () => {
|
|
18
|
+
i++;
|
|
19
|
+
let out = "";
|
|
20
|
+
for (;;) {
|
|
21
|
+
const c = text[i++];
|
|
22
|
+
if (c === undefined)
|
|
23
|
+
return fail("a string is not closed");
|
|
24
|
+
if (c === '"')
|
|
25
|
+
return out;
|
|
26
|
+
if (c !== "\\") {
|
|
27
|
+
out += c;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const e = text[i++];
|
|
31
|
+
switch (e) {
|
|
32
|
+
case '"':
|
|
33
|
+
case "\\":
|
|
34
|
+
case "/":
|
|
35
|
+
out += e;
|
|
36
|
+
break;
|
|
37
|
+
case "b":
|
|
38
|
+
out += "\b";
|
|
39
|
+
break;
|
|
40
|
+
case "f":
|
|
41
|
+
out += "\f";
|
|
42
|
+
break;
|
|
43
|
+
case "n":
|
|
44
|
+
out += "\n";
|
|
45
|
+
break;
|
|
46
|
+
case "r":
|
|
47
|
+
out += "\r";
|
|
48
|
+
break;
|
|
49
|
+
case "t":
|
|
50
|
+
out += "\t";
|
|
51
|
+
break;
|
|
52
|
+
case "u": {
|
|
53
|
+
const hex = text.slice(i, i + 4);
|
|
54
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex))
|
|
55
|
+
return fail("a \\u escape needs four hex digits");
|
|
56
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
57
|
+
i += 4;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
default: return fail(`unknown escape \\${e ?? ""}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const value = () => {
|
|
65
|
+
ws();
|
|
66
|
+
const start = i;
|
|
67
|
+
const v = bare();
|
|
68
|
+
return { ...v, start, end: i };
|
|
69
|
+
};
|
|
70
|
+
const bare = () => {
|
|
71
|
+
const c = text[i];
|
|
72
|
+
if (c === "{") {
|
|
73
|
+
i++;
|
|
74
|
+
const members = [];
|
|
75
|
+
ws();
|
|
76
|
+
if (text[i] === "}") {
|
|
77
|
+
i++;
|
|
78
|
+
return { t: "obj", v: members };
|
|
79
|
+
}
|
|
80
|
+
for (;;) {
|
|
81
|
+
ws();
|
|
82
|
+
if (text[i] !== '"')
|
|
83
|
+
return fail("an object key must be a string");
|
|
84
|
+
const k = str();
|
|
85
|
+
ws();
|
|
86
|
+
if (text[i++] !== ":")
|
|
87
|
+
return fail("a key must be followed by ':'");
|
|
88
|
+
members.push([k, value()]);
|
|
89
|
+
ws();
|
|
90
|
+
const sep = text[i++];
|
|
91
|
+
if (sep === "}")
|
|
92
|
+
return { t: "obj", v: members };
|
|
93
|
+
if (sep !== ",")
|
|
94
|
+
return fail("expected ',' or '}'");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (c === "[") {
|
|
98
|
+
i++;
|
|
99
|
+
const items = [];
|
|
100
|
+
ws();
|
|
101
|
+
if (text[i] === "]") {
|
|
102
|
+
i++;
|
|
103
|
+
return { t: "arr", v: items };
|
|
104
|
+
}
|
|
105
|
+
for (;;) {
|
|
106
|
+
items.push(value());
|
|
107
|
+
ws();
|
|
108
|
+
const sep = text[i++];
|
|
109
|
+
if (sep === "]")
|
|
110
|
+
return { t: "arr", v: items };
|
|
111
|
+
if (sep !== ",")
|
|
112
|
+
return fail("expected ',' or ']'");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (c === '"')
|
|
116
|
+
return { t: "str", v: str() };
|
|
117
|
+
for (const [word, v] of [["true", { t: "bool", v: true }], ["false", { t: "bool", v: false }], ["null", { t: "null" }]]) {
|
|
118
|
+
if (text.startsWith(word, i)) {
|
|
119
|
+
i += word.length;
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
124
|
+
if (m === null)
|
|
125
|
+
return fail("not a JSON value");
|
|
126
|
+
i += m[0].length;
|
|
127
|
+
return { t: "num", text: m[0] };
|
|
128
|
+
};
|
|
129
|
+
const v = value();
|
|
130
|
+
ws();
|
|
131
|
+
if (i < text.length)
|
|
132
|
+
fail("text after the value");
|
|
133
|
+
return v;
|
|
134
|
+
}
|
|
135
|
+
/** `escape_json`: a string as Postgres's json output quotes it */
|
|
136
|
+
export function quoteJson(s) {
|
|
137
|
+
let out = '"';
|
|
138
|
+
for (const ch of s) {
|
|
139
|
+
const code = ch.codePointAt(0);
|
|
140
|
+
if (ch === '"')
|
|
141
|
+
out += '\\"';
|
|
142
|
+
else if (ch === "\\")
|
|
143
|
+
out += "\\\\";
|
|
144
|
+
else if (ch === "\b")
|
|
145
|
+
out += "\\b";
|
|
146
|
+
else if (ch === "\f")
|
|
147
|
+
out += "\\f";
|
|
148
|
+
else if (ch === "\n")
|
|
149
|
+
out += "\\n";
|
|
150
|
+
else if (ch === "\r")
|
|
151
|
+
out += "\\r";
|
|
152
|
+
else if (ch === "\t")
|
|
153
|
+
out += "\\t";
|
|
154
|
+
else if (code < 0x20)
|
|
155
|
+
out += `\\u${code.toString(16).padStart(4, "0")}`;
|
|
156
|
+
else
|
|
157
|
+
out += ch;
|
|
158
|
+
}
|
|
159
|
+
return `${out}"`;
|
|
160
|
+
}
|
|
161
|
+
/** jsonb's key order: shorter first, then by the UTF-8 bytes */
|
|
162
|
+
function keyOrder(a, b) {
|
|
163
|
+
const x = Buffer.from(a, "utf8"), y = Buffer.from(b, "utf8");
|
|
164
|
+
return x.length - y.length || Buffer.compare(x, y);
|
|
165
|
+
}
|
|
166
|
+
/** a parsed value as jsonb writes it */
|
|
167
|
+
export function writeJson(v) {
|
|
168
|
+
switch (v.t) {
|
|
169
|
+
case "null": return "null";
|
|
170
|
+
case "bool": return v.v ? "true" : "false";
|
|
171
|
+
case "num": return numericText(parseNumeric(v.text));
|
|
172
|
+
case "str": return quoteJson(v.v);
|
|
173
|
+
case "arr": return `[${v.v.map(writeJson).join(", ")}]`;
|
|
174
|
+
case "obj": {
|
|
175
|
+
// a duplicate key keeps its LAST value (jsonb's uniqueifyJsonbObject)
|
|
176
|
+
const last = new Map();
|
|
177
|
+
for (const [k, x] of v.v)
|
|
178
|
+
last.set(k, x);
|
|
179
|
+
return `{${[...last.keys()].sort(keyOrder).map((k) => `${quoteJson(k)}: ${writeJson(last.get(k))}`).join(", ")}}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** A JSON text as Postgres's jsonb writes it; throws a json syntax error for a text that is no JSON. */
|
|
184
|
+
export function jsonbText(text) {
|
|
185
|
+
return writeJson(parseJson(text));
|
|
186
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A STORED JSON PATH, AS A STATEMENT READS IT (docs/DESIGN.md "Stored JSON paths"; the keeper is
|
|
3
|
+
* src/duck/kept.ts). A copied table's JSON column read by a path a statement names with literal steps — `payload ->>
|
|
4
|
+
* 'customer_id'`, `payload #> '{a,0}'`, `jsonb_extract_path_text(payload, 'a')` — is computed by a JavaScript function
|
|
5
|
+
* for every row of every statement (`__mason_json_get`, src/duck/functions.ts): a hop from DuckDB's thread to Node's
|
|
6
|
+
* main thread, ~4.5 µs a row on the test server, 98 s over 22 M rows. A path statements keep reading is stored instead,
|
|
7
|
+
* as a hidden column of the copy holding exactly what that function answers, computed once per row WRITTEN; the
|
|
8
|
+
* rewrite (src/duck/rewrite.ts `keptRead`) reads the column in place of the call once every row of it is stored.
|
|
9
|
+
*
|
|
10
|
+
* This module is what a statement's plan needs of it, and nothing that writes: the names, the READ a rewrite records,
|
|
11
|
+
* and `hideKept`, which keeps the hidden columns out of every answer — a hidden column is a column of the copied table
|
|
12
|
+
* to DuckDB, so a `*`, a `t.*`, a whole row or a NATURAL JOIN would list it. Postgres's analysis (the catalog, PGlite)
|
|
13
|
+
* never knows it, so a result's names, types and typmods are Postgres's whatever DuckDB holds.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { pathIndex } from "./functions.js";
|
|
17
|
+
/**
|
|
18
|
+
* A READ AS THE ONE PATH IT IS (the test server: `#>> '{order,status}'` and `-> 'order' ->> 'status'`
|
|
19
|
+
* were two ledger rows, two columns and two whole-table backfills of one path). A `#>` path whose every element is a
|
|
20
|
+
* key — none reads as an array index (`pathIndex`, the function's own definition) — reads what the same keys read by
|
|
21
|
+
* `->`: on an object each is a key, on anything else the read is NULL either way. So it IS the keys' read, and is
|
|
22
|
+
* written as one here: THE ONE PLACE every read passes through — the rewrite's (`keptRead`), the request log's key
|
|
23
|
+
* (`readKey`), the hidden column's name (`hiddenName`), the ledger and `mason status` (the keeper's rows are written
|
|
24
|
+
* from it). An element that reads as an index (`{arr,0}`) is not folded: on an array `#> '{arr,0}'` is its first
|
|
25
|
+
* element where `-> 'arr' -> '0'` (a key) is NULL.
|
|
26
|
+
*/
|
|
27
|
+
export function canonicalRead(r) {
|
|
28
|
+
if (!r.either)
|
|
29
|
+
return r;
|
|
30
|
+
const steps = JSON.parse(r.steps);
|
|
31
|
+
return steps.every((s) => typeof s === "string" && pathIndex(s) === null) ? { ...r, either: false } : r;
|
|
32
|
+
}
|
|
33
|
+
/** a read as one string: the request log's key (its `->>` or `->` included: the policy counts both for one path) */
|
|
34
|
+
export const readKey = (r) => { const c = canonicalRead(r); return JSON.stringify([c.source, c.column, c.steps, c.either, c.canonical, c.asText]); };
|
|
35
|
+
/** a read's key, back (null for a string that is not one) */
|
|
36
|
+
export function readOfKey(key) {
|
|
37
|
+
let v;
|
|
38
|
+
try {
|
|
39
|
+
v = JSON.parse(key);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
} // a key a later Mason wrote otherwise: not a read this one keeps
|
|
44
|
+
if (!Array.isArray(v) || v.length !== 6)
|
|
45
|
+
return null;
|
|
46
|
+
const [source, column, steps, either, canonical, asText] = v;
|
|
47
|
+
if (typeof source !== "string" || typeof column !== "string" || typeof steps !== "string" || typeof either !== "boolean" || typeof canonical !== "boolean" || typeof asText !== "boolean")
|
|
48
|
+
return null;
|
|
49
|
+
return canonicalRead({ source, column, steps, either, canonical, asText });
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* THE STORED PATH A READ IS ANSWERED FROM: its canonical read (`canonicalRead`) without `->>`. One column serves a
|
|
53
|
+
* path's `->` and its `->>` (the test server: a popular key was stored twice, as `-> 'k'` and `->> 'k'`, two
|
|
54
|
+
* whole-table backfills): it holds the `->` answer, the value's JSON text, from which the `->>` answer is derived
|
|
55
|
+
* without the document (src/duck/rewrite.ts `keptRead`) — a string unquoted, a JSON null NULL, anything else its text,
|
|
56
|
+
* which is what `->>` answers (a jsonb number's text is numeric's either way, src/duck/jsonb.ts writeJson). The other
|
|
57
|
+
* way would lose what `->` answers: `->>` reads the string "5" and the number 5 alike.
|
|
58
|
+
*/
|
|
59
|
+
export const pathKey = (r) => { const c = canonicalRead(r); return JSON.stringify([c.source, c.column, c.steps, c.either, c.canonical]); };
|
|
60
|
+
/**
|
|
61
|
+
* THE HIDDEN COLUMN'S NAME: `__mason_json_` and 12 hex digits of the stored path's hash (`pathKey`), over the table too, so two tables'
|
|
62
|
+
* columns never share a name (a NATURAL JOIN of two tables would join on it). Mason's prefix: a source column of that
|
|
63
|
+
* very name is refused by the keeper (DuckDB names are case-insensitive), never shadowed.
|
|
64
|
+
*/
|
|
65
|
+
export const HIDDEN_PREFIX = "__mason_json_";
|
|
66
|
+
export const HIDDEN_RE = /^__mason_json_[0-9a-f]{12}$/;
|
|
67
|
+
export const hiddenName = (r) => `${HIDDEN_PREFIX}${createHash("sha256").update(pathKey(r)).digest("hex").slice(0, 12)}`;
|
|
68
|
+
/**
|
|
69
|
+
* THE VIEWS OF THE COPIES' VISIBLE COLUMNS: an attached in-memory DuckDB database of this name holds, for every served
|
|
70
|
+
* copy `<schema>.<table>`, a view `<schema>.<table>` of its visible columns in their order — the copy as it was before
|
|
71
|
+
* any column was stored. A statement that lists every column of a copy reads it there (`hideKept`): the same schema and
|
|
72
|
+
* table name, so `public.orders.id` and `orders.id` still bind.
|
|
73
|
+
*/
|
|
74
|
+
export const VISIBLE_CATALOG = "__mason_visible";
|
|
75
|
+
/** a read as a developer writes it: `payload ->> 'customer_id'`, `payload #> '{a,0}'`; a stored path (no `asText`) as
|
|
76
|
+
* its `->` read */
|
|
77
|
+
export function pathText(r) {
|
|
78
|
+
const steps = JSON.parse(r.steps);
|
|
79
|
+
const op = (one) => one ? (r.asText ? "->>" : "->") : (r.asText ? "#>>" : "#>");
|
|
80
|
+
if (steps.length === 1 && !r.either) {
|
|
81
|
+
const s = steps[0];
|
|
82
|
+
return `${r.column} ${op(true)} ${typeof s === "number" ? String(s) : `'${s.replaceAll("'", "''")}'`}`;
|
|
83
|
+
}
|
|
84
|
+
const element = (s) => typeof s === "number" || /^[A-Za-z0-9_]+$/.test(s) ? String(s) : `"${String(s).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
85
|
+
return `${r.column} ${op(false)} '{${steps.map(element).join(",").replaceAll("'", "''")}}'`;
|
|
86
|
+
}
|
|
87
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
88
|
+
const kindOf = (n) => isObject(n) && Object.keys(n).length === 1 && /^[A-Z]/.test(Object.keys(n)[0]) ? Object.keys(n)[0] : null;
|
|
89
|
+
const bodyOf = (n) => n[kindOf(n)];
|
|
90
|
+
const sval = (n) => kindOf(n) === "String" ? String(bodyOf(n).sval ?? "") : null;
|
|
91
|
+
/** the schemas a bare table name is looked up in, as the session's search_path says (`"$user"` names none here) */
|
|
92
|
+
export function searchSchemas(searchPath) {
|
|
93
|
+
return searchPath.split(",").map((s) => s.trim()).filter((s) => s !== "" && s !== '"$user"' && s !== "$user")
|
|
94
|
+
.map((s) => s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).replaceAll('""', '"') : s.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
/** the RangeVars a FROM list names itself, through its joins (never inside a subquery: its columns are its list's) */
|
|
97
|
+
function directTables(from) {
|
|
98
|
+
const out = [];
|
|
99
|
+
const visit = (v) => {
|
|
100
|
+
if (Array.isArray(v)) {
|
|
101
|
+
v.forEach(visit);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const k = kindOf(v);
|
|
105
|
+
if (k === "RangeVar")
|
|
106
|
+
out.push(bodyOf(v));
|
|
107
|
+
else if (k === "JoinExpr") {
|
|
108
|
+
visit(bodyOf(v).larg);
|
|
109
|
+
visit(bodyOf(v).rarg);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
visit(from);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** the name a FROM item's columns are qualified by: its alias, else its table's name */
|
|
116
|
+
const aliasOf = (rv) => isObject(rv.alias) ? String(rv.alias.aliasname ?? "") : String(rv.relname ?? "");
|
|
117
|
+
/**
|
|
118
|
+
* WHETHER A STATEMENT LISTS A TABLE'S COLUMNS WITHOUT NAMING THEM (`hideKept`): a SELECT whose list holds a bare `*`
|
|
119
|
+
* and whose FROM names a table itself (a `*` over a subquery lists the subquery's columns); a `t.*` anywhere whose `t`
|
|
120
|
+
* is a table's name or alias in the statement; a NATURAL JOIN with a table on either side; or one bare name equal to a
|
|
121
|
+
* table's name or alias (a whole row — `row_to_json(t)`, `SELECT t FROM t` — or a column of that name, taken as one).
|
|
122
|
+
*/
|
|
123
|
+
export function exposes(tree) {
|
|
124
|
+
const aliases = new Set();
|
|
125
|
+
const collect = (v) => {
|
|
126
|
+
if (Array.isArray(v)) {
|
|
127
|
+
v.forEach(collect);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!isObject(v))
|
|
131
|
+
return;
|
|
132
|
+
const k = kindOf(v);
|
|
133
|
+
if (k === "RangeVar")
|
|
134
|
+
aliases.add(aliasOf(bodyOf(v)));
|
|
135
|
+
for (const x of Object.values(k === null ? v : bodyOf(v)))
|
|
136
|
+
collect(x);
|
|
137
|
+
};
|
|
138
|
+
collect(tree);
|
|
139
|
+
const star = (n) => kindOf(n) === "ColumnRef" && Array.isArray(bodyOf(n).fields) && bodyOf(n).fields.length === 1 && kindOf(bodyOf(n).fields[0]) === "A_Star";
|
|
140
|
+
const found = (v) => {
|
|
141
|
+
if (Array.isArray(v))
|
|
142
|
+
return v.some(found);
|
|
143
|
+
if (!isObject(v))
|
|
144
|
+
return false;
|
|
145
|
+
const k = kindOf(v);
|
|
146
|
+
const b = k === null ? v : bodyOf(v);
|
|
147
|
+
if (k === "SelectStmt" && Array.isArray(b.targetList) && directTables(b.fromClause).length > 0
|
|
148
|
+
&& b.targetList.some((t) => kindOf(t) === "ResTarget" && star(bodyOf(t).val)))
|
|
149
|
+
return true;
|
|
150
|
+
if (k === "JoinExpr" && b.isNatural === true && directTables([b.larg, b.rarg]).length > 0)
|
|
151
|
+
return true;
|
|
152
|
+
if (k === "ColumnRef" && Array.isArray(b.fields)) {
|
|
153
|
+
const fields = b.fields;
|
|
154
|
+
const last = fields[fields.length - 1];
|
|
155
|
+
if (fields.length >= 2 && kindOf(last) === "A_Star" && aliases.has(sval(fields[fields.length - 2]) ?? ""))
|
|
156
|
+
return true;
|
|
157
|
+
if (fields.length === 1 && aliases.has(sval(fields[0]) ?? "\u0000"))
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return Object.values(b).some(found);
|
|
161
|
+
};
|
|
162
|
+
return found(tree);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* THE HIDDEN COLUMNS KEPT OUT OF THE ANSWER (the rewritten tree, before it is printed). A statement that lists a table's
|
|
166
|
+
* columns without naming them (`exposes`) — a hidden column is a column to DuckDB, and would be listed — reads every
|
|
167
|
+
* copy it names through that copy's view of its visible columns (`VISIBLE_CATALOG`), and is rewritten with no stored
|
|
168
|
+
* read at all (src/duck/plan.ts: a stored read names no document to fall back on, so it cannot be turned back into its
|
|
169
|
+
* call here), so it answers exactly what it answered before any column was stored — a plan kept from before a table's
|
|
170
|
+
* first stored column included. Every other statement reads the copies themselves, naming only what it reads.
|
|
171
|
+
*
|
|
172
|
+
* A table name is resolved as Postgres resolved it: a qualified one is that table; a bare one is a WITH query of that
|
|
173
|
+
* name in scope (left alone), else the first schema on the search path that has a served copy of it. Answers whether the
|
|
174
|
+
* statement exposed its columns. The tree is changed in place.
|
|
175
|
+
*/
|
|
176
|
+
export function hideKept(tree, ctx) {
|
|
177
|
+
if (!exposes(tree))
|
|
178
|
+
return false;
|
|
179
|
+
// every copy it names, read through its view; a WITH query's name in scope is the WITH query's
|
|
180
|
+
const redirect = (v, ctes) => {
|
|
181
|
+
if (Array.isArray(v)) {
|
|
182
|
+
for (const x of v)
|
|
183
|
+
redirect(x, ctes);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (!isObject(v))
|
|
187
|
+
return;
|
|
188
|
+
const k = kindOf(v);
|
|
189
|
+
if (k === "RangeVar") {
|
|
190
|
+
const b = bodyOf(v);
|
|
191
|
+
if (typeof b.catalogname === "string" && b.catalogname !== "")
|
|
192
|
+
return;
|
|
193
|
+
const name = String(b.relname ?? "");
|
|
194
|
+
const schema = typeof b.schemaname === "string" && b.schemaname !== "" ? b.schemaname
|
|
195
|
+
: ctes.has(name) ? null : ctx.searchPath.find((s) => ctx.visible.has(`${s}.${name}`)) ?? null;
|
|
196
|
+
if (schema !== null && ctx.visible.has(`${schema}.${name}`)) {
|
|
197
|
+
b.catalogname = VISIBLE_CATALOG;
|
|
198
|
+
b.schemaname = schema;
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (k === "SelectStmt") {
|
|
203
|
+
const b = bodyOf(v);
|
|
204
|
+
const w = isObject(b.withClause) ? b.withClause : null;
|
|
205
|
+
const list = w !== null && Array.isArray(w.ctes) ? w.ctes.filter((c) => kindOf(c) === "CommonTableExpr").map(bodyOf) : [];
|
|
206
|
+
const all = new Set([...ctes, ...list.map((c) => String(c.ctename ?? ""))]);
|
|
207
|
+
// a WITH query sees the ones before it (all of them, RECURSIVE); the rest of the statement sees all
|
|
208
|
+
list.forEach((c, i) => redirect(c.ctequery, w?.recursive === true ? all : new Set([...ctes, ...list.slice(0, i).map((x) => String(x.ctename ?? ""))])));
|
|
209
|
+
for (const [key, x] of Object.entries(b))
|
|
210
|
+
if (key !== "withClause")
|
|
211
|
+
redirect(x, all);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (const x of Object.values(k === null ? v : bodyOf(v)))
|
|
215
|
+
redirect(x, ctes);
|
|
216
|
+
};
|
|
217
|
+
redirect(tree, new Set());
|
|
218
|
+
return true;
|
|
219
|
+
}
|