pgkiln 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/db/migrations/108_sql_history.sql +25 -0
  4. package/docs/apex-feature-parity.md +33 -4
  5. package/docs/guide/03-builder.md +123 -28
  6. package/docs/guide/04-pages-and-regions.md +74 -2
  7. package/docs/guide/05-items.md +14 -2
  8. package/docs/guide/08-security.md +1 -1
  9. package/docs/guide/09-reference.md +1 -1
  10. package/docs/guide/10-tutorial.md +1 -1
  11. package/docs/guide/11-from-apex.md +2 -1
  12. package/docs/guide/12-development.md +26 -3
  13. package/docs/guide/16-files.md +2 -2
  14. package/examples/hr/hr_61_form_designer.sql +80 -0
  15. package/package.json +1 -1
  16. package/public/app.css +62 -2
  17. package/public/app.js +244 -35
  18. package/public/builder.css +21 -1
  19. package/public/builder.js +67 -0
  20. package/public/sw.js +2 -1
  21. package/public/worksheet.css +244 -0
  22. package/public/worksheet.js +665 -0
  23. package/src/app.ts +9 -1
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/appwizard.ts +22 -6
  26. package/src/builder/arrange.ts +33 -12
  27. package/src/builder/code-editor.ts +1 -1
  28. package/src/builder/components.ts +1 -1
  29. package/src/builder/designer.ts +44 -17
  30. package/src/builder/errors.ts +65 -0
  31. package/src/builder/form-designer.ts +229 -0
  32. package/src/builder/locks.ts +6 -0
  33. package/src/builder/newapp.ts +2 -1
  34. package/src/builder/object-actions.ts +166 -0
  35. package/src/builder/object-browser.ts +111 -33
  36. package/src/builder/querybuilder.ts +1 -1
  37. package/src/builder/region-help.ts +2 -0
  38. package/src/builder/region-settings.ts +2 -0
  39. package/src/builder/reporter.ts +1 -1
  40. package/src/builder/routes.ts +7 -5
  41. package/src/builder/sampledata.ts +2 -2
  42. package/src/builder/scripts.ts +3 -3
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +2 -2
  45. package/src/builder/sql.ts +15 -73
  46. package/src/builder/table-editor.ts +219 -0
  47. package/src/builder/ui.ts +5 -3
  48. package/src/builder/unload.ts +8 -4
  49. package/src/builder/worksheet.ts +569 -0
  50. package/src/builder/workspaces.ts +1 -1
  51. package/src/cli/replace.ts +10 -0
  52. package/src/i18n/ar.ts +62 -0
  53. package/src/i18n/cs.ts +62 -0
  54. package/src/i18n/da.ts +62 -0
  55. package/src/i18n/de.ts +62 -0
  56. package/src/i18n/el.ts +62 -0
  57. package/src/i18n/es.ts +62 -0
  58. package/src/i18n/fi.ts +62 -0
  59. package/src/i18n/fr.ts +62 -0
  60. package/src/i18n/he.ts +62 -0
  61. package/src/i18n/it.ts +62 -0
  62. package/src/i18n/ja.ts +62 -0
  63. package/src/i18n/ko.ts +62 -0
  64. package/src/i18n/nb.ts +62 -0
  65. package/src/i18n/pl.ts +62 -0
  66. package/src/i18n/pt.ts +62 -0
  67. package/src/i18n/ru.ts +62 -0
  68. package/src/i18n/sv.ts +62 -0
  69. package/src/i18n/tr.ts +62 -0
  70. package/src/i18n/uk.ts +62 -0
  71. package/src/i18n/zh.ts +62 -0
  72. package/src/i18n.ts +124 -0
  73. package/src/metadata.ts +4 -2
  74. package/src/runtime/devbar.ts +1 -1
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +15 -8
  79. package/src/runtime/items.ts +33 -21
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/pdf.ts +2 -1
  82. package/src/runtime/regions.ts +4 -0
  83. package/src/runtime/render.ts +7 -3
  84. package/src/runtime/report.ts +2 -2
  85. package/src/runtime/routes.ts +3 -1
  86. package/src/runtime/smart-filters.ts +2 -1
  87. package/src/runtime/tasks.ts +6 -3
  88. package/src/sqlformat.ts +208 -0
  89. package/src/sqlplan.ts +133 -0
  90. package/src/sqlscript.ts +6 -2
  91. package/src/sqlworksheet.ts +278 -0
  92. package/src/tabledef.ts +226 -0
  93. package/src/unload.ts +14 -3
package/src/sqlplan.ts ADDED
@@ -0,0 +1,133 @@
1
+ // (sprint 92, Database Actions DA-4) A query plan from EXPLAIN (FORMAT JSON) as a tree for the SQL
2
+ // Worksheet's Explain Plan and Autotrace tabs: per step the operation, the relation or index, cost,
3
+ // estimated rows and width, and with ANALYZE the actual time, rows, loops and buffers, the time spent in
4
+ // the step itself (without its children), the slowest step, and estimates that are off by 10× or more.
5
+
6
+ export interface PlanNode {
7
+ op: string;
8
+ /** "on emp e", "using emp_pkey" … */
9
+ target: string;
10
+ details: [string, string][];
11
+ startupCost: number;
12
+ totalCost: number;
13
+ planRows: number;
14
+ width: number;
15
+ /** ANALYZE */
16
+ actualTotal?: number;
17
+ actualRows?: number;
18
+ loops?: number;
19
+ /** ms in this step itself: its time × loops minus its children's */
20
+ selfMs?: number;
21
+ sharedHit?: number;
22
+ sharedRead?: number;
23
+ /** estimated / actual rows, when off by 10× or more (> 1: overestimated) */
24
+ misestimate?: number;
25
+ slowest?: boolean;
26
+ /** never executed (ANALYZE) */
27
+ neverRun?: boolean;
28
+ children: PlanNode[];
29
+ }
30
+
31
+ export interface Plan {
32
+ root: PlanNode;
33
+ planningMs?: number;
34
+ executionMs?: number;
35
+ analyze: boolean;
36
+ }
37
+
38
+ const DETAIL_KEYS = ['Index Cond', 'Recheck Cond', 'Filter', 'Join Filter', 'Hash Cond', 'Merge Cond', 'Sort Key', 'Group Key', 'Rows Removed by Filter', 'Sort Method', 'Strategy', 'Subplan Name', 'CTE Name', 'Function Name'];
39
+
40
+ type Json = Record<string, unknown>;
41
+
42
+ function node(p: Json, analyze: boolean): PlanNode {
43
+ const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : undefined);
44
+ const num = (k: string) => (typeof p[k] === 'number' ? (p[k] as number) : undefined);
45
+ let op = str('Node Type') ?? '?';
46
+ // INSERT / UPDATE / DELETE / MERGE are a ModifyTable step: name it by its operation, like the text plan
47
+ if (op === 'ModifyTable' && str('Operation')) op = str('Operation')!;
48
+ if (str('Join Type') && /Join|Nested Loop/.test(op) && str('Join Type') !== 'Inner') op = `${op} (${str('Join Type')})`;
49
+ if (str('Strategy') && op === 'Aggregate') op = `${str('Strategy') === 'Plain' ? '' : `${str('Strategy')} `}Aggregate`;
50
+ if (p['Parallel Aware'] === true) op = `Parallel ${op}`;
51
+ const parts: string[] = [];
52
+ if (str('Index Name')) parts.push(`using ${str('Index Name')}`);
53
+ if (str('Relation Name')) parts.push(`on ${str('Schema') ? `${str('Schema')}.` : ''}${str('Relation Name')}${str('Alias') && str('Alias') !== str('Relation Name') ? ` ${str('Alias')}` : ''}`);
54
+ const details: [string, string][] = [];
55
+ for (const k of DETAIL_KEYS) {
56
+ const v = p[k];
57
+ if (v === undefined || v === null || k === 'Strategy') continue;
58
+ details.push([k, Array.isArray(v) ? v.join(', ') : String(v)]);
59
+ }
60
+ const children = Array.isArray(p.Plans) ? (p.Plans as Json[]).map((c) => node(c, analyze)) : [];
61
+ const n: PlanNode = {
62
+ op,
63
+ target: parts.join(' '),
64
+ details,
65
+ startupCost: num('Startup Cost') ?? 0,
66
+ totalCost: num('Total Cost') ?? 0,
67
+ planRows: num('Plan Rows') ?? 0,
68
+ width: num('Plan Width') ?? 0,
69
+ children,
70
+ };
71
+ if (analyze) {
72
+ n.loops = num('Actual Loops') ?? 0;
73
+ n.neverRun = n.loops === 0;
74
+ n.actualTotal = num('Actual Total Time') ?? 0;
75
+ n.actualRows = num('Actual Rows') ?? 0;
76
+ n.sharedHit = num('Shared Hit Blocks');
77
+ n.sharedRead = num('Shared Read Blocks');
78
+ const own = n.actualTotal * (n.loops || 0);
79
+ const kids = children.reduce((t, c) => t + (c.actualTotal ?? 0) * (c.loops ?? 0), 0);
80
+ n.selfMs = Math.max(0, own - kids);
81
+ if (!n.neverRun) {
82
+ const est = Math.max(n.planRows, 1);
83
+ const act = Math.max(n.actualRows, 1);
84
+ const ratio = est / act;
85
+ if (ratio >= 10 || ratio <= 0.1) n.misestimate = ratio;
86
+ }
87
+ }
88
+ return n;
89
+ }
90
+
91
+ const walk = (n: PlanNode, f: (n: PlanNode) => void) => {
92
+ f(n);
93
+ for (const c of n.children) walk(c, f);
94
+ };
95
+
96
+ /** The plan of EXPLAIN (FORMAT JSON) output: an array with one object holding Plan (and with ANALYZE the times). */
97
+ export function parsePlan(json: unknown, analyze: boolean): Plan {
98
+ const top = (Array.isArray(json) ? json[0] : json) as Json;
99
+ const root = node((top?.Plan ?? {}) as Json, analyze);
100
+ if (analyze) {
101
+ let slow: PlanNode | null = null;
102
+ walk(root, (n) => {
103
+ if (!slow || (n.selfMs ?? 0) > (slow.selfMs ?? 0)) slow = n;
104
+ });
105
+ if (slow && ((slow as PlanNode).selfMs ?? 0) > 0) (slow as PlanNode).slowest = true;
106
+ }
107
+ return {
108
+ root,
109
+ planningMs: typeof top?.['Planning Time'] === 'number' ? (top['Planning Time'] as number) : undefined,
110
+ executionMs: typeof top?.['Execution Time'] === 'number' ? (top['Execution Time'] as number) : undefined,
111
+ analyze,
112
+ };
113
+ }
114
+
115
+ const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
116
+
117
+ /** The plan as indented text, like psql's EXPLAIN output. */
118
+ export function planText(plan: Plan): string {
119
+ const lines: string[] = [];
120
+ const line = (n: PlanNode, depth: number) => {
121
+ const pad = depth ? `${' '.repeat(depth - 1)} -> ` : '';
122
+ const actual = plan.analyze ? (n.neverRun ? ' (never executed)' : ` (actual time=${fmt(n.actualTotal ?? 0)} rows=${n.actualRows} loops=${n.loops})`) : '';
123
+ lines.push(`${pad}${n.op}${n.target ? ` ${n.target}` : ''} (cost=${fmt(n.startupCost)}..${fmt(n.totalCost)} rows=${n.planRows} width=${n.width})${actual}`);
124
+ const ind = depth ? `${' '.repeat(depth - 1)} ` : ' ';
125
+ for (const [k, v] of n.details) lines.push(`${ind}${k}: ${v}`);
126
+ if (plan.analyze && (n.sharedHit || n.sharedRead)) lines.push(`${ind}Buffers: shared hit=${n.sharedHit ?? 0}${n.sharedRead ? ` read=${n.sharedRead}` : ''}`);
127
+ for (const c of n.children) line(c, depth + 1);
128
+ };
129
+ line(plan.root, 0);
130
+ if (plan.planningMs !== undefined) lines.push(`Planning Time: ${fmt(plan.planningMs)} ms`);
131
+ if (plan.executionMs !== undefined) lines.push(`Execution Time: ${fmt(plan.executionMs)} ms`);
132
+ return lines.join('\n');
133
+ }
package/src/sqlscript.ts CHANGED
@@ -13,6 +13,9 @@ export interface ScriptStatement {
13
13
  line: number;
14
14
  /** a psql meta-command (\set, \i, …): reported, not run */
15
15
  psql?: boolean;
16
+ /** (sprint 91) offsets of the statement text in the script: [start, end) */
17
+ start: number;
18
+ end: number;
16
19
  }
17
20
 
18
21
  const IDENT = /[A-Za-z0-9_$]/;
@@ -45,7 +48,7 @@ export function splitScript(script: string): ScriptStatement[] {
45
48
  lead = skipQuoted(raw, lead)!;
46
49
  }
47
50
  const sql = raw.slice(lead).trim();
48
- if (sql) out.push({ sql, line: lineAt(script, start + lead) });
51
+ if (sql) out.push({ sql, line: lineAt(script, start + lead), start: start + lead, end: start + lead + sql.length });
49
52
  };
50
53
 
51
54
  while (i < n) {
@@ -64,7 +67,8 @@ export function splitScript(script: string): ScriptStatement[] {
64
67
  push(i); // nothing but comments before it
65
68
  const eol = script.indexOf('\n', i);
66
69
  const end = eol === -1 ? n : eol;
67
- out.push({ sql: script.slice(i, end).trim(), line: lineAt(script, i), psql: true });
70
+ const text = script.slice(i, end).trim();
71
+ out.push({ sql: text, line: lineAt(script, i), psql: true, start: i, end: i + text.length });
68
72
  start = i = end;
69
73
  continue;
70
74
  }
@@ -0,0 +1,278 @@
1
+ import type pg from 'pg';
2
+ import { isNumeric } from './runtime/report.ts';
3
+ import { parsePlan, planText, type Plan } from './sqlplan.ts';
4
+ import { splitScript, type ScriptStatement } from './sqlscript.ts';
5
+ import { skipQuoted } from './binds.ts';
6
+ import type { Client } from './db.ts';
7
+
8
+ // (sprint 91, Database Actions DA-1/DA-2) The SQL Workshop's worksheet: which statements a run takes
9
+ // (the one at the cursor, a selection, or all of them), and running them on one connection with a log
10
+ // per statement like a script output (psql's command tags, the rows as text, notices, errors with their
11
+ // line), the last query's rows for the Query Result grid, and EXPLAIN for the plan tabs. The builder's
12
+ // page is src/builder/worksheet.ts.
13
+
14
+ export type RunMode = 'statement' | 'script' | 'explain' | 'autotrace';
15
+
16
+ /**
17
+ * The statements a run takes from the worksheet text: a selection (start < end) is split and run whole;
18
+ * otherwise Run Statement, Explain and Autotrace take the statement at the cursor (the one containing it,
19
+ * else the nearest one before it, else the first after it) and Run Script takes all. Without a cursor
20
+ * everything runs (pages without JavaScript, and posts from scripts).
21
+ */
22
+ export function pickStatements(text: string, mode: RunMode, cursor?: { start: number; end: number }): ScriptStatement[] {
23
+ if (cursor && cursor.end > cursor.start && cursor.start >= 0 && cursor.end <= text.length) {
24
+ const part = text.slice(cursor.start, cursor.end);
25
+ const lineOffset = lineOf(text, cursor.start) - 1;
26
+ return splitScript(part).map((s) => ({ ...s, line: s.line + lineOffset, start: s.start + cursor.start, end: s.end + cursor.start }));
27
+ }
28
+ const all = splitScript(text);
29
+ if (mode === 'script' || !cursor || !all.length) return all;
30
+ const at = Math.max(0, Math.min(cursor.start, text.length));
31
+ // the statement runs up to its semicolon: a cursor right after it still belongs to it
32
+ const inside = all.find((s) => at >= s.start && at <= s.end + 1);
33
+ if (inside) return [inside];
34
+ const before = all.filter((s) => s.end < at).pop();
35
+ return [before ?? all[0]];
36
+ }
37
+
38
+ /** 1-based line of offset `i`. */
39
+ export function lineOf(s: string, i: number) {
40
+ let line = 1;
41
+ for (let j = 0; j < i && j < s.length; j++) if (s.charCodeAt(j) === 10) line++;
42
+ return line;
43
+ }
44
+
45
+ const MODIFIERS = new Set(['or', 'replace', 'unique', 'temp', 'temporary', 'unlogged', 'global', 'local', 'trusted', 'procedural', 'recursive', 'if', 'not', 'exists', 'concurrently', 'default', 'constraint']);
46
+
47
+ /**
48
+ * A statement's command tag like psql prints it: INSERT 0 1, UPDATE 3, SELECT 14, CREATE TABLE.
49
+ * node-pg keeps only the first word of the server's tag, so DDL takes its object type from the statement.
50
+ */
51
+ export function commandTag(sql: string, command: string | null | undefined, rowCount: number | null | undefined): string {
52
+ const cmd = (command ?? '').toUpperCase();
53
+ if (cmd === 'INSERT') return `INSERT 0 ${rowCount ?? 0}`;
54
+ if (['SELECT', 'UPDATE', 'DELETE', 'MERGE', 'FETCH', 'MOVE', 'COPY'].includes(cmd)) return `${cmd} ${rowCount ?? 0}`;
55
+ if (['CREATE', 'ALTER', 'DROP', 'COMMENT', 'GRANT', 'REVOKE'].includes(cmd) || !cmd) {
56
+ const words = sql.replace(/--[^\n]*|\/\*[\s\S]*?\*\//g, ' ').trim().split(/\s+/).map((w) => w.toLowerCase().replace(/[^a-z_]/g, ''));
57
+ const verb = (words[0] || cmd.toLowerCase()).toUpperCase();
58
+ if (!['CREATE', 'ALTER', 'DROP'].includes(verb)) return verb || cmd || 'OK';
59
+ let k = 1;
60
+ while (k < words.length && MODIFIERS.has(words[k])) k++;
61
+ const obj = words[k] ?? '';
62
+ if (!obj) return verb;
63
+ if (obj === 'materialized' || obj === 'foreign' || obj === 'event') return `${verb} ${obj.toUpperCase()} ${(words[k + 1] ?? '').toUpperCase()}`.trim();
64
+ return `${verb} ${obj.toUpperCase()}`;
65
+ }
66
+ return cmd;
67
+ }
68
+
69
+ const MAX_VALUE = 500;
70
+
71
+ /** A value as text (the grid and the script output): JSON for objects, \x… for bytes; shortened. */
72
+ export function cellText(v: unknown, max = MAX_VALUE): string | null {
73
+ if (v === null || v === undefined) return null;
74
+ const s = Buffer.isBuffer(v) ? `\\x${v.subarray(0, max).toString('hex')}` : v instanceof Date ? v.toISOString() : typeof v === 'object' ? JSON.stringify(v) : String(v);
75
+ return s.length > max ? `${s.slice(0, max)}…` : s;
76
+ }
77
+ /** (sprint 92) Values in the Query Result grid: whole up to this (a cell shows the start, a click the rest). */
78
+ export const GRID_VALUE = 10_000;
79
+
80
+ /** Rows as a fixed-width text table like psql's (NULL as empty), for the script output. */
81
+ export function textTable(columns: string[], rows: (string | null)[][]): string {
82
+ const width = columns.map((c, k) => Math.min(60, Math.max(c.length, ...rows.map((r) => (r[k] ?? '').replace(/\s+/g, ' ').length))));
83
+ const cut = (v: string, w: number) => {
84
+ const one = v.replace(/\s+/g, ' ');
85
+ return one.length > w ? `${one.slice(0, w - 1)}…` : one;
86
+ };
87
+ const line = (cells: string[]) => cells.map((v, k) => ` ${cut(v, width[k]).padEnd(width[k])} `).join('|').trimEnd();
88
+ return [line(columns), width.map((w) => '-'.repeat(w + 2)).join('+'), ...rows.map((r) => line(r.map((v) => v ?? '')))].join('\n');
89
+ }
90
+
91
+ export interface Notice {
92
+ level: string;
93
+ message: string;
94
+ /** (sprint 92) the time it came, HH:MM:SS.mmm */
95
+ at: string;
96
+ /** the statement it came from (1-based) */
97
+ n: number;
98
+ }
99
+
100
+ export interface WorksheetResult {
101
+ n: number;
102
+ line: number;
103
+ sql: string;
104
+ start: number;
105
+ end: number;
106
+ status: 'ok' | 'error' | 'skipped' | 'not_run';
107
+ tag?: string;
108
+ rows?: number | null;
109
+ columns?: string[];
110
+ /** the rows kept for the output (all of them up to the limit for the last query) */
111
+ data?: (string | null)[][];
112
+ /** (sprint 92) which columns are numbers (right-aligned in the grid) */
113
+ numeric?: boolean[];
114
+ /** more rows than kept */
115
+ more?: boolean;
116
+ error?: string;
117
+ /** the error's line in the worksheet and its column */
118
+ errorLine?: number;
119
+ errorColumn?: number;
120
+ ms: number;
121
+ }
122
+
123
+ export interface WorksheetRun {
124
+ results: WorksheetResult[];
125
+ notices: Notice[];
126
+ failed: number;
127
+ ms: number;
128
+ /** the plan text (explain / autotrace) */
129
+ plan?: string;
130
+ /** (sprint 92) the plan as a tree */
131
+ tree?: Plan;
132
+ }
133
+
134
+ /** COPY with the client's stream: the worksheet has no file to read or write. */
135
+ export const usesClientCopy = (sql: string) => /^\s*\\?copy\b[\s\S]*\b(from\s+stdin|to\s+stdout)\b/i.test(sql);
136
+
137
+ export interface RunOptions {
138
+ /** run the rest after a failing statement (Run Script), default: stop */
139
+ continueOnError?: boolean;
140
+ /** rows kept for the last query's grid (default 500) and for the script output of the others (default 50) */
141
+ gridRows?: number;
142
+ logRows?: number;
143
+ }
144
+
145
+ /**
146
+ * Run statements on `c` (a connection of its own, destroyed by the caller: the SQL may change the role or
147
+ * settings). Each statement autocommits, like Database Actions. Notices are collected per statement.
148
+ */
149
+ export async function runStatements(c: Client, text: string, statements: ScriptStatement[], opts: RunOptions = {}): Promise<WorksheetRun> {
150
+ const started = performance.now();
151
+ const notices: Notice[] = [];
152
+ let current = 0;
153
+ const onNotice = (m: { severity?: string; message?: string }) => {
154
+ notices.push({ level: (m.severity ?? 'NOTICE').toUpperCase(), message: m.message ?? '', n: current, at: new Date().toISOString().slice(11, 23) });
155
+ };
156
+ c.on('notice', onNotice);
157
+ const lastQuery = [...statements].reverse().find((s) => !s.psql && /^\s*(\(|select|with|values|table|show|explain|fetch|returning)\b/i.test(s.sql)) ?? statements[statements.length - 1];
158
+ const results: WorksheetResult[] = statements.map((s, k) => ({ n: k + 1, line: s.line, sql: s.sql, start: s.start, end: s.end, status: 'not_run', ms: 0 }));
159
+ let failed = 0;
160
+ try {
161
+ for (const [k, s] of statements.entries()) {
162
+ const r = results[k];
163
+ current = k + 1;
164
+ if (s.psql || usesClientCopy(s.sql)) {
165
+ r.status = 'skipped';
166
+ r.error = s.psql ? 'psql meta-commands are not supported; the line was skipped.' : 'COPY … FROM STDIN / TO STDOUT needs a client stream: use Load Data or Unload Data.';
167
+ continue;
168
+ }
169
+ const t0 = performance.now();
170
+ try {
171
+ const out = (await c.query({ text: s.sql, rowMode: 'array' })) as pg.QueryResult<unknown[]> | pg.QueryResult<unknown[]>[];
172
+ const res = Array.isArray(out) ? out[out.length - 1] : out;
173
+ r.status = 'ok';
174
+ r.tag = commandTag(s.sql, res.command, res.rowCount);
175
+ r.rows = res.rowCount;
176
+ if (res.fields?.length) {
177
+ const keep = s === lastQuery ? (opts.gridRows ?? 500) : (opts.logRows ?? 50);
178
+ const max = s === lastQuery ? GRID_VALUE : MAX_VALUE;
179
+ r.columns = res.fields.map((f) => f.name);
180
+ r.numeric = res.fields.map((f) => isNumeric(f.dataTypeID));
181
+ r.data = (res.rows ?? []).slice(0, keep).map((row) => row.map((v) => cellText(v, max)));
182
+ r.more = (res.rows ?? []).length > keep;
183
+ }
184
+ } catch (e) {
185
+ const err = e as pg.DatabaseError;
186
+ r.status = 'error';
187
+ r.error = `${err.message}${err.code ? ` (${err.code})` : ''}`;
188
+ const pos = Number(err.position);
189
+ if (pos > 0) {
190
+ const before = s.sql.slice(0, pos - 1);
191
+ r.errorLine = s.line + (before.match(/\n/g)?.length ?? 0);
192
+ r.errorColumn = pos - before.lastIndexOf('\n') - 1;
193
+ } else r.errorLine = s.line;
194
+ failed++;
195
+ }
196
+ r.ms = Math.round(performance.now() - t0);
197
+ if (r.status === 'error' && !opts.continueOnError) break;
198
+ }
199
+ } finally {
200
+ c.removeListener('notice', onNotice);
201
+ }
202
+ return { results, notices, failed, ms: Math.round(performance.now() - started) };
203
+ }
204
+
205
+ /**
206
+ * EXPLAIN the statement (Explain Plan: nothing runs) or EXPLAIN ANALYZE it inside a transaction that is
207
+ * rolled back (Autotrace: it runs, its changes don't stay).
208
+ */
209
+ export async function explainStatement(c: Client, s: ScriptStatement, analyze: boolean): Promise<WorksheetRun> {
210
+ const started = performance.now();
211
+ const r: WorksheetResult = { n: 1, line: s.line, sql: s.sql, start: s.start, end: s.end, status: 'not_run', ms: 0 };
212
+ let plan: string | undefined;
213
+ let tree: Plan | undefined;
214
+ if (s.psql || usesClientCopy(s.sql)) {
215
+ r.status = 'skipped';
216
+ r.error = 'Only SQL statements have a plan.';
217
+ } else {
218
+ try {
219
+ await c.query('begin');
220
+ const out = await c.query<{ 'QUERY PLAN': unknown }>(`explain (format json, ${analyze ? 'analyze, buffers, ' : ''}verbose, costs) ${s.sql}`);
221
+ const json = out.rows[0]?.['QUERY PLAN'];
222
+ tree = parsePlan(typeof json === 'string' ? JSON.parse(json) : json, analyze);
223
+ plan = planText(tree);
224
+ r.status = 'ok';
225
+ r.tag = analyze ? 'EXPLAIN ANALYZE (rolled back)' : 'EXPLAIN';
226
+ } catch (e) {
227
+ const err = e as pg.DatabaseError;
228
+ r.status = 'error';
229
+ r.error = `${err.message}${err.code ? ` (${err.code})` : ''}`;
230
+ r.errorLine = s.line;
231
+ } finally {
232
+ await c.query('rollback').catch(() => {});
233
+ }
234
+ }
235
+ r.ms = Math.round(performance.now() - started);
236
+ return { results: [r], notices: [], failed: r.status === 'error' ? 1 : 0, ms: r.ms, plan, tree };
237
+ }
238
+
239
+ // ---------------------------------------------------------------- (sprint 93, DA-9) substitution variables
240
+
241
+ /** The &NAME. substitution variables of `sql` (outside strings and comments), upper-cased. */
242
+ export function substitutionNames(sql: string): string[] {
243
+ const names = new Set<string>();
244
+ substituteAmp(sql, (n) => {
245
+ names.add(n);
246
+ return '';
247
+ });
248
+ return [...names];
249
+ }
250
+
251
+ /** Replace each &NAME. by its value as text (SQL*Plus and APEX style: the value goes into the statement as it is). */
252
+ export function applySubstitutions(sql: string, values: Record<string, string | undefined>): string {
253
+ return substituteAmp(sql, (n) => values[n] ?? '');
254
+ }
255
+
256
+ function substituteAmp(sql: string, f: (name: string) => string): string {
257
+ let out = '';
258
+ let i = 0;
259
+ while (i < sql.length) {
260
+ const prev = i > 0 ? sql[i - 1] : '';
261
+ if (!(sql[i] === '$' && /[A-Za-z0-9_]/.test(prev))) {
262
+ const end = skipQuoted(sql, i);
263
+ if (end !== null && end > i) {
264
+ out += sql.slice(i, end);
265
+ i = end;
266
+ continue;
267
+ }
268
+ }
269
+ const m = sql[i] === '&' ? /^&([A-Za-z_][A-Za-z0-9_]{0,62})\./.exec(sql.slice(i, i + 66)) : null;
270
+ if (m) {
271
+ out += f(m[1].toUpperCase());
272
+ i += m[0].length;
273
+ continue;
274
+ }
275
+ out += sql[i++];
276
+ }
277
+ return out;
278
+ }