neonctl 2.22.0 → 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.
Files changed (116) hide show
  1. package/README.md +242 -16
  2. package/analytics.js +5 -2
  3. package/commands/branches.js +9 -1
  4. package/commands/checkout.js +249 -0
  5. package/commands/connection_string.js +15 -2
  6. package/commands/data_api.js +286 -0
  7. package/commands/functions.js +277 -0
  8. package/commands/index.js +12 -0
  9. package/commands/link.js +667 -0
  10. package/commands/neon_auth.js +1013 -0
  11. package/commands/projects.js +9 -1
  12. package/commands/psql.js +62 -0
  13. package/commands/set_context.js +7 -2
  14. package/context.js +86 -14
  15. package/functions_api.js +44 -0
  16. package/index.js +3 -0
  17. package/package.json +60 -51
  18. package/psql/cli.js +51 -0
  19. package/psql/command/cmd_cond.js +437 -0
  20. package/psql/command/cmd_connect.js +815 -0
  21. package/psql/command/cmd_copy.js +1025 -0
  22. package/psql/command/cmd_describe.js +1810 -0
  23. package/psql/command/cmd_format.js +909 -0
  24. package/psql/command/cmd_io.js +2187 -0
  25. package/psql/command/cmd_lo.js +385 -0
  26. package/psql/command/cmd_meta.js +970 -0
  27. package/psql/command/cmd_misc.js +187 -0
  28. package/psql/command/cmd_pipeline.js +1141 -0
  29. package/psql/command/cmd_restrict.js +171 -0
  30. package/psql/command/cmd_show.js +751 -0
  31. package/psql/command/dispatch.js +343 -0
  32. package/psql/command/inputQueue.js +42 -0
  33. package/psql/command/shared.js +71 -0
  34. package/psql/complete/filenames.js +139 -0
  35. package/psql/complete/index.js +104 -0
  36. package/psql/complete/matcher.js +314 -0
  37. package/psql/complete/psqlVars.js +247 -0
  38. package/psql/complete/queries.js +491 -0
  39. package/psql/complete/rules.js +2387 -0
  40. package/psql/core/common.js +1250 -0
  41. package/psql/core/help.js +576 -0
  42. package/psql/core/mainloop.js +1353 -0
  43. package/psql/core/prompt.js +437 -0
  44. package/psql/core/settings.js +684 -0
  45. package/psql/core/sqlHelp.js +1066 -0
  46. package/psql/core/startup.js +840 -0
  47. package/psql/core/syncVars.js +116 -0
  48. package/psql/core/variables.js +287 -0
  49. package/psql/describe/formatters.js +1277 -0
  50. package/psql/describe/processNamePattern.js +270 -0
  51. package/psql/describe/queries.js +2373 -0
  52. package/psql/describe/versionGate.js +43 -0
  53. package/psql/index.js +2005 -0
  54. package/psql/io/history.js +299 -0
  55. package/psql/io/input.js +120 -0
  56. package/psql/io/lineEditor/buffer.js +323 -0
  57. package/psql/io/lineEditor/complete.js +227 -0
  58. package/psql/io/lineEditor/filename.js +159 -0
  59. package/psql/io/lineEditor/index.js +891 -0
  60. package/psql/io/lineEditor/keymap.js +738 -0
  61. package/psql/io/lineEditor/vt100.js +363 -0
  62. package/psql/io/pgpass.js +202 -0
  63. package/psql/io/pgservice.js +194 -0
  64. package/psql/io/psqlrc.js +422 -0
  65. package/psql/print/aligned.js +1756 -0
  66. package/psql/print/asciidoc.js +248 -0
  67. package/psql/print/crosstab.js +460 -0
  68. package/psql/print/csv.js +92 -0
  69. package/psql/print/html.js +258 -0
  70. package/psql/print/json.js +96 -0
  71. package/psql/print/latex.js +396 -0
  72. package/psql/print/pager.js +265 -0
  73. package/psql/print/troff.js +258 -0
  74. package/psql/print/unaligned.js +118 -0
  75. package/psql/print/units.js +135 -0
  76. package/psql/scanner/slash.js +513 -0
  77. package/psql/scanner/sql.js +910 -0
  78. package/psql/scanner/stringutils.js +390 -0
  79. package/psql/types/backslash.js +1 -0
  80. package/psql/types/connection.js +1 -0
  81. package/psql/types/index.js +7 -0
  82. package/psql/types/printer.js +1 -0
  83. package/psql/types/repl.js +1 -0
  84. package/psql/types/scanner.js +24 -0
  85. package/psql/types/settings.js +1 -0
  86. package/psql/types/variables.js +1 -0
  87. package/psql/wire/connection.js +2844 -0
  88. package/psql/wire/copy.js +108 -0
  89. package/psql/wire/notify.js +59 -0
  90. package/psql/wire/pipeline.js +519 -0
  91. package/psql/wire/protocol.js +466 -0
  92. package/psql/wire/sasl.js +296 -0
  93. package/psql/wire/tls.js +596 -0
  94. package/test_utils/fixtures.js +1 -0
  95. package/utils/enrichers.js +18 -1
  96. package/utils/esbuild.js +147 -0
  97. package/utils/middlewares.js +1 -1
  98. package/utils/psql.js +107 -11
  99. package/utils/zip.js +4 -0
  100. package/writer.js +1 -1
  101. package/commands/auth.test.js +0 -211
  102. package/commands/branches.test.js +0 -460
  103. package/commands/connection_string.test.js +0 -196
  104. package/commands/databases.test.js +0 -39
  105. package/commands/help.test.js +0 -9
  106. package/commands/init.test.js +0 -56
  107. package/commands/ip_allow.test.js +0 -59
  108. package/commands/operations.test.js +0 -7
  109. package/commands/orgs.test.js +0 -7
  110. package/commands/projects.test.js +0 -144
  111. package/commands/roles.test.js +0 -37
  112. package/commands/set_context.test.js +0 -159
  113. package/commands/vpc_endpoints.test.js +0 -69
  114. package/env.test.js +0 -55
  115. package/utils/formats.test.js +0 -32
  116. package/writer.test.js +0 -104
@@ -0,0 +1,1277 @@
1
+ /**
2
+ * Rendering for psql's `\d*` describe commands.
3
+ *
4
+ * This module owns the runtime side of WP-20: take the SQL templates
5
+ * from {@link './queries.js'} (WP-19), run them against a real
6
+ * {@link Connection}, and render the result via the aligned printer
7
+ * for tabular sections plus free-form text for "footer" sections
8
+ * (`Indexes:`, `Foreign-key constraints:`, etc.) the way upstream
9
+ * `describe.c` does.
10
+ *
11
+ * Scope of the initial implementation:
12
+ *
13
+ * - {@link runListQuery} runs an arbitrary {@link DescribeQuery}
14
+ * (typically one of `listTables`, `describeFunctions`, etc.) and
15
+ * prints its result with the aligned printer. Title is taken from
16
+ * the query's `description`. This covers every `\d*` *list* command.
17
+ *
18
+ * - {@link describeOneTableDetails} fans out from the lookup query
19
+ * `describeTableDetails` into the per-relation detail render: a
20
+ * columns table at the top, followed by index / constraint / trigger
21
+ * sections as appropriate for the relkind. This is the bulk of
22
+ * upstream's `describeOneTableDetails()` from `describe.c`. We
23
+ * implement the *common* table layout (regular tables, views,
24
+ * materialized views, partitioned tables and indexes) — exotic
25
+ * sections (foreign-table options, replica identity, RLS policies,
26
+ * inheritance pretty-printing) are stubbed with the SQL queries in
27
+ * place but only minimal rendering. The output is sufficient for
28
+ * real-world `\d <name>` usage; gaps are flagged with TODO comments.
29
+ *
30
+ * - {@link describeOneSequence}, {@link describeOneFunctionDetails}
31
+ * and {@link describeOneViewDetails} are thinner: a single query +
32
+ * one section of output each.
33
+ *
34
+ * Pattern conditions: each list query has an `AND true /<!---->* TODO(WP-20)…`
35
+ * placeholder we replace via {@link applyPattern} before sending the
36
+ * query down the wire. See {@link processSQLNamePattern} for the
37
+ * pattern parser.
38
+ */
39
+ import { processSQLNamePattern } from './processNamePattern.js';
40
+ import { alignedPrinter } from '../print/aligned.js';
41
+ import { asciidocPrinter } from '../print/asciidoc.js';
42
+ import { csvPrinter } from '../print/csv.js';
43
+ import { htmlPrinter } from '../print/html.js';
44
+ import { jsonPrinter } from '../print/json.js';
45
+ import { latexLongtablePrinter, latexPrinter } from '../print/latex.js';
46
+ import { troffMsPrinter } from '../print/troff.js';
47
+ import { unalignedPrinter } from '../print/unaligned.js';
48
+ import { fetchForeignTableInfo, fetchInheritedBy, fetchInherits, fetchPartitionKey, fetchPartitionOf, fetchPerColumnFdwOptions, fetchPolicies, fetchStatisticsObjects, fetchTableInfo, fetchTablePublications, fetchTableSubscriptions, } from './queries.js';
49
+ import { applyPattern } from './processNamePattern.js';
50
+ import { fetchNotNullConstraints } from './queries.js';
51
+ import { serverAtLeast, PG_14 } from './versionGate.js';
52
+ /**
53
+ * Pick the printer for the active output format. Mirrors `pickPrinter`
54
+ * in `core/common.ts`, but operates off `PrintQueryOpts.topt.format`
55
+ * since formatters don't have access to the full `PsqlSettings`. The
56
+ * aligned printer covers both `aligned` and `wrapped`; everything else
57
+ * routes to its dedicated module so `\d <obj>` honours the user's
58
+ * `\pset format` choice (asciidoc/csv/html/latex/etc.) the same way
59
+ * regular SELECTs do.
60
+ */
61
+ const pickPrinterForFormat = (opts) => {
62
+ switch (opts.topt.format) {
63
+ case 'aligned':
64
+ case 'wrapped':
65
+ return alignedPrinter;
66
+ case 'unaligned':
67
+ return unalignedPrinter;
68
+ case 'csv':
69
+ return csvPrinter;
70
+ case 'json':
71
+ return jsonPrinter;
72
+ case 'html':
73
+ return htmlPrinter;
74
+ case 'asciidoc':
75
+ return asciidocPrinter;
76
+ case 'latex':
77
+ return latexPrinter;
78
+ case 'latex-longtable':
79
+ return latexLongtablePrinter;
80
+ case 'troff-ms':
81
+ return troffMsPrinter;
82
+ default:
83
+ return alignedPrinter;
84
+ }
85
+ };
86
+ /**
87
+ * Format a cell value coming back from the protocol layer. Connection
88
+ * decoded values arrive as strings (text mode) or null. We coerce
89
+ * everything to string for the printer.
90
+ */
91
+ const cellToString = (v) => {
92
+ if (v === null || v === undefined)
93
+ return '';
94
+ if (typeof v === 'string')
95
+ return v;
96
+ if (Buffer.isBuffer(v))
97
+ return v.toString('utf-8');
98
+ if (typeof v === 'number' ||
99
+ typeof v === 'boolean' ||
100
+ typeof v === 'bigint') {
101
+ return String(v);
102
+ }
103
+ // Non-primitive fallback: encode JSON. This branch shouldn't be hit
104
+ // under the protocol layer (which decodes to strings) but we guard
105
+ // against future shape changes.
106
+ try {
107
+ return JSON.stringify(v);
108
+ }
109
+ catch {
110
+ return '';
111
+ }
112
+ };
113
+ /**
114
+ * Materialize a {@link ResultSet} into something the aligned printer
115
+ * can render. The printer expects `rows: unknown[][]`; we keep the
116
+ * shape but ensure cells are strings or null for the null-print logic.
117
+ */
118
+ const coerceResultSet = (rs) => ({
119
+ ...rs,
120
+ rows: rs.rows.map((row) => row.map((c) => (c === null || c === undefined ? null : cellToString(c)))),
121
+ });
122
+ const makeSectionBuffer = () => {
123
+ let buf = '';
124
+ const write = (chunk) => {
125
+ if (typeof chunk === 'string') {
126
+ buf += chunk;
127
+ }
128
+ else {
129
+ buf += chunk.toString('utf-8');
130
+ }
131
+ return true;
132
+ };
133
+ // We only need `.write(chunk)` from the renderers; everything else on
134
+ // WritableStream is stubbed so the type checks pass.
135
+ const stub = {
136
+ write,
137
+ end: () => true,
138
+ on: () => stub,
139
+ once: () => stub,
140
+ emit: () => true,
141
+ removeListener: () => stub,
142
+ addListener: () => stub,
143
+ setDefaultEncoding: () => stub,
144
+ cork: () => undefined,
145
+ uncork: () => undefined,
146
+ destroy: () => stub,
147
+ writable: true,
148
+ writableEnded: false,
149
+ writableFinished: false,
150
+ toString: () => buf,
151
+ };
152
+ return stub;
153
+ };
154
+ /**
155
+ * Capture the output of a single render-section helper into a string
156
+ * suitable for `opts.footers`. Returns `null` when the section emitted
157
+ * nothing (so callers can skip pushing an empty entry). Trailing
158
+ * newlines are stripped: the printer re-appends a single `\n` per
159
+ * footer, and trailing blank lines (between sections / before next
160
+ * command) are emitted once by the printer's own footer-terminator.
161
+ */
162
+ const captureSection = async (fn) => {
163
+ const buf = makeSectionBuffer();
164
+ await fn(buf);
165
+ const text = buf.toString().replace(/\n+$/, '');
166
+ return text === '' ? null : text;
167
+ };
168
+ /**
169
+ * Run a list-style describe query and write its result via the aligned
170
+ * printer. Returns the {@link ResultSet} for callers that want to
171
+ * inspect or post-process. Used by `\dt`, `\df`, `\dn`, etc.
172
+ */
173
+ export const runListQuery = async (conn, query, patternResult, out, popt) => {
174
+ const { sql, params } = applyPattern(query.sql, patternResult, query.params);
175
+ const rs = await conn.query(sql, params);
176
+ const coerced = coerceResultSet(rs);
177
+ const titleOverride = query.description ?? popt.title;
178
+ const opts = {
179
+ ...popt,
180
+ title: titleOverride,
181
+ topt: { ...popt.topt, title: titleOverride ?? popt.topt.title },
182
+ footers: rs.rows.length === 0
183
+ ? popt.footers
184
+ : popt.footers !== null
185
+ ? popt.footers
186
+ : null,
187
+ };
188
+ await pickPrinterForFormat(opts).printQuery(coerced, opts, out);
189
+ return rs;
190
+ };
191
+ export const lookupRelations = async (conn, query, patternResult) => {
192
+ const { sql, params } = applyPattern(query.sql, patternResult, query.params);
193
+ const rs = await conn.query(sql, params);
194
+ return rs.rows.map((row) => ({
195
+ oid: Number(cellToString(row[0])),
196
+ nspname: cellToString(row[1]),
197
+ relname: cellToString(row[2]),
198
+ relkind: cellToString(row[3] ?? ''),
199
+ }));
200
+ };
201
+ /**
202
+ * Lookup of one specific relation by `schema.name` for the `\d <name>`
203
+ * dispatch. Returns the row we need to choose the right `describeOne*`
204
+ * renderer — including `relkind` which the upstream code reads from
205
+ * a separate SELECT.
206
+ */
207
+ export const lookupOneRelation = async (conn, pattern) => {
208
+ // Route the bare-name lookup through processSQLNamePattern so the name is
209
+ // case-folded (unquoted → lower) and dequoted exactly like the list views:
210
+ // `\d Foo` matches catalog relation `foo`, `\d "MyTable"` matches the
211
+ // mixed-case `MyTable`, and `schema.name` splits correctly. The old raw
212
+ // `^(name)$` interpolation matched neither (review item #22).
213
+ const np = processSQLNamePattern({
214
+ pattern,
215
+ namevar: 'c.relname',
216
+ schemavar: 'n.nspname',
217
+ visibilityrule: 'pg_catalog.pg_table_is_visible(c.oid)',
218
+ });
219
+ // A db-qualified pattern (3+ dotted components → dotCount > 1) is a
220
+ // cross-database reference that this single-DB detail short-circuit cannot
221
+ // honour. Return null so the caller falls through to the LIST path, which
222
+ // emits upstream's "cross-database references are not implemented" /
223
+ // "improper qualified name (too many dotted names)" diagnostic. Without
224
+ // this, the detail lookup ignored the db literal and wrongly matched
225
+ // (e.g. `\d nonesuch.pg_catalog.pg_class` rendered the table).
226
+ if (np.dotCount > 1)
227
+ return null;
228
+ const conds = [
229
+ ...np.schemaConditions,
230
+ ...np.nameConditions,
231
+ ...np.visibilityConditions,
232
+ ];
233
+ let sql = 'SELECT c.oid, n.nspname, c.relname, c.relkind\n' +
234
+ 'FROM pg_catalog.pg_class c\n' +
235
+ ' LEFT JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n';
236
+ if (conds.length > 0)
237
+ sql += `WHERE ${conds.join('\n AND ')}\n`;
238
+ sql += 'ORDER BY 2, 3 LIMIT 1;';
239
+ const rs = await conn.query(sql, np.params);
240
+ if (rs.rows.length === 0)
241
+ return null;
242
+ const row = rs.rows[0];
243
+ return {
244
+ oid: Number(cellToString(row[0])),
245
+ nspname: cellToString(row[1]),
246
+ relname: cellToString(row[2]),
247
+ relkind: cellToString(row[3]),
248
+ };
249
+ };
250
+ /**
251
+ * Render `Table "schema.name"` (or the relkind-specific header) plus the
252
+ * column listing, followed by per-relkind sections (Indexes, Check
253
+ * constraints, Foreign-key constraints, Referenced-by, Triggers).
254
+ *
255
+ * Upstream `describeOneTableDetails()` is ~1500 LOC in `describe.c`;
256
+ * this implementation focuses on the headline experience and leaves
257
+ * exotic sections (RLS, replica identity, partition bounds rendering,
258
+ * pretty-printed inheritance) as TODOs. The query layer fetches the
259
+ * raw data so a follow-up WP can extend rendering without re-running
260
+ * queries.
261
+ */
262
+ export const describeOneTableDetails = async (conn, oid, schema, name, relkind, verbose, out, popt, hideTableam = false, hideCompression = false) => {
263
+ // ----- One-shot relation info (RLS flags, replica identity,
264
+ // partition flag, tablespace, access method). Fetched before
265
+ // columns so the matview header can carry an "Access method:"
266
+ // line and the per-column FDW options can be merged inline.
267
+ const relInfo = await fetchRelationInfo(conn, oid);
268
+ // Compose the title. Matviews with a non-default access method get
269
+ // a second line ("Access method: <amname>") between the header and
270
+ // the column table — see upstream `describeOneTableDetails`. The
271
+ // matview-inline form is also gated by `HIDE_TABLEAM` so the user can
272
+ // opt out of access-method noise.
273
+ const baseTitle = headerForRelkind(relkind, schema, name);
274
+ const title = !hideTableam && relkind === 'm' && relInfo.relam !== 0 && relInfo.amname
275
+ ? `${baseTitle}\nAccess method: ${relInfo.amname}`
276
+ : baseTitle;
277
+ // ----- Pre-fetch per-column FDW options (foreign tables only) so we
278
+ // can fold them into each column row. Upstream renders these
279
+ // inline as a trailing "FDW options: (k 'v', ...)" annotation
280
+ // rather than a separate footer section.
281
+ const fdwOptionsByColumn = relkind === 'f'
282
+ ? await fetchPerColumnFdwOptionsMap(conn, oid)
283
+ : new Map();
284
+ // ----- Columns -----
285
+ // Verbose mode adds Storage / Stats target / Description columns to
286
+ // mirror upstream's `\d+`. These apply to every relkind that carries
287
+ // a column listing, including views and materialized views (upstream
288
+ // `describeOneTableDetails` gates the verbose column block on `verbose`
289
+ // alone, not on relkind).
290
+ const verboseCols = verbose &&
291
+ (relkind === 'r' ||
292
+ relkind === 'm' ||
293
+ relkind === 'p' ||
294
+ relkind === 'f' ||
295
+ relkind === 'v' ||
296
+ relkind === 'I' ||
297
+ relkind === 'i');
298
+ // Compression column (upstream `\d+`): present when the server is
299
+ // PG 14+, the `HIDE_TOAST_COMPRESSION` var is off, and the relkind is
300
+ // a regular table / partitioned table / materialized view (describe.c
301
+ // ~1953: `sversion >= 140000 && !hide_compression && relkind in
302
+ // (RELATION, PARTITIONED_TABLE, MATVIEW)`). When suppressed the column
303
+ // is dropped entirely — matching the conformance regress which runs
304
+ // with HIDE_TOAST_COMPRESSION=on.
305
+ const includeCompression = verboseCols &&
306
+ serverAtLeast(conn.serverVersion, PG_14) &&
307
+ !hideCompression &&
308
+ (relkind === 'r' || relkind === 'p' || relkind === 'm');
309
+ // Stats target column (upstream `\d+`): every verbose relkind EXCEPT a
310
+ // plain view — views have no per-column statistics targets (describe.c
311
+ // ~1964: RELATION, INDEX, PARTITIONED_INDEX, MATVIEW, FOREIGN_TABLE,
312
+ // PARTITIONED_TABLE).
313
+ const includeStatsTarget = verboseCols &&
314
+ (relkind === 'r' ||
315
+ relkind === 'i' ||
316
+ relkind === 'I' ||
317
+ relkind === 'm' ||
318
+ relkind === 'f' ||
319
+ relkind === 'p');
320
+ const colSql = 'SELECT a.attname,\n' +
321
+ ' pg_catalog.format_type(a.atttypid, a.atttypmod),\n' +
322
+ ' (SELECT pg_catalog.pg_get_expr(d.adbin, d.adrelid, true)\n' +
323
+ ' FROM pg_catalog.pg_attrdef d\n' +
324
+ ' WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef),\n' +
325
+ ' a.attnotnull,\n' +
326
+ ' (SELECT c.collname FROM pg_catalog.pg_collation c, pg_catalog.pg_type t\n' +
327
+ ' WHERE c.oid = a.attcollation AND t.oid = a.atttypid AND a.attcollation <> t.typcollation) AS attcollation,\n' +
328
+ ' a.attidentity,\n' +
329
+ ' a.attgenerated' +
330
+ (verboseCols
331
+ ? ',\n CASE a.attstorage' +
332
+ " WHEN 'p' THEN 'plain'" +
333
+ " WHEN 'e' THEN 'external'" +
334
+ " WHEN 'm' THEN 'main'" +
335
+ " WHEN 'x' THEN 'extended'" +
336
+ " ELSE '???'" +
337
+ ' END AS attstorage' +
338
+ (includeCompression
339
+ ? ',\n CASE a.attcompression' +
340
+ " WHEN 'p' THEN 'pglz'" +
341
+ " WHEN 'l' THEN 'lz4'" +
342
+ " WHEN '' THEN ''" +
343
+ " ELSE '???'" +
344
+ ' END AS attcompression'
345
+ : '') +
346
+ (includeStatsTarget
347
+ ? ',\n CASE WHEN a.attstattarget = -1 THEN NULL ELSE a.attstattarget::text END AS attstattarget'
348
+ : '') +
349
+ ',\n pg_catalog.col_description(a.attrelid, a.attnum)'
350
+ : '') +
351
+ '\nFROM pg_catalog.pg_attribute a\n' +
352
+ `WHERE a.attrelid = '${oid}' AND a.attnum > 0 AND NOT a.attisdropped\n` +
353
+ 'ORDER BY a.attnum;';
354
+ const colsRs = await conn.query(colSql, []);
355
+ // Foreign tables get an extra "FDW options" column when at least one
356
+ // attribute actually has options set (matches upstream — the column
357
+ // slot is conditional on the row data, not just the relkind).
358
+ const hasAnyFdwOptions = fdwOptionsByColumn.size > 0;
359
+ // TOAST tables show a slimmer column listing: Column + Type only, no
360
+ // Collation/Nullable/Default (those are uniformly empty for the three
361
+ // fixed columns chunk_id/chunk_seq/chunk_data). Matches upstream's
362
+ // `\d <toast>` output.
363
+ const isToast = relkind === 't';
364
+ // Synthesize a printable result set: Column, Type[, Collation, Nullable,
365
+ // Default[, Storage[, Compression], Stats target, Description]][, FDW options].
366
+ const fields = [fakeField('Column'), fakeField('Type')];
367
+ if (!isToast) {
368
+ fields.push(fakeField('Collation'));
369
+ fields.push(fakeField('Nullable'));
370
+ fields.push(fakeField('Default'));
371
+ }
372
+ if (verboseCols) {
373
+ fields.push(fakeField('Storage'));
374
+ if (includeCompression)
375
+ fields.push(fakeField('Compression'));
376
+ if (includeStatsTarget)
377
+ fields.push(fakeField('Stats target'));
378
+ fields.push(fakeField('Description'));
379
+ }
380
+ if (hasAnyFdwOptions)
381
+ fields.push(fakeField('FDW options'));
382
+ const rows = colsRs.rows.map((r) => {
383
+ const colName = cellToString(r[0]);
384
+ const colType = cellToString(r[1]);
385
+ const colDefault = r[2] === null ? null : cellToString(r[2]);
386
+ const notnull = String(r[3]) === 't' || r[3] === true;
387
+ const collation = r[4] === null ? null : cellToString(r[4]);
388
+ const identity = cellToString(r[5] ?? '');
389
+ const generated = cellToString(r[6] ?? '');
390
+ const nullable = notnull ? 'not null' : '';
391
+ let dflt = colDefault ?? '';
392
+ if (identity === 'a') {
393
+ dflt = 'generated always as identity';
394
+ }
395
+ else if (identity === 'd') {
396
+ dflt = 'generated by default as identity';
397
+ }
398
+ else if (generated === 's') {
399
+ // STORED generated column (PG 12+).
400
+ dflt = dflt ? `generated always as (${dflt}) stored` : '';
401
+ }
402
+ else if (generated === 'v') {
403
+ // VIRTUAL generated column (PG 18+). Same expression rendering as
404
+ // STORED but without the trailing keyword.
405
+ dflt = dflt ? `generated always as (${dflt})` : '';
406
+ }
407
+ const row = isToast
408
+ ? [colName, colType]
409
+ : [colName, colType, collation ?? '', nullable, dflt];
410
+ if (verboseCols) {
411
+ // Slot offsets: 7 = storage, [8 = compression if PG14+], stats, desc.
412
+ let idx = 7;
413
+ const storage = cellToString(r[idx++] ?? '');
414
+ row.push(storage);
415
+ if (includeCompression) {
416
+ const compression = cellToString(r[idx++] ?? '');
417
+ row.push(compression);
418
+ }
419
+ if (includeStatsTarget) {
420
+ const statsTarget = r[idx] === null ? '' : cellToString(r[idx] ?? '');
421
+ idx++;
422
+ row.push(statsTarget);
423
+ }
424
+ const description = r[idx] === null ? '' : cellToString(r[idx] ?? '');
425
+ row.push(description);
426
+ }
427
+ if (hasAnyFdwOptions) {
428
+ const opts = fdwOptionsByColumn.get(colName);
429
+ row.push(opts ? `(${opts})` : '');
430
+ }
431
+ return row;
432
+ });
433
+ const colsResult = {
434
+ command: 'SELECT',
435
+ rowCount: rows.length,
436
+ oid: null,
437
+ fields,
438
+ rows,
439
+ notices: [],
440
+ };
441
+ // ----- Per-section footers, accumulated *before* the column table is
442
+ // printed. Upstream `describeOneTableDetails` attaches each
443
+ // relkind-specific footer to the columns table via
444
+ // `printTableAddFooter()`, then `printTable()` emits them flush
445
+ // against the data rows with a single trailing blank line at the
446
+ // end of the whole block. Routing every section through
447
+ // `opts.footers` mirrors that layout — single-line annotations
448
+ // (`Access method:`, `Tablespace:`, …) sit immediately under the
449
+ // last data row, multi-line group footers (`Indexes:`,
450
+ // `Foreign-key constraints:`, …) follow, and the trailing blank
451
+ // only fires after the last footer rather than between data and
452
+ // the first footer.
453
+ const footers = [];
454
+ const push = (s) => {
455
+ if (s !== null)
456
+ footers.push(s);
457
+ };
458
+ // ----- View definition (views / matviews, verbose only) -----
459
+ // Upstream attaches this as a table FOOTER (describe.c ~3175), so it
460
+ // renders flush against the column rows with the single trailing
461
+ // blank line the footer machinery adds — not as a separate block.
462
+ if ((relkind === 'v' || relkind === 'm') && verbose) {
463
+ const vrs = await conn.query(`SELECT pg_catalog.pg_get_viewdef('${oid}'::pg_catalog.oid, true);`, []);
464
+ if (vrs.rows.length > 0) {
465
+ push(`View definition:\n${cellToString(vrs.rows[0][0])}`);
466
+ }
467
+ }
468
+ // ----- Partition-key (partitioned-table parent only) -----
469
+ if (relkind === 'p') {
470
+ push(await captureSection((b) => renderPartitionKeySection(conn, oid, b)));
471
+ }
472
+ // ----- Partition-of (child partition only) -----
473
+ if (relInfo.relispartition) {
474
+ push(await captureSection((b) => renderPartitionOfSection(conn, oid, verbose, b)));
475
+ }
476
+ // ----- Owning table (TOAST tables only — printed before Indexes).
477
+ // Upstream `describeOneTableDetails` adds the owning-table footer
478
+ // prior to attaching the indexes footer for `RELKIND_TOASTVALUE`.
479
+ if (relkind === 't') {
480
+ push(await captureSection((b) => renderToastOwningTableFooter(conn, oid, b)));
481
+ }
482
+ // ----- Indexes (tables / matviews / partitioned tables / TOAST) -----
483
+ if (relkind === 'r' ||
484
+ relkind === 'm' ||
485
+ relkind === 'p' ||
486
+ relkind === 't') {
487
+ push(await captureSection((b) => renderIndexesSection(conn, oid, b)));
488
+ }
489
+ // ----- Check constraints -----
490
+ if (relkind === 'r' || relkind === 'p' || relkind === 'f') {
491
+ push(await captureSection((b) => renderCheckConstraintsSection(conn, oid, b)));
492
+ }
493
+ // ----- Not-null constraints (PG 18+ named NOT NULL constraints) -----
494
+ // Upstream renders this footer in verbose mode between Check
495
+ // constraints and Foreign-key constraints (describe.c ~3104).
496
+ // The query returns empty on pre-PG-18 servers (no contype = 'n'
497
+ // rows), so the section is naturally absent there.
498
+ if (verbose && (relkind === 'r' || relkind === 'p' || relkind === 'f')) {
499
+ push(await captureSection((b) => renderNotNullConstraintsSection(conn, oid, b)));
500
+ }
501
+ // ----- Foreign-key constraints -----
502
+ if (relkind === 'r' || relkind === 'p') {
503
+ push(await captureSection((b) => renderForeignKeyConstraintsSection(conn, oid, b)));
504
+ push(await captureSection((b) => renderReferencedBySection(conn, oid, b)));
505
+ }
506
+ // ----- Triggers -----
507
+ if (relkind === 'r' || relkind === 'p' || relkind === 'v') {
508
+ push(await captureSection((b) => renderTriggersSection(conn, oid, b)));
509
+ }
510
+ // ----- RLS policies (regular + partitioned tables) -----
511
+ if (relkind === 'r' || relkind === 'p') {
512
+ push(await captureSection((b) => renderPoliciesSection(conn, oid, relInfo, b)));
513
+ }
514
+ // ----- Foreign-table footer: Server + FDW options -----
515
+ // Per-column FDW options are rendered inline within the columns
516
+ // table (see fdwOptionsByColumn above); no separate footer here.
517
+ if (relkind === 'f') {
518
+ push(await captureSection((b) => renderForeignTableFooter(conn, oid, b)));
519
+ }
520
+ // ----- Inherits: (parents) — for tables, partitioned tables, foreign -----
521
+ if (relkind === 'r' || relkind === 'p' || relkind === 'f') {
522
+ push(await captureSection((b) => renderInheritsSection(conn, oid, b)));
523
+ }
524
+ // ----- Inherited by / Partitions / Number of [child tables|partitions] -----
525
+ if (relkind === 'r' || relkind === 'p' || relkind === 'f') {
526
+ push(await captureSection((b) => renderInheritedBySection(conn, oid, relkind, verbose, b)));
527
+ }
528
+ // ----- Publications (any publishable relkind) -----
529
+ if (relkind === 'r' ||
530
+ relkind === 'p' ||
531
+ relkind === 'm' ||
532
+ relkind === 'f') {
533
+ push(await captureSection((b) => renderPublicationsSection(conn, oid, b)));
534
+ }
535
+ // ----- Subscriptions (any publishable relkind; permission-denied silent) -----
536
+ if (relkind === 'r' ||
537
+ relkind === 'p' ||
538
+ relkind === 'm' ||
539
+ relkind === 'f') {
540
+ push(await captureSection((b) => renderSubscriptionsSection(conn, oid, b)));
541
+ }
542
+ // ----- Statistics objects (verbose; r/m/p/f) -----
543
+ if (verbose &&
544
+ (relkind === 'r' || relkind === 'm' || relkind === 'p' || relkind === 'f')) {
545
+ push(await captureSection((b) => renderStatisticsObjectsSection(conn, oid, b)));
546
+ }
547
+ // ----- Replica Identity (verbose, non-default, regular & matview).
548
+ // INDEX mode is rendered inline within Indexes:, so the footer
549
+ // is only emitted for FULL / NOTHING.
550
+ if (verbose && (relkind === 'r' || relkind === 'm')) {
551
+ push(await captureSection((b) => {
552
+ renderReplicaIdentitySection(schema, relInfo, b);
553
+ }));
554
+ }
555
+ // ----- Tablespace footer (verbose: explicit tablespace only) -----
556
+ if (verbose) {
557
+ push(await captureSection((b) => {
558
+ renderTablespaceFooter(relkind, relInfo, b);
559
+ }));
560
+ }
561
+ // ----- Access method footer (verbose: relkind r/p with relam set).
562
+ // Matviews ('m') show their access method inline in the header,
563
+ // so we don't double up here. Gated by `HIDE_TABLEAM` to mirror
564
+ // upstream — the per-test psql.sql toggles the variable to
565
+ // suppress access-method noise.
566
+ if (!hideTableam && verbose && (relkind === 'r' || relkind === 'p')) {
567
+ push(await captureSection((b) => {
568
+ renderAccessMethodFooter(relInfo, b);
569
+ }));
570
+ }
571
+ // Upstream's `printTable` is invoked with `default_footer = false`
572
+ // for the column listing: the row-count footer ("(N rows)") is
573
+ // suppressed so the relkind-specific footers we just collected drive
574
+ // the post-table layout. Pass them via `opts.footers` so the printer
575
+ // emits each one flush against the data rows and ends the block with
576
+ // a single trailing blank line.
577
+ const colOpts = {
578
+ ...popt,
579
+ title,
580
+ topt: { ...popt.topt, title, defaultFooter: false },
581
+ footers: footers.length > 0 ? footers : null,
582
+ };
583
+ await pickPrinterForFormat(colOpts).printQuery(coerceResultSet(colsResult), colOpts, out);
584
+ };
585
+ /**
586
+ * Helper that runs {@link fetchTableInfo} and parses the resulting row
587
+ * into a {@link RelationInfo}. Returns sensible falsy defaults when the
588
+ * row is missing (shouldn't happen given the caller already looked up
589
+ * the relation, but we don't want to throw mid-render).
590
+ */
591
+ const fetchRelationInfo = async (conn, oid) => {
592
+ const q = fetchTableInfo({ oid, serverVersion: conn.serverVersion });
593
+ const rs = await conn.query(q.sql, q.params);
594
+ if (rs.rows.length === 0) {
595
+ return {
596
+ rowsecurity: false,
597
+ forcerowsecurity: false,
598
+ relreplident: 'd',
599
+ relispartition: false,
600
+ reltablespace: 0,
601
+ relam: 0,
602
+ spcname: null,
603
+ amname: null,
604
+ };
605
+ }
606
+ const r = rs.rows[0];
607
+ return {
608
+ rowsecurity: parseBool(r[0]),
609
+ forcerowsecurity: parseBool(r[1]),
610
+ relreplident: cellToString(r[2] ?? 'd') || 'd',
611
+ relispartition: parseBool(r[3]),
612
+ reltablespace: Number(cellToString(r[4] ?? '0')) || 0,
613
+ relam: Number(cellToString(r[5] ?? '0')) || 0,
614
+ spcname: r[6] === null || r[6] === undefined ? null : cellToString(r[6]),
615
+ amname: r[7] === null || r[7] === undefined ? null : cellToString(r[7]),
616
+ };
617
+ };
618
+ /** Coerce a Postgres "t"/"f" text-mode boolean (or a real bool) to JS. */
619
+ const parseBool = (v) => v === true || (typeof v === 'string' && (v === 't' || v === 'true'));
620
+ /**
621
+ * Render `Partition key: <partkeydef>` for partitioned-table parents.
622
+ */
623
+ const renderPartitionKeySection = async (conn, oid, out) => {
624
+ const q = fetchPartitionKey({ oid });
625
+ const rs = await conn.query(q.sql, q.params);
626
+ if (rs.rows.length === 0)
627
+ return;
628
+ const def = cellToString(rs.rows[0][0] ?? '');
629
+ if (def === '')
630
+ return;
631
+ out.write(`Partition key: ${def}\n`);
632
+ };
633
+ /**
634
+ * Render the "Partition of: <parent> <bound>[ DETACH PENDING]" line and
635
+ * the verbose-only "Partition constraint:" follow-up for a child
636
+ * partition (`relispartition = true`).
637
+ */
638
+ const renderPartitionOfSection = async (conn, oid, verbose, out) => {
639
+ const q = fetchPartitionOf({
640
+ oid,
641
+ serverVersion: conn.serverVersion,
642
+ withConstraint: verbose,
643
+ });
644
+ const rs = await conn.query(q.sql, q.params);
645
+ if (rs.rows.length === 0)
646
+ return;
647
+ const row = rs.rows[0];
648
+ const parent = cellToString(row[0] ?? '');
649
+ const bound = cellToString(row[1] ?? '');
650
+ const detached = parseBool(row[2]);
651
+ const tail = detached ? ' DETACH PENDING' : '';
652
+ out.write(`Partition of: ${parent} ${bound}${tail}\n`);
653
+ if (verbose) {
654
+ const constraintdef = row[3] === null || row[3] === undefined ? '' : cellToString(row[3]);
655
+ if (constraintdef === '') {
656
+ out.write('No partition constraint\n');
657
+ }
658
+ else {
659
+ out.write(`Partition constraint: ${constraintdef}\n`);
660
+ }
661
+ }
662
+ };
663
+ /**
664
+ * Render the `Policies[...]:` header + one POLICY line per row. The
665
+ * exact header text encodes (rowsecurity, forcerowsecurity, has-policies)
666
+ * the same way upstream does, including the "(none)" tail for the
667
+ * enabled-but-no-policies cases.
668
+ */
669
+ const renderPoliciesSection = async (conn, oid, relInfo, out) => {
670
+ const q = fetchPolicies({ oid, serverVersion: conn.serverVersion });
671
+ const rs = await conn.query(q.sql, q.params);
672
+ const tuples = rs.rows.length;
673
+ const { rowsecurity, forcerowsecurity } = relInfo;
674
+ let header = null;
675
+ if (rowsecurity && !forcerowsecurity && tuples > 0) {
676
+ header = 'Policies:';
677
+ }
678
+ else if (rowsecurity && forcerowsecurity && tuples > 0) {
679
+ header = 'Policies (forced row security enabled):';
680
+ }
681
+ else if (rowsecurity && !forcerowsecurity && tuples === 0) {
682
+ header = 'Policies (row security enabled): (none)';
683
+ }
684
+ else if (rowsecurity && forcerowsecurity && tuples === 0) {
685
+ header = 'Policies (forced row security enabled): (none)';
686
+ }
687
+ else if (!rowsecurity && tuples > 0) {
688
+ header = 'Policies (row security disabled):';
689
+ }
690
+ if (header === null)
691
+ return;
692
+ out.write(`${header}\n`);
693
+ for (const r of rs.rows) {
694
+ const polname = cellToString(r[0]);
695
+ const permissive = parseBool(r[1]);
696
+ const roles = r[2] === null || r[2] === undefined ? null : cellToString(r[2]);
697
+ const qual = r[3] === null || r[3] === undefined ? null : cellToString(r[3]);
698
+ const withcheck = r[4] === null || r[4] === undefined ? null : cellToString(r[4]);
699
+ const cmd = r[5] === null || r[5] === undefined ? null : cellToString(r[5]);
700
+ let line = ` POLICY "${polname}"`;
701
+ if (!permissive)
702
+ line += ' AS RESTRICTIVE';
703
+ if (cmd !== null && cmd !== '')
704
+ line += ` FOR ${cmd}`;
705
+ if (roles !== null)
706
+ line += `\n TO ${roles}`;
707
+ if (qual !== null)
708
+ line += `\n USING (${qual})`;
709
+ if (withcheck !== null)
710
+ line += `\n WITH CHECK (${withcheck})`;
711
+ out.write(`${line}\n`);
712
+ }
713
+ };
714
+ /**
715
+ * Render the foreign-table footer: `Server: <name>` + optional
716
+ * `FDW options: (key 'val', key 'val')`. Upstream pulls these in a
717
+ * single follow-up query; we mirror that shape via
718
+ * {@link fetchForeignTableInfo}.
719
+ */
720
+ const renderForeignTableFooter = async (conn, oid, out) => {
721
+ const q = fetchForeignTableInfo({ oid });
722
+ const rs = await conn.query(q.sql, q.params);
723
+ if (rs.rows.length === 0)
724
+ return;
725
+ const row = rs.rows[0];
726
+ const server = cellToString(row[0] ?? '');
727
+ const ftoptions = row[1] === null || row[1] === undefined ? '' : cellToString(row[1]);
728
+ if (server !== '')
729
+ out.write(`Server: ${server}\n`);
730
+ if (ftoptions !== '')
731
+ out.write(`FDW options: (${ftoptions})\n`);
732
+ };
733
+ /**
734
+ * Render `Inherits: <parent>[, ...]` for relations with parents in
735
+ * `pg_inherits`. Partition parents are excluded (they're rendered via
736
+ * `Partition of:` instead) inside the query builder.
737
+ */
738
+ const renderInheritsSection = async (conn, oid, out) => {
739
+ const q = fetchInherits({ oid });
740
+ const rs = await conn.query(q.sql, q.params);
741
+ if (rs.rows.length === 0)
742
+ return;
743
+ const label = 'Inherits';
744
+ const indent = ' '.repeat(label.length);
745
+ rs.rows.forEach((r, idx) => {
746
+ const parent = cellToString(r[0]);
747
+ const prefix = idx === 0 ? `${label}: ` : `${indent} `;
748
+ const trailing = idx < rs.rows.length - 1 ? ',' : '';
749
+ out.write(`${prefix}${parent}${trailing}\n`);
750
+ });
751
+ };
752
+ /**
753
+ * Render the child-relation footer for inheritance / partition parents.
754
+ *
755
+ * - Partitioned parents always emit a `Number of partitions: N` footer
756
+ * (even when zero, even in verbose mode); when verbose=false and N>0
757
+ * the footer adds the `(Use \d+ to list them.)` hint. Verbose mode
758
+ * replaces the count with a full `Partitions:` list including bounds.
759
+ * - Non-partition parents (regular tables) emit `Number of child
760
+ * tables: N (Use \d+ to list them.)` (non-verbose) or `Child tables:`
761
+ * list (verbose).
762
+ */
763
+ const renderInheritedBySection = async (conn, oid, relkind, verbose, out) => {
764
+ const isPartitioned = relkind === 'p' || relkind === 'I';
765
+ const q = fetchInheritedBy({ oid, serverVersion: conn.serverVersion });
766
+ const rs = await conn.query(q.sql, q.params);
767
+ const tuples = rs.rows.length;
768
+ if (isPartitioned && tuples === 0) {
769
+ out.write('Number of partitions: 0\n');
770
+ return;
771
+ }
772
+ if (!verbose) {
773
+ if (tuples === 0)
774
+ return;
775
+ if (isPartitioned) {
776
+ out.write(`Number of partitions: ${tuples} (Use \\d+ to list them.)\n`);
777
+ }
778
+ else {
779
+ out.write(`Number of child tables: ${tuples} (Use \\d+ to list them.)\n`);
780
+ }
781
+ return;
782
+ }
783
+ // Verbose mode: list each child with its bound (for partitions) and
784
+ // child-relkind annotations.
785
+ const label = isPartitioned ? 'Partitions' : 'Child tables';
786
+ const indent = ' '.repeat(label.length);
787
+ rs.rows.forEach((r, idx) => {
788
+ const relname = cellToString(r[0]);
789
+ const childKind = cellToString(r[1] ?? '');
790
+ const detached = parseBool(r[2]);
791
+ const bound = r[3] === null || r[3] === undefined ? '' : cellToString(r[3]);
792
+ const prefix = idx === 0 ? `${label}: ` : `${indent} `;
793
+ let line = `${prefix}${relname}`;
794
+ if (bound !== '')
795
+ line += ` ${bound}`;
796
+ if (childKind === 'p' || childKind === 'I')
797
+ line += ', PARTITIONED';
798
+ else if (childKind === 'f')
799
+ line += ', FOREIGN';
800
+ if (detached)
801
+ line += ' (DETACH PENDING)';
802
+ if (idx < rs.rows.length - 1)
803
+ line += ',';
804
+ out.write(`${line}\n`);
805
+ });
806
+ };
807
+ /**
808
+ * Render `Replica Identity: <value>` when the relation's `relreplident`
809
+ * is non-default and non-INDEX. Upstream skips this footer entirely for
810
+ * the default value ('d' in user schemas, 'n' for pg_catalog relations);
811
+ * INDEX-mode (relreplident = 'i') is surfaced inline on the matching
812
+ * index line in the Indexes: section, so no footer is emitted there
813
+ * either.
814
+ */
815
+ const renderReplicaIdentitySection = (schema, relInfo, out) => {
816
+ const ri = relInfo.relreplident;
817
+ // INDEX mode is rendered inline on the matching index — no footer.
818
+ if (ri === 'i')
819
+ return;
820
+ // pg_catalog relations default to 'n', user relations to 'd' — both
821
+ // suppress the footer when the value matches the schema default.
822
+ const isCatalog = schema === 'pg_catalog';
823
+ if (!isCatalog && ri === 'd')
824
+ return;
825
+ if (isCatalog && ri === 'n')
826
+ return;
827
+ const label = ri === 'f'
828
+ ? 'FULL'
829
+ : ri === 'd'
830
+ ? 'NOTHING'
831
+ : ri === 'n'
832
+ ? 'NOTHING'
833
+ : '???';
834
+ out.write(`Replica Identity: ${label}\n`);
835
+ };
836
+ /**
837
+ * Emit `Tablespace: "<name>"` when the relation has an explicit
838
+ * (non-default) tablespace. Only meaningful for relkinds that support
839
+ * tablespaces — caller enforces the relkind filter.
840
+ */
841
+ const renderTablespaceFooter = (relkind, relInfo, out) => {
842
+ const tsSupported = relkind === 'r' ||
843
+ relkind === 'm' ||
844
+ relkind === 'i' ||
845
+ relkind === 'I' ||
846
+ relkind === 'p' ||
847
+ relkind === 't';
848
+ if (!tsSupported)
849
+ return;
850
+ if (relInfo.reltablespace === 0 || !relInfo.spcname)
851
+ return;
852
+ out.write(`Tablespace: "${relInfo.spcname}"\n`);
853
+ };
854
+ /**
855
+ * Emit `Access method: <name>` when the relation has an explicit table
856
+ * access method (PG 12+). Indexes have their AM rendered inline within
857
+ * the index definition string, so this footer covers only
858
+ * tables / materialized views / partitioned tables.
859
+ */
860
+ const renderAccessMethodFooter = (relInfo, out) => {
861
+ if (relInfo.relam === 0 || !relInfo.amname)
862
+ return;
863
+ out.write(`Access method: ${relInfo.amname}\n`);
864
+ };
865
+ /**
866
+ * Render `Indexes:\n "name" PRIMARY KEY, btree (col)` for each index
867
+ * on `oid`. Free-form section — not a table.
868
+ *
869
+ * When the relation has INDEX-mode replica identity (relreplident = 'i'),
870
+ * the corresponding index gets a trailing " REPLICA IDENTITY" marker on
871
+ * its line, matching upstream `\d` output. The marker comes from each
872
+ * index's own `pg_index.indisreplident` flag — only one index can carry
873
+ * it, so no follow-up footer is needed for INDEX-mode RI.
874
+ */
875
+ const renderIndexesSection = async (conn, oid, out) => {
876
+ const sql = 'SELECT c2.relname, i.indisprimary, i.indisunique, i.indisclustered,\n' +
877
+ ' i.indisvalid,\n' +
878
+ ' pg_catalog.pg_get_indexdef(i.indexrelid, 0, true),\n' +
879
+ ' pg_catalog.pg_get_constraintdef(con.oid, true),\n' +
880
+ ' contype, condeferrable, condeferred,\n' +
881
+ ' i.indisreplident,\n' +
882
+ ' c2.reltablespace\n' +
883
+ 'FROM pg_catalog.pg_class c, pg_catalog.pg_class c2, pg_catalog.pg_index i\n' +
884
+ ` LEFT JOIN pg_catalog.pg_constraint con ON (conrelid = i.indrelid AND conindid = i.indexrelid AND contype IN ('p','u','x'))\n` +
885
+ `WHERE c.oid = '${oid}' AND c.oid = i.indrelid AND i.indexrelid = c2.oid\n` +
886
+ 'ORDER BY i.indisprimary DESC, c2.relname;';
887
+ const rs = await conn.query(sql, []);
888
+ if (rs.rows.length === 0)
889
+ return;
890
+ out.write('Indexes:\n');
891
+ for (const r of rs.rows) {
892
+ const idxName = cellToString(r[0]);
893
+ const isPrimary = String(r[1]) === 't' || r[1] === true;
894
+ const isUnique = String(r[2]) === 't' || r[2] === true;
895
+ const isClustered = String(r[3]) === 't' || r[3] === true;
896
+ const isValid = String(r[4]) === 't' || r[4] === true;
897
+ const indexdef = cellToString(r[5]);
898
+ const constrDef = r[6] !== null ? cellToString(r[6]) : '';
899
+ const contype = r[7] === null ? '' : cellToString(r[7]);
900
+ const condeferrable = String(r[8]) === 't' || r[8] === true;
901
+ const condeferred = String(r[9]) === 't' || r[9] === true;
902
+ const isReplIdent = String(r[10]) === 't' || r[10] === true;
903
+ let line = ` "${idxName}"`;
904
+ // Strip everything up through " USING " from the indexdef so we get
905
+ // the trailing `btree (...)` clause.
906
+ const usingPos = indexdef.indexOf(' USING ');
907
+ const tail = usingPos >= 0 ? indexdef.slice(usingPos + 7) : indexdef;
908
+ if (contype === 'x') {
909
+ // Exclusion constraint: emit constraintdef verbatim, no tail.
910
+ line += ` ${constrDef}`;
911
+ }
912
+ else {
913
+ // Prefix label per upstream describe.c:
914
+ // indisprimary -> " PRIMARY KEY,"
915
+ // indisunique && contype=='u' -> " UNIQUE CONSTRAINT,"
916
+ // indisunique -> " UNIQUE,"
917
+ // No prefix for plain non-unique indexes.
918
+ if (isPrimary) {
919
+ line += ' PRIMARY KEY,';
920
+ }
921
+ else if (isUnique) {
922
+ line += contype === 'u' ? ' UNIQUE CONSTRAINT,' : ' UNIQUE,';
923
+ }
924
+ line += ` ${tail}`;
925
+ if (condeferrable)
926
+ line += ' DEFERRABLE';
927
+ if (condeferred)
928
+ line += ' INITIALLY DEFERRED';
929
+ }
930
+ if (isClustered)
931
+ line += ' CLUSTER';
932
+ if (!isValid)
933
+ line += ' INVALID';
934
+ if (isReplIdent)
935
+ line += ' REPLICA IDENTITY';
936
+ out.write(`${line}\n`);
937
+ }
938
+ };
939
+ /**
940
+ * Render `Check constraints:\n "name" CHECK (expr)` list.
941
+ */
942
+ const renderCheckConstraintsSection = async (conn, oid, out) => {
943
+ const sql = 'SELECT r.conname, pg_catalog.pg_get_constraintdef(r.oid, true)\n' +
944
+ 'FROM pg_catalog.pg_constraint r\n' +
945
+ `WHERE r.conrelid = '${oid}' AND r.contype = 'c'\n` +
946
+ 'ORDER BY 1;';
947
+ const rs = await conn.query(sql, []);
948
+ if (rs.rows.length === 0)
949
+ return;
950
+ out.write('Check constraints:\n');
951
+ for (const r of rs.rows) {
952
+ out.write(` "${cellToString(r[0])}" ${cellToString(r[1])}\n`);
953
+ }
954
+ };
955
+ /**
956
+ * Render `Not-null constraints:\n "name" NOT NULL "col"[ NO INHERIT]`
957
+ * list (PG 18+ named NOT NULL constraints, `pg_constraint.contype = 'n'`).
958
+ *
959
+ * Upstream `describeOneTableDetails` (describe.c ~3104) emits one line per
960
+ * constraint in `attnum` order. `connoinherit` adds a trailing
961
+ * ` NO INHERIT`; inherited-only constraints (`conislocal = false`) are
962
+ * tagged ` (inherited)` to match vanilla `\d+`. On pre-PG-18 servers the
963
+ * query returns no rows, so the whole section is suppressed.
964
+ */
965
+ const renderNotNullConstraintsSection = async (conn, oid, out) => {
966
+ const q = fetchNotNullConstraints({ oid, serverVersion: conn.serverVersion });
967
+ const rs = await conn.query(q.sql, q.params);
968
+ if (rs.rows.length === 0)
969
+ return;
970
+ out.write('Not-null constraints:\n');
971
+ for (const r of rs.rows) {
972
+ const conname = cellToString(r[0]);
973
+ const attname = cellToString(r[1]);
974
+ const noInherit = parseBool(r[2]);
975
+ const isLocal = parseBool(r[3]);
976
+ let line = ` "${conname}" NOT NULL "${attname}"`;
977
+ if (noInherit)
978
+ line += ' NO INHERIT';
979
+ else if (!isLocal)
980
+ line += ' (inherited)';
981
+ out.write(`${line}\n`);
982
+ }
983
+ };
984
+ /**
985
+ * Render `Foreign-key constraints:\n "name" FOREIGN KEY ...` list.
986
+ */
987
+ const renderForeignKeyConstraintsSection = async (conn, oid, out) => {
988
+ const sql = 'SELECT conname, pg_catalog.pg_get_constraintdef(oid, true) AS condef\n' +
989
+ 'FROM pg_catalog.pg_constraint\n' +
990
+ `WHERE conrelid = '${oid}' AND contype = 'f'\n` +
991
+ 'ORDER BY conname;';
992
+ const rs = await conn.query(sql, []);
993
+ if (rs.rows.length === 0)
994
+ return;
995
+ out.write('Foreign-key constraints:\n');
996
+ for (const r of rs.rows) {
997
+ out.write(` "${cellToString(r[0])}" ${cellToString(r[1])}\n`);
998
+ }
999
+ };
1000
+ /**
1001
+ * Render `Referenced by:\n TABLE "..." CONSTRAINT "..." FOREIGN KEY ...`
1002
+ * (incoming FKs from other tables).
1003
+ */
1004
+ const renderReferencedBySection = async (conn, oid, out) => {
1005
+ const sql = 'SELECT conname, conrelid::pg_catalog.regclass,\n' +
1006
+ ' pg_catalog.pg_get_constraintdef(oid, true) AS condef\n' +
1007
+ 'FROM pg_catalog.pg_constraint\n' +
1008
+ `WHERE confrelid = '${oid}' AND contype = 'f'\n` +
1009
+ 'ORDER BY conname;';
1010
+ const rs = await conn.query(sql, []);
1011
+ if (rs.rows.length === 0)
1012
+ return;
1013
+ out.write('Referenced by:\n');
1014
+ for (const r of rs.rows) {
1015
+ out.write(` TABLE "${cellToString(r[1])}" CONSTRAINT "${cellToString(r[0])}" ${cellToString(r[2])}\n`);
1016
+ }
1017
+ };
1018
+ /**
1019
+ * Render `Triggers:\n name AFTER ... EXECUTE ...` list.
1020
+ */
1021
+ const renderTriggersSection = async (conn, oid, out) => {
1022
+ const sql = 'SELECT t.tgname, pg_catalog.pg_get_triggerdef(t.oid, true) AS tgdef, t.tgenabled\n' +
1023
+ 'FROM pg_catalog.pg_trigger t\n' +
1024
+ `WHERE t.tgrelid = '${oid}' AND NOT t.tgisinternal\n` +
1025
+ 'ORDER BY 1;';
1026
+ const rs = await conn.query(sql, []);
1027
+ if (rs.rows.length === 0)
1028
+ return;
1029
+ out.write('Triggers:\n');
1030
+ for (const r of rs.rows) {
1031
+ out.write(` ${cellToString(r[1])}\n`);
1032
+ }
1033
+ };
1034
+ /**
1035
+ * Render `Statistics objects:\n "schema"."name" (kinds) ON cols FROM tbl`
1036
+ * for each `pg_statistic_ext` row on the relation. Verbose-only.
1037
+ *
1038
+ * Upstream concatenates the active "kinds" (ndistinct / dependencies / mcv)
1039
+ * inside parentheses; we preserve insertion order matching upstream.
1040
+ */
1041
+ const renderStatisticsObjectsSection = async (conn, oid, out) => {
1042
+ const q = fetchStatisticsObjects({ oid, serverVersion: conn.serverVersion });
1043
+ const rs = await conn.query(q.sql, q.params);
1044
+ if (rs.rows.length === 0)
1045
+ return;
1046
+ out.write('Statistics objects:\n');
1047
+ for (const r of rs.rows) {
1048
+ const nsp = cellToString(r[0] ?? '');
1049
+ const name = cellToString(r[1] ?? '');
1050
+ const ndist = parseBool(r[2]);
1051
+ const deps = parseBool(r[3]);
1052
+ const mcv = parseBool(r[4]);
1053
+ const columns = cellToString(r[5] ?? '');
1054
+ const relname = cellToString(r[6] ?? '');
1055
+ const kinds = [];
1056
+ if (ndist)
1057
+ kinds.push('ndistinct');
1058
+ if (deps)
1059
+ kinds.push('dependencies');
1060
+ if (mcv)
1061
+ kinds.push('mcv');
1062
+ const kindStr = kinds.length > 0 ? ` (${kinds.join(', ')})` : '';
1063
+ out.write(` "${nsp}"."${name}"${kindStr} ON ${columns} FROM ${relname}\n`);
1064
+ }
1065
+ };
1066
+ /**
1067
+ * Render `Publications:\n "name"` (one per row) for any publication
1068
+ * the relation belongs to (explicit, FOR ALL TABLES, or FOR ALL TABLES
1069
+ * IN SCHEMA). No-op when the result set is empty.
1070
+ */
1071
+ const renderPublicationsSection = async (conn, oid, out) => {
1072
+ const q = fetchTablePublications({ oid, serverVersion: conn.serverVersion });
1073
+ const rs = await conn.query(q.sql, q.params);
1074
+ if (rs.rows.length === 0)
1075
+ return;
1076
+ out.write('Publications:\n');
1077
+ for (const r of rs.rows) {
1078
+ out.write(` "${cellToString(r[0] ?? '')}"\n`);
1079
+ }
1080
+ };
1081
+ /**
1082
+ * Render `Subscriptions:\n "name"` (one per row). Requires superuser
1083
+ * access to `pg_subscription` — when the query fails with a permission
1084
+ * error, the section is silently omitted (mirroring upstream behaviour).
1085
+ */
1086
+ const renderSubscriptionsSection = async (conn, oid, out) => {
1087
+ const q = fetchTableSubscriptions({ oid, serverVersion: conn.serverVersion });
1088
+ let rs;
1089
+ try {
1090
+ rs = await conn.query(q.sql, q.params);
1091
+ }
1092
+ catch (err) {
1093
+ if (isPermissionDeniedError(err))
1094
+ return;
1095
+ throw err;
1096
+ }
1097
+ if (rs.rows.length === 0)
1098
+ return;
1099
+ out.write('Subscriptions:\n');
1100
+ for (const r of rs.rows) {
1101
+ out.write(` "${cellToString(r[0] ?? '')}"\n`);
1102
+ }
1103
+ };
1104
+ /**
1105
+ * Pre-fetch per-column FDW options for a foreign table and index them by
1106
+ * column name so the column-table renderer can fold them in inline.
1107
+ * Upstream renders these as a trailing "FDW options: (k 'v', ...)" cell
1108
+ * on each affected column row, not as a separate footer.
1109
+ */
1110
+ const fetchPerColumnFdwOptionsMap = async (conn, oid) => {
1111
+ const q = fetchPerColumnFdwOptions({ oid });
1112
+ const rs = await conn.query(q.sql, q.params);
1113
+ const m = new Map();
1114
+ for (const r of rs.rows) {
1115
+ const attname = cellToString(r[0] ?? '');
1116
+ const opts = cellToString(r[1] ?? '');
1117
+ if (attname !== '' && opts !== '')
1118
+ m.set(attname, opts);
1119
+ }
1120
+ return m;
1121
+ };
1122
+ /**
1123
+ * Render `Owning table: "schema.name"` for a TOAST relation. Matches
1124
+ * upstream's `\d <toast>` footer — upstream always emits the qualified
1125
+ * `"schema.name"` form (even for `pg_catalog` parents that would
1126
+ * otherwise be elided by search_path), so we look up the nsp+rel pair
1127
+ * directly rather than relying on regclass-cast text.
1128
+ */
1129
+ const renderToastOwningTableFooter = async (conn, oid, out) => {
1130
+ // Side-step the regclass-cast query (which honours search_path and
1131
+ // would drop the `pg_catalog.` prefix for pg_catalog parents). Look
1132
+ // up the parent's schema + relname directly so we can render the
1133
+ // schema-qualified form unconditionally.
1134
+ const sql = 'SELECT n.nspname, c.relname\n' +
1135
+ 'FROM pg_catalog.pg_class c\n' +
1136
+ 'JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n' +
1137
+ `WHERE c.reltoastrelid = '${oid}'\n` +
1138
+ 'LIMIT 1;';
1139
+ const rs = await conn.query(sql, []);
1140
+ if (rs.rows.length === 0)
1141
+ return;
1142
+ const nspname = cellToString(rs.rows[0][0] ?? '');
1143
+ const relname = cellToString(rs.rows[0][1] ?? '');
1144
+ if (relname === '')
1145
+ return;
1146
+ out.write(`Owning table: "${nspname}.${relname}"\n`);
1147
+ };
1148
+ /**
1149
+ * Detect a "permission denied" PostgresError (SQLSTATE 42501) on a
1150
+ * thrown value. We look at both `code` (SQLSTATE) and the message text
1151
+ * because not every transport layer surfaces the code. The check is
1152
+ * intentionally conservative — we only swallow genuine privilege
1153
+ * errors, not arbitrary failures.
1154
+ */
1155
+ const isPermissionDeniedError = (err) => {
1156
+ if (err === null || typeof err !== 'object')
1157
+ return false;
1158
+ const code = err.code;
1159
+ if (typeof code === 'string' && code === '42501')
1160
+ return true;
1161
+ const message = err.message;
1162
+ if (typeof message === 'string' && /permission denied/i.test(message)) {
1163
+ return true;
1164
+ }
1165
+ return false;
1166
+ };
1167
+ /**
1168
+ * `\ds <name>` — sequence details. Renders the columns of pg_sequence
1169
+ * plus the `Owned by:` footer if applicable.
1170
+ */
1171
+ export const describeOneSequence = async (conn, oid, schema, name, out, popt) => {
1172
+ const sql = 'SELECT pg_catalog.format_type(seqtypid, NULL) AS "Type",\n' +
1173
+ ' seqstart AS "Start", seqmin AS "Minimum", seqmax AS "Maximum",\n' +
1174
+ ' seqincrement AS "Increment",\n' +
1175
+ " CASE WHEN seqcycle THEN 'yes' ELSE 'no' END AS \"Cycles?\",\n" +
1176
+ ' seqcache AS "Cache"\n' +
1177
+ `FROM pg_catalog.pg_sequence WHERE seqrelid = '${oid}';`;
1178
+ const rs = await conn.query(sql, []);
1179
+ const title = `Sequence "${schema}.${name}"`;
1180
+ // Owned-by footer text is collected up-front so the printer can place
1181
+ // it inside the body of the result (between the data row and the
1182
+ // trailing blank line), matching upstream where `\d <seq>` renders
1183
+ // `Owned by:` AS a footer of the printed table — not as a separate
1184
+ // post-table line.
1185
+ const ownedSql = "SELECT pg_catalog.quote_ident(nspname) || '.' || pg_catalog.quote_ident(relname) || '.' || pg_catalog.quote_ident(attname)\n" +
1186
+ 'FROM pg_catalog.pg_class c\n' +
1187
+ 'JOIN pg_catalog.pg_depend d ON c.oid = d.refobjid\n' +
1188
+ 'JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace\n' +
1189
+ 'JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.refobjsubid\n' +
1190
+ `WHERE d.classid = 'pg_catalog.pg_class'::regclass AND d.refclassid = 'pg_catalog.pg_class'::regclass\n` +
1191
+ ` AND d.objid = '${oid}' AND d.deptype IN ('a', 'i');`;
1192
+ const ownRs = await conn.query(ownedSql, []);
1193
+ const footers = [];
1194
+ if (ownRs.rows.length > 0) {
1195
+ footers.push(`Owned by: ${cellToString(ownRs.rows[0][0])}`);
1196
+ }
1197
+ // Suppress the row-count footer — upstream's sequence detail output is
1198
+ // a single row with no `(1 row)` line. Pass the Owned-by line as a
1199
+ // user footer so the printer places it before the trailing blank.
1200
+ const seqOpts = {
1201
+ ...popt,
1202
+ title,
1203
+ topt: { ...popt.topt, title, defaultFooter: false },
1204
+ footers: footers.length > 0 ? footers : null,
1205
+ };
1206
+ await pickPrinterForFormat(seqOpts).printQuery(coerceResultSet(rs), seqOpts, out);
1207
+ };
1208
+ /**
1209
+ * `\sf <name>` — show function definition (full CREATE FUNCTION).
1210
+ * Renders the single-column result as raw text.
1211
+ */
1212
+ export const describeOneFunctionDetails = async (conn, oid, out) => {
1213
+ const sql = `SELECT pg_catalog.pg_get_functiondef('${oid}'::pg_catalog.oid) AS def;`;
1214
+ const rs = await conn.query(sql, []);
1215
+ if (rs.rows.length > 0) {
1216
+ out.write(cellToString(rs.rows[0][0]));
1217
+ out.write('\n');
1218
+ }
1219
+ };
1220
+ /**
1221
+ * `\sv <name>` — show view definition.
1222
+ */
1223
+ export const describeOneViewDetails = async (conn, oid, schema, name, out, popt, verbose = false, hideCompression = false) => {
1224
+ // Use the table renderer for columns first (views have columns). In
1225
+ // verbose mode this also adds the Storage / Stats target / Description
1226
+ // columns, matching upstream `\d+ <view>`.
1227
+ await describeOneTableDetails(conn, oid, schema, name, 'v', verbose, out, popt, false, hideCompression);
1228
+ // The "View definition:" footer (verbose-only) is emitted by
1229
+ // describeOneTableDetails as a table footer so it renders flush with the
1230
+ // column rows and gets the single trailing blank — matching upstream
1231
+ // `describeOneTableDetails` (describe.c ~3151/3175). Nothing more to do.
1232
+ };
1233
+ /**
1234
+ * Translate a relkind char into the canonical header psql uses for
1235
+ * `\d <name>`. Examples: 'r' → `Table "schema.name"`; 'v' → `View "..."`.
1236
+ */
1237
+ const headerForRelkind = (relkind, schema, name) => {
1238
+ switch (relkind) {
1239
+ case 'r':
1240
+ return `Table "${schema}.${name}"`;
1241
+ case 'v':
1242
+ return `View "${schema}.${name}"`;
1243
+ case 'm':
1244
+ return `Materialized view "${schema}.${name}"`;
1245
+ case 'S':
1246
+ return `Sequence "${schema}.${name}"`;
1247
+ case 'i':
1248
+ return `Index "${schema}.${name}"`;
1249
+ case 'I':
1250
+ return `Partitioned index "${schema}.${name}"`;
1251
+ case 'p':
1252
+ return `Partitioned table "${schema}.${name}"`;
1253
+ case 'f':
1254
+ return `Foreign table "${schema}.${name}"`;
1255
+ case 't':
1256
+ return `TOAST table "${schema}.${name}"`;
1257
+ case 'c':
1258
+ return `Composite type "${schema}.${name}"`;
1259
+ default:
1260
+ return `Relation "${schema}.${name}"`;
1261
+ }
1262
+ };
1263
+ /**
1264
+ * Build a minimal {@link FieldDescription} for synthesized rows where
1265
+ * we don't actually have a wire-level row description. Used by the
1266
+ * columns table in `describeOneTableDetails` because we synthesize
1267
+ * the layout from pg_attribute data.
1268
+ */
1269
+ const fakeField = (name) => ({
1270
+ name,
1271
+ tableID: 0,
1272
+ columnID: 0,
1273
+ dataTypeID: 25,
1274
+ dataTypeSize: -1,
1275
+ dataTypeModifier: -1,
1276
+ format: 0,
1277
+ });