neonctl 2.22.2 → 2.23.1

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.
Files changed (113) hide show
  1. package/README.md +84 -0
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/connection_string.js +9 -1
  5. package/commands/functions.js +268 -0
  6. package/commands/index.js +4 -0
  7. package/commands/neon_auth.js +1013 -0
  8. package/commands/projects.js +9 -1
  9. package/commands/psql.js +6 -1
  10. package/functions_api.js +43 -0
  11. package/package.json +15 -5
  12. package/psql/cli.js +51 -0
  13. package/psql/command/cmd_cond.js +437 -0
  14. package/psql/command/cmd_connect.js +815 -0
  15. package/psql/command/cmd_copy.js +1025 -0
  16. package/psql/command/cmd_describe.js +1810 -0
  17. package/psql/command/cmd_format.js +909 -0
  18. package/psql/command/cmd_io.js +2187 -0
  19. package/psql/command/cmd_lo.js +385 -0
  20. package/psql/command/cmd_meta.js +970 -0
  21. package/psql/command/cmd_misc.js +187 -0
  22. package/psql/command/cmd_pipeline.js +1141 -0
  23. package/psql/command/cmd_restrict.js +171 -0
  24. package/psql/command/cmd_show.js +751 -0
  25. package/psql/command/dispatch.js +343 -0
  26. package/psql/command/inputQueue.js +42 -0
  27. package/psql/command/shared.js +71 -0
  28. package/psql/complete/filenames.js +139 -0
  29. package/psql/complete/index.js +104 -0
  30. package/psql/complete/matcher.js +314 -0
  31. package/psql/complete/psqlVars.js +247 -0
  32. package/psql/complete/queries.js +491 -0
  33. package/psql/complete/rules.js +2387 -0
  34. package/psql/core/common.js +1250 -0
  35. package/psql/core/help.js +576 -0
  36. package/psql/core/mainloop.js +1353 -0
  37. package/psql/core/prompt.js +437 -0
  38. package/psql/core/settings.js +684 -0
  39. package/psql/core/sqlHelp.js +1066 -0
  40. package/psql/core/startup.js +840 -0
  41. package/psql/core/syncVars.js +116 -0
  42. package/psql/core/variables.js +287 -0
  43. package/psql/describe/formatters.js +1277 -0
  44. package/psql/describe/processNamePattern.js +270 -0
  45. package/psql/describe/queries.js +2373 -0
  46. package/psql/describe/versionGate.js +43 -0
  47. package/psql/index.js +2005 -0
  48. package/psql/io/history.js +299 -0
  49. package/psql/io/input.js +120 -0
  50. package/psql/io/lineEditor/buffer.js +323 -0
  51. package/psql/io/lineEditor/complete.js +227 -0
  52. package/psql/io/lineEditor/filename.js +159 -0
  53. package/psql/io/lineEditor/index.js +891 -0
  54. package/psql/io/lineEditor/keymap.js +738 -0
  55. package/psql/io/lineEditor/vt100.js +363 -0
  56. package/psql/io/pgpass.js +202 -0
  57. package/psql/io/pgservice.js +194 -0
  58. package/psql/io/psqlrc.js +422 -0
  59. package/psql/print/aligned.js +1756 -0
  60. package/psql/print/asciidoc.js +248 -0
  61. package/psql/print/crosstab.js +460 -0
  62. package/psql/print/csv.js +92 -0
  63. package/psql/print/html.js +258 -0
  64. package/psql/print/json.js +96 -0
  65. package/psql/print/latex.js +396 -0
  66. package/psql/print/pager.js +265 -0
  67. package/psql/print/troff.js +258 -0
  68. package/psql/print/unaligned.js +118 -0
  69. package/psql/print/units.js +135 -0
  70. package/psql/scanner/slash.js +513 -0
  71. package/psql/scanner/sql.js +910 -0
  72. package/psql/scanner/stringutils.js +390 -0
  73. package/psql/types/backslash.js +1 -0
  74. package/psql/types/connection.js +1 -0
  75. package/psql/types/index.js +7 -0
  76. package/psql/types/printer.js +1 -0
  77. package/psql/types/repl.js +1 -0
  78. package/psql/types/scanner.js +24 -0
  79. package/psql/types/settings.js +1 -0
  80. package/psql/types/variables.js +1 -0
  81. package/psql/wire/connection.js +2844 -0
  82. package/psql/wire/copy.js +108 -0
  83. package/psql/wire/notify.js +59 -0
  84. package/psql/wire/pipeline.js +519 -0
  85. package/psql/wire/protocol.js +466 -0
  86. package/psql/wire/sasl.js +296 -0
  87. package/psql/wire/tls.js +596 -0
  88. package/test_utils/fixtures.js +1 -0
  89. package/utils/esbuild.js +147 -0
  90. package/utils/psql.js +107 -11
  91. package/utils/zip.js +4 -0
  92. package/writer.js +1 -1
  93. package/commands/auth.test.js +0 -211
  94. package/commands/branches.test.js +0 -460
  95. package/commands/checkout.test.js +0 -170
  96. package/commands/connection_string.test.js +0 -196
  97. package/commands/data_api.test.js +0 -169
  98. package/commands/databases.test.js +0 -39
  99. package/commands/help.test.js +0 -9
  100. package/commands/init.test.js +0 -56
  101. package/commands/ip_allow.test.js +0 -59
  102. package/commands/link.test.js +0 -381
  103. package/commands/operations.test.js +0 -7
  104. package/commands/orgs.test.js +0 -7
  105. package/commands/projects.test.js +0 -144
  106. package/commands/psql.test.js +0 -49
  107. package/commands/roles.test.js +0 -37
  108. package/commands/set_context.test.js +0 -159
  109. package/commands/vpc_endpoints.test.js +0 -69
  110. package/context.test.js +0 -119
  111. package/env.test.js +0 -55
  112. package/utils/formats.test.js +0 -32
  113. 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
+ };