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.
- 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 +268 -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 +43 -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,2187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* psql I/O & control backslash commands.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of the following `exec_command_*` functions in upstream
|
|
5
|
+
* PostgreSQL's `src/bin/psql/command.c`:
|
|
6
|
+
*
|
|
7
|
+
* - `\i`, `\include` → exec_command_include (normal)
|
|
8
|
+
* - `\ir`, `\include_relative` → exec_command_include (relative=true)
|
|
9
|
+
* - `\o`, `\out` → exec_command_out
|
|
10
|
+
* - `\w`, `\write` → exec_command_write
|
|
11
|
+
* - `\g` → exec_command_g
|
|
12
|
+
* - `\gx` → exec_command_g (force_expanded=true)
|
|
13
|
+
* - `\gset` → exec_command_gset
|
|
14
|
+
* - `\gdesc` → exec_command_gdesc
|
|
15
|
+
* - `\gexec` → exec_command_gexec
|
|
16
|
+
* - `\watch` → exec_command_watch
|
|
17
|
+
*
|
|
18
|
+
* Each is exported as a `BackslashCmdSpec` and registered via
|
|
19
|
+
* {@link registerIoCommands}. The single line that wires us into the
|
|
20
|
+
* default dispatcher lives in `dispatch.ts::defaultRegistry()`.
|
|
21
|
+
*
|
|
22
|
+
* # Integration touch-points and known limitations
|
|
23
|
+
*
|
|
24
|
+
* Several of these commands really want to participate in the mainloop's
|
|
25
|
+
* scanner/printer pipeline. This WP keeps `src/psql/core/mainloop.ts`
|
|
26
|
+
* untouched, so we provide the data structures and let a follow-up WP wire
|
|
27
|
+
* the consumption sites. Limitations documented per-command:
|
|
28
|
+
*
|
|
29
|
+
* - `\i FILE` enqueues the file's contents on a small input queue
|
|
30
|
+
* (`./inputQueue.ts`) AND, as a stop-gap, executes the file's SQL
|
|
31
|
+
* directly via `Connection.execSimple`. Backslash commands embedded in
|
|
32
|
+
* the file are NOT processed by the scanner; the include is a "best
|
|
33
|
+
* effort: run as one big SQL blob". Once mainloop adopts the queue API
|
|
34
|
+
* this becomes a true include.
|
|
35
|
+
*
|
|
36
|
+
* - `\o FILE` opens a writable stream and stashes it under a symbol on
|
|
37
|
+
* `settings`. We expose a getter (`getQueryFout`) for the mainloop to
|
|
38
|
+
* consult; until that wiring happens, query output continues to flow
|
|
39
|
+
* to the mainloop's `ctx.stdout`. The stash + close-on-rebind logic is
|
|
40
|
+
* in place and fully tested.
|
|
41
|
+
*
|
|
42
|
+
* - `\g` (no arg) executes the current queryBuf directly through
|
|
43
|
+
* `Connection.execSimple` and renders via the aligned printer. This
|
|
44
|
+
* duplicates a tiny slice of mainloop's send/print pipeline, which is
|
|
45
|
+
* fine for the bytewise-simple cases this WP needs to support. For
|
|
46
|
+
* `\g FILE` / `\g |cmd` the output goes through the temporary writer.
|
|
47
|
+
*
|
|
48
|
+
* - `\gx` toggles `topt.expanded` for the single execution and restores
|
|
49
|
+
* the prior value in a `try { ... } finally { ... }`.
|
|
50
|
+
*
|
|
51
|
+
* - `\gset [PREFIX]` executes via `execSimple`, requires the last result
|
|
52
|
+
* to have exactly one row, and stores `${prefix}${colname}` → value
|
|
53
|
+
* for each column on `settings.vars`.
|
|
54
|
+
*
|
|
55
|
+
* - `\gdesc` parses the buffered query with the extended protocol
|
|
56
|
+
* (Parse + Describe by statement, no Execute), then assembles a
|
|
57
|
+
* synthetic `Column / Type` ResultSet and renders it through the
|
|
58
|
+
* active printer (`alignedPrinter` by default; the format picker
|
|
59
|
+
* honours `\pset format`). Tuples-only mode (`\t on`) and `\o FILE`
|
|
60
|
+
* redirects ride along automatically because the same ResultSet
|
|
61
|
+
* goes through the same printer the REPL would use for a query.
|
|
62
|
+
*
|
|
63
|
+
* - `\gexec` iterates the cells of the last result row-major and feeds
|
|
64
|
+
* each non-null cell back as SQL through `execSimple`. Each statement's
|
|
65
|
+
* output is rendered to stdout (or to the active queryFout stash).
|
|
66
|
+
*
|
|
67
|
+
* - `\watch [INTERVAL]` re-executes the queryBuf every `INTERVAL` seconds
|
|
68
|
+
* (default 2) until SIGINT or until the iteration count limit is hit.
|
|
69
|
+
* We hook SIGINT via a transient listener that's removed on completion.
|
|
70
|
+
* Tests bypass the listener by using an AbortController exposed via
|
|
71
|
+
* `WATCH_TEST_CONTROLLER`.
|
|
72
|
+
*
|
|
73
|
+
* # Error format
|
|
74
|
+
*
|
|
75
|
+
* Upstream prints `<cmd>: <msg>` to stderr and returns failure. We mirror
|
|
76
|
+
* that and also stash the message on `settings.lastErrorResult` so the
|
|
77
|
+
* mainloop's `writeError()` wrapper can pick it up.
|
|
78
|
+
*/
|
|
79
|
+
import { spawn } from 'node:child_process';
|
|
80
|
+
import { promises as fsPromises, closeSync, createWriteStream, fsyncSync, openSync, } from 'node:fs';
|
|
81
|
+
import * as path from 'node:path';
|
|
82
|
+
import { platform } from 'node:os';
|
|
83
|
+
import { alignedPrinter } from '../print/aligned.js';
|
|
84
|
+
import { asciidocPrinter } from '../print/asciidoc.js';
|
|
85
|
+
import { csvPrinter } from '../print/csv.js';
|
|
86
|
+
import { htmlPrinter } from '../print/html.js';
|
|
87
|
+
import { jsonPrinter } from '../print/json.js';
|
|
88
|
+
import { latexLongtablePrinter, latexPrinter } from '../print/latex.js';
|
|
89
|
+
import { troffMsPrinter } from '../print/troff.js';
|
|
90
|
+
import { unalignedPrinter } from '../print/unaligned.js';
|
|
91
|
+
import { writeErr, writeOut } from './shared.js';
|
|
92
|
+
import { formatErrorReport, psqlErrorPrefix } from './cmd_meta.js';
|
|
93
|
+
import { applyPset } from './cmd_format.js';
|
|
94
|
+
import { consumeBindState, lookupPrepared, stagedNamedBindPresent, } from './cmd_pipeline.js';
|
|
95
|
+
import { captureLastError, refreshErrorVars, stripLeadingCommentsAndWS, } from '../core/common.js';
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Query-output (queryFout) stash.
|
|
98
|
+
//
|
|
99
|
+
// psql tracks a "query output" file pointer separately from stdout (see
|
|
100
|
+
// pset.queryFout in upstream settings.h). Our PsqlSettings type is frozen
|
|
101
|
+
// at WP-00, so we stash the stream on the settings object via a well-known
|
|
102
|
+
// symbol — the same approach used for the CondStack in cmd_cond.ts.
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
const QUERY_FOUT_KEY = Symbol.for('neonctl.psql.queryFout');
|
|
105
|
+
/**
|
|
106
|
+
* Return the currently active queryFout stream (or `null` if none).
|
|
107
|
+
* The mainloop is encouraged to call this in lieu of writing directly to
|
|
108
|
+
* `ctx.stdout` for query results.
|
|
109
|
+
*/
|
|
110
|
+
export const getQueryFout = (settings) => {
|
|
111
|
+
const stash = settings;
|
|
112
|
+
return stash[QUERY_FOUT_KEY]?.stream ?? null;
|
|
113
|
+
};
|
|
114
|
+
const setQueryFout = (settings, entry) => {
|
|
115
|
+
const stash = settings;
|
|
116
|
+
if (entry === null) {
|
|
117
|
+
stash[QUERY_FOUT_KEY] = undefined;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
stash[QUERY_FOUT_KEY] = entry;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const closeQueryFout = async (settings) => {
|
|
124
|
+
const stash = settings;
|
|
125
|
+
const prev = stash[QUERY_FOUT_KEY];
|
|
126
|
+
if (prev) {
|
|
127
|
+
stash[QUERY_FOUT_KEY] = undefined;
|
|
128
|
+
await prev.close();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Watch SIGINT escape hatch (tests).
|
|
133
|
+
//
|
|
134
|
+
// `\watch` installs a SIGINT handler so Ctrl-C breaks the polling loop in
|
|
135
|
+
// real psql sessions. Tests need to break the loop deterministically; we
|
|
136
|
+
// expose an AbortController hook that, if set, takes precedence.
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
export const WATCH_TEST_CONTROLLER = {
|
|
139
|
+
ref: null,
|
|
140
|
+
};
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Small helpers.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
const errResult = (ctx, message) => {
|
|
145
|
+
ctx.settings.lastErrorResult = { message };
|
|
146
|
+
// Upstream psql prefixes every diagnostic with the `psql:[<file>:<n>]:`
|
|
147
|
+
// tag that `pg_log_pre_callback` adds. Mirror that here so backslash
|
|
148
|
+
// command errors look like upstream when surfaced via `psql_fails_like`.
|
|
149
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
150
|
+
writeErr(`${prefix}\\${ctx.cmdName}: ${message}\n`);
|
|
151
|
+
// Tell the mainloop the error has already been surfaced — without this
|
|
152
|
+
// it would also write a `psql: ERROR: <msg>` fallback, producing a stray
|
|
153
|
+
// duplicate that breaks the `\errverbose` ordering check on tests like
|
|
154
|
+
// `SELECT error\gdesc\n\errverbose`.
|
|
155
|
+
return { status: 'error', errorWritten: true };
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Reject buffer-consuming commands when an extended pipeline is open. Upstream
|
|
159
|
+
* `exec_command_g` / `gx` / `gset` / `gexec` / `watch` all guard with
|
|
160
|
+
* `PQpipelineStatus(pset.db) != PQ_PIPELINE_OFF` and emit
|
|
161
|
+
* `pg_log_error("\\%s not allowed in pipeline mode", cmd)` (note: no `:`
|
|
162
|
+
* after the command name — different shape from `errResult`).
|
|
163
|
+
*
|
|
164
|
+
* `\gdesc` is the odd one out: upstream uses
|
|
165
|
+
* `pg_log_error("synchronous command execution functions are not allowed in
|
|
166
|
+
* pipeline mode")` because the underlying `PQdescribePrepared`/`PQfn`-style
|
|
167
|
+
* helpers all share that text — the regress baseline asserts this exact
|
|
168
|
+
* wording at three call sites in `psql_pipeline.out`.
|
|
169
|
+
*
|
|
170
|
+
* If the command proceeded it would inject a synchronous Query/Sync into the
|
|
171
|
+
* queue, corrupt the pipeline state, and leave `\endpipeline` waiting forever.
|
|
172
|
+
*
|
|
173
|
+
* Returns `null` when not in pipeline mode (caller proceeds); otherwise
|
|
174
|
+
* returns a populated error result the caller should bubble up.
|
|
175
|
+
*
|
|
176
|
+
* Upstream psql 18.4 leaks gate diagnostics through `pg_log_error_internal`
|
|
177
|
+
* which appends to the libpq result error log on the underlying PGresult;
|
|
178
|
+
* each subsequent gate hit RE-EMITS the full accumulated log plus its own
|
|
179
|
+
* line ("Error messages accumulate and are repeated" — the regress comment
|
|
180
|
+
* is the spec). Two `\gdesc` calls back-to-back therefore emit 3 lines
|
|
181
|
+
* total: 1 for the first call, 2 for the second (one accumulated + one
|
|
182
|
+
* own). Mirror that with a settings-stashed accumulator keyed off the
|
|
183
|
+
* current pipeline session; reset when the pipeline ends.
|
|
184
|
+
*/
|
|
185
|
+
const PIPELINE_GATE_ERRORS_KEY = Symbol.for('neonctl.psql.pipelineGateErrors');
|
|
186
|
+
const getGateErrors = (settings) => {
|
|
187
|
+
const s = settings;
|
|
188
|
+
let cur = s[PIPELINE_GATE_ERRORS_KEY];
|
|
189
|
+
if (!cur) {
|
|
190
|
+
cur = [];
|
|
191
|
+
s[PIPELINE_GATE_ERRORS_KEY] = cur;
|
|
192
|
+
}
|
|
193
|
+
return cur;
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Drop the accumulated pipeline-gate errors. Called from
|
|
197
|
+
* `\endpipeline` so the next pipeline session starts fresh — without
|
|
198
|
+
* this, gate errors from a closed pipeline would leak into the next
|
|
199
|
+
* one.
|
|
200
|
+
*/
|
|
201
|
+
export const clearPipelineGateErrors = (settings) => {
|
|
202
|
+
const s = settings;
|
|
203
|
+
s[PIPELINE_GATE_ERRORS_KEY] = undefined;
|
|
204
|
+
};
|
|
205
|
+
const pipelineGate = (ctx) => {
|
|
206
|
+
if (ctx.settings.sendMode !== 'extended-pipeline')
|
|
207
|
+
return null;
|
|
208
|
+
const message = ctx.cmdName === 'gdesc'
|
|
209
|
+
? 'synchronous command execution functions are not allowed in pipeline mode'
|
|
210
|
+
: `\\${ctx.cmdName} not allowed in pipeline mode`;
|
|
211
|
+
ctx.settings.lastErrorResult = { message };
|
|
212
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
213
|
+
// Only `\gdesc` accumulates: each call appends its own line to the
|
|
214
|
+
// log AND re-emits the full log to stderr ("Error messages
|
|
215
|
+
// accumulate and are repeated" — regress spec at expected line 648:
|
|
216
|
+
// two consecutive `\gdesc` emit 1+2 = 3 lines total). Upstream's
|
|
217
|
+
// underlying `PQdescribePrepared` path is the one that leaks into
|
|
218
|
+
// the session-scoped error log; other gated commands (`\g`, `\gx`,
|
|
219
|
+
// `\gset`, `\gexec`, `\watch`) emit a single line per invocation
|
|
220
|
+
// and do NOT participate in the accumulator.
|
|
221
|
+
if (ctx.cmdName === 'gdesc') {
|
|
222
|
+
const log = getGateErrors(ctx.settings);
|
|
223
|
+
log.push(message);
|
|
224
|
+
for (const m of log) {
|
|
225
|
+
writeErr(`${prefix}${m}\n`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
writeErr(`${prefix}${message}\n`);
|
|
230
|
+
}
|
|
231
|
+
return { status: 'error', errorWritten: true };
|
|
232
|
+
};
|
|
233
|
+
/**
|
|
234
|
+
* Set of psql variables upstream marks as "specially treated" — i.e. names
|
|
235
|
+
* that have a substitute / assign hook installed in `startup.c`'s
|
|
236
|
+
* `EstablishVariableSpace`. Used by `\gset` to reject assignments into
|
|
237
|
+
* those names (matching upstream `StoreQueryTuple`'s `VariableHasHook`
|
|
238
|
+
* check). We mirror the upstream list directly so a `\gset IGNORE` into
|
|
239
|
+
* `IGNOREEOF` produces the conformance-expected warning even though our
|
|
240
|
+
* settings.ts hasn't installed the IGNOREEOF / HISTFILE hooks yet — that
|
|
241
|
+
* gap is tracked separately and harmless because the values are read-only
|
|
242
|
+
* for us.
|
|
243
|
+
*/
|
|
244
|
+
const UPSTREAM_SPECIAL_VAR_NAMES = new Set([
|
|
245
|
+
'AUTOCOMMIT',
|
|
246
|
+
'COMP_KEYWORD_CASE',
|
|
247
|
+
'ECHO',
|
|
248
|
+
'ECHO_HIDDEN',
|
|
249
|
+
'FETCH_COUNT',
|
|
250
|
+
'HIDE_TABLEAM',
|
|
251
|
+
'HIDE_TOAST_COMPRESSION',
|
|
252
|
+
'HISTCONTROL',
|
|
253
|
+
'HISTFILE',
|
|
254
|
+
'HISTSIZE',
|
|
255
|
+
'IGNOREEOF',
|
|
256
|
+
'ON_ERROR_ROLLBACK',
|
|
257
|
+
'ON_ERROR_STOP',
|
|
258
|
+
'PROMPT1',
|
|
259
|
+
'PROMPT2',
|
|
260
|
+
'PROMPT3',
|
|
261
|
+
'QUIET',
|
|
262
|
+
'SHOW_ALL_RESULTS',
|
|
263
|
+
'SHOW_CONTEXT',
|
|
264
|
+
'SINGLELINE',
|
|
265
|
+
'SINGLESTEP',
|
|
266
|
+
'VERBOSITY',
|
|
267
|
+
]);
|
|
268
|
+
/**
|
|
269
|
+
* True when `name` is a psql variable that `\gset` must skip with an
|
|
270
|
+
* "attempt to \gset into specially treated variable" message. Combines the
|
|
271
|
+
* registered-hook check (so future hook installations are automatically
|
|
272
|
+
* covered) with the upstream-canonical list above (so cases like
|
|
273
|
+
* IGNOREEOF that aren't hooked in our settings.ts still match upstream's
|
|
274
|
+
* `\gset` behaviour exactly).
|
|
275
|
+
*/
|
|
276
|
+
const isSpeciallyTreatedVar = (settings, name) => settings.vars.hasSubstituteHook(name) || UPSTREAM_SPECIAL_VAR_NAMES.has(name);
|
|
277
|
+
// `stripLeadingCommentsAndWS` lives in core/common.ts so the wire path
|
|
278
|
+
// (sendQuery / executeAndPrint) and the slash-command paths share one
|
|
279
|
+
// implementation. Re-imported from there at the top of the file.
|
|
280
|
+
/**
|
|
281
|
+
* Strip line and block comments from `sql` so a COPY-shaped token inside a
|
|
282
|
+
* comment (e.g. dash-dash `COPY t TO STDOUT`) doesn't trigger the
|
|
283
|
+
* `\g FILE` mid-batch sink. Mirrors the cheap normaliser the mainloop uses
|
|
284
|
+
* before wiring `copyOutMidBatchSink`. Embedded literals are NOT stripped —
|
|
285
|
+
* `'COPY x TO STDOUT'` would still match `hasCopyToStdout`, but that's the
|
|
286
|
+
* same false-positive shape upstream tolerates (the regex sweep is
|
|
287
|
+
* intentionally conservative, and the worst-case outcome is "route bytes
|
|
288
|
+
* that never arrive to the file" — harmless).
|
|
289
|
+
*/
|
|
290
|
+
const stripSqlCommentsForCopyScan = (sql) => sql.replace(/\/\*[\s\S]*?\*\//gu, '').replace(/--[^\n]*/gu, '');
|
|
291
|
+
/**
|
|
292
|
+
* True when `sql` contains at least one `COPY ... TO STDOUT` segment.
|
|
293
|
+
* Used by `runGCore` to install a CopyData sink while `\g` / `\gx` /
|
|
294
|
+
* `\g FILE` / `\g |cmd` dispatches a `\;`-chained batch that mixes
|
|
295
|
+
* COPY-OUT with regular SELECT statements. Without the sink the wire
|
|
296
|
+
* layer drops the CopyData bytes on the floor, and the file/pipe ends
|
|
297
|
+
* up with the surrounding tuple results only — see regress/psql lines
|
|
298
|
+
* 5760-5787 (`COPY (SELECT 'foo') TO STDOUT \; COPY (SELECT 'bar') TO
|
|
299
|
+
* STDOUT \g :g_out_file`).
|
|
300
|
+
*/
|
|
301
|
+
const hasCopyToStdout = (sql) => /\bCOPY\b[\s\S]*?\bTO\s+STDOUT\b/iu.test(stripSqlCommentsForCopyScan(sql));
|
|
302
|
+
/**
|
|
303
|
+
* Render a server-side error in upstream psql's 3-line shape (severity +
|
|
304
|
+
* message, then `LINE N:` / `^` re-print) and refresh the `:LAST_ERROR_*`
|
|
305
|
+
* diagnostic variables so a subsequent `\errverbose` sees the rich payload.
|
|
306
|
+
*
|
|
307
|
+
* Mirrors the path that `core/common.ts::writeQueryError` takes for top-level
|
|
308
|
+
* statement errors: capture full ErrorResponse fields onto
|
|
309
|
+
* `settings.lastErrorResult`, render via `formatErrorReport` (honouring
|
|
310
|
+
* VERBOSITY + SHOW_CONTEXT), prefix only the leading severity line with
|
|
311
|
+
* `psql:[<file>:<n>]:`, and update the per-statement diagnostic vars via
|
|
312
|
+
* `refreshErrorVars`.
|
|
313
|
+
*
|
|
314
|
+
* Used by `\g`, `\gx`, `\gset`, `\gdesc`, and `\gexec` so a server-rejected
|
|
315
|
+
* statement dispatched through them renders the same shape vanilla psql
|
|
316
|
+
* produces, instead of the legacy `\<cmd>: <message>` one-liner.
|
|
317
|
+
*/
|
|
318
|
+
const formatServerError = (ctx, err, sql) => {
|
|
319
|
+
// Stash full ErrorResponse payload so `\errverbose` can re-render later.
|
|
320
|
+
const msg = captureLastError(ctx.settings, err, sql);
|
|
321
|
+
const e = ctx.settings.lastErrorResult;
|
|
322
|
+
if (e) {
|
|
323
|
+
const lines = formatErrorReport(e, ctx.settings.verbosity, ctx.settings.showContext);
|
|
324
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
325
|
+
const prefixed = [prefix + lines[0], ...lines.slice(1)];
|
|
326
|
+
writeErr(prefixed.join('\n') + '\n');
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
// Defensive fallback — captureLastError always sets lastErrorResult,
|
|
330
|
+
// but if a future caller bypasses it, surface at least the message.
|
|
331
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
332
|
+
writeErr(`${prefix}ERROR: ${msg}\n`);
|
|
333
|
+
}
|
|
334
|
+
// Refresh `:SQLSTATE`, `:ERROR`, `:LAST_ERROR_*`, `:ROW_COUNT` so the
|
|
335
|
+
// following `\echo :LAST_ERROR_MESSAGE` and `\errverbose` see the new
|
|
336
|
+
// outcome. Matches upstream's `SetErrorVariables` call after every
|
|
337
|
+
// failed dispatch.
|
|
338
|
+
refreshErrorVars(ctx.settings, { kind: 'error' });
|
|
339
|
+
return { status: 'error', errorWritten: true };
|
|
340
|
+
};
|
|
341
|
+
/**
|
|
342
|
+
* Open a writable destination for `\o` / `\w` / `\g FILE` / `\g |cmd`.
|
|
343
|
+
*
|
|
344
|
+
* `target` of the form `|cmd` spawns `sh -c cmd` and pipes to its stdin.
|
|
345
|
+
* The returned closer waits for the child to exit and resolves to its
|
|
346
|
+
* status + terminating signal (if any) so callers can render
|
|
347
|
+
* `wait_result_to_str`-style errors. Any other string is treated as a
|
|
348
|
+
* file path; the file is truncated.
|
|
349
|
+
*/
|
|
350
|
+
const openWriter = (target) => {
|
|
351
|
+
if (target.startsWith('|')) {
|
|
352
|
+
const cmd = target.slice(1);
|
|
353
|
+
const child = spawn('sh', ['-c', cmd], {
|
|
354
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
355
|
+
});
|
|
356
|
+
// Swallow EPIPE on the stdin pipe — the child may exit before we
|
|
357
|
+
// finish writing, and Node would otherwise raise an unhandled error.
|
|
358
|
+
child.stdin.on('error', (err) => {
|
|
359
|
+
if (err.code !== 'EPIPE') {
|
|
360
|
+
// Re-raise non-EPIPE errors as a crash so they show up; tests
|
|
361
|
+
// run with the default unhandledRejection handler and will see
|
|
362
|
+
// these via the failing assertion.
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
return {
|
|
367
|
+
stream: child.stdin,
|
|
368
|
+
isPipe: true,
|
|
369
|
+
close: () => new Promise((resolve) => {
|
|
370
|
+
let settled = false;
|
|
371
|
+
const finish = (code, signal) => {
|
|
372
|
+
if (settled)
|
|
373
|
+
return;
|
|
374
|
+
settled = true;
|
|
375
|
+
resolve({ exitCode: code, signal });
|
|
376
|
+
};
|
|
377
|
+
child.once('close', (code, signal) => {
|
|
378
|
+
finish(code, signal);
|
|
379
|
+
});
|
|
380
|
+
child.once('error', () => {
|
|
381
|
+
// spawn failure or stdio glitch — treat as a non-zero exit so
|
|
382
|
+
// \w sees a failure. \g intentionally ignores this, mirroring
|
|
383
|
+
// upstream `CloseGOutput` which only sets SHELL_ERROR /
|
|
384
|
+
// SHELL_EXIT_CODE.
|
|
385
|
+
finish(127, null);
|
|
386
|
+
});
|
|
387
|
+
// Half-close stdin so the child sees EOF and exits.
|
|
388
|
+
if (!child.stdin.destroyed) {
|
|
389
|
+
child.stdin.end();
|
|
390
|
+
}
|
|
391
|
+
}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Open the file synchronously up-front so a bad path (ENOENT,
|
|
395
|
+
// EACCES, EISDIR, …) throws here — before any write — instead of
|
|
396
|
+
// emitting an asynchronous `'error'` event on the lazily-opened
|
|
397
|
+
// WriteStream that Node would then re-raise as an unhandled
|
|
398
|
+
// exception and kill the process. Upstream psql calls `fopen()`
|
|
399
|
+
// synchronously and reports the failure via `pg_log_error` while
|
|
400
|
+
// continuing to read the next command, which is the behaviour we
|
|
401
|
+
// need to mirror for `\g FILE`, `\o FILE`, `\w FILE` and friends.
|
|
402
|
+
//
|
|
403
|
+
// Wrapping the resulting fd in `createWriteStream({ fd })` retains
|
|
404
|
+
// the streaming write interface the rest of the code expects. Disable
|
|
405
|
+
// `autoClose` so we control the close order — we fsync before close so
|
|
406
|
+
// a follow-on server-side `COPY FROM` (Docker bind-mount on macOS) sees
|
|
407
|
+
// the fully flushed file even when the next command immediately follows
|
|
408
|
+
// the `\g`.
|
|
409
|
+
const fd = openSync(target, 'w');
|
|
410
|
+
const stream = createWriteStream(target, {
|
|
411
|
+
encoding: 'utf8',
|
|
412
|
+
fd,
|
|
413
|
+
autoClose: false,
|
|
414
|
+
});
|
|
415
|
+
// openSync catches OPEN failures synchronously, but a WRITE-time failure
|
|
416
|
+
// (ENOSPC / EDQUOT after a clean open, e.g. a multi-MB result to a
|
|
417
|
+
// quota-limited fs) emits an asynchronous 'error'. Without a listener Node
|
|
418
|
+
// re-raises it as an uncaught exception and kills the whole neonctl process.
|
|
419
|
+
// Capture it; close() surfaces it to the caller.
|
|
420
|
+
let writeError = null;
|
|
421
|
+
stream.on('error', (err) => {
|
|
422
|
+
writeError = writeError ?? err;
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
stream,
|
|
426
|
+
isPipe: false,
|
|
427
|
+
close: () => new Promise((resolve, reject) => {
|
|
428
|
+
if (writeError !== null) {
|
|
429
|
+
try {
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// swallow — the write error takes precedence
|
|
434
|
+
}
|
|
435
|
+
reject(writeError);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// `stream.end(cb)` fires after the internal buffer drains to the
|
|
439
|
+
// underlying fd. Once that returns, the fd still holds dirty data
|
|
440
|
+
// in the kernel buffer cache; on macOS + Docker bind mounts the
|
|
441
|
+
// server inside the container can read the file before the cache
|
|
442
|
+
// flushes through to the bind mount, returning a partial view.
|
|
443
|
+
// Force an fsync against the open fd before closing so the
|
|
444
|
+
// bytes are guaranteed visible to subsequent reads — including
|
|
445
|
+
// server-side `COPY FROM` reading via the mount.
|
|
446
|
+
stream.end((err) => {
|
|
447
|
+
if (err) {
|
|
448
|
+
try {
|
|
449
|
+
closeSync(fd);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// swallow — the original error takes precedence
|
|
453
|
+
}
|
|
454
|
+
reject(err);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
fsyncSync(fd);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// ignore — fsync best-effort; the close below still cleans up.
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
closeSync(fd);
|
|
465
|
+
}
|
|
466
|
+
catch (closeErr) {
|
|
467
|
+
reject(closeErr);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// Docker Desktop on macOS uses virtiofs/gRPC-FUSE for bind
|
|
471
|
+
// mounts; cache propagation from host writes to the container's
|
|
472
|
+
// view is eventual, not synchronous. A subsequent server-side
|
|
473
|
+
// `COPY FROM '/bind/mount/file'` can read a partial view even
|
|
474
|
+
// though the file is fully synced on the host. Linux + Windows
|
|
475
|
+
// bind mounts are coherent, so this branch is macOS-only.
|
|
476
|
+
if (platform() === 'darwin') {
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
resolve({});
|
|
479
|
+
}, 25);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
resolve({});
|
|
483
|
+
});
|
|
484
|
+
}),
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Map a Node.js errno (`err.code`) to the libc `strerror()` string
|
|
489
|
+
* upstream psql renders in its `pg_log_error("%s: %m", fname)` path.
|
|
490
|
+
*
|
|
491
|
+
* Falls back to `err.message` (with the verbose `ENOENT: ...` prefix
|
|
492
|
+
* stripped if present) so unmapped errno values still surface
|
|
493
|
+
* meaningful text instead of a cryptic Node-internal phrasing.
|
|
494
|
+
*/
|
|
495
|
+
const errnoToStrerror = (err) => {
|
|
496
|
+
switch (err.code) {
|
|
497
|
+
case 'ENOENT':
|
|
498
|
+
return 'No such file or directory';
|
|
499
|
+
case 'EACCES':
|
|
500
|
+
return 'Permission denied';
|
|
501
|
+
case 'EISDIR':
|
|
502
|
+
return 'Is a directory';
|
|
503
|
+
case 'ENOTDIR':
|
|
504
|
+
return 'Not a directory';
|
|
505
|
+
case 'EEXIST':
|
|
506
|
+
return 'File exists';
|
|
507
|
+
case 'EROFS':
|
|
508
|
+
return 'Read-only file system';
|
|
509
|
+
case 'ELOOP':
|
|
510
|
+
return 'Too many levels of symbolic links';
|
|
511
|
+
case 'ENAMETOOLONG':
|
|
512
|
+
return 'File name too long';
|
|
513
|
+
case 'ENOSPC':
|
|
514
|
+
return 'No space left on device';
|
|
515
|
+
case 'EMFILE':
|
|
516
|
+
return 'Too many open files';
|
|
517
|
+
case 'ENFILE':
|
|
518
|
+
return 'Too many open files in system';
|
|
519
|
+
case 'EIO':
|
|
520
|
+
return 'Input/output error';
|
|
521
|
+
case 'EFBIG':
|
|
522
|
+
return 'File too large';
|
|
523
|
+
case 'EDQUOT':
|
|
524
|
+
return 'Disk quota exceeded';
|
|
525
|
+
case 'EPERM':
|
|
526
|
+
return 'Operation not permitted';
|
|
527
|
+
case 'EINVAL':
|
|
528
|
+
return 'Invalid argument';
|
|
529
|
+
default: {
|
|
530
|
+
// Strip Node's `ENOENT: no such file or directory, open '/x'`
|
|
531
|
+
// prefix when present so the fallback at least looks like the
|
|
532
|
+
// libc form. The leading `/, ` slice keeps the human-readable
|
|
533
|
+
// phrase ("no such file or directory") if Node's message
|
|
534
|
+
// mirrors the `strerror` text but lowercases it.
|
|
535
|
+
const m = /^[A-Z]+: ([^,]+)/.exec(err.message);
|
|
536
|
+
return m ? m[1] : err.message;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
/**
|
|
541
|
+
* Emit a file-open failure for `\g FILE`, `\o FILE`, `\w FILE` in the
|
|
542
|
+
* exact shape vanilla psql produces: a bare `<path>: <strerror>` line
|
|
543
|
+
* on stderr, no `\<cmd>:` prefix (matches `pg_log_error` under terse
|
|
544
|
+
* mode, which is what `psql -X` uses).
|
|
545
|
+
*
|
|
546
|
+
* The leading `psql:[<file>:<n>]:` tag is still applied when we're
|
|
547
|
+
* reading SQL from a `\i FILE` include — `psqlErrorPrefix` returns ''
|
|
548
|
+
* for stdin so the line stays bare for the interactive / harness case.
|
|
549
|
+
*
|
|
550
|
+
* Returns an `error` envelope with `errorWritten: true` so the mainloop
|
|
551
|
+
* doesn't write a duplicate `psql: ERROR:` fallback.
|
|
552
|
+
*/
|
|
553
|
+
const reportFileOpenFailure = (ctx, target, err) => {
|
|
554
|
+
const errno = err;
|
|
555
|
+
const phrase = errnoToStrerror(errno);
|
|
556
|
+
const line = `${target}: ${phrase}`;
|
|
557
|
+
ctx.settings.lastErrorResult = { message: line };
|
|
558
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
559
|
+
writeErr(`${prefix}${line}\n`);
|
|
560
|
+
return { status: 'error', errorWritten: true };
|
|
561
|
+
};
|
|
562
|
+
/**
|
|
563
|
+
* True when `err` was thrown by our synchronous `openSync` in
|
|
564
|
+
* {@link openWriter} (i.e. has an errno `code`) and the caller should
|
|
565
|
+
* render it via {@link reportFileOpenFailure} rather than the generic
|
|
566
|
+
* `\<cmd>: <msg>` path.
|
|
567
|
+
*/
|
|
568
|
+
const isFileOpenFailure = (err) => {
|
|
569
|
+
if (!err || typeof err !== 'object')
|
|
570
|
+
return false;
|
|
571
|
+
const e = err;
|
|
572
|
+
return typeof e.code === 'string' && e.code.startsWith('E');
|
|
573
|
+
};
|
|
574
|
+
/**
|
|
575
|
+
* Format a child process exit code + signal into upstream psql's
|
|
576
|
+
* `wait_result_to_str` style. Mirrors the C helper in
|
|
577
|
+
* `src/common/wait_error.c`:
|
|
578
|
+
*
|
|
579
|
+
* - exit code 127 → `command not found`
|
|
580
|
+
* - exit code 126 → `command was not executable`
|
|
581
|
+
* - any other code → `child process exited with exit code N`
|
|
582
|
+
* - terminated by signal S → `child process was terminated by
|
|
583
|
+
* signal N: <SIG>`
|
|
584
|
+
*
|
|
585
|
+
* Returns null when the child exited cleanly (code 0, no signal).
|
|
586
|
+
*/
|
|
587
|
+
const formatChildWaitResult = (exitCode, signal) => {
|
|
588
|
+
if (signal) {
|
|
589
|
+
// Node doesn't expose the numeric signal number; surface the name as
|
|
590
|
+
// upstream's `pg_strsignal` would, with a stable prefix.
|
|
591
|
+
return `child process was terminated by signal: ${signal}`;
|
|
592
|
+
}
|
|
593
|
+
if (exitCode === null || exitCode === undefined)
|
|
594
|
+
return null;
|
|
595
|
+
if (exitCode === 0)
|
|
596
|
+
return null;
|
|
597
|
+
if (exitCode === 127)
|
|
598
|
+
return 'command not found';
|
|
599
|
+
if (exitCode === 126)
|
|
600
|
+
return 'command was not executable';
|
|
601
|
+
return `child process exited with exit code ${String(exitCode)}`;
|
|
602
|
+
};
|
|
603
|
+
/**
|
|
604
|
+
* Compose the `CommandComplete`-tag line upstream prints for non-tuples-
|
|
605
|
+
* producing results (DDL, DML without RETURNING, COPY). Mirrors
|
|
606
|
+
* `formatCommandTag` in `core/common.ts` — duplicated to avoid the
|
|
607
|
+
* cmd_io → common import cycle. Returns an empty string when no tag is
|
|
608
|
+
* available (e.g. `EmptyQueryResponse` carries `command = ''`).
|
|
609
|
+
*/
|
|
610
|
+
const formatCommandTagText = (rs) => {
|
|
611
|
+
const command = (rs.command || '').trim();
|
|
612
|
+
if (command.length === 0)
|
|
613
|
+
return '';
|
|
614
|
+
if (command === 'INSERT') {
|
|
615
|
+
// INSERT is the only tag with the legacy oid in front of rowCount.
|
|
616
|
+
return `INSERT ${String(rs.oid ?? 0)} ${String(rs.rowCount ?? 0)}`;
|
|
617
|
+
}
|
|
618
|
+
if (rs.rowCount !== null && rs.rowCount !== undefined) {
|
|
619
|
+
return `${command} ${String(rs.rowCount)}`;
|
|
620
|
+
}
|
|
621
|
+
return command;
|
|
622
|
+
};
|
|
623
|
+
/**
|
|
624
|
+
* Render a `ResultSet` to the supplied writable stream using the printer
|
|
625
|
+
* picked from the active `\pset format`. Upstream's `do_watch`, `do_gset`,
|
|
626
|
+
* `do_gexec`, and `\i` all funnel results through the standard query
|
|
627
|
+
* output pipeline (`ExecQueryAndProcessResults` → `printQuery`), which
|
|
628
|
+
* honours `\pset format`. Hard-coding the aligned printer here breaks the
|
|
629
|
+
* conformance harness's `psql -A` runs (which expect unaligned tuples-only
|
|
630
|
+
* output for things like `\watch` polled rows).
|
|
631
|
+
*
|
|
632
|
+
* Non-tuples-producing results (CommandComplete with `fields.length === 0`,
|
|
633
|
+
* which covers DDL, DML without RETURNING, and the post-CopyDone tag for
|
|
634
|
+
* `COPY ... TO STDOUT`) are rendered as a single status line instead of
|
|
635
|
+
* the printer's `(0 rows)` empty-table block — matching `renderResultSets`
|
|
636
|
+
* in `core/common.ts`. Tuples-only (`\t`) and quiet (`--quiet`) both
|
|
637
|
+
* suppress the tag entirely.
|
|
638
|
+
*/
|
|
639
|
+
const renderResult = async (settings, rs, out) => {
|
|
640
|
+
// `COPY ... TO STDOUT` segment — emit the accumulated CopyData payloads
|
|
641
|
+
// in arrival order at this result's position in the `\;`-chain.
|
|
642
|
+
if (rs.copyOutBytes && rs.copyOutBytes.length > 0) {
|
|
643
|
+
for (const chunk of rs.copyOutBytes) {
|
|
644
|
+
out.write(chunk);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (rs.fields.length === 0) {
|
|
648
|
+
// Mirrors `renderResultSets`'s zero-fields branch: emit the tag (e.g.
|
|
649
|
+
// `COPY 1`) unless quiet / tuples-only suppresses it. Without this
|
|
650
|
+
// branch the aligned printer renders an empty header + `(0 rows)`
|
|
651
|
+
// footer for the COPY-TO-STDOUT command complete, which doesn't
|
|
652
|
+
// match upstream (where the data already streamed via the COPY-OUT
|
|
653
|
+
// sink and the tag goes to the user's status stream, not the
|
|
654
|
+
// queryFout). The regress fixture sets QUIET=true before the
|
|
655
|
+
// COPY-OUT `\g` shape so the tag stays out of the file under test.
|
|
656
|
+
// For COPY-out results, the tag is suppressed regardless — the bytes
|
|
657
|
+
// already flowed; upstream's `handleCopyOut` doesn't emit `COPY N`
|
|
658
|
+
// on the queryFout.
|
|
659
|
+
if (!settings.popt.topt.tuplesOnly && !settings.quiet && !rs.copyOutBytes) {
|
|
660
|
+
const tag = formatCommandTagText(rs);
|
|
661
|
+
if (tag.length > 0)
|
|
662
|
+
out.write(`${tag}\n`);
|
|
663
|
+
}
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
await pickActivePrinter(settings).printQuery(rs, settings.popt, out);
|
|
667
|
+
};
|
|
668
|
+
/**
|
|
669
|
+
* Pick the printer for the active output format. Mirrors `pickPrinter`
|
|
670
|
+
* in `core/common.ts` — duplicated here to avoid the cmd_io → common
|
|
671
|
+
* import cycle (common.ts depends on this file for `getQueryFout`).
|
|
672
|
+
*
|
|
673
|
+
* `wrapped` falls back to the aligned printer (which renders `wrapped`
|
|
674
|
+
* mode itself via `topt.format`).
|
|
675
|
+
*/
|
|
676
|
+
const pickActivePrinter = (settings) => {
|
|
677
|
+
switch (settings.popt.topt.format) {
|
|
678
|
+
case 'aligned':
|
|
679
|
+
case 'wrapped':
|
|
680
|
+
return alignedPrinter;
|
|
681
|
+
case 'unaligned':
|
|
682
|
+
return unalignedPrinter;
|
|
683
|
+
case 'csv':
|
|
684
|
+
return csvPrinter;
|
|
685
|
+
case 'json':
|
|
686
|
+
return jsonPrinter;
|
|
687
|
+
case 'html':
|
|
688
|
+
return htmlPrinter;
|
|
689
|
+
case 'asciidoc':
|
|
690
|
+
return asciidocPrinter;
|
|
691
|
+
case 'latex':
|
|
692
|
+
return latexPrinter;
|
|
693
|
+
case 'latex-longtable':
|
|
694
|
+
return latexLongtablePrinter;
|
|
695
|
+
case 'troff-ms':
|
|
696
|
+
return troffMsPrinter;
|
|
697
|
+
default:
|
|
698
|
+
return alignedPrinter;
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
/**
|
|
702
|
+
* Pick the output target for a query result.
|
|
703
|
+
*
|
|
704
|
+
* Precedence: explicit `oneShot` (e.g. `\g FILE`) > the settings stash
|
|
705
|
+
* (`\o FILE`) > `process.stdout`.
|
|
706
|
+
*/
|
|
707
|
+
const pickOut = (settings, oneShot) => {
|
|
708
|
+
if (oneShot)
|
|
709
|
+
return oneShot;
|
|
710
|
+
return getQueryFout(settings) ?? process.stdout;
|
|
711
|
+
};
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// \i FILE / \include FILE
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
const runInclude = async (ctx, relative) => {
|
|
716
|
+
const arg = ctx.nextArg('normal');
|
|
717
|
+
if (arg === null || arg.length === 0) {
|
|
718
|
+
return errResult(ctx, 'missing required argument');
|
|
719
|
+
}
|
|
720
|
+
// Resolve path: \ir resolves relative to the current input file's
|
|
721
|
+
// directory (if any); \i resolves relative to cwd unless absolute.
|
|
722
|
+
let resolved;
|
|
723
|
+
if (path.isAbsolute(arg)) {
|
|
724
|
+
resolved = arg;
|
|
725
|
+
}
|
|
726
|
+
else if (relative && ctx.settings.inputfile) {
|
|
727
|
+
resolved = path.resolve(path.dirname(ctx.settings.inputfile), arg);
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
resolved = path.resolve(process.cwd(), arg);
|
|
731
|
+
}
|
|
732
|
+
let contents;
|
|
733
|
+
try {
|
|
734
|
+
contents = await fsPromises.readFile(resolved, 'utf8');
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
738
|
+
return errResult(ctx, msg);
|
|
739
|
+
}
|
|
740
|
+
// Execute the included file's SQL directly here. This is the single
|
|
741
|
+
// execution path for BOTH the interactive REPL and the non-interactive
|
|
742
|
+
// -c/-f/stdin path: the latter (`executeInputString`) does not drain the
|
|
743
|
+
// `\i` input queue, so an `enqueueInput()` here would (a) never run under
|
|
744
|
+
// -f/-c and (b) double-run interactively (the mainloop drains the queue
|
|
745
|
+
// AND we run execSimple). See.
|
|
746
|
+
if (!ctx.settings.db) {
|
|
747
|
+
return errResult(ctx, 'no connection to the server');
|
|
748
|
+
}
|
|
749
|
+
const trimmed = contents.trim();
|
|
750
|
+
if (trimmed.length === 0) {
|
|
751
|
+
return { status: 'ok' };
|
|
752
|
+
}
|
|
753
|
+
// Track the prior inputfile so `\ir` chains relative to the included
|
|
754
|
+
// file's directory.
|
|
755
|
+
const priorInputFile = ctx.settings.inputfile;
|
|
756
|
+
ctx.settings.inputfile = resolved;
|
|
757
|
+
try {
|
|
758
|
+
const results = await ctx.settings.db.execSimple(trimmed);
|
|
759
|
+
const out = pickOut(ctx.settings, null);
|
|
760
|
+
for (const rs of results) {
|
|
761
|
+
await renderResult(ctx.settings, rs, out);
|
|
762
|
+
}
|
|
763
|
+
return { status: 'ok' };
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
767
|
+
return errResult(ctx, msg);
|
|
768
|
+
}
|
|
769
|
+
finally {
|
|
770
|
+
ctx.settings.inputfile = priorInputFile;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
export const cmdInclude = {
|
|
774
|
+
name: 'i',
|
|
775
|
+
aliases: ['include'],
|
|
776
|
+
helpKey: 'i',
|
|
777
|
+
run: (ctx) => runInclude(ctx, false),
|
|
778
|
+
};
|
|
779
|
+
export const cmdIncludeRel = {
|
|
780
|
+
name: 'ir',
|
|
781
|
+
aliases: ['include_relative'],
|
|
782
|
+
helpKey: 'ir',
|
|
783
|
+
run: (ctx) => runInclude(ctx, true),
|
|
784
|
+
};
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// \o [FILE|cmd] / \out
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
export const cmdOut = {
|
|
789
|
+
name: 'o',
|
|
790
|
+
aliases: ['out'],
|
|
791
|
+
helpKey: 'o',
|
|
792
|
+
async run(ctx) {
|
|
793
|
+
const arg = ctx.nextArg('filepipe');
|
|
794
|
+
// Drain any previous target first so writes flush before we rebind.
|
|
795
|
+
await closeQueryFout(ctx.settings);
|
|
796
|
+
if (arg === null || arg.length === 0) {
|
|
797
|
+
// Restore default (stdout).
|
|
798
|
+
return { status: 'ok' };
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const entry = openWriter(arg);
|
|
802
|
+
setQueryFout(ctx.settings, entry);
|
|
803
|
+
return { status: 'ok' };
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
// File targets fail synchronously in `openWriter` via `openSync`;
|
|
807
|
+
// surface them in upstream's `<path>: <strerror>` shape (bare,
|
|
808
|
+
// no `\o:` prefix) and continue with the loop so a follow-up
|
|
809
|
+
// `SELECT` still executes. Pipe spawn failures (which lack an
|
|
810
|
+
// errno code) fall through to the generic `\o: <msg>` path.
|
|
811
|
+
if (!arg.startsWith('|') && isFileOpenFailure(err)) {
|
|
812
|
+
return reportFileOpenFailure(ctx, arg, err);
|
|
813
|
+
}
|
|
814
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
815
|
+
return errResult(ctx, msg);
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
// \w FILE / \write FILE
|
|
821
|
+
// ---------------------------------------------------------------------------
|
|
822
|
+
export const cmdWrite = {
|
|
823
|
+
name: 'w',
|
|
824
|
+
aliases: ['write'],
|
|
825
|
+
helpKey: 'w',
|
|
826
|
+
async run(ctx) {
|
|
827
|
+
const arg = ctx.nextArg('filepipe');
|
|
828
|
+
if (arg === null || arg.length === 0) {
|
|
829
|
+
return errResult(ctx, 'missing required argument');
|
|
830
|
+
}
|
|
831
|
+
let entry;
|
|
832
|
+
try {
|
|
833
|
+
entry = openWriter(arg);
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
// Same upstream-shape pivot as `\o`: a missing / unwritable file
|
|
837
|
+
// path errors out as a bare `<path>: <strerror>` line and the
|
|
838
|
+
// shim keeps reading commands. Pipe spawn failures still use
|
|
839
|
+
// the generic `\w: <msg>` envelope.
|
|
840
|
+
if (!arg.startsWith('|') && isFileOpenFailure(err)) {
|
|
841
|
+
return reportFileOpenFailure(ctx, arg, err);
|
|
842
|
+
}
|
|
843
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
844
|
+
return errResult(ctx, msg);
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await new Promise((resolve, reject) => {
|
|
848
|
+
entry.stream.write(ctx.queryBuf, (err) => {
|
|
849
|
+
if (err)
|
|
850
|
+
reject(err);
|
|
851
|
+
else
|
|
852
|
+
resolve();
|
|
853
|
+
});
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
// On pipe targets a fast-exiting child (e.g. `| false` or a
|
|
858
|
+
// command-not-found shell exit) closes its stdin before we finish
|
|
859
|
+
// writing, surfacing as EPIPE. Linux fires this reliably; macOS
|
|
860
|
+
// sometimes races it past us. In either case the child's exit
|
|
861
|
+
// status is what we want to report, NOT the write error — so we
|
|
862
|
+
// swallow EPIPE on pipes and fall through to entry.close() which
|
|
863
|
+
// awaits the child and emits the upstream-shape wait_result_to_str.
|
|
864
|
+
const isEpipe = err instanceof Error && err.code === 'EPIPE';
|
|
865
|
+
if (!entry.isPipe || !isEpipe) {
|
|
866
|
+
try {
|
|
867
|
+
await entry.close();
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
// ignore
|
|
871
|
+
}
|
|
872
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
873
|
+
return errResult(ctx, msg);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// Wait for the target to drain. For pipe targets a non-zero exit /
|
|
877
|
+
// killing signal is surfaced as `<fname>: <wait_result_to_str>`,
|
|
878
|
+
// mirroring upstream `exec_command_write`:
|
|
879
|
+
//
|
|
880
|
+
// pg_log_error("%s: %s", fname, wait_result_to_str(result));
|
|
881
|
+
//
|
|
882
|
+
// Note that upstream's `fname` retains the leading `|`, and the
|
|
883
|
+
// message does NOT carry the `\w:` cmd-prefix that the other
|
|
884
|
+
// backslash-command errors use — `pg_log_error` writes the bare
|
|
885
|
+
// formatted message (under terse mode, which is the conformance
|
|
886
|
+
// harness setup). We bypass `errResult` to match that shape exactly.
|
|
887
|
+
try {
|
|
888
|
+
const result = await entry.close();
|
|
889
|
+
if (entry.isPipe) {
|
|
890
|
+
const msg = formatChildWaitResult(result.exitCode, result.signal);
|
|
891
|
+
if (msg !== null) {
|
|
892
|
+
// `arg` still has the leading `|`; emit it verbatim so the
|
|
893
|
+
// text reads `| program: child process exited with exit code 1`.
|
|
894
|
+
const line = `${arg}: ${msg}`;
|
|
895
|
+
ctx.settings.lastErrorResult = { message: line };
|
|
896
|
+
const prefix = psqlErrorPrefix(ctx.settings);
|
|
897
|
+
writeErr(`${prefix}${line}\n`);
|
|
898
|
+
return { status: 'error', errorWritten: true };
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return { status: 'ok' };
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
905
|
+
return errResult(ctx, msg);
|
|
906
|
+
}
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
// \g, \gx — execute the query buffer with optional one-shot redirect.
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
/**
|
|
913
|
+
* Parse the body of a `\g (option=value option2=value2 ...)` clause —
|
|
914
|
+
* the text between the outer parentheses, already stripped. Options
|
|
915
|
+
* are separated by whitespace; values may be single-quoted to embed
|
|
916
|
+
* spaces. Unquoted values run to the next whitespace.
|
|
917
|
+
*
|
|
918
|
+
* Mirrors upstream's `parse_slash_pgopts_list`. We deliberately stay
|
|
919
|
+
* narrow — the conformance corpus exercises `format=`, `csv_fieldsep=`,
|
|
920
|
+
* and `title=` only.
|
|
921
|
+
*/
|
|
922
|
+
const parseGPsetOptions = (body) => {
|
|
923
|
+
const out = [];
|
|
924
|
+
let i = 0;
|
|
925
|
+
while (i < body.length) {
|
|
926
|
+
// Skip whitespace between pairs.
|
|
927
|
+
while (i < body.length && /\s/.test(body[i]))
|
|
928
|
+
i++;
|
|
929
|
+
if (i >= body.length)
|
|
930
|
+
break;
|
|
931
|
+
// Read option name up to `=`.
|
|
932
|
+
const optStart = i;
|
|
933
|
+
while (i < body.length && body[i] !== '=' && !/\s/.test(body[i]))
|
|
934
|
+
i++;
|
|
935
|
+
const option = body.slice(optStart, i);
|
|
936
|
+
if (option.length === 0)
|
|
937
|
+
break;
|
|
938
|
+
let value = '';
|
|
939
|
+
if (body[i] === '=') {
|
|
940
|
+
i++; // skip '='
|
|
941
|
+
// Value: single-quoted or unquoted.
|
|
942
|
+
if (body[i] === "'") {
|
|
943
|
+
i++;
|
|
944
|
+
while (i < body.length && body[i] !== "'") {
|
|
945
|
+
// Single-quoted strings support `''` doubling and a few
|
|
946
|
+
// C-style escapes (\n, \t, \\, \'). Mirror enough of the
|
|
947
|
+
// upstream `xslashquote` handling to round-trip the regress
|
|
948
|
+
// corpus.
|
|
949
|
+
if (body[i] === '\\' && i + 1 < body.length) {
|
|
950
|
+
const next = body[i + 1];
|
|
951
|
+
if (next === 'n')
|
|
952
|
+
value += '\n';
|
|
953
|
+
else if (next === 't')
|
|
954
|
+
value += '\t';
|
|
955
|
+
else if (next === 'r')
|
|
956
|
+
value += '\r';
|
|
957
|
+
else if (next === '\\')
|
|
958
|
+
value += '\\';
|
|
959
|
+
else if (next === "'")
|
|
960
|
+
value += "'";
|
|
961
|
+
else
|
|
962
|
+
value += next;
|
|
963
|
+
i += 2;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
value += body[i++];
|
|
967
|
+
}
|
|
968
|
+
if (body[i] === "'")
|
|
969
|
+
i++;
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
const vStart = i;
|
|
973
|
+
while (i < body.length && !/\s/.test(body[i]))
|
|
974
|
+
i++;
|
|
975
|
+
value = body.slice(vStart, i);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
out.push({ option, value });
|
|
979
|
+
}
|
|
980
|
+
return out;
|
|
981
|
+
};
|
|
982
|
+
const runGCore = async (ctx, forceExpanded) => {
|
|
983
|
+
const gated = pipelineGate(ctx);
|
|
984
|
+
if (gated !== null)
|
|
985
|
+
return gated;
|
|
986
|
+
// Strip leading whitespace + `--`/`/* */` comments so the SQL we hand to
|
|
987
|
+
// the wire (and use for `LINE N:` re-print on error) matches what vanilla
|
|
988
|
+
// psql sends through `PQexec`. Without the strip, queryBuf accumulated
|
|
989
|
+
// across `\bind` re-entries carries blank+comment lines from the gap
|
|
990
|
+
// between the previous `\g` and this one, and the server-relative
|
|
991
|
+
// position lands on `LINE 3` instead of `LINE 1`.
|
|
992
|
+
const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
|
|
993
|
+
const bufSql = trimmedBuf.trim();
|
|
994
|
+
let target;
|
|
995
|
+
let psetOverrides = null;
|
|
996
|
+
// `\g (option=value ...)` — temporary pset overrides for this query
|
|
997
|
+
// only. Upstream `exec_command_g` recognises a leading `(` and slurps
|
|
998
|
+
// the rest of the args until matching `)`. We can't call nextArg in
|
|
999
|
+
// two different modes against the BackslashContext (each mode has its
|
|
1000
|
+
// own cursor), so when the leading char is `(`, parse the entire raw
|
|
1001
|
+
// arg block ourselves; otherwise fall back to normal filepipe arg
|
|
1002
|
+
// extraction.
|
|
1003
|
+
const rawTrimmed = ctx.rawArgs.trimStart();
|
|
1004
|
+
if (rawTrimmed.startsWith('(')) {
|
|
1005
|
+
const close = rawTrimmed.indexOf(')');
|
|
1006
|
+
if (close === -1) {
|
|
1007
|
+
return errResult(ctx, 'missing right parenthesis in \\g options');
|
|
1008
|
+
}
|
|
1009
|
+
// Strip parens; parse `key=value` pairs (values may be single-
|
|
1010
|
+
// quoted). The conformance corpus exercises `format=`,
|
|
1011
|
+
// `csv_fieldsep=`, and `title=` only.
|
|
1012
|
+
psetOverrides = parseGPsetOptions(rawTrimmed.slice(1, close).trim());
|
|
1013
|
+
// Anything after the matching `)` is the output target — `\g (format=csv)
|
|
1014
|
+
// out.txt` writes to out.txt. Previously this was dropped, so the file/pipe
|
|
1015
|
+
// redirect was silently ignored whenever options were present.
|
|
1016
|
+
const afterParen = rawTrimmed.slice(close + 1).trim();
|
|
1017
|
+
target = afterParen.length > 0 ? afterParen : null;
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
target = ctx.nextArg('filepipe');
|
|
1021
|
+
}
|
|
1022
|
+
// `\g` / `\gx` with an empty buffer re-runs the most recently submitted
|
|
1023
|
+
// query — upstream tracks this in `pset.last_query` and `PSQLexec` reads
|
|
1024
|
+
// it when the active buffer is empty. We mirror via `settings.lastQuery`,
|
|
1025
|
+
// populated in `sendQuery` before dispatch. Preserve trailing whitespace
|
|
1026
|
+
// on the re-run so the server's `position` (and the `LINE N:` echo we
|
|
1027
|
+
// render on failure) match upstream byte-for-byte — vanilla passes the
|
|
1028
|
+
// un-trimmed `pset.last_query` straight to `PQexec`.
|
|
1029
|
+
const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery;
|
|
1030
|
+
// If a `\bind_named NAME` has staged a server-side prepared statement
|
|
1031
|
+
// lookup, we don't need any SQL text — the prepared statement carries
|
|
1032
|
+
// it server-side. Skip the empty-sql guard so the bind branch below
|
|
1033
|
+
// can do its thing.
|
|
1034
|
+
const hasPendingNamedBind = stagedNamedBindPresent(ctx.settings);
|
|
1035
|
+
if (sql.length === 0 && !hasPendingNamedBind) {
|
|
1036
|
+
// No buffered SQL, no prior query, no staged bind — silent no-op
|
|
1037
|
+
// like upstream.
|
|
1038
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1039
|
+
}
|
|
1040
|
+
if (!ctx.settings.db) {
|
|
1041
|
+
return errResult(ctx, 'no connection to the server');
|
|
1042
|
+
}
|
|
1043
|
+
// Open the one-shot writer if a target was supplied; close it on the way
|
|
1044
|
+
// out so the file/pipe is flushed before we return.
|
|
1045
|
+
let oneShot = null;
|
|
1046
|
+
if (target !== null && target.length > 0) {
|
|
1047
|
+
try {
|
|
1048
|
+
oneShot = openWriter(target);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
// A `\g FILE` whose path is unopenable (ENOENT, EACCES, EISDIR,
|
|
1052
|
+
// …) — typically because an unresolved `:VAR` substitution left
|
|
1053
|
+
// a literal `:VAR` in the path — must NOT crash the process the
|
|
1054
|
+
// way Node's lazy WriteStream `'error'` event would. Render in
|
|
1055
|
+
// upstream's bare `<path>: <strerror>` shape and continue so the
|
|
1056
|
+
// next command in the script still executes. Pipe spawn
|
|
1057
|
+
// failures retain the generic `\g: <msg>` envelope.
|
|
1058
|
+
if (!target.startsWith('|') && isFileOpenFailure(err)) {
|
|
1059
|
+
return reportFileOpenFailure(ctx, target, err);
|
|
1060
|
+
}
|
|
1061
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1062
|
+
return errResult(ctx, msg);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
const topt = ctx.settings.popt.topt;
|
|
1066
|
+
// Snapshot topt BEFORE any per-query mutation so the restore in
|
|
1067
|
+
// `finally` covers both `\gx`'s `expanded = 'on'` and any `\g (...)`
|
|
1068
|
+
// pset overrides in one shot. Snapshotting AFTER the `forceExpanded`
|
|
1069
|
+
// mutation would persist `expanded = 'on'` across queries.
|
|
1070
|
+
const toptSnapshot = { ...topt };
|
|
1071
|
+
if (forceExpanded)
|
|
1072
|
+
topt.expanded = 'on';
|
|
1073
|
+
// Apply per-query pset overrides silently. Upstream applies the
|
|
1074
|
+
// temporary options without emitting the status lines that
|
|
1075
|
+
// interactive `\pset` would.
|
|
1076
|
+
if (psetOverrides) {
|
|
1077
|
+
for (const { option, value } of psetOverrides) {
|
|
1078
|
+
applyPset(topt, option, value, ctx.cmdName, true);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Track for `\g` / `\gx` re-run with empty buffer. Upstream sets
|
|
1082
|
+
// `pset.last_query` in `PSQLexec` before dispatch.
|
|
1083
|
+
ctx.settings.lastQuery = sql;
|
|
1084
|
+
// Consume any pending `\bind` / `\bind_named` state. Upstream's
|
|
1085
|
+
// `\g` routes through the extended-query protocol when bind params
|
|
1086
|
+
// are set: anonymous `\bind` re-prepares from the buffer; named
|
|
1087
|
+
// `\bind_named NAME` looks up the server-side prepared statement
|
|
1088
|
+
// by NAME (set earlier via `\parse NAME`) and just runs Bind +
|
|
1089
|
+
// Execute against it.
|
|
1090
|
+
const bindState = consumeBindState(ctx.settings);
|
|
1091
|
+
let execError = null;
|
|
1092
|
+
// Track whether we wired the mid-batch COPY-OUT sink so the `finally`
|
|
1093
|
+
// can clear it deterministically — even if `execSimple` threw.
|
|
1094
|
+
let copyOutSinkConn = null;
|
|
1095
|
+
try {
|
|
1096
|
+
const out = pickOut(ctx.settings, oneShot?.stream ?? null);
|
|
1097
|
+
if (bindState?.byName) {
|
|
1098
|
+
// \bind_named NAME — execute the previously-prepared statement
|
|
1099
|
+
// identified by NAME. The cache was populated by `\parse NAME`.
|
|
1100
|
+
// The empty-string NAME is the upstream "unnamed" prepared
|
|
1101
|
+
// statement slot.
|
|
1102
|
+
const ps = lookupPrepared(ctx.settings, bindState.name);
|
|
1103
|
+
if (!ps) {
|
|
1104
|
+
// Synthesise a thrown-Error-like object so formatServerError can
|
|
1105
|
+
// render the same `ERROR: <msg>` shape vanilla emits for the
|
|
1106
|
+
// server's `prepared statement "X" does not exist` error.
|
|
1107
|
+
execError = Object.assign(new Error(`prepared statement "${bindState.name}" does not exist`), { severity: 'ERROR', code: '26000' });
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
// Bind + Execute MUST go in one extended-protocol batch: the
|
|
1111
|
+
// anonymous portal is implicitly closed at the next Sync, so a
|
|
1112
|
+
// separate ps.bind() then ps.execute() would lose the portal in
|
|
1113
|
+
// between. `bindAndExecute` issues both messages before the
|
|
1114
|
+
// Sync.
|
|
1115
|
+
const rs = await ps.bindAndExecute(bindState.values);
|
|
1116
|
+
await renderResult(ctx.settings, rs, out);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
else if (bindState) {
|
|
1120
|
+
// Anonymous \bind — re-prepare from the current buffer (or
|
|
1121
|
+
// lastQuery fallback) and execute with the supplied params.
|
|
1122
|
+
const rs = await ctx.settings.db.query(sql, bindState.values);
|
|
1123
|
+
await renderResult(ctx.settings, rs, out);
|
|
1124
|
+
}
|
|
1125
|
+
else {
|
|
1126
|
+
// Plain `\g` / `\gx`: simple-query dispatch.
|
|
1127
|
+
//
|
|
1128
|
+
// When the batch contains `COPY ... TO STDOUT`, the wire layer
|
|
1129
|
+
// forwards CopyData bytes via `copyOutMidBatchSink`. Mainloop wires
|
|
1130
|
+
// that sink to `ctx.stdout` for top-level dispatches; here in `\g`
|
|
1131
|
+
// we redirect it to the current output target (`\g FILE`,
|
|
1132
|
+
// `\g |cmd`, or `\o`-stashed stream when neither is set). Without
|
|
1133
|
+
// this, `COPY (SELECT 'foo') TO STDOUT \g :file` silently drops
|
|
1134
|
+
// `foo` on the floor and the file ends up with only the empty
|
|
1135
|
+
// `(0 rows)` shape printed by `renderResult` for the wire's empty
|
|
1136
|
+
// ResultSet. Matches upstream's `do_copy` / `handleCopyOut` path:
|
|
1137
|
+
// the COPY OUT bytes go wherever the active queryFout points.
|
|
1138
|
+
if (hasCopyToStdout(sql)) {
|
|
1139
|
+
copyOutSinkConn = ctx.settings.db;
|
|
1140
|
+
copyOutSinkConn.copyOutMidBatchSink = (chunk) => {
|
|
1141
|
+
out.write(chunk);
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
const results = await ctx.settings.db.execSimple(sql);
|
|
1145
|
+
for (const rs of results) {
|
|
1146
|
+
await renderResult(ctx.settings, rs, out);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
catch (err) {
|
|
1151
|
+
execError = err;
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
// Restore the pre-query topt verbatim — covers both the `\gx`
|
|
1155
|
+
// `expanded = 'on'` swap and any `\g (...)` pset overrides, so a
|
|
1156
|
+
// subsequent plain `\g` runs in the user's persistent print mode.
|
|
1157
|
+
Object.assign(topt, toptSnapshot);
|
|
1158
|
+
// Tear down the COPY-OUT sink so subsequent top-level batches reach
|
|
1159
|
+
// mainloop's installer with a clean slate. (Mainloop reinstalls per
|
|
1160
|
+
// batch; leaving ours pointed at a now-closed file would cause a
|
|
1161
|
+
// write-after-close on the next CopyData burst.)
|
|
1162
|
+
if (copyOutSinkConn)
|
|
1163
|
+
copyOutSinkConn.copyOutMidBatchSink = null;
|
|
1164
|
+
}
|
|
1165
|
+
// Close the one-shot writer regardless of execution success so any
|
|
1166
|
+
// partial output is flushed.
|
|
1167
|
+
//
|
|
1168
|
+
// Note: a non-zero exit from `\g | program` is intentionally NOT
|
|
1169
|
+
// surfaced as an error. Upstream `CloseGOutput` (src/bin/psql/common.c)
|
|
1170
|
+
// only feeds the wait status to `SetShellResultVariables`, which sets
|
|
1171
|
+
// `SHELL_ERROR` / `SHELL_EXIT_CODE` for user inspection — no
|
|
1172
|
+
// `pg_log_error` call. This matches `\g | false` in vanilla psql:
|
|
1173
|
+
// silent, exit code 0, the next command (`\echo after`) prints
|
|
1174
|
+
// normally. Bookkeeping for the SHELL_* vars is a follow-up; what
|
|
1175
|
+
// matters here is that we don't emit a stray "program exited" line
|
|
1176
|
+
// that the conformance harness would diff against an empty upstream
|
|
1177
|
+
// stderr.
|
|
1178
|
+
//
|
|
1179
|
+
// The only failure we still surface from a pipe target is a synchronous
|
|
1180
|
+
// `close()` rejection (e.g. EPIPE escaping the swallow above), which
|
|
1181
|
+
// would indicate a genuine bug in our wiring rather than the child
|
|
1182
|
+
// program's exit code.
|
|
1183
|
+
let pipeError = null;
|
|
1184
|
+
if (oneShot) {
|
|
1185
|
+
try {
|
|
1186
|
+
await oneShot.close();
|
|
1187
|
+
}
|
|
1188
|
+
catch (err) {
|
|
1189
|
+
pipeError = err instanceof Error ? err.message : String(err);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (execError !== null) {
|
|
1193
|
+
// Render in upstream's `ERROR: <msg>\nLINE N: ...\n ^` shape
|
|
1194
|
+
// by funnelling through `formatServerError` — same path top-level
|
|
1195
|
+
// statement errors take in `core/common.ts::writeQueryError`. The
|
|
1196
|
+
// `\<cmd>:` prefix is reserved for client-side I/O / parse errors
|
|
1197
|
+
// (e.g. `\g: no connection`), not server-side ErrorResponse-shaped
|
|
1198
|
+
// failures. Pass the COMMENT-STRIPPED buffer (`trimmedBuf`) so the
|
|
1199
|
+
// `LINE N:` count starts at the first content line — vanilla strips
|
|
1200
|
+
// leading comments + blank lines from queryBuf before `PQexec`, and
|
|
1201
|
+
// the server's reported `position` is a 1-based offset into THAT
|
|
1202
|
+
// trimmed buffer. We preserve trailing whitespace so a `\g` after
|
|
1203
|
+
// `SELECT $1, $2 ` still renders `LINE 1: SELECT $1, $2 ` verbatim.
|
|
1204
|
+
// When buffer was empty (lastQuery fallback or named-bind path), the
|
|
1205
|
+
// dispatched SQL is `sql` — pass that instead so the `LINE N:` echo
|
|
1206
|
+
// still reflects the executed statement (e.g. `\bind_named NAME \g`
|
|
1207
|
+
// after a `\parse NAME` of `SELECT $1, $2`).
|
|
1208
|
+
return formatServerError(ctx, execError, bufSql.length > 0 ? trimmedBuf : sql);
|
|
1209
|
+
}
|
|
1210
|
+
if (pipeError !== null) {
|
|
1211
|
+
return errResult(ctx, pipeError);
|
|
1212
|
+
}
|
|
1213
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1214
|
+
};
|
|
1215
|
+
export const cmdG = {
|
|
1216
|
+
name: 'g',
|
|
1217
|
+
helpKey: 'g',
|
|
1218
|
+
run: (ctx) => runGCore(ctx, false),
|
|
1219
|
+
};
|
|
1220
|
+
export const cmdGx = {
|
|
1221
|
+
name: 'gx',
|
|
1222
|
+
helpKey: 'gx',
|
|
1223
|
+
run: (ctx) => runGCore(ctx, true),
|
|
1224
|
+
};
|
|
1225
|
+
// ---------------------------------------------------------------------------
|
|
1226
|
+
// \p / \print — print the current or previous query buffer.
|
|
1227
|
+
// ---------------------------------------------------------------------------
|
|
1228
|
+
/**
|
|
1229
|
+
* `\p` / `\print` — print the query buffer the next `\g` would execute.
|
|
1230
|
+
*
|
|
1231
|
+
* Mirrors upstream `exec_command_print` in `src/bin/psql/command.c`:
|
|
1232
|
+
*
|
|
1233
|
+
* if (query_buf && query_buf->len > 0)
|
|
1234
|
+
* puts(query_buf->data);
|
|
1235
|
+
* else if (previous_buf && previous_buf->len > 0)
|
|
1236
|
+
* puts(previous_buf->data);
|
|
1237
|
+
* else if (!pset.quiet)
|
|
1238
|
+
* puts(_("Query buffer is empty."));
|
|
1239
|
+
*
|
|
1240
|
+
* Buffer-vs-previous-buffer precedence matters for the regress sequence:
|
|
1241
|
+
*
|
|
1242
|
+
* SELECT 1; -- executes, previous_buf := "SELECT 1;"
|
|
1243
|
+
* \p -- queryBuf empty → prints previous_buf
|
|
1244
|
+
* SELECT 2 \r -- queryBuf="SELECT 2 ", \r resets to "" without
|
|
1245
|
+
* -- touching previous_buf
|
|
1246
|
+
* \p -- queryBuf still empty → prints previous_buf
|
|
1247
|
+
* SELECT 3 \p -- queryBuf="SELECT 3 ", non-empty → prints queryBuf
|
|
1248
|
+
*
|
|
1249
|
+
* Implementation notes:
|
|
1250
|
+
*
|
|
1251
|
+
* - We use `settings.lastQuery` as the previous-buffer source. Upstream
|
|
1252
|
+
* tracks `previous_buf` independently of `pset.last_query`, but our
|
|
1253
|
+
* `lastQuery` is set at the exact same point upstream sets
|
|
1254
|
+
* `previous_buf` (the dispatch site in `SendQuery`-equivalent code paths
|
|
1255
|
+
* in `core/common.ts` and `cmd_io.ts`'s `\g` implementation), so the
|
|
1256
|
+
* semantics match for every shape exercised by the conformance corpus.
|
|
1257
|
+
* - We must NOT clear queryBuf — return `status: 'ok'` so the mainloop
|
|
1258
|
+
* leaves the buffer untouched. The user is inspecting, not executing.
|
|
1259
|
+
* - `puts()` appends a trailing newline. We use `writeOut` and append `\n`
|
|
1260
|
+
* explicitly to match.
|
|
1261
|
+
*/
|
|
1262
|
+
export const cmdPrint = {
|
|
1263
|
+
name: 'p',
|
|
1264
|
+
aliases: ['print'],
|
|
1265
|
+
helpKey: 'p',
|
|
1266
|
+
run: (ctx) => {
|
|
1267
|
+
// `queryBuf.trim()` for the emptiness check — not the printed text.
|
|
1268
|
+
// Upstream's `query_buf->len > 0` is a byte-length check that, in
|
|
1269
|
+
// upstream, is reliably zero after a `;`-dispatch (because PQexec is
|
|
1270
|
+
// followed by `resetPQExpBuffer(query_buf)`). Our mainloop leaves a
|
|
1271
|
+
// residual `\n` in queryBuf after a top-level dispatch when the next
|
|
1272
|
+
// source line starts with a slash command — so a raw `length > 0`
|
|
1273
|
+
// check here would route to the "print the buffer" arm and emit
|
|
1274
|
+
// `\n\n` instead of falling through to `lastQuery`. The trim-only
|
|
1275
|
+
// emptiness check is purely an empty-vs-content discriminator; the
|
|
1276
|
+
// actual writeOut still uses the un-trimmed buffer text so an inline
|
|
1277
|
+
// `SELECT 3 \p` correctly emits the trailing space upstream prints.
|
|
1278
|
+
if (ctx.queryBuf.trim().length > 0) {
|
|
1279
|
+
writeOut(`${ctx.queryBuf}\n`);
|
|
1280
|
+
}
|
|
1281
|
+
else if (ctx.settings.lastQuery.length > 0) {
|
|
1282
|
+
writeOut(`${ctx.settings.lastQuery}\n`);
|
|
1283
|
+
}
|
|
1284
|
+
else if (!ctx.settings.quiet) {
|
|
1285
|
+
writeOut('Query buffer is empty.\n');
|
|
1286
|
+
}
|
|
1287
|
+
return Promise.resolve({ status: 'ok' });
|
|
1288
|
+
},
|
|
1289
|
+
};
|
|
1290
|
+
// ---------------------------------------------------------------------------
|
|
1291
|
+
// \gset [PREFIX]
|
|
1292
|
+
// ---------------------------------------------------------------------------
|
|
1293
|
+
const formatCell = (value) => {
|
|
1294
|
+
if (value === null || value === undefined)
|
|
1295
|
+
return '';
|
|
1296
|
+
if (typeof value === 'string')
|
|
1297
|
+
return value;
|
|
1298
|
+
if (Buffer.isBuffer(value))
|
|
1299
|
+
return value.toString('utf8');
|
|
1300
|
+
if (typeof value === 'number' ||
|
|
1301
|
+
typeof value === 'boolean' ||
|
|
1302
|
+
typeof value === 'bigint') {
|
|
1303
|
+
return String(value);
|
|
1304
|
+
}
|
|
1305
|
+
// Plain objects / arrays from JSON columns: JSON-stringify so the test
|
|
1306
|
+
// surface is deterministic and avoids "[object Object]".
|
|
1307
|
+
try {
|
|
1308
|
+
return JSON.stringify(value);
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
return '';
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
export const cmdGset = {
|
|
1315
|
+
name: 'gset',
|
|
1316
|
+
helpKey: 'gset',
|
|
1317
|
+
async run(ctx) {
|
|
1318
|
+
const gated = pipelineGate(ctx);
|
|
1319
|
+
if (gated !== null)
|
|
1320
|
+
return gated;
|
|
1321
|
+
// Strip leading whitespace + comments — see runGCore for the rationale.
|
|
1322
|
+
const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
|
|
1323
|
+
const bufSql = trimmedBuf.trim();
|
|
1324
|
+
const prefix = ctx.nextArg('normal') ?? '';
|
|
1325
|
+
// Empty buffer behaviour mirrors upstream `exec_command_gset`'s
|
|
1326
|
+
// `PSQL_CMD_SEND` return: the dispatch loop sends the active
|
|
1327
|
+
// `pset.last_query` (or nothing). Upstream does NOT emit an error
|
|
1328
|
+
// — it's a silent no-op when there's no buffer AND no prior query.
|
|
1329
|
+
// We mirror via `settings.lastQuery`, populated in `sendQuery` before
|
|
1330
|
+
// dispatch.
|
|
1331
|
+
const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery.trim();
|
|
1332
|
+
if (sql.length === 0) {
|
|
1333
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1334
|
+
}
|
|
1335
|
+
if (!ctx.settings.db) {
|
|
1336
|
+
return errResult(ctx, 'no connection to the server');
|
|
1337
|
+
}
|
|
1338
|
+
// Track for a subsequent `\g` re-run with empty buffer. Upstream
|
|
1339
|
+
// `exec_command_gset` updates `pset.last_query` to the dispatched SQL
|
|
1340
|
+
// before sending, so a follow-on `\g` (with the buffer reset by the
|
|
1341
|
+
// implicit `\\` separator in `... \gset pref01_ \\ \g`) re-executes
|
|
1342
|
+
// this same statement and prints the result table.
|
|
1343
|
+
ctx.settings.lastQuery = sql;
|
|
1344
|
+
let results;
|
|
1345
|
+
try {
|
|
1346
|
+
results = await ctx.settings.db.execSimple(sql);
|
|
1347
|
+
}
|
|
1348
|
+
catch (err) {
|
|
1349
|
+
// Server-side ErrorResponse — render in upstream's 3-line shape
|
|
1350
|
+
// (severity + message + LINE N / caret) instead of `\gset: <msg>`.
|
|
1351
|
+
// Pass the comment-stripped buffer so the `LINE N:` count matches
|
|
1352
|
+
// vanilla. When the buffer was empty, fall back to the re-run SQL
|
|
1353
|
+
// — the user wants to see WHICH statement failed.
|
|
1354
|
+
return formatServerError(ctx, err, bufSql.length > 0 ? trimmedBuf : sql);
|
|
1355
|
+
}
|
|
1356
|
+
// `\;`-chained batches: render every result EXCEPT the last to the
|
|
1357
|
+
// active output before `\gset` captures the last. Upstream's
|
|
1358
|
+
// `ExecQueryAndProcessResults` walks the libpq result list and runs
|
|
1359
|
+
// `PrintQueryResults` on each one in order; the trailing `\gset`
|
|
1360
|
+
// applies to the FINAL result (`StoreQueryTuple` in common.c) and
|
|
1361
|
+
// suppresses its print. Without this loop, a script like
|
|
1362
|
+
// `SELECT 3 AS three \; SELECT warn('3.5') \; SELECT 4 AS four \gset`
|
|
1363
|
+
// would silently drop the `three` table + the warn NOTICE's
|
|
1364
|
+
// surrounding tuples row.
|
|
1365
|
+
if (results.length > 1) {
|
|
1366
|
+
const out = pickOut(ctx.settings, null);
|
|
1367
|
+
for (let i = 0; i < results.length - 1; i++) {
|
|
1368
|
+
await renderResult(ctx.settings, results[i], out);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Use the last result that returned rows. Upstream uses the most-recent
|
|
1372
|
+
// tuples-producing statement; results without a row descriptor (e.g.
|
|
1373
|
+
// pure DDL) are skipped.
|
|
1374
|
+
// Upstream `StoreQueryTuple` only runs against the LAST PGresult and
|
|
1375
|
+
// only when that result is `PGRES_TUPLES_OK` (a tuples-producing
|
|
1376
|
+
// statement). Non-tuples results (DDL, INSERT/UPDATE without RETURNING)
|
|
1377
|
+
// fall through to a plain status print — `\gset` is a no-op there, NOT
|
|
1378
|
+
// an error. Mirror that here: pick the last result; if it isn't
|
|
1379
|
+
// tuples-producing, skip the variable-assignment step entirely.
|
|
1380
|
+
const lastRs = results[results.length - 1];
|
|
1381
|
+
if (!lastRs || lastRs.fields.length === 0) {
|
|
1382
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1383
|
+
}
|
|
1384
|
+
const rs = lastRs;
|
|
1385
|
+
if (rs.rows.length === 0) {
|
|
1386
|
+
// Bare `no rows returned for \gset` (no `\gset:` prefix) — matches
|
|
1387
|
+
// upstream psql's `pg_log_error("no rows returned for \\gset")`.
|
|
1388
|
+
ctx.settings.lastErrorResult = {
|
|
1389
|
+
message: 'no rows returned for \\gset',
|
|
1390
|
+
};
|
|
1391
|
+
const errPrefix = psqlErrorPrefix(ctx.settings);
|
|
1392
|
+
writeErr(`${errPrefix}no rows returned for \\gset\n`);
|
|
1393
|
+
return { status: 'error', errorWritten: true };
|
|
1394
|
+
}
|
|
1395
|
+
if (rs.rows.length > 1) {
|
|
1396
|
+
// Match upstream psql's exact wording from `exec_command_gset` —
|
|
1397
|
+
// bare `more than one row returned for \gset` (no `\gset:` prefix).
|
|
1398
|
+
// Verified against vanilla psql; vendored psql.out emits it bare.
|
|
1399
|
+
ctx.settings.lastErrorResult = {
|
|
1400
|
+
message: 'more than one row returned for \\gset',
|
|
1401
|
+
};
|
|
1402
|
+
const errPrefix = psqlErrorPrefix(ctx.settings);
|
|
1403
|
+
writeErr(`${errPrefix}more than one row returned for \\gset\n`);
|
|
1404
|
+
return { status: 'error', errorWritten: true };
|
|
1405
|
+
}
|
|
1406
|
+
const row = rs.rows[0];
|
|
1407
|
+
for (let i = 0; i < rs.fields.length; i++) {
|
|
1408
|
+
const fieldName = rs.fields[i].name;
|
|
1409
|
+
const name = `${prefix}${fieldName}`;
|
|
1410
|
+
const cell = row[i];
|
|
1411
|
+
const isNull = cell === null || cell === undefined;
|
|
1412
|
+
// Upstream skips assignments where the target maps to a "specially
|
|
1413
|
+
// treated" variable (one with a substitute / assign hook installed)
|
|
1414
|
+
// whose value would be rejected by the hook. The non-special columns
|
|
1415
|
+
// continue to be assigned: only the offending one is skipped, with
|
|
1416
|
+
// an informational stderr line. See psql.out line ~240:
|
|
1417
|
+
// attempt to \gset into specially treated variable "IGNOREEOF" ignored
|
|
1418
|
+
if (isSpeciallyTreatedVar(ctx.settings, name)) {
|
|
1419
|
+
// The target maps to a "specially treated" variable (one with a
|
|
1420
|
+
// substitute / assign hook installed). Upstream skips just this
|
|
1421
|
+
// assignment with an informational stderr line; other columns
|
|
1422
|
+
// are still processed. We don't actually call the hook — even a
|
|
1423
|
+
// value that the hook would accept must be rejected per upstream:
|
|
1424
|
+
// see `exec_command_gset` and `VariableHasHook`.
|
|
1425
|
+
const errPrefix = psqlErrorPrefix(ctx.settings);
|
|
1426
|
+
writeErr(`${errPrefix}attempt to \\gset into specially treated variable ` +
|
|
1427
|
+
`"${name}" ignored\n`);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
// Upstream `StoreQueryTuple` in src/bin/psql/common.c:
|
|
1431
|
+
//
|
|
1432
|
+
// if (PQgetisnull(result, 0, i))
|
|
1433
|
+
// UnsetVariable(pset.vars, varname);
|
|
1434
|
+
// else if (!SetVariable(pset.vars, varname, PQgetvalue(...))) { ... }
|
|
1435
|
+
//
|
|
1436
|
+
// i.e. a NULL cell unsets the target variable (so a subsequent
|
|
1437
|
+
// `:var` interpolates to the literal `:var` via the scanner's
|
|
1438
|
+
// unset-var passthrough) rather than setting it to the empty
|
|
1439
|
+
// string. Mirror that semantics here.
|
|
1440
|
+
if (isNull) {
|
|
1441
|
+
ctx.settings.vars.unset(name);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
const value = formatCell(cell);
|
|
1445
|
+
if (!ctx.settings.vars.set(name, value)) {
|
|
1446
|
+
// Bare `invalid variable name: "<name>"` (no `\gset:` prefix) —
|
|
1447
|
+
// matches upstream psql.out wording for `\gset` exactly.
|
|
1448
|
+
ctx.settings.lastErrorResult = {
|
|
1449
|
+
message: `invalid variable name: "${fieldName}"`,
|
|
1450
|
+
};
|
|
1451
|
+
const errPrefix = psqlErrorPrefix(ctx.settings);
|
|
1452
|
+
writeErr(`${errPrefix}invalid variable name: "${fieldName}"\n`);
|
|
1453
|
+
return { status: 'error', errorWritten: true };
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
// ---------------------------------------------------------------------------
|
|
1460
|
+
// \gdesc — describe the current query without executing it.
|
|
1461
|
+
//
|
|
1462
|
+
// Mirrors upstream `exec_command_gdesc` in `src/bin/psql/command.c`: parse
|
|
1463
|
+
// the buffered query through the extended protocol (Parse + Describe by
|
|
1464
|
+
// statement, no Execute), then build a synthetic two-column ResultSet of
|
|
1465
|
+
// `Column` and `Type` rows and route it through the printer the user's
|
|
1466
|
+
// `\pset format` selected. Tuples-only mode (`\t on`) suppresses the
|
|
1467
|
+
// header / `(N columns)` footer the same way it would for a real query
|
|
1468
|
+
// result, because we hand the synthetic ResultSet to the same printer.
|
|
1469
|
+
//
|
|
1470
|
+
// Type names come from a follow-up `SELECT ... format_type(tp, tpm)`
|
|
1471
|
+
// over a VALUES literal — exactly the round-trip upstream uses so
|
|
1472
|
+
// non-builtin types and typmod modifiers (`numeric(10,2)`, `varchar(64)`)
|
|
1473
|
+
// render with their canonical form.
|
|
1474
|
+
// ---------------------------------------------------------------------------
|
|
1475
|
+
/**
|
|
1476
|
+
* Build the SQL that resolves each describe-result column's `Type` via
|
|
1477
|
+
* `pg_catalog.format_type(typoid, typmod)`. We feed the names + OIDs
|
|
1478
|
+
* + typmods through a `VALUES` literal so the server does the formatting
|
|
1479
|
+
* for us — the same query upstream issues from `describeFieldsByType`.
|
|
1480
|
+
*
|
|
1481
|
+
* Returns null when there are zero fields (caller emits `(0 rows)` form
|
|
1482
|
+
* by hand because PostgreSQL rejects an empty VALUES list).
|
|
1483
|
+
*/
|
|
1484
|
+
const buildGdescFormatQuery = (fields) => {
|
|
1485
|
+
if (fields.length === 0)
|
|
1486
|
+
return null;
|
|
1487
|
+
// Each row literal escapes the column name with the standard E'' string
|
|
1488
|
+
// form so embedded quotes survive the round trip. The pg_type catalogue
|
|
1489
|
+
// expects oid + int4 typmod, so we cast accordingly. `_idx` keeps the
|
|
1490
|
+
// VALUES list in insertion order; `format_type` handles -1 typmod
|
|
1491
|
+
// (== "no modifier") natively.
|
|
1492
|
+
const rows = fields
|
|
1493
|
+
.map((f, i) => {
|
|
1494
|
+
const safeName = f.name.replace(/'/gu, "''");
|
|
1495
|
+
const oid = String(f.dataTypeID >>> 0);
|
|
1496
|
+
const typmod = String(f.dataTypeModifier | 0);
|
|
1497
|
+
return `(${String(i)}, '${safeName}', ${oid}::oid, ${typmod}::int4)`;
|
|
1498
|
+
})
|
|
1499
|
+
.join(', ');
|
|
1500
|
+
// ORDER BY _idx preserves the describe order regardless of how the server
|
|
1501
|
+
// happens to evaluate the VALUES list. Aliases match upstream column
|
|
1502
|
+
// titles exactly so the printer header is identical.
|
|
1503
|
+
return ('SELECT name AS "Column", pg_catalog.format_type(tp, tpm) AS "Type"' +
|
|
1504
|
+
` FROM (VALUES ${rows}) AS x(_idx, name, tp, tpm) ORDER BY _idx`);
|
|
1505
|
+
};
|
|
1506
|
+
/**
|
|
1507
|
+
* Field descriptors for the synthetic `Column / Type` ResultSet that
|
|
1508
|
+
* `\gdesc` emits when format_type resolution fails or yields nothing.
|
|
1509
|
+
*
|
|
1510
|
+
* We fall back to the field's raw OID so the user still sees a value.
|
|
1511
|
+
*/
|
|
1512
|
+
const GDESC_SYNTHETIC_FIELDS = [
|
|
1513
|
+
{
|
|
1514
|
+
name: 'Column',
|
|
1515
|
+
tableID: 0,
|
|
1516
|
+
columnID: 0,
|
|
1517
|
+
dataTypeID: 25,
|
|
1518
|
+
dataTypeSize: -1,
|
|
1519
|
+
dataTypeModifier: -1,
|
|
1520
|
+
format: 0,
|
|
1521
|
+
},
|
|
1522
|
+
{
|
|
1523
|
+
name: 'Type',
|
|
1524
|
+
tableID: 0,
|
|
1525
|
+
columnID: 0,
|
|
1526
|
+
dataTypeID: 25,
|
|
1527
|
+
dataTypeSize: -1,
|
|
1528
|
+
dataTypeModifier: -1,
|
|
1529
|
+
format: 0,
|
|
1530
|
+
},
|
|
1531
|
+
];
|
|
1532
|
+
const buildSyntheticGdescResultSet = (rows) => ({
|
|
1533
|
+
command: 'SELECT',
|
|
1534
|
+
rowCount: rows.length,
|
|
1535
|
+
oid: null,
|
|
1536
|
+
fields: GDESC_SYNTHETIC_FIELDS,
|
|
1537
|
+
rows,
|
|
1538
|
+
notices: [],
|
|
1539
|
+
});
|
|
1540
|
+
export const cmdGdesc = {
|
|
1541
|
+
name: 'gdesc',
|
|
1542
|
+
helpKey: 'gdesc',
|
|
1543
|
+
async run(ctx) {
|
|
1544
|
+
const gated = pipelineGate(ctx);
|
|
1545
|
+
if (gated !== null)
|
|
1546
|
+
return gated;
|
|
1547
|
+
// Strip leading whitespace + comments — see runGCore for the rationale.
|
|
1548
|
+
const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
|
|
1549
|
+
const sql = trimmedBuf.trim();
|
|
1550
|
+
if (sql.length === 0) {
|
|
1551
|
+
// Upstream `\gdesc` with no buffer falls through `PSQL_CMD_SEND` to
|
|
1552
|
+
// the printer which renders the synthetic 0-column result via
|
|
1553
|
+
// `PrintQueryStatus`'s "The command has no result, or the result
|
|
1554
|
+
// has no columns." line. Stdout, exit 0 — not an error. Verified
|
|
1555
|
+
// against vanilla psql 18.
|
|
1556
|
+
process.stdout.write('The command has no result, or the result has no columns.\n');
|
|
1557
|
+
// Match upstream's post-PSQL_CMD_SEND state vars: success, 0 rows.
|
|
1558
|
+
refreshErrorVars(ctx.settings, { kind: 'success', rowCount: 0 });
|
|
1559
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1560
|
+
}
|
|
1561
|
+
if (!ctx.settings.db) {
|
|
1562
|
+
return errResult(ctx, 'no connection to the server');
|
|
1563
|
+
}
|
|
1564
|
+
// Track for a subsequent `\g` re-run with empty buffer. Upstream
|
|
1565
|
+
// `exec_command_gdesc` updates `pset.last_query` to the dispatched SQL
|
|
1566
|
+
// before sending, so a follow-on `\g` (with the buffer reset because
|
|
1567
|
+
// `\gdesc` dispatches via PSQL_CMD_SEND) re-executes this same statement
|
|
1568
|
+
// and prints the result table. Without this, the regress sequence
|
|
1569
|
+
// SELECT 1 AS x, ... \gdesc
|
|
1570
|
+
// \g
|
|
1571
|
+
// would silently drop the `\g` (empty buffer + stale lastQuery), and
|
|
1572
|
+
// any later `TABLE bububu;` failure would taint `\g`'s re-run output.
|
|
1573
|
+
ctx.settings.lastQuery = sql;
|
|
1574
|
+
let fields;
|
|
1575
|
+
try {
|
|
1576
|
+
const stmt = await ctx.settings.db.prepare('', sql);
|
|
1577
|
+
fields = await stmt.describe();
|
|
1578
|
+
// Close the unnamed prepared statement so we don't leak it. Failure
|
|
1579
|
+
// to close (e.g. server already in error state) is non-fatal.
|
|
1580
|
+
try {
|
|
1581
|
+
await stmt.close();
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
// ignore
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
// Capture + render the full ErrorResponse-shaped payload in upstream's
|
|
1589
|
+
// 3-line shape (severity + message + LINE N / caret), refresh the
|
|
1590
|
+
// diagnostic vars, and signal `errorWritten` to the mainloop so the
|
|
1591
|
+
// `\errverbose` re-render after `\gdesc` sees the rich layers. Pass
|
|
1592
|
+
// the comment-stripped buffer so the `LINE N:` count starts at the
|
|
1593
|
+
// first content line (matches vanilla — see runGCore).
|
|
1594
|
+
return formatServerError(ctx, err, trimmedBuf);
|
|
1595
|
+
}
|
|
1596
|
+
// When the prepared statement describes back zero columns (DDL, empty
|
|
1597
|
+
// SELECT list, etc.), upstream `exec_command_gdesc` prints the
|
|
1598
|
+
// pg_log_info "The command has no result, or the result has no
|
|
1599
|
+
// columns." line to stdout and skips the synthetic-table render.
|
|
1600
|
+
// Verified against vanilla psql 18: `SELECT \gdesc` and
|
|
1601
|
+
// `CREATE TABLE bububu(a int) \gdesc` both produce that text.
|
|
1602
|
+
if (fields.length === 0) {
|
|
1603
|
+
process.stdout.write('The command has no result, or the result has no columns.\n');
|
|
1604
|
+
// Match upstream's post-PSQL_CMD_SEND state vars: success, 0 rows.
|
|
1605
|
+
refreshErrorVars(ctx.settings, { kind: 'success', rowCount: 0 });
|
|
1606
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1607
|
+
}
|
|
1608
|
+
// Resolve canonical type names via a follow-up round trip when we have
|
|
1609
|
+
// at least one field. On failure (or when the server returns nothing —
|
|
1610
|
+
// a mock or an unusual connection state) fall back to the raw OID so
|
|
1611
|
+
// the user still sees a row per described column.
|
|
1612
|
+
let rows;
|
|
1613
|
+
const formatQuery = buildGdescFormatQuery(fields);
|
|
1614
|
+
if (formatQuery === null) {
|
|
1615
|
+
rows = [];
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
const fallbackRows = () => fields.map((f) => [f.name, String(f.dataTypeID)]);
|
|
1619
|
+
try {
|
|
1620
|
+
const sets = await ctx.settings.db.execSimple(formatQuery);
|
|
1621
|
+
const last = sets[sets.length - 1];
|
|
1622
|
+
rows = last && last.rows.length > 0 ? last.rows : fallbackRows();
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
rows = fallbackRows();
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const rs = buildSyntheticGdescResultSet(rows);
|
|
1629
|
+
const printer = pickActivePrinter(ctx.settings);
|
|
1630
|
+
const out = pickOut(ctx.settings, null);
|
|
1631
|
+
try {
|
|
1632
|
+
await printer.printQuery(rs, ctx.settings.popt, out);
|
|
1633
|
+
}
|
|
1634
|
+
catch (err) {
|
|
1635
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1636
|
+
return errResult(ctx, msg);
|
|
1637
|
+
}
|
|
1638
|
+
// Refresh state vars to mark the describe success: `:ERROR=false`,
|
|
1639
|
+
// `:SQLSTATE=00000`, `:ROW_COUNT=<#-described-columns>`. Upstream
|
|
1640
|
+
// routes `\gdesc` through `PSQL_CMD_SEND` so its post-dispatch
|
|
1641
|
+
// `SetResultVariables` sees the synthetic 2-column tuple result and
|
|
1642
|
+
// assigns ROW_COUNT to the field count we just rendered.
|
|
1643
|
+
refreshErrorVars(ctx.settings, {
|
|
1644
|
+
kind: 'success',
|
|
1645
|
+
rowCount: rs.rowCount,
|
|
1646
|
+
});
|
|
1647
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1648
|
+
},
|
|
1649
|
+
};
|
|
1650
|
+
// ---------------------------------------------------------------------------
|
|
1651
|
+
// \gexec — treat each cell of the result as SQL to execute.
|
|
1652
|
+
// ---------------------------------------------------------------------------
|
|
1653
|
+
export const cmdGexec = {
|
|
1654
|
+
name: 'gexec',
|
|
1655
|
+
helpKey: 'gexec',
|
|
1656
|
+
async run(ctx) {
|
|
1657
|
+
const gated = pipelineGate(ctx);
|
|
1658
|
+
if (gated !== null)
|
|
1659
|
+
return gated;
|
|
1660
|
+
// Strip leading whitespace + comments — see runGCore for the rationale.
|
|
1661
|
+
const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
|
|
1662
|
+
const bufSql = trimmedBuf.trim();
|
|
1663
|
+
// Upstream `\gexec` with no buffer falls through `PSQL_CMD_SEND` and
|
|
1664
|
+
// re-runs `pset.last_query` (or nothing). Silent on empty + no prior
|
|
1665
|
+
// query — exit 0, no message. Verified against vanilla psql 18.
|
|
1666
|
+
const sql = bufSql.length > 0 ? bufSql : ctx.settings.lastQuery.trim();
|
|
1667
|
+
if (sql.length === 0) {
|
|
1668
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1669
|
+
}
|
|
1670
|
+
if (!ctx.settings.db) {
|
|
1671
|
+
return errResult(ctx, 'no connection to the server');
|
|
1672
|
+
}
|
|
1673
|
+
// Track the outer (meta) query for a subsequent `\g` re-run with an empty
|
|
1674
|
+
// buffer. Upstream `exec_command_gexec` runs through PSQL_CMD_SEND, which
|
|
1675
|
+
// bumps `pset.last_query` before dispatch.
|
|
1676
|
+
ctx.settings.lastQuery = sql;
|
|
1677
|
+
let firstPass;
|
|
1678
|
+
try {
|
|
1679
|
+
firstPass = await ctx.settings.db.execSimple(sql);
|
|
1680
|
+
}
|
|
1681
|
+
catch (err) {
|
|
1682
|
+
// Render the first-pass server error in upstream's 3-line shape.
|
|
1683
|
+
// Pass the comment-stripped buffer so the `LINE N:` count matches
|
|
1684
|
+
// vanilla — see runGCore for the rationale.
|
|
1685
|
+
return formatServerError(ctx, err, trimmedBuf);
|
|
1686
|
+
}
|
|
1687
|
+
const tupled = firstPass.filter((r) => r.fields.length > 0);
|
|
1688
|
+
if (tupled.length === 0) {
|
|
1689
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1690
|
+
}
|
|
1691
|
+
const out = pickOut(ctx.settings, null);
|
|
1692
|
+
// Echo each generated SQL when ECHO is `all` or `queries`. Vanilla
|
|
1693
|
+
// `exec_command_gexec` calls `SendQuery` for each row's text, and
|
|
1694
|
+
// SendQuery itself prints the statement via the standard query-echo
|
|
1695
|
+
// path: stdout, no `\gexec:` / `psql:` prefix, trailing LF. The echo
|
|
1696
|
+
// appears BEFORE the result body so the conformance harness sees
|
|
1697
|
+
// the same interleaving vanilla produces.
|
|
1698
|
+
const echo = ctx.settings.echo;
|
|
1699
|
+
const shouldEcho = echo === 'all' || echo === 'queries';
|
|
1700
|
+
// Per-row errors are tolerated: upstream `\gexec` calls
|
|
1701
|
+
// `SendQuery` in a loop and ignores its return value (the only
|
|
1702
|
+
// escape is the global ON_ERROR_STOP variable, which the
|
|
1703
|
+
// conformance harness sets to 0). Without this, the regress
|
|
1704
|
+
// expects `drop table gexec_test\nERROR: ...\nselect ...` and we'd
|
|
1705
|
+
// truncate at the ERROR.
|
|
1706
|
+
let sawError = false;
|
|
1707
|
+
for (const rs of tupled) {
|
|
1708
|
+
for (const row of rs.rows) {
|
|
1709
|
+
for (const cell of row) {
|
|
1710
|
+
if (cell === null || cell === undefined)
|
|
1711
|
+
continue;
|
|
1712
|
+
const statement = formatCell(cell).trim();
|
|
1713
|
+
if (statement.length === 0)
|
|
1714
|
+
continue;
|
|
1715
|
+
if (shouldEcho) {
|
|
1716
|
+
out.write(statement + '\n');
|
|
1717
|
+
}
|
|
1718
|
+
try {
|
|
1719
|
+
const nested = await ctx.settings.db.execSimple(statement);
|
|
1720
|
+
for (const sub of nested) {
|
|
1721
|
+
if (sub.fields.length > 0) {
|
|
1722
|
+
await renderResult(ctx.settings, sub, out);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
catch (err) {
|
|
1727
|
+
// Each iteration is its own statement; render the per-row
|
|
1728
|
+
// server error in upstream's 3-line shape (LINE / caret are
|
|
1729
|
+
// positioned against `statement`, the offending row text)
|
|
1730
|
+
// but DO NOT return — vanilla continues to the next row.
|
|
1731
|
+
formatServerError(ctx, err, statement);
|
|
1732
|
+
sawError = true;
|
|
1733
|
+
// Honour ON_ERROR_STOP: when set, halt the loop after the
|
|
1734
|
+
// first failing row. Upstream's `do_gexec` consults the
|
|
1735
|
+
// global `pset.on_error_stop` flag via `SendQuery`'s
|
|
1736
|
+
// return; we mirror by checking the setting directly.
|
|
1737
|
+
if (ctx.settings.onErrorStop) {
|
|
1738
|
+
return { status: 'error', errorWritten: true };
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// Even with errors, return `reset-buf` so the mainloop clears the
|
|
1745
|
+
// outer `\gexec` buffer. Per-row error rendering already happened;
|
|
1746
|
+
// returning `error` here would re-trigger the writeError path.
|
|
1747
|
+
void sawError;
|
|
1748
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
1749
|
+
},
|
|
1750
|
+
};
|
|
1751
|
+
// ---------------------------------------------------------------------------
|
|
1752
|
+
// \watch [args...]
|
|
1753
|
+
//
|
|
1754
|
+
// Upstream `\watch` accepts:
|
|
1755
|
+
//
|
|
1756
|
+
// \watch [SEC] — legacy positional interval (seconds)
|
|
1757
|
+
// \watch i=SEC — interval as named flag
|
|
1758
|
+
// \watch c=N — iteration count limit
|
|
1759
|
+
// \watch m=N — minimum row count: keep polling until the
|
|
1760
|
+
// result has >= N rows; uses `interval` as the
|
|
1761
|
+
// sleep between polls
|
|
1762
|
+
// \watch min_rows=N — long-form alias of `m=`
|
|
1763
|
+
//
|
|
1764
|
+
// Flags may be combined in any order. Duplicates (including the positional
|
|
1765
|
+
// interval colliding with `i=`) are rejected upstream with the message
|
|
1766
|
+
// "<thing> is specified more than once".
|
|
1767
|
+
//
|
|
1768
|
+
// The `WATCH_INTERVAL` psql variable supplies the default `interval` value
|
|
1769
|
+
// when `i=` is not given (and when there is no positional). The variable is
|
|
1770
|
+
// validated at `\set` time via a hook installed by `defaultSettings`.
|
|
1771
|
+
// ---------------------------------------------------------------------------
|
|
1772
|
+
const sleepCancellable = (ms, signal) => new Promise((resolve) => {
|
|
1773
|
+
const timer = setTimeout(() => {
|
|
1774
|
+
signal.removeEventListener('abort', onAbort);
|
|
1775
|
+
resolve();
|
|
1776
|
+
}, ms);
|
|
1777
|
+
const onAbort = () => {
|
|
1778
|
+
clearTimeout(timer);
|
|
1779
|
+
signal.removeEventListener('abort', onAbort);
|
|
1780
|
+
resolve();
|
|
1781
|
+
};
|
|
1782
|
+
if (signal.aborted) {
|
|
1783
|
+
clearTimeout(timer);
|
|
1784
|
+
resolve();
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
signal.addEventListener('abort', onAbort);
|
|
1788
|
+
});
|
|
1789
|
+
/**
|
|
1790
|
+
* Strictly parse a non-negative finite float.
|
|
1791
|
+
*
|
|
1792
|
+
* Returns the parsed number, or `null` for any of:
|
|
1793
|
+
* - empty string
|
|
1794
|
+
* - non-numeric trailing characters (e.g. `10ab`)
|
|
1795
|
+
* - negative values (e.g. `-10`)
|
|
1796
|
+
* - out-of-range / non-finite results (e.g. `10e400` → Infinity)
|
|
1797
|
+
*
|
|
1798
|
+
* Used to validate `\watch` intervals and the `WATCH_INTERVAL` variable.
|
|
1799
|
+
*/
|
|
1800
|
+
const parseStrictNonNegativeFloat = (raw) => {
|
|
1801
|
+
if (raw.length === 0)
|
|
1802
|
+
return null;
|
|
1803
|
+
// Reject anything that doesn't look like a plain float literal. We
|
|
1804
|
+
// accept optional sign + digits + optional fractional + optional
|
|
1805
|
+
// exponent. Trailing garbage (`10ab`), negative values, and exponents
|
|
1806
|
+
// that overflow to Infinity all funnel into the null result.
|
|
1807
|
+
const re = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
1808
|
+
if (!re.test(raw))
|
|
1809
|
+
return null;
|
|
1810
|
+
const value = parseFloat(raw);
|
|
1811
|
+
if (!Number.isFinite(value))
|
|
1812
|
+
return null;
|
|
1813
|
+
if (value < 0)
|
|
1814
|
+
return null;
|
|
1815
|
+
return value;
|
|
1816
|
+
};
|
|
1817
|
+
/**
|
|
1818
|
+
* Parse a strict non-negative integer (no exponent, no fractional).
|
|
1819
|
+
* Used for `c=` and `m=` / `min_rows=` argument values.
|
|
1820
|
+
*/
|
|
1821
|
+
const parseStrictNonNegativeInt = (raw) => {
|
|
1822
|
+
if (raw.length === 0)
|
|
1823
|
+
return null;
|
|
1824
|
+
if (!/^\d+$/.test(raw))
|
|
1825
|
+
return null;
|
|
1826
|
+
const value = parseInt(raw, 10);
|
|
1827
|
+
if (!Number.isFinite(value))
|
|
1828
|
+
return null;
|
|
1829
|
+
return value;
|
|
1830
|
+
};
|
|
1831
|
+
/**
|
|
1832
|
+
* Default `\watch` interval (seconds). Mirrors upstream
|
|
1833
|
+
* `DEFAULT_WATCH_INTERVAL`. Exported so `defaultSettings` can substitute
|
|
1834
|
+
* it when the user unsets the `WATCH_INTERVAL` variable — upstream's
|
|
1835
|
+
* `watch_interval_substitute_hook` reseeds the value to `2` on null.
|
|
1836
|
+
*/
|
|
1837
|
+
export const DEFAULT_WATCH_INTERVAL = '2';
|
|
1838
|
+
/**
|
|
1839
|
+
* Render `\watch`'s per-iteration timestamp in upstream psql's
|
|
1840
|
+
* `ctime`-style layout: `Day Mon DD HH:MM:SS YYYY` (e.g. `Mon May 25
|
|
1841
|
+
* 19:41:55 2026`). Upstream calls `strftime("%c", &tm)` with the C locale;
|
|
1842
|
+
* we reproduce the field order in vanilla English so the output matches
|
|
1843
|
+
* regardless of the host locale.
|
|
1844
|
+
*
|
|
1845
|
+
* Exported only for unit-testing the format ladder.
|
|
1846
|
+
*/
|
|
1847
|
+
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
1848
|
+
const MONTHS = [
|
|
1849
|
+
'Jan',
|
|
1850
|
+
'Feb',
|
|
1851
|
+
'Mar',
|
|
1852
|
+
'Apr',
|
|
1853
|
+
'May',
|
|
1854
|
+
'Jun',
|
|
1855
|
+
'Jul',
|
|
1856
|
+
'Aug',
|
|
1857
|
+
'Sep',
|
|
1858
|
+
'Oct',
|
|
1859
|
+
'Nov',
|
|
1860
|
+
'Dec',
|
|
1861
|
+
];
|
|
1862
|
+
const pad2 = (n) => (n < 10 ? `0${String(n)}` : String(n));
|
|
1863
|
+
export const formatWatchTimestamp = (now) => {
|
|
1864
|
+
const weekday = WEEKDAYS[now.getDay()];
|
|
1865
|
+
const month = MONTHS[now.getMonth()];
|
|
1866
|
+
const day = pad2(now.getDate());
|
|
1867
|
+
const hh = pad2(now.getHours());
|
|
1868
|
+
const mm = pad2(now.getMinutes());
|
|
1869
|
+
const ss = pad2(now.getSeconds());
|
|
1870
|
+
const year = String(now.getFullYear());
|
|
1871
|
+
return `${weekday} ${month} ${day} ${hh}:${mm}:${ss} ${year}`;
|
|
1872
|
+
};
|
|
1873
|
+
/**
|
|
1874
|
+
* Upper bound on the `WATCH_INTERVAL` variable and the positional interval
|
|
1875
|
+
* — matches upstream which rejects "out of range" values. Upstream uses
|
|
1876
|
+
* `strtod` and rejects ±Infinity; we tighten further so a single watch loop
|
|
1877
|
+
* cannot sleep for longer than ~100 hours, which catches obvious typos
|
|
1878
|
+
* without breaking legitimate slow polls.
|
|
1879
|
+
*/
|
|
1880
|
+
const WATCH_INTERVAL_MAX_SECONDS = 100 * 3600;
|
|
1881
|
+
/**
|
|
1882
|
+
* Resolve the effective default `\watch` interval from the `WATCH_INTERVAL`
|
|
1883
|
+
* psql variable. Returns the parsed value, the documented default
|
|
1884
|
+
* (`DEFAULT_WATCH_INTERVAL`), or an `error` envelope if the variable is set
|
|
1885
|
+
* but parses out of range.
|
|
1886
|
+
*/
|
|
1887
|
+
const resolveWatchIntervalDefault = (settings) => {
|
|
1888
|
+
// The variable is seeded to `DEFAULT_WATCH_INTERVAL` by `defaultSettings`
|
|
1889
|
+
// (and re-seeded on `\unset`), so it's typically a string at use time.
|
|
1890
|
+
// If a future code path leaves it undefined we fall back to the same
|
|
1891
|
+
// documented default — upstream's `ParseVariableDouble` substitutes
|
|
1892
|
+
// `DEFAULT_WATCH_INTERVAL` when the var slot is empty.
|
|
1893
|
+
const raw = settings.vars.get('WATCH_INTERVAL') ?? DEFAULT_WATCH_INTERVAL;
|
|
1894
|
+
const parsed = parseStrictNonNegativeFloat(raw);
|
|
1895
|
+
if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
|
|
1896
|
+
return {
|
|
1897
|
+
error: `WATCH_INTERVAL "${raw}" is out of range`,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
return { value: parsed };
|
|
1901
|
+
};
|
|
1902
|
+
/**
|
|
1903
|
+
* Spawn the `\watch` pager for the full duration of the polling loop.
|
|
1904
|
+
*
|
|
1905
|
+
* Upstream `do_watch` wraps the loop in a single `popen` of
|
|
1906
|
+
* `PSQL_WATCH_PAGER`. It deliberately ignores `PSQL_PAGER` and `$PAGER`:
|
|
1907
|
+
*
|
|
1908
|
+
* > we ignore the regular PSQL_PAGER or PAGER environment variables,
|
|
1909
|
+
* > because traditional pagers probably won't be very useful for
|
|
1910
|
+
* > showing a stream of results.
|
|
1911
|
+
*
|
|
1912
|
+
* Mirror that here. Reading from `$PAGER` would silently hijack
|
|
1913
|
+
* `\watch` output for any user whose shell sets `PAGER=less` (the
|
|
1914
|
+
* common default), which makes the loop's output disappear into a
|
|
1915
|
+
* subprocess that diff harnesses can't capture.
|
|
1916
|
+
*
|
|
1917
|
+
* We use a single `sh -c <pager>` spawn so the user can set the
|
|
1918
|
+
* variable to a full command string (`less -R`, `tee /tmp/log`, …)
|
|
1919
|
+
* without the caller having to tokenise it. EPIPE on the stdin pipe is
|
|
1920
|
+
* swallowed for the same reason as in `openWriter`: the user may quit
|
|
1921
|
+
* `less` while we still have writes pending in the next iteration.
|
|
1922
|
+
*
|
|
1923
|
+
* Returns `null` when `PSQL_WATCH_PAGER` is unset or whitespace-only
|
|
1924
|
+
* (upstream's "no pager" rule), so the caller falls back to the normal
|
|
1925
|
+
* output target.
|
|
1926
|
+
*/
|
|
1927
|
+
const openWatchPager = () => {
|
|
1928
|
+
const cmd = process.env.PSQL_WATCH_PAGER ?? '';
|
|
1929
|
+
if (cmd.trim().length === 0)
|
|
1930
|
+
return null;
|
|
1931
|
+
const child = spawn('sh', ['-c', cmd], {
|
|
1932
|
+
stdio: ['pipe', 'inherit', 'inherit'],
|
|
1933
|
+
});
|
|
1934
|
+
child.stdin.on('error', (err) => {
|
|
1935
|
+
if (err.code !== 'EPIPE') {
|
|
1936
|
+
throw err;
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
return {
|
|
1940
|
+
stream: child.stdin,
|
|
1941
|
+
close: () => new Promise((resolve) => {
|
|
1942
|
+
// If the pager already exited (e.g. PSQL_WATCH_PAGER=false, or the
|
|
1943
|
+
// user quit it) the 'close'/'error' events have ALREADY fired, so a
|
|
1944
|
+
// freshly-registered `once()` listener would never run and close()
|
|
1945
|
+
// would hang forever — taking `\watch` with it.
|
|
1946
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1947
|
+
resolve();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
let settled = false;
|
|
1951
|
+
const finish = () => {
|
|
1952
|
+
if (settled)
|
|
1953
|
+
return;
|
|
1954
|
+
settled = true;
|
|
1955
|
+
resolve();
|
|
1956
|
+
};
|
|
1957
|
+
child.once('close', finish);
|
|
1958
|
+
child.once('error', finish);
|
|
1959
|
+
if (!child.stdin.destroyed) {
|
|
1960
|
+
try {
|
|
1961
|
+
child.stdin.end();
|
|
1962
|
+
}
|
|
1963
|
+
catch (err) {
|
|
1964
|
+
const e = err;
|
|
1965
|
+
if (e.code !== 'EPIPE')
|
|
1966
|
+
finish();
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
}),
|
|
1970
|
+
};
|
|
1971
|
+
};
|
|
1972
|
+
export const cmdWatch = {
|
|
1973
|
+
name: 'watch',
|
|
1974
|
+
helpKey: 'watch',
|
|
1975
|
+
async run(ctx) {
|
|
1976
|
+
const gated = pipelineGate(ctx);
|
|
1977
|
+
if (gated !== null)
|
|
1978
|
+
return gated;
|
|
1979
|
+
// Strip leading whitespace + comments — see runGCore for the rationale.
|
|
1980
|
+
const trimmedBuf = stripLeadingCommentsAndWS(ctx.queryBuf);
|
|
1981
|
+
const sql = trimmedBuf.trim();
|
|
1982
|
+
if (sql.length === 0) {
|
|
1983
|
+
return errResult(ctx, 'no query buffer');
|
|
1984
|
+
}
|
|
1985
|
+
if (!ctx.settings.db) {
|
|
1986
|
+
return errResult(ctx, 'no connection to the server');
|
|
1987
|
+
}
|
|
1988
|
+
// Track which options have been seen so we can reject duplicates with
|
|
1989
|
+
// the upstream-formatted "<thing> is specified more than once" message.
|
|
1990
|
+
let intervalSet = false;
|
|
1991
|
+
let interval = null;
|
|
1992
|
+
let iterSet = false;
|
|
1993
|
+
let iterMax = 0; // 0 = unlimited (matches upstream's "no -c").
|
|
1994
|
+
let minRowsSet = false;
|
|
1995
|
+
let minRows = 0;
|
|
1996
|
+
let positionalSeen = false;
|
|
1997
|
+
// Drain all args. Each is either a `key=value` token or a bare
|
|
1998
|
+
// positional (only allowed as the very first arg, and only once).
|
|
1999
|
+
while (true) {
|
|
2000
|
+
const arg = ctx.nextArg('normal');
|
|
2001
|
+
if (arg === null)
|
|
2002
|
+
break;
|
|
2003
|
+
if (arg.length === 0)
|
|
2004
|
+
continue;
|
|
2005
|
+
// Identify named flags by looking for `=`. Upstream tolerates an
|
|
2006
|
+
// empty value (treats it as the option not being provided), but we
|
|
2007
|
+
// mirror its stricter behaviour for the values we care about.
|
|
2008
|
+
const eqIdx = arg.indexOf('=');
|
|
2009
|
+
if (eqIdx > 0) {
|
|
2010
|
+
const key = arg.slice(0, eqIdx);
|
|
2011
|
+
const value = arg.slice(eqIdx + 1);
|
|
2012
|
+
if (key === 'i') {
|
|
2013
|
+
if (intervalSet) {
|
|
2014
|
+
return errResult(ctx, 'interval value is specified more than once');
|
|
2015
|
+
}
|
|
2016
|
+
const parsed = parseStrictNonNegativeFloat(value);
|
|
2017
|
+
if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
|
|
2018
|
+
return errResult(ctx, `incorrect interval value "${value}"`);
|
|
2019
|
+
}
|
|
2020
|
+
interval = parsed;
|
|
2021
|
+
intervalSet = true;
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
if (key === 'c') {
|
|
2025
|
+
if (iterSet) {
|
|
2026
|
+
return errResult(ctx, 'iteration count is specified more than once');
|
|
2027
|
+
}
|
|
2028
|
+
const parsed = parseStrictNonNegativeInt(value);
|
|
2029
|
+
// Upstream parses the count with `option_parse_int(..., 1, INT_MAX)`
|
|
2030
|
+
// so the iteration count must be >= 1; `c=0` is rejected as out of
|
|
2031
|
+
// range. We reserve the internal `iterMax = 0` sentinel purely for
|
|
2032
|
+
// "no `c=` given" (unlimited continuous mode), so accepting `c=0`
|
|
2033
|
+
// here would silently cap the loop at a single iteration instead.
|
|
2034
|
+
if (parsed === null || parsed === 0) {
|
|
2035
|
+
return errResult(ctx, `incorrect iteration count "${value}"`);
|
|
2036
|
+
}
|
|
2037
|
+
iterMax = parsed;
|
|
2038
|
+
iterSet = true;
|
|
2039
|
+
continue;
|
|
2040
|
+
}
|
|
2041
|
+
if (key === 'm' || key === 'min_rows') {
|
|
2042
|
+
if (minRowsSet) {
|
|
2043
|
+
return errResult(ctx, 'minimum row count specified more than once');
|
|
2044
|
+
}
|
|
2045
|
+
const parsed = parseStrictNonNegativeInt(value);
|
|
2046
|
+
if (parsed === null) {
|
|
2047
|
+
return errResult(ctx, `incorrect minimum row count "${value}"`);
|
|
2048
|
+
}
|
|
2049
|
+
minRows = parsed;
|
|
2050
|
+
minRowsSet = true;
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
// Unknown key=value: surface a generic error mirroring upstream
|
|
2054
|
+
// ("unrecognized value …").
|
|
2055
|
+
return errResult(ctx, `unrecognized option "${key}"`);
|
|
2056
|
+
}
|
|
2057
|
+
// Positional argument — legacy interval. Allowed only once, and
|
|
2058
|
+
// only collides with `i=` under the same upstream "specified more
|
|
2059
|
+
// than once" rubric.
|
|
2060
|
+
if (positionalSeen || intervalSet) {
|
|
2061
|
+
return errResult(ctx, 'interval value is specified more than once');
|
|
2062
|
+
}
|
|
2063
|
+
const parsed = parseStrictNonNegativeFloat(arg);
|
|
2064
|
+
if (parsed === null || parsed > WATCH_INTERVAL_MAX_SECONDS) {
|
|
2065
|
+
return errResult(ctx, `incorrect interval value "${arg}"`);
|
|
2066
|
+
}
|
|
2067
|
+
interval = parsed;
|
|
2068
|
+
intervalSet = true;
|
|
2069
|
+
positionalSeen = true;
|
|
2070
|
+
}
|
|
2071
|
+
// If no explicit interval was supplied, fall back to WATCH_INTERVAL.
|
|
2072
|
+
if (interval === null) {
|
|
2073
|
+
const resolved = resolveWatchIntervalDefault(ctx.settings);
|
|
2074
|
+
if ('error' in resolved) {
|
|
2075
|
+
return errResult(ctx, resolved.error);
|
|
2076
|
+
}
|
|
2077
|
+
interval = resolved.value;
|
|
2078
|
+
}
|
|
2079
|
+
const intervalMs = Math.round(interval * 1000);
|
|
2080
|
+
// Prefer a test-supplied controller; otherwise install a transient
|
|
2081
|
+
// SIGINT listener that aborts the loop.
|
|
2082
|
+
const controller = WATCH_TEST_CONTROLLER.ref ?? new AbortController();
|
|
2083
|
+
const sigintHandler = () => {
|
|
2084
|
+
controller.abort();
|
|
2085
|
+
};
|
|
2086
|
+
const installedSigint = WATCH_TEST_CONTROLLER.ref === null;
|
|
2087
|
+
if (installedSigint) {
|
|
2088
|
+
process.once('SIGINT', sigintHandler);
|
|
2089
|
+
}
|
|
2090
|
+
// Open the pager once for the whole loop (upstream `do_watch` wraps the
|
|
2091
|
+
// entire session, not each iteration, so the user can scroll the
|
|
2092
|
+
// accumulated output in one go). When PSQL_WATCH_PAGER / PAGER aren't
|
|
2093
|
+
// set we fall through to the normal `pickOut` target.
|
|
2094
|
+
const pager = openWatchPager();
|
|
2095
|
+
const out = pager?.stream ?? pickOut(ctx.settings, null);
|
|
2096
|
+
try {
|
|
2097
|
+
// CONTINUOUS mode: when `c=` is absent, `iterSet` stays false and the
|
|
2098
|
+
// iteration-cap break below never fires, so the loop re-runs the query
|
|
2099
|
+
// on the interval forever — exactly upstream `do_watch`'s `for (i = 0;
|
|
2100
|
+
// !iter || i < iter; i++)` when `iter == 0`. The only exits are a
|
|
2101
|
+
// SIGINT (or the test controller) aborting `controller.signal`, a
|
|
2102
|
+
// server error, or the `min_rows` CONTINUE predicate failing.
|
|
2103
|
+
let iter = 0;
|
|
2104
|
+
while (!controller.signal.aborted) {
|
|
2105
|
+
iter++;
|
|
2106
|
+
const stamp = formatWatchTimestamp(new Date());
|
|
2107
|
+
out.write(`${stamp} (every ${String(interval)}s)\n\n`);
|
|
2108
|
+
let lastRowCount = 0;
|
|
2109
|
+
try {
|
|
2110
|
+
const results = await ctx.settings.db.execSimple(sql);
|
|
2111
|
+
for (const rs of results) {
|
|
2112
|
+
if (rs.fields.length > 0) {
|
|
2113
|
+
await renderResult(ctx.settings, rs, out);
|
|
2114
|
+
lastRowCount = rs.rows.length;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
catch (err) {
|
|
2119
|
+
// Surface in upstream's 3-line ErrorResponse shape (severity +
|
|
2120
|
+
// message + LINE / caret) — same path top-level statement errors
|
|
2121
|
+
// take. The `\watch:` prefix is reserved for client-side
|
|
2122
|
+
// argument-parsing errors (e.g. `incorrect interval value "-10"`).
|
|
2123
|
+
// Pass the comment-stripped buffer so the `LINE N:` count starts
|
|
2124
|
+
// at the first content line — see runGCore for the rationale.
|
|
2125
|
+
return formatServerError(ctx, err, trimmedBuf);
|
|
2126
|
+
}
|
|
2127
|
+
// Stop if `c=` reached the configured iteration cap, OR if `m=`
|
|
2128
|
+
// was set and the previous result returned FEWER than `min_rows`
|
|
2129
|
+
// tuples. Upstream's `ExecQueryAndProcessResults` sets `return_early`
|
|
2130
|
+
// exactly when `min_rows > 0 && PQntuples(result) < min_rows`, and
|
|
2131
|
+
// `do_watch` breaks out of the loop on that signal — see PG source
|
|
2132
|
+
// `src/bin/psql/common.c::ExecQueryAndProcessResults`. In other
|
|
2133
|
+
// words `min_rows` is a CONTINUE predicate: keep polling while
|
|
2134
|
+
// the result has at least `min_rows` rows; stop the moment it
|
|
2135
|
+
// doesn't.
|
|
2136
|
+
if (iterSet && iter >= iterMax)
|
|
2137
|
+
break;
|
|
2138
|
+
if (minRowsSet && lastRowCount < minRows)
|
|
2139
|
+
break;
|
|
2140
|
+
if (controller.signal.aborted)
|
|
2141
|
+
break;
|
|
2142
|
+
// sleep_ms == 0 is upstream's "tight loop, no wait needed" — skip
|
|
2143
|
+
// the timer round-trip entirely so we don't queue a setTimeout(0)
|
|
2144
|
+
// for every iteration. Matches `do_watch`'s `if (sleep_ms == 0)
|
|
2145
|
+
// continue;` branch.
|
|
2146
|
+
if (intervalMs > 0) {
|
|
2147
|
+
await sleepCancellable(intervalMs, controller.signal);
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
// Upstream `do_watch` injects a trailing newline AFTER the loop
|
|
2151
|
+
// ends when no pager is attached, to clear the cursor after a
|
|
2152
|
+
// possible `^C` echo. Mirror that here so the conformance output
|
|
2153
|
+
// shape (`...\n(N rows)\n\n\n`) matches vanilla psql.
|
|
2154
|
+
if (!pager) {
|
|
2155
|
+
out.write('\n');
|
|
2156
|
+
}
|
|
2157
|
+
return { status: 'reset-buf', newBuf: '' };
|
|
2158
|
+
}
|
|
2159
|
+
finally {
|
|
2160
|
+
if (installedSigint) {
|
|
2161
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
2162
|
+
}
|
|
2163
|
+
// Drain the pager so its child has a chance to exit before \watch
|
|
2164
|
+
// returns. Failures are swallowed: a broken pager shouldn't mask the
|
|
2165
|
+
// (already-flushed) query results.
|
|
2166
|
+
if (pager) {
|
|
2167
|
+
await pager.close();
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
},
|
|
2171
|
+
};
|
|
2172
|
+
// ---------------------------------------------------------------------------
|
|
2173
|
+
// Registration entry point.
|
|
2174
|
+
// ---------------------------------------------------------------------------
|
|
2175
|
+
export const registerIoCommands = (registry) => {
|
|
2176
|
+
registry.register(cmdInclude);
|
|
2177
|
+
registry.register(cmdIncludeRel);
|
|
2178
|
+
registry.register(cmdOut);
|
|
2179
|
+
registry.register(cmdWrite);
|
|
2180
|
+
registry.register(cmdG);
|
|
2181
|
+
registry.register(cmdGx);
|
|
2182
|
+
registry.register(cmdPrint);
|
|
2183
|
+
registry.register(cmdGset);
|
|
2184
|
+
registry.register(cmdGdesc);
|
|
2185
|
+
registry.register(cmdGexec);
|
|
2186
|
+
registry.register(cmdWatch);
|
|
2187
|
+
};
|