pgkiln 0.46.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.
- package/db/migrations/108_sql_history.sql +25 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +89 -18
- package/docs/guide/08-security.md +1 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +20 -2
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/worksheet.css +244 -0
- package/public/worksheet.js +665 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/object-actions.ts +166 -0
- package/src/builder/object-browser.ts +40 -25
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql.ts +15 -73
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +3 -3
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +569 -0
- package/src/builder/workspaces.ts +1 -1
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
package/src/sqlformat.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { skipQuoted } from './binds.ts';
|
|
2
|
+
|
|
3
|
+
// (sprint 93, Database Actions DA-9) Format SQL for the worksheet (Ctrl+F7): keywords in one case, each clause
|
|
4
|
+
// of a query on its own line (SELECT, FROM, WHERE, GROUP BY, …), joins indented, AND / OR of a condition and the
|
|
5
|
+
// columns of a select list one per line, subqueries indented. Strings, quoted identifiers, dollar-quoted bodies
|
|
6
|
+
// and comments are kept as they are; everything else is re-spaced. It is a layout aid, not a parser: SQL it
|
|
7
|
+
// doesn't understand keeps its words and order.
|
|
8
|
+
|
|
9
|
+
const KEYWORDS = new Set(`
|
|
10
|
+
select from where and or not in is null like ilike between exists as on join inner left right full outer cross natural
|
|
11
|
+
using group by order having limit offset fetch first next rows row only union all intersect except distinct case when
|
|
12
|
+
then else end insert into values update set delete returning with recursive create table view materialized index
|
|
13
|
+
replace drop alter add column constraint primary key foreign references unique check default cascade restrict
|
|
14
|
+
truncate grant revoke to begin commit rollback do language function procedure returns return true false asc desc
|
|
15
|
+
nulls last over partition window filter lateral any some array cast interval if conflict nothing merge matched
|
|
16
|
+
explain analyze schema sequence trigger policy for each execute`.trim().split(/\s+/));
|
|
17
|
+
|
|
18
|
+
/** Clauses that start a line in a query; two-word ones are matched as their first word plus the next. */
|
|
19
|
+
const CLAUSES = new Set(['select', 'from', 'where', 'having', 'limit', 'offset', 'fetch', 'union', 'intersect', 'except', 'window', 'returning', 'values', 'set', 'with', 'insert', 'update', 'delete', 'on conflict']);
|
|
20
|
+
const TWO_WORD = new Set(['group by', 'order by', 'partition by', 'on conflict', 'union all']);
|
|
21
|
+
const JOIN_START = new Set(['join', 'inner', 'left', 'right', 'full', 'cross', 'natural']);
|
|
22
|
+
|
|
23
|
+
type Tok = { t: 'ws' | 'word' | 'quoted' | 'comment' | 'punct' | 'num'; v: string };
|
|
24
|
+
|
|
25
|
+
function tokens(sql: string): Tok[] {
|
|
26
|
+
const out: Tok[] = [];
|
|
27
|
+
let i = 0;
|
|
28
|
+
while (i < sql.length) {
|
|
29
|
+
const c = sql[i];
|
|
30
|
+
if (/\s/.test(c)) {
|
|
31
|
+
let j = i + 1;
|
|
32
|
+
while (j < sql.length && /\s/.test(sql[j])) j++;
|
|
33
|
+
out.push({ t: 'ws', v: sql.slice(i, j) });
|
|
34
|
+
i = j;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const prev = i > 0 ? sql[i - 1] : '';
|
|
38
|
+
if (!(c === '$' && /[A-Za-z0-9_]/.test(prev))) {
|
|
39
|
+
const end = skipQuoted(sql, i);
|
|
40
|
+
if (end !== null && end > i) {
|
|
41
|
+
out.push({ t: c === '-' || c === '/' ? 'comment' : 'quoted', v: sql.slice(i, end) });
|
|
42
|
+
i = end;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (/[A-Za-z_\u0080-]/.test(c)) {
|
|
47
|
+
let j = i + 1;
|
|
48
|
+
while (j < sql.length && /[A-Za-z0-9_$\u0080-]/.test(sql[j])) j++;
|
|
49
|
+
// E'…' strings: the E belongs to the string
|
|
50
|
+
if ((sql[j] === "'") && j === i + 1 && /[eE]/.test(c)) {
|
|
51
|
+
const end = skipQuoted(sql, j)!;
|
|
52
|
+
out.push({ t: 'quoted', v: sql.slice(i, end) });
|
|
53
|
+
i = end;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push({ t: 'word', v: sql.slice(i, j) });
|
|
57
|
+
i = j;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (/[0-9]/.test(c)) {
|
|
61
|
+
let j = i + 1;
|
|
62
|
+
while (j < sql.length && /[0-9.eE_]/.test(sql[j])) j++;
|
|
63
|
+
out.push({ t: 'num', v: sql.slice(i, j) });
|
|
64
|
+
i = j;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// $1 parameters
|
|
68
|
+
const param = c === '$' ? /^\$\d+/.exec(sql.slice(i, i + 12)) : null;
|
|
69
|
+
if (param) {
|
|
70
|
+
out.push({ t: 'num', v: param[0] });
|
|
71
|
+
i += param[0].length;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// operators of several characters stay together (::, <=, <>, ->>, ||, :=)
|
|
75
|
+
const op = /^(::|->>|->|#>>|#>|<=|>=|<>|!=|\|\||:=|=>)/.exec(sql.slice(i, i + 3));
|
|
76
|
+
out.push({ t: 'punct', v: op ? op[0] : c });
|
|
77
|
+
i += op ? op[0].length : 1;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function formatSql(sql: string, keywordCase: 'upper' | 'lower' = 'lower'): string {
|
|
83
|
+
const toks = tokens(sql).filter((t) => t.t !== 'ws');
|
|
84
|
+
const kw = (w: string) => (keywordCase === 'upper' ? w.toUpperCase() : w.toLowerCase());
|
|
85
|
+
interface Frame { query: boolean; base: number; clause: string; between: boolean }
|
|
86
|
+
const frames: Frame[] = [{ query: true, base: 0, clause: '', between: false }];
|
|
87
|
+
let out = '';
|
|
88
|
+
let line = ''; // the current line
|
|
89
|
+
let glue = false; // the next token sticks to the last one (a unary minus)
|
|
90
|
+
const flush = () => {
|
|
91
|
+
out += `${line.replace(/\s+$/, '')}\n`;
|
|
92
|
+
line = '';
|
|
93
|
+
};
|
|
94
|
+
const newline = (indent: number) => {
|
|
95
|
+
if (line.trim()) flush();
|
|
96
|
+
line = ' '.repeat(Math.max(0, indent));
|
|
97
|
+
};
|
|
98
|
+
const put = (s: string, space = true) => {
|
|
99
|
+
if (glue) {
|
|
100
|
+
glue = false;
|
|
101
|
+
line += s;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (space && line.trim() && !/[\s(]$/.test(line) && !/^[,).;:[\]]/.test(s) && !line.endsWith('::') && !line.endsWith('.')) line += ' ';
|
|
105
|
+
else if (!line.trim() && line.length === 0) line = ' '.repeat(frames[frames.length - 1].base);
|
|
106
|
+
line += s;
|
|
107
|
+
};
|
|
108
|
+
for (let k = 0; k < toks.length; k++) {
|
|
109
|
+
const t = toks[k];
|
|
110
|
+
const f = frames[frames.length - 1];
|
|
111
|
+
const lower = t.v.toLowerCase();
|
|
112
|
+
if (t.t === 'comment') {
|
|
113
|
+
put(t.v);
|
|
114
|
+
if (t.v.startsWith('--')) newline(f.base);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (t.t === 'word' && KEYWORDS.has(lower)) {
|
|
118
|
+
const nextWord = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
119
|
+
const two = nextWord && nextWord.t === 'word' ? `${lower} ${nextWord.v.toLowerCase()}` : '';
|
|
120
|
+
if (f.query && TWO_WORD.has(two) && two !== 'partition by') {
|
|
121
|
+
newline(f.base);
|
|
122
|
+
put(`${kw(lower)} ${kw(nextWord!.v.toLowerCase())}`);
|
|
123
|
+
f.clause = two;
|
|
124
|
+
k = toks.indexOf(nextWord!);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (f.query && CLAUSES.has(lower) && !(lower === 'set' && f.clause !== 'update') && !(lower === 'values' && f.clause === 'values')) {
|
|
128
|
+
if ((out || line.trim()) && !line.endsWith('(')) newline(f.base);
|
|
129
|
+
put(kw(lower));
|
|
130
|
+
f.clause = lower;
|
|
131
|
+
f.between = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (f.query && JOIN_START.has(lower)) {
|
|
135
|
+
const prevWord = [...toks.slice(0, k)].reverse().find((x) => x.t !== 'comment');
|
|
136
|
+
const afterJoinWord = prevWord?.t === 'word' && /^(inner|left|right|full|cross|natural|outer)$/i.test(prevWord.v);
|
|
137
|
+
// left( and right( are functions; JOIN right after INNER / LEFT … stays on its line
|
|
138
|
+
const starts = lower === 'join' ? !afterJoinWord : !!nextWord && /^(join|outer|inner)$/i.test(nextWord.v);
|
|
139
|
+
if (starts && !afterJoinWord) {
|
|
140
|
+
newline(f.base + 2);
|
|
141
|
+
put(kw(lower));
|
|
142
|
+
f.clause = 'from';
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (f.query && (lower === 'and' || lower === 'or') && ['where', 'having', 'from'].includes(f.clause)) {
|
|
147
|
+
if (lower === 'and' && f.between) {
|
|
148
|
+
f.between = false;
|
|
149
|
+
put(kw(lower));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
newline(f.base + (f.clause === 'from' ? 4 : 2) + (lower === 'and' ? 1 : 2));
|
|
153
|
+
put(kw(lower));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (lower === 'between') f.between = true;
|
|
157
|
+
put(kw(lower));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (t.t === 'punct') {
|
|
161
|
+
if (t.v === '(') {
|
|
162
|
+
const next = toks.slice(k + 1).find((x) => x.t !== 'comment');
|
|
163
|
+
const sub = !!next && next.t === 'word' && /^(select|with|values)$/i.test(next.v);
|
|
164
|
+
const prevTok = toks[k - 1];
|
|
165
|
+
const before = toks[k - 2];
|
|
166
|
+
const named = before?.t === 'word' && /^(into|table|view|index|on|using|references|function|procedure|type)$/i.test(before.v);
|
|
167
|
+
const fn = !!prevTok && (prevTok.t === 'word' || prevTok.t === 'quoted') && !named
|
|
168
|
+
&& (!KEYWORDS.has(prevTok.v.toLowerCase()) || /^(left|right|replace|cast|any|some)$/i.test(prevTok.v));
|
|
169
|
+
put('(', !fn);
|
|
170
|
+
frames.push({ query: sub, base: sub ? line.length : f.base, clause: '', between: false });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (t.v === ')') {
|
|
174
|
+
if (frames.length > 1) frames.pop();
|
|
175
|
+
put(')', false);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (t.v === ',') {
|
|
179
|
+
put(',', false);
|
|
180
|
+
if (f.query && f.clause === 'select') newline(f.base + 7);
|
|
181
|
+
else if (f.query && (f.clause === 'group by' || f.clause === 'order by')) newline(f.base + 9);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (t.v === ';') {
|
|
185
|
+
put(';', false);
|
|
186
|
+
flush();
|
|
187
|
+
out += '\n';
|
|
188
|
+
frames.splice(1);
|
|
189
|
+
frames[0].clause = '';
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (t.v === '.' || t.v === '::') {
|
|
193
|
+
line += t.v;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const prevTok = toks[k - 1];
|
|
197
|
+
const unary = (t.v === '-' || t.v === '+') && (!prevTok || prevTok.t === 'punct' && prevTok.v !== ')' || (prevTok.t === 'word' && KEYWORDS.has(prevTok.v.toLowerCase())));
|
|
198
|
+
put(t.v);
|
|
199
|
+
if (unary) glue = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// a word after "." or "::" sticks to it
|
|
203
|
+
if (line.endsWith('.') || line.endsWith('::')) line += t.v;
|
|
204
|
+
else put(t.v);
|
|
205
|
+
}
|
|
206
|
+
if (line.trim()) flush();
|
|
207
|
+
return out.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + (sql.trimEnd().endsWith(';') ? '\n' : '');
|
|
208
|
+
}
|
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
|
-
|
|
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
|
+
}
|