neonctl 2.22.2 → 2.23.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 +84 -0
- package/analytics.js +5 -2
- package/commands/branches.js +9 -1
- package/commands/connection_string.js +9 -1
- package/commands/functions.js +277 -0
- package/commands/index.js +4 -0
- package/commands/neon_auth.js +1013 -0
- package/commands/projects.js +9 -1
- package/commands/psql.js +6 -1
- package/functions_api.js +44 -0
- package/package.json +15 -5
- package/psql/cli.js +51 -0
- package/psql/command/cmd_cond.js +437 -0
- package/psql/command/cmd_connect.js +815 -0
- package/psql/command/cmd_copy.js +1025 -0
- package/psql/command/cmd_describe.js +1810 -0
- package/psql/command/cmd_format.js +909 -0
- package/psql/command/cmd_io.js +2187 -0
- package/psql/command/cmd_lo.js +385 -0
- package/psql/command/cmd_meta.js +970 -0
- package/psql/command/cmd_misc.js +187 -0
- package/psql/command/cmd_pipeline.js +1141 -0
- package/psql/command/cmd_restrict.js +171 -0
- package/psql/command/cmd_show.js +751 -0
- package/psql/command/dispatch.js +343 -0
- package/psql/command/inputQueue.js +42 -0
- package/psql/command/shared.js +71 -0
- package/psql/complete/filenames.js +139 -0
- package/psql/complete/index.js +104 -0
- package/psql/complete/matcher.js +314 -0
- package/psql/complete/psqlVars.js +247 -0
- package/psql/complete/queries.js +491 -0
- package/psql/complete/rules.js +2387 -0
- package/psql/core/common.js +1250 -0
- package/psql/core/help.js +576 -0
- package/psql/core/mainloop.js +1353 -0
- package/psql/core/prompt.js +437 -0
- package/psql/core/settings.js +684 -0
- package/psql/core/sqlHelp.js +1066 -0
- package/psql/core/startup.js +840 -0
- package/psql/core/syncVars.js +116 -0
- package/psql/core/variables.js +287 -0
- package/psql/describe/formatters.js +1277 -0
- package/psql/describe/processNamePattern.js +270 -0
- package/psql/describe/queries.js +2373 -0
- package/psql/describe/versionGate.js +43 -0
- package/psql/index.js +2005 -0
- package/psql/io/history.js +299 -0
- package/psql/io/input.js +120 -0
- package/psql/io/lineEditor/buffer.js +323 -0
- package/psql/io/lineEditor/complete.js +227 -0
- package/psql/io/lineEditor/filename.js +159 -0
- package/psql/io/lineEditor/index.js +891 -0
- package/psql/io/lineEditor/keymap.js +738 -0
- package/psql/io/lineEditor/vt100.js +363 -0
- package/psql/io/pgpass.js +202 -0
- package/psql/io/pgservice.js +194 -0
- package/psql/io/psqlrc.js +422 -0
- package/psql/print/aligned.js +1756 -0
- package/psql/print/asciidoc.js +248 -0
- package/psql/print/crosstab.js +460 -0
- package/psql/print/csv.js +92 -0
- package/psql/print/html.js +258 -0
- package/psql/print/json.js +96 -0
- package/psql/print/latex.js +396 -0
- package/psql/print/pager.js +265 -0
- package/psql/print/troff.js +258 -0
- package/psql/print/unaligned.js +118 -0
- package/psql/print/units.js +135 -0
- package/psql/scanner/slash.js +513 -0
- package/psql/scanner/sql.js +910 -0
- package/psql/scanner/stringutils.js +390 -0
- package/psql/types/backslash.js +1 -0
- package/psql/types/connection.js +1 -0
- package/psql/types/index.js +7 -0
- package/psql/types/printer.js +1 -0
- package/psql/types/repl.js +1 -0
- package/psql/types/scanner.js +24 -0
- package/psql/types/settings.js +1 -0
- package/psql/types/variables.js +1 -0
- package/psql/wire/connection.js +2844 -0
- package/psql/wire/copy.js +108 -0
- package/psql/wire/notify.js +59 -0
- package/psql/wire/pipeline.js +519 -0
- package/psql/wire/protocol.js +466 -0
- package/psql/wire/sasl.js +296 -0
- package/psql/wire/tls.js +596 -0
- package/test_utils/fixtures.js +1 -0
- package/utils/esbuild.js +147 -0
- package/utils/psql.js +107 -11
- package/utils/zip.js +4 -0
- package/writer.js +1 -1
- package/commands/auth.test.js +0 -211
- package/commands/branches.test.js +0 -460
- package/commands/checkout.test.js +0 -170
- package/commands/connection_string.test.js +0 -196
- package/commands/data_api.test.js +0 -169
- package/commands/databases.test.js +0 -39
- package/commands/help.test.js +0 -9
- package/commands/init.test.js +0 -56
- package/commands/ip_allow.test.js +0 -59
- package/commands/link.test.js +0 -381
- package/commands/operations.test.js +0 -7
- package/commands/orgs.test.js +0 -7
- package/commands/projects.test.js +0 -144
- package/commands/psql.test.js +0 -49
- package/commands/roles.test.js +0 -37
- package/commands/set_context.test.js +0 -159
- package/commands/vpc_endpoints.test.js +0 -69
- package/context.test.js +0 -119
- package/env.test.js +0 -55
- package/utils/formats.test.js +0 -32
- package/writer.test.js +0 -104
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* psql `\crosstabview` pivot + render (WP-22).
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of `src/bin/psql/crosstabview.c`:
|
|
5
|
+
*
|
|
6
|
+
* - `pivotResultSet` is the moral equivalent of `PrintResultInCrosstab`'s
|
|
7
|
+
* "first/second/third part" — resolve the four column references against
|
|
8
|
+
* the source ResultSet's fields, collect the distinct vertical / horizontal
|
|
9
|
+
* header values (in first-appearance order, matching upstream's AVL-tree
|
|
10
|
+
* `rank = tree->count` assignment), detect duplicate `(colV, colH)` pairs,
|
|
11
|
+
* and produce a new ResultSet whose first column is the vertical header
|
|
12
|
+
* and whose remaining columns are one per horizontal-header value.
|
|
13
|
+
*
|
|
14
|
+
* - `printCrosstab` runs the pivot and then forwards the pivoted ResultSet
|
|
15
|
+
* to the existing aligned printer (`alignedPrinter.printQuery`). We
|
|
16
|
+
* deliberately reuse the aligned printer so border / nullPrint / locale /
|
|
17
|
+
* unicode glyphs / expanded all keep working without re-implementation.
|
|
18
|
+
*
|
|
19
|
+
* Column references accept upstream's two forms — a 1-based column number
|
|
20
|
+
* (`"1"`, `"2"`, ...) and a column name (matched against `ResultSet.fields`
|
|
21
|
+
* using upstream's "dequote then downcase" rule). The fourth argument
|
|
22
|
+
* (`sortColH`) additionally honours a leading `+` / `-` to request
|
|
23
|
+
* ascending / descending sort on the horizontal header values — a small
|
|
24
|
+
* extension over upstream's "sort by the sort-column's payload" semantics
|
|
25
|
+
* that lets callers pivot without an explicit numeric sort column.
|
|
26
|
+
*
|
|
27
|
+
* Error cases (text matches `pg_log_error` strings from crosstabview.c):
|
|
28
|
+
*
|
|
29
|
+
* - "query must return at least three columns",
|
|
30
|
+
* - "vertical and horizontal headers must be different columns",
|
|
31
|
+
* - "column number N is out of range 1..M",
|
|
32
|
+
* - "column name not found: \"…\"",
|
|
33
|
+
* - "ambiguous column name: \"…\"",
|
|
34
|
+
* - "maximum number of columns (1600) exceeded",
|
|
35
|
+
* - "query result contains multiple data values for row \"…\", column \"…\"".
|
|
36
|
+
*/
|
|
37
|
+
import { alignedPrinter } from './aligned.js';
|
|
38
|
+
const DIGIT_RE = /^\d+$/;
|
|
39
|
+
const SIGNED_DIGIT_RE = /^[+-]?\d+$/;
|
|
40
|
+
const dequoteDowncase = (raw) => {
|
|
41
|
+
let out = '';
|
|
42
|
+
let inquotes = false;
|
|
43
|
+
let hadQuotes = false;
|
|
44
|
+
let i = 0;
|
|
45
|
+
while (i < raw.length) {
|
|
46
|
+
const c = raw[i];
|
|
47
|
+
if (c === '"') {
|
|
48
|
+
hadQuotes = true;
|
|
49
|
+
if (inquotes && raw[i + 1] === '"') {
|
|
50
|
+
out += '"';
|
|
51
|
+
i += 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
inquotes = !inquotes;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
out += inquotes ? c : c.toLowerCase();
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
return { value: out, hadQuotes };
|
|
62
|
+
};
|
|
63
|
+
const indexOfColumn = (arg, fields, allowSign) => {
|
|
64
|
+
// Numeric arg path: number type, or string that parses as integer.
|
|
65
|
+
if (typeof arg === 'number') {
|
|
66
|
+
if (!Number.isInteger(arg)) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `column number ${String(arg)} is not an integer`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const sign = arg < 0 ? -1 : 1;
|
|
73
|
+
const abs = Math.abs(arg);
|
|
74
|
+
if (abs < 1 || abs > fields.length) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
error: `column number ${String(abs)} is out of range 1..${String(fields.length)}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, index: abs - 1, sign: allowSign ? sign : 1 };
|
|
81
|
+
}
|
|
82
|
+
let str = arg.trim();
|
|
83
|
+
let sign = 1;
|
|
84
|
+
if (allowSign && (str.startsWith('+') || str.startsWith('-'))) {
|
|
85
|
+
if (str.startsWith('-'))
|
|
86
|
+
sign = -1;
|
|
87
|
+
// Peel only when the rest looks like it could be a referencing token.
|
|
88
|
+
const rest = str.slice(1);
|
|
89
|
+
if (rest.length > 0)
|
|
90
|
+
str = rest;
|
|
91
|
+
}
|
|
92
|
+
if (str.length === 0) {
|
|
93
|
+
return { ok: false, error: 'empty column reference' };
|
|
94
|
+
}
|
|
95
|
+
if (DIGIT_RE.test(str)) {
|
|
96
|
+
const n = parseInt(str, 10);
|
|
97
|
+
if (n < 1 || n > fields.length) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
error: `column number ${String(n)} is out of range 1..${String(fields.length)}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, index: n - 1, sign };
|
|
104
|
+
}
|
|
105
|
+
// Name lookup: upstream `indexOfColumn` dequotes & downcases the arg
|
|
106
|
+
// in place, then runs `strcmp(arg, PQfname(res, i))` against the raw
|
|
107
|
+
// field name. The field name is NOT itself downcased, so:
|
|
108
|
+
// - unquoted `B` → "b" → matches field `b` only (not `B`);
|
|
109
|
+
// - unquoted `Foo` → "foo" → does NOT match field `Foo`;
|
|
110
|
+
// - quoted `"B"` → "B" → matches field `B` only;
|
|
111
|
+
// - quoted `"Foo"` → "Foo" → matches field `Foo`.
|
|
112
|
+
// No case-insensitive fallback — that mismatched the
|
|
113
|
+
// "need to quote name" test in the conformance corpus.
|
|
114
|
+
const { value: needle } = dequoteDowncase(str);
|
|
115
|
+
let found = -1;
|
|
116
|
+
for (let i = 0; i < fields.length; i++) {
|
|
117
|
+
if (fields[i].name === needle) {
|
|
118
|
+
if (found >= 0) {
|
|
119
|
+
// Upstream's `indexOfColumn` formats the dequoted/downcased name
|
|
120
|
+
// in the error message, not the raw arg with its leading
|
|
121
|
+
// quotes. Matches `pg_log_error("ambiguous column name: \"%s\"")`
|
|
122
|
+
// after the in-place `dequote_downcase_identifier(arg)` mutation.
|
|
123
|
+
return { ok: false, error: `ambiguous column name: "${needle}"` };
|
|
124
|
+
}
|
|
125
|
+
found = i;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (found === -1) {
|
|
129
|
+
// Same convention as the ambiguous-name branch: format the
|
|
130
|
+
// dequoted/downcased name so quoted `"B"` reads as `"B"` and
|
|
131
|
+
// unquoted `Foo` reads as `"foo"`.
|
|
132
|
+
return { ok: false, error: `column name not found: "${needle}"` };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, index: found, sign };
|
|
135
|
+
};
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Cell value → comparable key + display string
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
/**
|
|
140
|
+
* Build a stable string key for a header value so we can deduplicate
|
|
141
|
+
* vertical / horizontal headers consistently. We use `JSON.stringify`-ish
|
|
142
|
+
* encoding plus a leading discriminator so e.g. the string `"1"` and the
|
|
143
|
+
* number `1` don't collide (matches upstream where PQgetvalue returns a
|
|
144
|
+
* type-specific text form — the rows pre-serialised by the server are
|
|
145
|
+
* compared byte-for-byte).
|
|
146
|
+
*
|
|
147
|
+
* Null is represented by a sentinel so the printer can substitute
|
|
148
|
+
* `popt.nullPrint`.
|
|
149
|
+
*/
|
|
150
|
+
const NULL_SENTINEL = Symbol.for('neonctl.psql.crosstab.null');
|
|
151
|
+
const headerKey = (value) => {
|
|
152
|
+
if (value === null || value === undefined)
|
|
153
|
+
return NULL_SENTINEL;
|
|
154
|
+
if (typeof value === 'string')
|
|
155
|
+
return `s:${value}`;
|
|
156
|
+
if (typeof value === 'number')
|
|
157
|
+
return `n:${String(value)}`;
|
|
158
|
+
if (typeof value === 'bigint')
|
|
159
|
+
return `i:${String(value)}`;
|
|
160
|
+
if (typeof value === 'boolean')
|
|
161
|
+
return `b:${String(value)}`;
|
|
162
|
+
if (value instanceof Date)
|
|
163
|
+
return `d:${value.toISOString()}`;
|
|
164
|
+
if (value instanceof Uint8Array) {
|
|
165
|
+
let hex = 'x:';
|
|
166
|
+
for (const b of value)
|
|
167
|
+
hex += b.toString(16).padStart(2, '0');
|
|
168
|
+
return hex;
|
|
169
|
+
}
|
|
170
|
+
return `j:${JSON.stringify(value)}`;
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Display form for a header value — used to populate the synthetic
|
|
174
|
+
* ResultSet's `fields[i].name` (which must be a string, not nullable).
|
|
175
|
+
* Mirrors upstream's `colname = piv_columns[…].name ? … : popt.nullPrint`.
|
|
176
|
+
*/
|
|
177
|
+
const headerDisplay = (value, nullPrint) => {
|
|
178
|
+
if (value === null || value === undefined)
|
|
179
|
+
return nullPrint;
|
|
180
|
+
if (typeof value === 'string')
|
|
181
|
+
return value;
|
|
182
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
183
|
+
return value.toString();
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === 'boolean')
|
|
186
|
+
return value ? 't' : 'f';
|
|
187
|
+
if (value instanceof Date)
|
|
188
|
+
return value.toISOString();
|
|
189
|
+
if (value instanceof Uint8Array) {
|
|
190
|
+
let hex = '\\x';
|
|
191
|
+
for (const b of value)
|
|
192
|
+
hex += b.toString(16).padStart(2, '0');
|
|
193
|
+
return hex;
|
|
194
|
+
}
|
|
195
|
+
return JSON.stringify(value);
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Compare two cells under `+`/`-` numeric semantics: if both look like
|
|
199
|
+
* `/^-?\d+$/` (matching upstream's `rankSort` regex), compare as integers;
|
|
200
|
+
* otherwise compare as strings. Nulls sort last. The `sign` flag flips the
|
|
201
|
+
* order (descending).
|
|
202
|
+
*/
|
|
203
|
+
const cmpForSort = (a, b, sign) => {
|
|
204
|
+
const aNull = a === null || a === undefined;
|
|
205
|
+
const bNull = b === null || b === undefined;
|
|
206
|
+
if (aNull && bNull)
|
|
207
|
+
return 0;
|
|
208
|
+
if (aNull)
|
|
209
|
+
return 1; // null last
|
|
210
|
+
if (bNull)
|
|
211
|
+
return -1;
|
|
212
|
+
const aStr = typeof a === 'string' ? a : headerDisplay(a, '');
|
|
213
|
+
const bStr = typeof b === 'string' ? b : headerDisplay(b, '');
|
|
214
|
+
if (SIGNED_DIGIT_RE.test(aStr) && SIGNED_DIGIT_RE.test(bStr)) {
|
|
215
|
+
const an = parseInt(aStr, 10);
|
|
216
|
+
const bn = parseInt(bStr, 10);
|
|
217
|
+
if (an < bn)
|
|
218
|
+
return -1 * sign;
|
|
219
|
+
if (an > bn)
|
|
220
|
+
return 1 * sign;
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (aStr < bStr)
|
|
224
|
+
return -1 * sign;
|
|
225
|
+
if (aStr > bStr)
|
|
226
|
+
return 1 * sign;
|
|
227
|
+
return 0;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Pivot a ResultSet into a `{ rowHeaders, colHeaders, matrix }` shape,
|
|
231
|
+
* returning a synthetic ResultSet ready for the aligned printer.
|
|
232
|
+
*
|
|
233
|
+
* Detailed algorithm:
|
|
234
|
+
*
|
|
235
|
+
* 1. Resolve `colV` / `colH` / `colD` / `sortColH` to zero-based field
|
|
236
|
+
* indices via {@link indexOfColumn}. `colV` defaults to 0, `colH` to 1.
|
|
237
|
+
* `colD` defaults to the only remaining column when there are exactly
|
|
238
|
+
* three; otherwise we require it explicitly (matching upstream).
|
|
239
|
+
* 2. Walk rows once. For each row:
|
|
240
|
+
* - `vKey` = header-key of the row's colV cell;
|
|
241
|
+
* - `hKey` = header-key of the row's colH cell;
|
|
242
|
+
* - If we haven't seen `vKey` or `hKey` before, give them the next
|
|
243
|
+
* rank in first-appearance order, and capture the sort value
|
|
244
|
+
* (from colSort) for hKey.
|
|
245
|
+
* - Stash the cell at `(vRank, hRank)`. If a value already lives
|
|
246
|
+
* there, surface a duplicate-pair error.
|
|
247
|
+
* 3. If `sortColH` was supplied, stable-sort the horizontal header entries
|
|
248
|
+
* by their captured sort value (numeric if both look numeric, else
|
|
249
|
+
* string; `sign` for ascending/descending) and reassign ranks.
|
|
250
|
+
* 4. Construct the synthetic ResultSet: one field for colV (carries colV's
|
|
251
|
+
* original FieldDescription) + one field per horizontal header value
|
|
252
|
+
* (each carrying colD's FieldDescription, so the aligned printer's
|
|
253
|
+
* right-align heuristic kicks in for numeric data).
|
|
254
|
+
* 5. Rows: for each vertical header in rank order, emit `[vHeaderValue,
|
|
255
|
+
* ...cellsByHorizontalRank]`. Unfilled cells are `""` so the printer
|
|
256
|
+
* just emits empty padding (matching upstream's
|
|
257
|
+
* "non-initialized cells must be set to an empty string" pass).
|
|
258
|
+
*/
|
|
259
|
+
export const pivotResultSet = (rs, opts,
|
|
260
|
+
/**
|
|
261
|
+
* Substitution string for `null` header values when synthesising the
|
|
262
|
+
* pivoted FieldDescription.name. Upstream's `\crosstabview` formats a
|
|
263
|
+
* NULL horizontal header using the current `\pset null` setting (an
|
|
264
|
+
* empty string by default). Callers that don't care can omit; tests
|
|
265
|
+
* that drive the function in isolation can supply a sentinel.
|
|
266
|
+
*/
|
|
267
|
+
nullPrint = '') => {
|
|
268
|
+
// (1) Field resolution. Upstream `crosstabview.c` requires PQnfields >= 3
|
|
269
|
+
// unconditionally — pivoting two columns is degenerate (V × H with no
|
|
270
|
+
// payload). Match the error text verbatim so the conformance test sees
|
|
271
|
+
// the same line.
|
|
272
|
+
if (rs.fields.length < 3) {
|
|
273
|
+
return { error: 'query must return at least three columns' };
|
|
274
|
+
}
|
|
275
|
+
const colV = opts.colV ?? 1;
|
|
276
|
+
const colH = opts.colH ?? 2;
|
|
277
|
+
const vRes = indexOfColumn(colV, rs.fields, false);
|
|
278
|
+
if (!vRes.ok)
|
|
279
|
+
return { error: vRes.error };
|
|
280
|
+
const hRes = indexOfColumn(colH, rs.fields, false);
|
|
281
|
+
if (!hRes.ok)
|
|
282
|
+
return { error: hRes.error };
|
|
283
|
+
if (vRes.index === hRes.index) {
|
|
284
|
+
return {
|
|
285
|
+
error: 'vertical and horizontal headers must be different columns',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
let dataIdx;
|
|
289
|
+
if (opts.colD === undefined) {
|
|
290
|
+
// With exactly three columns and no explicit `colD`, the data column
|
|
291
|
+
// is the remaining one. With more than three, upstream picks the
|
|
292
|
+
// first non-V/H column too — we mirror that here so `SELECT v,h,c,i`
|
|
293
|
+
// pivots `c` by default.
|
|
294
|
+
let candidate = -1;
|
|
295
|
+
for (let i = 0; i < rs.fields.length; i++) {
|
|
296
|
+
if (i !== vRes.index && i !== hRes.index) {
|
|
297
|
+
candidate = i;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (candidate < 0) {
|
|
302
|
+
return { error: 'no data column available' };
|
|
303
|
+
}
|
|
304
|
+
dataIdx = candidate;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
const dRes = indexOfColumn(opts.colD, rs.fields, false);
|
|
308
|
+
if (!dRes.ok)
|
|
309
|
+
return { error: dRes.error };
|
|
310
|
+
dataIdx = dRes.index;
|
|
311
|
+
}
|
|
312
|
+
let sortIdx = -1;
|
|
313
|
+
let sortSign = 1;
|
|
314
|
+
if (opts.sortColH !== undefined) {
|
|
315
|
+
const sRes = indexOfColumn(opts.sortColH, rs.fields, true);
|
|
316
|
+
if (!sRes.ok)
|
|
317
|
+
return { error: sRes.error };
|
|
318
|
+
sortIdx = sRes.index;
|
|
319
|
+
sortSign = sRes.sign;
|
|
320
|
+
}
|
|
321
|
+
// (2) Single-pass row walk: build distinct V/H header sets in
|
|
322
|
+
// first-appearance order and populate the data matrix.
|
|
323
|
+
const vHeaders = new Map();
|
|
324
|
+
const hHeaders = new Map();
|
|
325
|
+
// matrix keyed by `${vRank}|${hRank}` rather than a 2D array because we
|
|
326
|
+
// don't know the final dimensions until we've walked all rows. The
|
|
327
|
+
// string key keeps lookups O(1) without packing into an array.
|
|
328
|
+
const matrix = new Map();
|
|
329
|
+
for (const row of rs.rows) {
|
|
330
|
+
const vVal = row[vRes.index];
|
|
331
|
+
const hVal = row[hRes.index];
|
|
332
|
+
const dVal = row[dataIdx];
|
|
333
|
+
const vk = headerKey(vVal);
|
|
334
|
+
const hk = headerKey(hVal);
|
|
335
|
+
let vEntry = vHeaders.get(vk);
|
|
336
|
+
if (!vEntry) {
|
|
337
|
+
vEntry = {
|
|
338
|
+
key: vk,
|
|
339
|
+
value: vVal,
|
|
340
|
+
rank: vHeaders.size,
|
|
341
|
+
sortValue: null,
|
|
342
|
+
};
|
|
343
|
+
vHeaders.set(vk, vEntry);
|
|
344
|
+
}
|
|
345
|
+
let hEntry = hHeaders.get(hk);
|
|
346
|
+
if (!hEntry) {
|
|
347
|
+
// Upstream `crosstabview.c` caps distinct horizontal-header values
|
|
348
|
+
// at `CROSSTABVIEW_MAX_COLUMNS` (1600). Past that, the synthesised
|
|
349
|
+
// result wouldn't be printable in a reasonable width anyway, so
|
|
350
|
+
// we mirror the cap and the error text verbatim.
|
|
351
|
+
if (hHeaders.size >= 1600) {
|
|
352
|
+
return { error: 'maximum number of columns (1600) exceeded' };
|
|
353
|
+
}
|
|
354
|
+
hEntry = {
|
|
355
|
+
key: hk,
|
|
356
|
+
value: hVal,
|
|
357
|
+
rank: hHeaders.size,
|
|
358
|
+
sortValue: sortIdx >= 0 ? row[sortIdx] : null,
|
|
359
|
+
};
|
|
360
|
+
hHeaders.set(hk, hEntry);
|
|
361
|
+
}
|
|
362
|
+
const cellKey = `${String(vEntry.rank)}|${String(hEntry.rank)}`;
|
|
363
|
+
if (matrix.has(cellKey)) {
|
|
364
|
+
const vDisp = headerDisplay(vEntry.value, '(null)');
|
|
365
|
+
const hDisp = headerDisplay(hEntry.value, '(null)');
|
|
366
|
+
return {
|
|
367
|
+
error: `query result contains multiple data values for row "${vDisp}", column "${hDisp}"`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
matrix.set(cellKey, dVal);
|
|
371
|
+
}
|
|
372
|
+
// (3) Sort horizontal headers if requested. We stable-sort by capturing
|
|
373
|
+
// the original rank to break ties (and by using Array.prototype.sort
|
|
374
|
+
// which is stable in V8/Node).
|
|
375
|
+
//
|
|
376
|
+
// We deliberately DO NOT mutate `HeaderEntry.rank` here — the matrix is
|
|
377
|
+
// keyed by `${vRank}|${origHRank}` and changing `hArr[i].rank` to the
|
|
378
|
+
// post-sort position would desynchronise lookups in step 5. Display
|
|
379
|
+
// order is carried implicitly by the array index.
|
|
380
|
+
const hArr = Array.from(hHeaders.values());
|
|
381
|
+
if (sortIdx >= 0) {
|
|
382
|
+
hArr.sort((a, b) => {
|
|
383
|
+
const c = cmpForSort(a.sortValue, b.sortValue, sortSign);
|
|
384
|
+
if (c !== 0)
|
|
385
|
+
return c;
|
|
386
|
+
// Tie-break on first-appearance rank to keep ordering deterministic.
|
|
387
|
+
return a.rank - b.rank;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const vArr = Array.from(vHeaders.values()).sort((a, b) => a.rank - b.rank);
|
|
391
|
+
// (4) Build the synthetic ResultSet. Use the caller-supplied
|
|
392
|
+
// `nullPrint` for any horizontal header whose source value was NULL —
|
|
393
|
+
// upstream `do_crosstabview` calls `PQgetvalue(res, …)` which returns
|
|
394
|
+
// the empty string for null, but then formats the header through
|
|
395
|
+
// `popt.nullPrint` when the source cell was actually null. Pass the
|
|
396
|
+
// active `\pset null` string in so `--null='#null#'` lights up the
|
|
397
|
+
// last column header on a pivot with a NULL H value.
|
|
398
|
+
const newFields = [
|
|
399
|
+
{
|
|
400
|
+
...rs.fields[vRes.index],
|
|
401
|
+
// Keep colV's name as-is for the row-header column.
|
|
402
|
+
},
|
|
403
|
+
...hArr.map((h) => ({
|
|
404
|
+
...rs.fields[dataIdx],
|
|
405
|
+
// Headers come from H values; we serialise to strings so the
|
|
406
|
+
// FieldDescription.name contract (string, not unknown) is satisfied.
|
|
407
|
+
name: headerDisplay(h.value, nullPrint),
|
|
408
|
+
})),
|
|
409
|
+
];
|
|
410
|
+
// (5) Emit rows in vertical-header rank order. Each row is
|
|
411
|
+
// [vHeaderValue, ...cellsByHorizontalRank]. Unfilled cells become "".
|
|
412
|
+
const newRows = vArr.map((v) => {
|
|
413
|
+
const row = new Array(hArr.length + 1);
|
|
414
|
+
row[0] = v.value;
|
|
415
|
+
for (let i = 0; i < hArr.length; i++) {
|
|
416
|
+
const key = `${String(v.rank)}|${String(hArr[i].rank)}`;
|
|
417
|
+
// Unfilled cells: empty string so the aligned printer emits nothing
|
|
418
|
+
// (rather than substituting nullPrint). Matches upstream's
|
|
419
|
+
// "non-initialized cells must be set to an empty string" pass.
|
|
420
|
+
row[i + 1] = matrix.has(key) ? matrix.get(key) : '';
|
|
421
|
+
}
|
|
422
|
+
return row;
|
|
423
|
+
});
|
|
424
|
+
const synthetic = {
|
|
425
|
+
command: rs.command,
|
|
426
|
+
rowCount: vArr.length,
|
|
427
|
+
oid: null,
|
|
428
|
+
fields: newFields,
|
|
429
|
+
rows: newRows,
|
|
430
|
+
notices: [],
|
|
431
|
+
};
|
|
432
|
+
return { rs: synthetic };
|
|
433
|
+
};
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// printCrosstab — pivot + delegate to alignedPrinter
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
/**
|
|
438
|
+
* High-level entry: pivot `rs` per `opts`, then forward the synthetic
|
|
439
|
+
* ResultSet to `alignedPrinter.printQuery`. Returns a `CrosstabError` if
|
|
440
|
+
* pivoting fails; on success returns `undefined` (matching the rest of the
|
|
441
|
+
* `print/` API surface).
|
|
442
|
+
*
|
|
443
|
+
* We deliberately use the aligned printer regardless of `printOpts.topt.format`:
|
|
444
|
+
* upstream psql's `\crosstabview` always renders through the table printer
|
|
445
|
+
* (it ignores `\pset format` for the duration of the call), and our tests
|
|
446
|
+
* lean on the aligned printer's borders / nullPrint / numericLocale handling
|
|
447
|
+
* matching exactly.
|
|
448
|
+
*/
|
|
449
|
+
export const printCrosstab = async (rs, opts, printOpts, out) => {
|
|
450
|
+
// Thread the active `\pset null` value through so a NULL horizontal
|
|
451
|
+
// header renders as e.g. `#null#` in the column header row (mirroring
|
|
452
|
+
// the way the aligned printer would otherwise render it for a body
|
|
453
|
+
// cell). Without this, the synthetic FieldDescription.name comes out
|
|
454
|
+
// as the empty string and the column header is just whitespace.
|
|
455
|
+
const result = pivotResultSet(rs, opts, printOpts.topt.nullPrint);
|
|
456
|
+
if ('error' in result)
|
|
457
|
+
return result;
|
|
458
|
+
await alignedPrinter.printQuery(result.rs, printOpts, out);
|
|
459
|
+
return undefined;
|
|
460
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { formatNumericLocale } from './units.js';
|
|
2
|
+
/**
|
|
3
|
+
* RFC 4180 CSV printer.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors print.c `print_csv_text` / `print_csv_vertical`.
|
|
6
|
+
*
|
|
7
|
+
* - Field separator defaults to `,`; honors `topt.csvFieldSep`. The
|
|
8
|
+
* separator must be a single character and may not be `"`, `\n`, or
|
|
9
|
+
* `\r` (matches the psql `\pset csv_fieldsep` check).
|
|
10
|
+
* - Line ending is always `\n` regardless of `topt.recordSep`, since
|
|
11
|
+
* `print_csv_text` writes `'\n'` literally.
|
|
12
|
+
* - Header row is printed only when `startTable && !tuplesOnly`
|
|
13
|
+
* (matches `print_csv_text` — see the `start_table && !tuples_only`
|
|
14
|
+
* guard upstream). `\pset tuples_only true` suppresses the header.
|
|
15
|
+
* - No footer in CSV.
|
|
16
|
+
* - Expanded mode (`print_csv_vertical`) prints `name,value` lines and
|
|
17
|
+
* never emits a standalone header row.
|
|
18
|
+
*
|
|
19
|
+
* Quoting rule (from `csv_print_field`): wrap in double quotes if the
|
|
20
|
+
* value contains the separator, a `"`, `\n`, or `\r`; inside quotes,
|
|
21
|
+
* double the embedded `"`. We additionally quote `\.` (psql's COPY
|
|
22
|
+
* sentinel) when the separator is `\` or `.`, matching upstream.
|
|
23
|
+
*/
|
|
24
|
+
export const csvPrinter = {
|
|
25
|
+
format: 'csv',
|
|
26
|
+
printQuery(rs, opts, out) {
|
|
27
|
+
const topt = opts.topt;
|
|
28
|
+
const sep = topt.csvFieldSep !== undefined && topt.csvFieldSep !== ''
|
|
29
|
+
? topt.csvFieldSep
|
|
30
|
+
: ',';
|
|
31
|
+
if (sep.length !== 1 || sep === '"' || sep === '\n' || sep === '\r') {
|
|
32
|
+
throw new RangeError(`csv_fieldsep must be a single character other than '"', '\\n', or '\\r' (got ${JSON.stringify(sep)})`);
|
|
33
|
+
}
|
|
34
|
+
const nullPrint = opts.nullPrint !== '' ? opts.nullPrint : topt.nullPrint;
|
|
35
|
+
const expanded = topt.expanded === 'on';
|
|
36
|
+
const headers = rs.fields.map((f) => f.name);
|
|
37
|
+
const cells = rs.rows.map((row) => row.map((cell) => renderCell(cell, nullPrint, topt.numericLocale)));
|
|
38
|
+
let outBuf = '';
|
|
39
|
+
if (expanded) {
|
|
40
|
+
for (const row of cells) {
|
|
41
|
+
row.forEach((value, colIdx) => {
|
|
42
|
+
outBuf +=
|
|
43
|
+
csvField(headers[colIdx], sep) + sep + csvField(value, sep) + '\n';
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
// Header is gated on startTable && !tuplesOnly (cf. print.c).
|
|
49
|
+
if (topt.startTable && !topt.tuplesOnly) {
|
|
50
|
+
outBuf += headers.map((h) => csvField(h, sep)).join(sep) + '\n';
|
|
51
|
+
}
|
|
52
|
+
for (const row of cells) {
|
|
53
|
+
outBuf += row.map((c) => csvField(c, sep)).join(sep) + '\n';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
out.write(outBuf);
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const renderCell = (cell, nullPrint, numericLocale) => {
|
|
61
|
+
if (cell === null || cell === undefined)
|
|
62
|
+
return nullPrint;
|
|
63
|
+
if (typeof cell === 'string') {
|
|
64
|
+
return formatNumericLocale(cell, numericLocale);
|
|
65
|
+
}
|
|
66
|
+
if (typeof cell === 'number' || typeof cell === 'bigint') {
|
|
67
|
+
return formatNumericLocale(cell.toString(), numericLocale);
|
|
68
|
+
}
|
|
69
|
+
if (typeof cell === 'boolean')
|
|
70
|
+
return cell ? 't' : 'f';
|
|
71
|
+
if (cell instanceof Date)
|
|
72
|
+
return cell.toISOString();
|
|
73
|
+
if (cell instanceof Uint8Array) {
|
|
74
|
+
let hex = '\\x';
|
|
75
|
+
for (const b of cell)
|
|
76
|
+
hex += b.toString(16).padStart(2, '0');
|
|
77
|
+
return hex;
|
|
78
|
+
}
|
|
79
|
+
return JSON.stringify(cell);
|
|
80
|
+
};
|
|
81
|
+
const csvField = (value, sep) => {
|
|
82
|
+
const needsQuote = value.includes(sep) ||
|
|
83
|
+
value.includes('"') ||
|
|
84
|
+
value.includes('\n') ||
|
|
85
|
+
value.includes('\r') ||
|
|
86
|
+
value === '\\.' ||
|
|
87
|
+
sep === '\\' ||
|
|
88
|
+
sep === '.';
|
|
89
|
+
if (!needsQuote)
|
|
90
|
+
return value;
|
|
91
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
92
|
+
};
|