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
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { owner } from '../db.ts';
|
|
6
|
+
import { devDbForApp } from '../workspace-db.ts';
|
|
7
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
|
+
import { back, bicon, BASE, csrf, developer, flash, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
|
+
import { clearCompletions } from './code-editor.ts';
|
|
10
|
+
import { createTemplate, listObjects, OB_TYPES, pickSchema, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
|
|
11
|
+
import { objectMenu } from './object-actions.ts';
|
|
12
|
+
import { currentWorkspace, inWorkspaceSql, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
13
|
+
import { applySubstitutions, explainStatement, pickStatements, runStatements, substitutionNames, textTable, type RunMode, type WorksheetRun } from '../sqlworksheet.ts';
|
|
14
|
+
import { applyBinds, bindNames } from '../binds.ts';
|
|
15
|
+
import { formatSql } from '../sqlformat.ts';
|
|
16
|
+
import type { Plan, PlanNode } from '../sqlplan.ts';
|
|
17
|
+
import { unloadStatement } from '../unload.ts';
|
|
18
|
+
|
|
19
|
+
// (sprint 91, Database Actions DA-1/DA-2) The SQL Worksheet: one screen with a navigator (schema, object
|
|
20
|
+
// type, filter; tables and views open to their columns and give SELECT / INSERT / UPDATE / DELETE
|
|
21
|
+
// statements, by drag and drop too), the editor with a toolbar, and output tabs: Query Result, Script
|
|
22
|
+
// Output, Notices, Explain Plan, Autotrace and SQL History, over a status bar. Run Statement (Ctrl+Enter)
|
|
23
|
+
// runs the statement at the cursor or the selection, Run Script (F5) all of them with a log per statement,
|
|
24
|
+
// Explain Plan (F10) plans it and Autotrace (F6) runs it with EXPLAIN ANALYZE in a transaction that is
|
|
25
|
+
// rolled back. Everything is one form: without JavaScript the buttons post it and the panes stack (Run
|
|
26
|
+
// Statement then runs everything, as SQL Commands did). Runs use the workshop's connection (an isolated
|
|
27
|
+
// workspace's developer login for its developers), or, with "Run as", an application's database role so
|
|
28
|
+
// row level security applies. /sql/commands (and a post to /sql) is the same page.
|
|
29
|
+
|
|
30
|
+
const NAV_TYPES: [string, string][] = [['all', 'All Objects'], ...OB_TYPES.map(([t, label]) => [t, label] as [string, string]), ['policy', 'Policies']];
|
|
31
|
+
const MODES: RunMode[] = ['statement', 'script', 'explain', 'autotrace'];
|
|
32
|
+
const TABS = [
|
|
33
|
+
['result', 'Query Result'], ['output', 'Script Output'], ['notices', 'Notices'], ['explain', 'Explain Plan'], ['autotrace', 'Autotrace'], ['history', 'SQL History'],
|
|
34
|
+
] as const;
|
|
35
|
+
type Tab = (typeof TABS)[number][0];
|
|
36
|
+
|
|
37
|
+
/** An identifier as SQL writes it: quoted only when it has to be. */
|
|
38
|
+
const ident = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) && !RESERVED.has(n) ? n : pg.escapeIdentifier(n));
|
|
39
|
+
const RESERVED = new Set(['all', 'analyse', 'analyze', 'and', 'any', 'array', 'as', 'asc', 'case', 'cast', 'check', 'collate', 'column', 'constraint', 'create', 'default', 'desc', 'distinct', 'do', 'else', 'end', 'except', 'false', 'for', 'foreign', 'from', 'grant', 'group', 'having', 'in', 'into', 'limit', 'not', 'null', 'offset', 'on', 'only', 'or', 'order', 'primary', 'references', 'select', 'table', 'then', 'to', 'true', 'union', 'unique', 'user', 'using', 'when', 'where', 'with']);
|
|
40
|
+
|
|
41
|
+
interface Column { name: string; type: string; notnull: boolean; pk: boolean }
|
|
42
|
+
|
|
43
|
+
/** The statements the navigator offers for a table or view (SELECT for a view or materialized view only). */
|
|
44
|
+
export function objectStatements(schema: string, name: string, cols: Column[], kind: string) {
|
|
45
|
+
const q = `${ident(schema)}.${ident(name)}`;
|
|
46
|
+
const names = cols.map((c) => ident(c.name));
|
|
47
|
+
const select = `select ${names.length ? names.join(',\n ') : '*'}\n from ${q}`;
|
|
48
|
+
if (kind !== 'table') return { select };
|
|
49
|
+
const keys = cols.filter((c) => c.pk);
|
|
50
|
+
const where = (keys.length ? keys : cols.slice(0, 1)).map((c) => `${ident(c.name)} = null`).join('\n and ');
|
|
51
|
+
const editable = cols.filter((c) => !c.pk);
|
|
52
|
+
return {
|
|
53
|
+
select,
|
|
54
|
+
insert: `insert into ${q} (${names.join(', ')})\nvalues (${cols.map(() => 'null').join(', ')})`,
|
|
55
|
+
update: `update ${q}\n set ${(editable.length ? editable : cols).map((c) => `${ident(c.name)} = null`).join(',\n ')}\n where ${where}`,
|
|
56
|
+
delete: `delete from ${q}\n where ${where}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MAX_COLUMN_OBJECTS = 400;
|
|
61
|
+
/** (sprint 92) Rows of a query (the Query Result grid): normally, and with "All rows". */
|
|
62
|
+
const GRID_ROWS = 500;
|
|
63
|
+
const ALL_ROWS = 10_000;
|
|
64
|
+
/** (sprint 92) SQL History: rows kept per developer and workspace, and shown. */
|
|
65
|
+
const HISTORY_KEEP = 500;
|
|
66
|
+
const HISTORY_SHOWN = 200;
|
|
67
|
+
|
|
68
|
+
interface HistoryRow { id: string; sql: string; status: 'ok' | 'error'; rows: string | null; ms: number | null; error: string | null; runs: number; at: string }
|
|
69
|
+
|
|
70
|
+
const fmtMs = (n: number) => (n >= 100 ? n.toFixed(0) : n >= 1 ? n.toFixed(1) : n.toFixed(3));
|
|
71
|
+
|
|
72
|
+
/** (sprint 92, DA-4) A plan as a tree of steps (open), with the text plan behind a toggle. */
|
|
73
|
+
function planView(plan: Plan, text: string): Raw {
|
|
74
|
+
const step = (n: PlanNode): Raw => html`<li><details open${n.slowest ? raw(' class="is-slowest"') : ''}>
|
|
75
|
+
<summary><span class="ws-plan-op">${n.op}</span>${n.target ? html` <span class="ws-plan-target">${n.target}</span>` : ''}
|
|
76
|
+
<span class="ws-plan-nums">cost ${n.startupCost.toFixed(2)}..${n.totalCost.toFixed(2)} · rows ${n.planRows} · width ${n.width}${plan.analyze
|
|
77
|
+
? n.neverRun ? html` · <em>never executed</em>` : html` · <strong>${fmtMs(n.actualTotal ?? 0)} ms</strong> × ${n.loops} · ${n.actualRows} row${n.actualRows === 1 ? '' : 's'}${n.selfMs !== undefined ? ` · self ${fmtMs(n.selfMs)} ms` : ''}${n.sharedHit || n.sharedRead ? ` · buffers ${n.sharedHit ?? 0} hit${n.sharedRead ? `, ${n.sharedRead} read` : ''}` : ''}`
|
|
78
|
+
: ''}</span>
|
|
79
|
+
${n.slowest ? html`<span class="tag tag-warn">slowest step</span>` : ''}
|
|
80
|
+
${n.misestimate ? html`<span class="tag tag-warn" title="Estimated ${n.planRows} rows, got ${n.actualRows}: ANALYZE the table, or look at the statistics">estimate ${n.misestimate >= 1 ? `${Math.round(n.misestimate)}× too high` : `${Math.round(1 / n.misestimate)}× too low`}</span>` : ''}
|
|
81
|
+
</summary>
|
|
82
|
+
${n.details.length ? html`<dl class="ws-plan-details">${n.details.map(([k, v]) => html`<dt>${k}</dt><dd><code>${v}</code></dd>`)}</dl>` : ''}
|
|
83
|
+
${n.children.length ? html`<ul class="ws-plan-tree">${n.children.map(step)}</ul>` : ''}
|
|
84
|
+
</details></li>`;
|
|
85
|
+
return html`${plan.planningMs !== undefined || plan.executionMs !== undefined ? html`<p class="muted small ws-info">${plan.planningMs !== undefined ? `Planning ${fmtMs(plan.planningMs)} ms` : ''}${plan.executionMs !== undefined ? ` · execution ${fmtMs(plan.executionMs)} ms (rolled back)` : ''}</p>` : ''}
|
|
86
|
+
<ul class="ws-plan-tree ws-plan-root">${step(plan.root)}</ul>
|
|
87
|
+
<details class="ws-plan-text"><summary>Text plan</summary><pre class="ws-log ws-plan">${text}</pre></details>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** (sprint 92, DA-5) Keep the statements of a run in SQL History: the same statement again right after counts on its row. */
|
|
91
|
+
async function recordHistory(s: Session, run: WorksheetRun) {
|
|
92
|
+
const ws = currentWorkspace(s);
|
|
93
|
+
for (const r of run.results) {
|
|
94
|
+
if (r.status !== 'ok' && r.status !== 'error') continue;
|
|
95
|
+
const sql = r.sql.length > 20000 ? `${r.sql.slice(0, 19999)}…` : r.sql;
|
|
96
|
+
const rows = r.status === 'ok' ? (r.rows ?? null) : null;
|
|
97
|
+
const updated = await owner.query(
|
|
98
|
+
`update meta.sql_history h set runs = runs + 1, at = now(), status = $4, rows = $5, ms = $6, error = $7
|
|
99
|
+
where h.id = (select id from meta.sql_history where username = $1 and workspace_id = $2 order by id desc limit 1) and h.sql = $3`,
|
|
100
|
+
[s.username, ws, sql, r.status, rows, r.ms, r.error ?? null]);
|
|
101
|
+
if (!updated.rowCount)
|
|
102
|
+
await owner.query(`insert into meta.sql_history (username, workspace_id, sql, status, rows, ms, error) values ($1, $2, $3, $4, $5, $6, $7)`,
|
|
103
|
+
[s.username, ws, sql, r.status, rows, r.ms, r.error ?? null]);
|
|
104
|
+
}
|
|
105
|
+
await owner.query(
|
|
106
|
+
`delete from meta.sql_history where username = $1 and workspace_id = $2
|
|
107
|
+
and id < (select min(id) from (select id from meta.sql_history where username = $1 and workspace_id = $2 order by id desc limit $3) x)`,
|
|
108
|
+
[s.username, ws, HISTORY_KEEP]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The navigator's tree for a schema and an object type. */
|
|
112
|
+
async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, type: string): Promise<Raw> {
|
|
113
|
+
if (!schema) return html`<p class="muted ws-nav-empty">No schema you may use.</p>`;
|
|
114
|
+
let objects: (Obj | { type: 'policy'; name: string; ref: string; rls: false; ext: false })[] = await listObjects(db, schema, false);
|
|
115
|
+
if (type === 'all' || type === 'policy') {
|
|
116
|
+
const pol = (await db.query<{ name: string; ref: string }>(
|
|
117
|
+
`select p.polname || ' on ' || c.relname as name, c.oid::regclass::text as ref
|
|
118
|
+
from pg_policy p join pg_class c on c.oid = p.polrelid join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 order by 1`, [schema])).rows;
|
|
119
|
+
objects = [...objects, ...pol.map((p) => ({ type: 'policy' as const, name: p.name, ref: p.ref, rls: false as const, ext: false as const }))];
|
|
120
|
+
}
|
|
121
|
+
if (type !== 'all') objects = objects.filter((o) => o.type === type);
|
|
122
|
+
const rels = objects.filter((o) => o.type === 'table' || o.type === 'view' || o.type === 'matview').slice(0, MAX_COLUMN_OBJECTS);
|
|
123
|
+
const cols = new Map<string, Column[]>();
|
|
124
|
+
if (rels.length) {
|
|
125
|
+
const rows = (await db.query<Column & { ref: string }>(
|
|
126
|
+
`select c.oid::regclass::text as ref, a.attname as name, format_type(a.atttypid, a.atttypmod) as type, a.attnotnull as notnull,
|
|
127
|
+
exists (select 1 from pg_constraint k where k.conrelid = c.oid and k.contype = 'p' and a.attnum = any (k.conkey)) as pk
|
|
128
|
+
from pg_class c join pg_attribute a on a.attrelid = c.oid
|
|
129
|
+
where c.oid::regclass::text = any ($1) and a.attnum > 0 and not a.attisdropped
|
|
130
|
+
order by c.oid, a.attnum`, [rels.map((o) => o.ref)])).rows;
|
|
131
|
+
for (const r of rows) cols.set(r.ref, [...(cols.get(r.ref) ?? []), r]);
|
|
132
|
+
}
|
|
133
|
+
const iconOf = (t: string) => (OB_TYPES.find(([k]) => k === t)?.[2] ?? 'shield');
|
|
134
|
+
const label = (t: string) => NAV_TYPES.find(([k]) => k === t)?.[1] ?? t;
|
|
135
|
+
if (!objects.length) return html`<p class="muted ws-nav-empty">No ${type === 'all' ? 'objects' : label(type).toLowerCase()} in ${schema}.</p>`;
|
|
136
|
+
return html`<ul class="ws-tree" id="ws-tree">${objects.map((o) => {
|
|
137
|
+
const list = cols.get(o.ref);
|
|
138
|
+
const obLink = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
|
|
139
|
+
if (!list) return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}"><a href="${obLink}" title="${label(o.type)}">${icon(iconOf(o.type))}<span>${o.name}</span></a>${objectMenu(schema, o)}</li>`;
|
|
140
|
+
const st = objectStatements(schema, o.name, list, o.type);
|
|
141
|
+
return html`<li class="ws-obj" data-name="${o.name.toLowerCase()}">${objectMenu(schema, o)}<details>
|
|
142
|
+
<summary draggable="true" data-ws-sql="${st.select}" title="${label(o.type).replace(/s$/, '')}: drag into the editor for a SELECT">${icon(iconOf(o.type))}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}</summary>
|
|
143
|
+
<ul class="ws-cols">${list.map((c) => html`<li title="${c.type}${c.notnull ? ' not null' : ''}">${c.pk ? icon('key') : icon('minus')}<span>${c.name}</span><small class="muted">${c.type}</small></li>`)}</ul>
|
|
144
|
+
<div class="ws-obj-actions">
|
|
145
|
+
${Object.entries(st).map(([k, sql]) => html`<button type="button" class="btn btn-sm" data-ws-insert="${sql}" hidden>${k.toUpperCase()}</button>`)}
|
|
146
|
+
<a class="btn btn-sm" href="${obLink}">Open</a>
|
|
147
|
+
</div>
|
|
148
|
+
</details></li>`;
|
|
149
|
+
})}</ul>${objects.length > rels.length && rels.length === MAX_COLUMN_OBJECTS ? html`<p class="muted small ws-nav-empty">Columns are listed for the first ${MAX_COLUMN_OBJECTS} tables and views.</p>` : ''}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The output panes of a run (empty: the page as opened). */
|
|
153
|
+
function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
|
|
154
|
+
const results = run?.results ?? [];
|
|
155
|
+
const ran = results.filter((r) => r.status !== 'not_run');
|
|
156
|
+
const query = [...ran].reverse().find((r) => r.columns);
|
|
157
|
+
const errors = ran.filter((r) => r.status === 'error');
|
|
158
|
+
const warnings = run?.notices.filter((n) => n.level === 'WARNING').length ?? 0;
|
|
159
|
+
const oneLine = (sql: string) => { const x = sql.replace(/\s+/g, ' '); return x.length > 80 ? `${x.slice(0, 79)}…` : x; };
|
|
160
|
+
|
|
161
|
+
const rowsLabel = (q: NonNullable<typeof query>) => `${q.rows ?? q.data!.length} row${q.rows === 1 ? '' : 's'}`;
|
|
162
|
+
let download: Raw | '' = '';
|
|
163
|
+
if (query) {
|
|
164
|
+
try {
|
|
165
|
+
unloadStatement(query.sql);
|
|
166
|
+
download = html`<details class="menu ws-dl"><summary class="btn btn-sm">${icon('download')}<span>Download</span></summary>
|
|
167
|
+
<div class="menu-panel align-right"><div class="menu-section menu-links">${(['csv', 'json', 'xml', 'xlsx', 'sql'] as const).map((f) =>
|
|
168
|
+
html`<button form="ws-dl" name="format" value="${f}">${{ csv: 'CSV', json: 'JSON', xml: 'XML', xlsx: 'Excel', sql: 'SQL inserts' }[f]}</button>`)}</div></div></details>`;
|
|
169
|
+
} catch {
|
|
170
|
+
download = '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const result: Raw = query
|
|
174
|
+
? html`<div class="ws-result-bar">
|
|
175
|
+
<p class="muted small ws-info">${rowsLabel(query)} · ${query.ms} ms${query.more ? html` · showing the first ${query.data!.length}` : ''} · line ${query.line}</p>
|
|
176
|
+
<span class="ws-spacer"></span>
|
|
177
|
+
${query.more ? html`<button class="btn btn-sm" name="mode" value="statement_all" title="Run the statement again and show up to ${ALL_ROWS.toLocaleString('en')} rows">All rows</button>` : ''}
|
|
178
|
+
<button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span>Copy</span></button>
|
|
179
|
+
${download}
|
|
180
|
+
</div>
|
|
181
|
+
<div class="table-wrap ws-grid"><table class="report" data-ws-grid><thead><tr><th scope="col" class="ws-rownum">#</th>${query.columns!.map((c, k) => html`<th scope="col"${query.numeric?.[k] ? raw(' class="num"') : ''}><button type="button" class="ws-sort" data-ws-sort="${k}" hidden>${c}</button><span class="ws-sort-label">${c}</span></th>`)}</tr></thead>
|
|
182
|
+
<tbody>${query.data!.map((r, k) => html`<tr><td class="ws-rownum">${k + 1}</td>${r.map((v, i) => html`<td${query.numeric?.[i] ? raw(' class="num"') : ''}>${v === null ? html`<span class="null">(null)</span>` : v}</td>`)}</tr>`)}</tbody></table></div>
|
|
183
|
+
<dialog class="ws-cell" id="ws-cell" aria-labelledby="ws-cell-title"><div class="ws-cell-head"><strong id="ws-cell-title">Value</strong><button type="button" class="btn btn-sm" data-ws-cell-close>Close</button></div><pre></pre></dialog>`
|
|
184
|
+
: run && mode !== 'explain' && mode !== 'autotrace'
|
|
185
|
+
? html`<p class="muted">${errors.length ? 'The statement failed: see Script Output.' : 'No rows: the last statement was not a query. See Script Output.'}</p>`
|
|
186
|
+
: html`<p class="muted">Run a query (Ctrl+Enter) to see its rows here.</p>`;
|
|
187
|
+
|
|
188
|
+
const log = run
|
|
189
|
+
? html`<pre class="ws-log">${results.map((r) => {
|
|
190
|
+
if (r.status === 'not_run') return html`<span class="muted">-- ${r.n}. line ${r.line}: not run (an earlier statement failed)\n</span>`;
|
|
191
|
+
const head = html`<span class="ws-log-sql">-- ${r.n}. line ${r.line}: ${oneLine(r.sql)}</span>\n`;
|
|
192
|
+
if (r.status === 'skipped') return html`${head}<span class="ws-log-warn">${r.error}</span>\n\n`;
|
|
193
|
+
if (r.status === 'error') return html`${head}<span class="ws-log-err">Error${r.errorLine ? ` at line ${r.errorLine}${r.errorColumn ? `, column ${r.errorColumn}` : ''}` : ''}: ${r.error}</span>\n\n`;
|
|
194
|
+
const body = r.columns ? `${textTable(r.columns, r.data ?? [])}\n${r.more ? `(first ${r.data!.length} of ${r.rows} rows)\n` : ''}` : '';
|
|
195
|
+
const notes = run.notices.filter((n) => n.n === r.n).map((n) => `${n.level}: ${n.message}\n`).join('');
|
|
196
|
+
return html`${head}${body}${notes}<span class="ws-log-ok">${r.tag}</span> <span class="muted">(${r.ms} ms)</span>\n\n`;
|
|
197
|
+
})}<span class="muted">${ran.length} statement${ran.length === 1 ? '' : 's'} run, ${errors.length} failed, ${run.ms} ms.</span></pre>`
|
|
198
|
+
: html`<p class="muted">Run Script (F5) runs every statement and logs each one here.</p>`;
|
|
199
|
+
|
|
200
|
+
const notices = run?.notices.length
|
|
201
|
+
? html`<ul class="ws-notices">${run.notices.map((n) => html`<li><span class="tag${n.level === 'WARNING' ? ' tag-warn' : ''}">${n.level}</span> <span>${n.message}</span> <small class="muted">statement ${n.n} · ${n.at}</small></li>`)}</ul>`
|
|
202
|
+
: html`<p class="muted">RAISE NOTICE, INFO and WARNING messages of the last run show here.</p>`;
|
|
203
|
+
|
|
204
|
+
const planOf = (m: RunMode, empty: string) => mode === m && run
|
|
205
|
+
? (run.tree ? planView(run.tree, run.plan ?? '') : html`<p class="alert alert-error">${run.results[0]?.error ?? 'No plan.'}</p>`)
|
|
206
|
+
: html`<p class="muted">${empty}</p>`;
|
|
207
|
+
|
|
208
|
+
const when = (at: string) => new Date(at).toISOString().slice(0, 16).replace('T', ' ');
|
|
209
|
+
const hist = history.length
|
|
210
|
+
? html`<div class="ws-hist-bar" hidden>
|
|
211
|
+
<label class="sr-only" for="ws-hist-q">Search the history</label><input id="ws-hist-q" type="search" placeholder="Search the history" data-ws-hist-q>
|
|
212
|
+
<label class="sr-only" for="ws-hist-s">Status</label><select id="ws-hist-s" data-ws-hist-s><option value="">All</option><option value="ok">Succeeded</option><option value="error">Failed</option></select>
|
|
213
|
+
</div>
|
|
214
|
+
<ul class="ws-history">${history.map((h) => html`<li data-status="${h.status}" data-sql="${h.sql.toLowerCase()}">
|
|
215
|
+
<span class="ws-hist-icon ${h.status === 'error' ? 'is-err' : 'is-ok'}" title="${h.status === 'error' ? `Failed: ${h.error ?? ''}` : 'Succeeded'}">${icon(h.status === 'error' ? 'x-circle' : 'check-circle')}</span>
|
|
216
|
+
<a href="${BASE}/sql/worksheet?history=${h.id}" data-ws-history="${h.sql}" title="Insert at the cursor: ${h.sql.slice(0, 500)}"><code>${oneLine(h.sql)}</code></a>
|
|
217
|
+
<small class="muted ws-hist-meta">${h.status === 'ok' && h.rows !== null ? `${h.rows} row${h.rows === '1' ? '' : 's'} · ` : ''}${h.ms ?? 0} ms${h.runs > 1 ? ` · ${h.runs}×` : ''} · ${when(h.at)}</small>
|
|
218
|
+
<button type="button" class="btn btn-sm" data-ws-run="${h.sql}" title="Put it in the editor and run it" hidden>Run</button>
|
|
219
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-copy-text="${h.sql}" title="Copy" hidden>${icon('copy')}<span class="sr-only">Copy</span></button>
|
|
220
|
+
</li>`)}</ul>`
|
|
221
|
+
: html`<p class="muted">Your statements show here after you run them.</p>`;
|
|
222
|
+
|
|
223
|
+
const active: Tab = !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
|
|
224
|
+
const time = new Date().toTimeString().slice(0, 8);
|
|
225
|
+
const last = !run ? 'Ready.' : errors.length
|
|
226
|
+
? `Error${errors[0].errorLine ? ` at line ${errors[0].errorLine}` : ''}: ${errors[0].error}`
|
|
227
|
+
: mode === 'explain' || mode === 'autotrace' ? `${mode === 'explain' ? 'Explain plan' : 'Autotrace'} in ${run.ms} ms.`
|
|
228
|
+
: query && ran.length === 1 ? `${query.rows} row${query.rows === 1 ? '' : 's'} in ${query.ms} ms.`
|
|
229
|
+
: `${ran.length} statement${ran.length === 1 ? '' : 's'} in ${run.ms} ms.`;
|
|
230
|
+
const status = html`<span class="ws-count ${errors.length ? 'is-err' : ''}" title="Errors">${icon('x-circle')} ${errors.length}</span>
|
|
231
|
+
<span class="ws-count ${warnings ? 'is-warn' : ''}" title="Warnings">${icon('alert')} ${warnings}</span>
|
|
232
|
+
<span class="ws-busy" hidden>${icon('refresh')} Running…</span>
|
|
233
|
+
<span class="ws-last">${last}</span><span class="muted ws-time">${run ? time : ''}</span>`;
|
|
234
|
+
return { panes: { result, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The applications whose database role a run can take (the developer's workspace). */
|
|
238
|
+
async function runAsApps(s: Session) {
|
|
239
|
+
return (await owner.query<{ id: number; name: string; db_role: string }>(
|
|
240
|
+
`select a.id, a.name, a.db_role from meta.app a where ${inWorkspaceSql('a', 1)} and a.db_role is not null order by a.name, a.id`, [currentWorkspace(s)])).rows;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function worksheet(req: Req, reply: FastifyReply) {
|
|
244
|
+
const s = await developer(req, reply);
|
|
245
|
+
if (!s) return;
|
|
246
|
+
const db = await workshopDb(s);
|
|
247
|
+
const body = req.body ?? {};
|
|
248
|
+
const isPost = req.method === 'POST';
|
|
249
|
+
// a browser posts line breaks as CRLF; the cursor's offsets count LF
|
|
250
|
+
let text: string = isPost ? String(body.sql ?? '').replace(/\r\n?/g, '\n') : '';
|
|
251
|
+
// (sprint 93, DA-7) where the editor's text came from, for the page's worksheet tabs: a run, a saved script, a
|
|
252
|
+
// statement to insert (history, a template, a draft from the Object Browser) or nothing (restore the tabs)
|
|
253
|
+
let source: 'run' | 'script' | 'insert' | 'blank' = isPost ? 'run' : 'blank';
|
|
254
|
+
const scriptOf = (id: unknown) => /^\d{1,9}$/.test(String(id ?? ''))
|
|
255
|
+
? owner.one<{ id: number; name: string; content: string }>('select id, name, content from meta.sql_script where id = $1 and workspace_id = $2', [String(id), currentWorkspace(s)])
|
|
256
|
+
: Promise.resolve(undefined);
|
|
257
|
+
let script: { id: number; name: string } | null = null;
|
|
258
|
+
if (isPost) {
|
|
259
|
+
const r = await scriptOf(body.script_id);
|
|
260
|
+
if (r) script = { id: r.id, name: r.name };
|
|
261
|
+
} else {
|
|
262
|
+
const r = await scriptOf(req.query?.script);
|
|
263
|
+
if (req.query?.new === '1') source = 'insert'; // a new, empty worksheet
|
|
264
|
+
if (r) {
|
|
265
|
+
script = { id: r.id, name: r.name };
|
|
266
|
+
text = r.content;
|
|
267
|
+
source = 'script';
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const tabKey = /^[a-z0-9]{1,20}$/.test(String((isPost ? body.ws_tab : req.query?.wstab) ?? '')) ? String(isPost ? body.ws_tab : req.query!.wstab) : '';
|
|
271
|
+
|
|
272
|
+
// (sprint 93, DA-7) the worksheet as a file: Save, Save As, Rename (SQL Scripts of the workspace), Download
|
|
273
|
+
let fileError = '';
|
|
274
|
+
if (isPost && ['save', 'save_as', 'rename', 'download'].includes(String(body.mode ?? ''))) {
|
|
275
|
+
const name = String(body.ws_name ?? '').trim();
|
|
276
|
+
if (body.mode === 'download') {
|
|
277
|
+
const file = (script?.name ?? (name || 'worksheet')).replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'worksheet';
|
|
278
|
+
return reply.header('content-disposition', `attachment; filename="${file}.sql"`).header('cache-control', 'private, no-store').type('application/sql; charset=utf-8').send(text);
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
if (name.length > 200) fileError = 'A name has at most 200 characters.';
|
|
282
|
+
else if (text.length > 5_000_000) fileError = 'A script holds at most 5 MB.';
|
|
283
|
+
else if (body.mode === 'save' && script) {
|
|
284
|
+
await owner.query('update meta.sql_script set content = $2, updated_by = $3, updated_at = now() where id = $1 and workspace_id = $4', [script.id, text, s.username, currentWorkspace(s)]);
|
|
285
|
+
} else if (body.mode === 'rename' && script) {
|
|
286
|
+
if (!name) fileError = 'Enter the new name.';
|
|
287
|
+
else await owner.query('update meta.sql_script set name = $2, updated_by = $3, updated_at = now() where id = $1 and workspace_id = $4', [script.id, name, s.username, currentWorkspace(s)]);
|
|
288
|
+
} else if (!name) fileError = 'Name the worksheet to save it as a script.';
|
|
289
|
+
else {
|
|
290
|
+
const r = await owner.one<{ id: number }>(
|
|
291
|
+
`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, text, s.username, currentWorkspace(s)]);
|
|
292
|
+
script = { id: r!.id, name };
|
|
293
|
+
}
|
|
294
|
+
} catch (e) {
|
|
295
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
296
|
+
fileError = `A script named “${name}” already exists.`;
|
|
297
|
+
}
|
|
298
|
+
if (!fileError && script) {
|
|
299
|
+
flash(s, body.mode === 'rename' ? `Renamed to “${name}”.` : `Saved as the script “${body.mode === 'save' ? script.name : name}”.`);
|
|
300
|
+
return back(reply, s, `${BASE}/sql/worksheet?${new URLSearchParams({ script: String(script.id), ...(tabKey ? { wstab: tabKey } : {}) })}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!isPost) {
|
|
304
|
+
// a command from SQL History or the home page's Recent SQL Commands (the developer's own)
|
|
305
|
+
if (/^\d{1,18}$/.test(String(req.query?.recent ?? ''))) {
|
|
306
|
+
const r = await owner.one<{ detail: string }>(`select detail from meta.activity_log where id = $1 and username = $2 and event = 'sql_command'`, [req.query!.recent, s.username]);
|
|
307
|
+
if (r) {
|
|
308
|
+
text = r.detail;
|
|
309
|
+
source = 'insert';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// (sprint 92) a statement from SQL History (one's own, in this workspace)
|
|
313
|
+
if (/^\d{1,18}$/.test(String(req.query?.history ?? ''))) {
|
|
314
|
+
const r = await owner.one<{ sql: string }>(`select sql from meta.sql_history where id = $1 and username = $2 and workspace_id = $3`, [req.query!.history, s.username, currentWorkspace(s)]);
|
|
315
|
+
if (r) {
|
|
316
|
+
text = r.sql;
|
|
317
|
+
source = 'insert';
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const [schemas, searchSchema, apps] = await Promise.all([usableSchemas(db), sessionSchema(s, db), runAsApps(s)]);
|
|
322
|
+
if (!isPost) {
|
|
323
|
+
// (sprint 75) a statement to review: from the Object Browser's column actions, or a CREATE template
|
|
324
|
+
if (typeof s.state.__SQLDRAFT === 'string') {
|
|
325
|
+
text = s.state.__SQLDRAFT;
|
|
326
|
+
delete s.state.__SQLDRAFT;
|
|
327
|
+
source = 'insert';
|
|
328
|
+
} else if (typeof req.query?.template === 'string') {
|
|
329
|
+
const into = typeof req.query.schema === 'string' && schemas.includes(req.query.schema) ? req.query.schema : (searchSchema ?? 'public');
|
|
330
|
+
const t = createTemplate(req.query.template, into);
|
|
331
|
+
if (t) {
|
|
332
|
+
text = t;
|
|
333
|
+
source = 'insert';
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// the navigator: schema and object type
|
|
338
|
+
const navSchemas = schemas.filter((n) => !SYSTEM_SCHEMAS.includes(n));
|
|
339
|
+
const navSchema = await pickSchema(s, db, navSchemas, isPost ? body.nav_schema : req.query?.schema);
|
|
340
|
+
const navType = NAV_TYPES.some(([k]) => k === (isPost ? body.nav_type : req.query?.type)) ? String(isPost ? body.nav_type : req.query?.type) : 'all';
|
|
341
|
+
const asApp = isPost && /^\d{1,9}$/.test(String(body.as_app ?? '')) ? apps.find((a) => String(a.id) === String(body.as_app)) : undefined;
|
|
342
|
+
if (isPost && body.as_app && !asApp) return reply.code(404).send('Not found');
|
|
343
|
+
|
|
344
|
+
let run: WorksheetRun | null = null;
|
|
345
|
+
let mode: RunMode | null = null;
|
|
346
|
+
let ran: { start: number; end: number } | null = null;
|
|
347
|
+
const continueOnError = body.continue_on_error === 'on';
|
|
348
|
+
// (sprint 93, DA-9) bind variables (:NAME) and substitution variables (&NAME.) asked for before a run
|
|
349
|
+
let askValues: { binds: string[]; subs: string[]; mode: string } | null = null;
|
|
350
|
+
const remembered: Record<string, string> = (() => {
|
|
351
|
+
try {
|
|
352
|
+
const v = JSON.parse(s.state.__WSBINDS ?? '{}');
|
|
353
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
354
|
+
} catch {
|
|
355
|
+
return {};
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
if (isPost && text.trim() && !['nav', 'save', 'save_as', 'rename', 'download'].includes(String(body.mode ?? ''))) {
|
|
359
|
+
const m: RunMode = MODES.find((x) => x === body.mode) ?? 'statement';
|
|
360
|
+
const allRows = body.mode === 'statement_all';
|
|
361
|
+
mode = m;
|
|
362
|
+
const sel = /^\d{1,9}$/.test(String(body.sel_start ?? '')) && /^\d{1,9}$/.test(String(body.sel_end ?? '')) ? { start: Number(body.sel_start), end: Number(body.sel_end) } : undefined;
|
|
363
|
+
const picked = pickStatements(text, m, sel);
|
|
364
|
+
const binds = [...new Set(picked.flatMap((x) => (x.psql ? [] : bindNames(x.sql))))].slice(0, 50);
|
|
365
|
+
const subs = [...new Set(picked.flatMap((x) => (x.psql ? [] : substitutionNames(x.sql))))].slice(0, 50);
|
|
366
|
+
if (picked.length && (binds.length || subs.length) && body.binds_given !== '1') {
|
|
367
|
+
askValues = { binds, subs, mode: String(body.mode ?? 'statement') };
|
|
368
|
+
ran = { start: picked[0].start, end: picked[picked.length - 1].end };
|
|
369
|
+
}
|
|
370
|
+
const value = (prefix: string, n: string) => String(body[`${prefix}_${n}`] ?? '').slice(0, 4000);
|
|
371
|
+
if (!askValues && (binds.length || subs.length)) {
|
|
372
|
+
for (const n of binds) remembered[`:${n}`] = value('bind', n);
|
|
373
|
+
for (const n of subs) remembered[`&${n}`] = value('sub', n);
|
|
374
|
+
s.state.__WSBINDS = JSON.stringify(Object.fromEntries(Object.entries(remembered).slice(-100)));
|
|
375
|
+
}
|
|
376
|
+
const statements = askValues ? [] : picked.map((x) => (x.psql ? x : {
|
|
377
|
+
...x,
|
|
378
|
+
sql: applyBinds(applySubstitutions(x.sql, Object.fromEntries(subs.map((n) => [n, value('sub', n)]))), Object.fromEntries(binds.map((n) => [n, value('bind', n)]))),
|
|
379
|
+
}));
|
|
380
|
+
if (statements.length) {
|
|
381
|
+
ran = { start: statements[0].start, end: statements[statements.length - 1].end };
|
|
382
|
+
// a connection of its own, closed afterwards: the SQL may SET ROLE or change settings, which must not
|
|
383
|
+
// leak into the builder's pooled connections
|
|
384
|
+
const c = await (asApp ? await devDbForApp(asApp.id) : db).pool.connect();
|
|
385
|
+
const started = performance.now();
|
|
386
|
+
try {
|
|
387
|
+
if (searchSchema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(searchSchema)}, public`]);
|
|
388
|
+
if (asApp) {
|
|
389
|
+
await c.query(`select set_config('pgkiln.app_id', $1, false)`, [String(asApp.id)]);
|
|
390
|
+
await c.query(`set role ${pg.escapeIdentifier(asApp.db_role)}`);
|
|
391
|
+
}
|
|
392
|
+
run = m === 'explain' || m === 'autotrace'
|
|
393
|
+
? await explainStatement(c, statements[0], m === 'autotrace')
|
|
394
|
+
: await runStatements(c, text, statements, { continueOnError: m === 'script' && continueOnError, gridRows: allRows ? ALL_ROWS : GRID_ROWS });
|
|
395
|
+
} catch (e) {
|
|
396
|
+
run = { results: [{ n: 1, line: statements[0].line, sql: statements[0].sql, start: 0, end: 0, status: 'error', error: (e as Error).message, ms: 0 }], notices: [], failed: 1, ms: 0 };
|
|
397
|
+
} finally {
|
|
398
|
+
c.release(true);
|
|
399
|
+
}
|
|
400
|
+
// the log and history show the statements as written (with :NAME), not with the values
|
|
401
|
+
if (run) for (const r of run.results) r.sql = picked[r.n - 1]?.sql ?? r.sql;
|
|
402
|
+
if (mode === 'statement' || mode === 'script') {
|
|
403
|
+
clearCompletions(); // the statements may have changed tables or grants
|
|
404
|
+
if (run) await recordHistory(s, run);
|
|
405
|
+
const detail = picked.map((x) => x.sql).join(';\n');
|
|
406
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: detail.length > 2000 ? `${detail.slice(0, 2000)}…` : detail });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const history = (await owner.query<HistoryRow>(
|
|
411
|
+
`select id, sql, status, rows, ms, error, runs, at from meta.sql_history where username = $1 and workspace_id = $2 order by id desc limit $3`,
|
|
412
|
+
[s.username, currentWorkspace(s), HISTORY_SHOWN])).rows;
|
|
413
|
+
const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''));
|
|
414
|
+
const lastQuery = [...(run?.results ?? [])].reverse().find((r) => r.status === 'ok' && r.columns);
|
|
415
|
+
const downloadable = (() => {
|
|
416
|
+
try {
|
|
417
|
+
return lastQuery ? unloadStatement(lastQuery.sql) : null;
|
|
418
|
+
} catch {
|
|
419
|
+
return null; // not one SELECT: Unload Data would refuse it
|
|
420
|
+
}
|
|
421
|
+
})();
|
|
422
|
+
const firstError = run?.results.find((r) => r.status === 'error');
|
|
423
|
+
const files = (await owner.query<{ id: number; name: string; updated_at: string }>(
|
|
424
|
+
'select id, name, updated_at from meta.sql_script where workspace_id = $1 order by lower(name) limit 300', [currentWorkspace(s)])).rows;
|
|
425
|
+
const bindDialog = askValues ? html`<dialog class="ws-binds" id="ws-binds" open aria-labelledby="ws-binds-title" data-ws-binds>
|
|
426
|
+
<h2 id="ws-binds-title">Values for the run</h2>
|
|
427
|
+
<p class="muted small">Bind variables (<code>:NAME</code>) get a value as a quoted literal; substitution variables (<code>&NAME.</code>) put the text into the statement as it is. Empty is NULL for a bind variable.</p>
|
|
428
|
+
<input type="hidden" name="binds_given" value="1">
|
|
429
|
+
<div class="ws-binds-fields">${askValues.binds.map((n) => html`<label class="field"><span class="label"><code>:${n}</code></span><input name="bind_${n}" value="${remembered[`:${n}`] ?? ''}" autocomplete="off"></label>`)}${askValues.subs.map((n) => html`<label class="field"><span class="label"><code>&${n}.</code></span><input name="sub_${n}" value="${remembered[`&${n}`] ?? ''}" autocomplete="off"></label>`)}</div>
|
|
430
|
+
<div class="buttons"><button class="btn btn-hot" name="mode" value="${askValues.mode}">${icon('play')} Run</button><button class="btn" name="mode" value="nav" data-ws-binds-cancel>Cancel</button></div>
|
|
431
|
+
</dialog>` : '';
|
|
432
|
+
const runsAs = asApp ? `the role ${asApp.db_role} of ${asApp.name}` : workshopRunsAs(db);
|
|
433
|
+
|
|
434
|
+
const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
|
|
435
|
+
html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
|
|
436
|
+
const main = html`<div class="ws-page">
|
|
437
|
+
<h1 class="sr-only">SQL Worksheet</h1>
|
|
438
|
+
${workshopTabs('sql')}
|
|
439
|
+
<form method="post" action="${BASE}/sql/worksheet" class="ws" id="ws" data-ran="${ran ? `${ran.start},${ran.end}` : ''}" data-error-line="${firstError?.errorLine ?? ''}"
|
|
440
|
+
data-source="${source}" data-script-id="${script?.id ?? ''}" data-script-name="${script?.name ?? ''}" data-tab="${tabKey}">${csrf(s)}
|
|
441
|
+
<button class="ws-default" name="mode" value="nav" tabindex="-1">Refresh the navigator</button>
|
|
442
|
+
<input type="hidden" name="sel_start" value=""><input type="hidden" name="sel_end" value="">
|
|
443
|
+
<input type="hidden" name="script_id" value="${script?.id ?? ''}"><input type="hidden" name="ws_tab" value="${tabKey}">
|
|
444
|
+
${bindDialog}
|
|
445
|
+
<aside class="ws-nav" id="ws-nav" aria-label="Navigator">
|
|
446
|
+
<div class="ws-nav-switch" role="tablist" aria-label="Navigator" hidden>
|
|
447
|
+
<button type="button" role="tab" aria-selected="true" aria-controls="ws-nav-objects" data-ws-navtab="objects">Objects</button>
|
|
448
|
+
<button type="button" role="tab" aria-selected="false" aria-controls="ws-files" data-ws-navtab="files">Files</button>
|
|
449
|
+
</div>
|
|
450
|
+
<div id="ws-nav-objects" class="ws-nav-panel">
|
|
451
|
+
<div class="ws-nav-head">
|
|
452
|
+
<label class="sr-only" for="ws-schema">Schema</label>
|
|
453
|
+
<select id="ws-schema" name="nav_schema" data-ws-nav>${navSchemas.map((n) => html`<option value="${n}"${n === navSchema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
454
|
+
<label class="sr-only" for="ws-type">Object type</label>
|
|
455
|
+
<select id="ws-type" name="nav_type" data-ws-nav>${NAV_TYPES.map(([k, label]) => html`<option value="${k}"${k === navType ? raw(' selected') : ''}>${label}</option>`)}</select>
|
|
456
|
+
<div class="ws-nav-search">
|
|
457
|
+
<label class="sr-only" for="ws-filter">Search objects</label>
|
|
458
|
+
<input id="ws-filter" type="search" placeholder="Search" autocomplete="off" data-ws-filter>
|
|
459
|
+
<button class="btn btn-sm btn-icon" name="mode" value="nav" title="Refresh">${icon('refresh')}<span class="sr-only">Refresh</span></button>
|
|
460
|
+
</div>
|
|
461
|
+
</div>
|
|
462
|
+
<div class="ws-nav-body" id="ws-nav-body">${await navigatorTree(db, navSchema, navType)}</div>
|
|
463
|
+
</div>
|
|
464
|
+
<section id="ws-files" class="ws-nav-panel ws-files" aria-label="Files">
|
|
465
|
+
<h2 class="ws-files-title">Files <small class="muted">(SQL Scripts)</small></h2>
|
|
466
|
+
${files.length ? html`<ul>${files.map((f) => html`<li><a href="${BASE}/sql/worksheet?script=${f.id}"${script?.id === f.id ? raw(' aria-current="true"') : ''}>${icon('file')}<span>${f.name}</span></a></li>`)}</ul>` : html`<p class="muted small">No scripts yet: Save a worksheet to make one.</p>`}
|
|
467
|
+
</section>
|
|
468
|
+
</aside>
|
|
469
|
+
<div class="ws-split ws-split-v" role="separator" aria-orientation="vertical" aria-label="Resize the navigator" tabindex="0" data-ws-split="nav" hidden></div>
|
|
470
|
+
<section class="ws-main" aria-label="Worksheet">
|
|
471
|
+
<div class="ws-toolbar" role="toolbar" aria-label="Worksheet">
|
|
472
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-toggle-nav title="Hide or show the navigator" hidden>${bicon('left')}<span class="sr-only">Navigator</span></button>
|
|
473
|
+
<details class="menu ws-file"><summary class="btn btn-sm" title="Save, open, rename or download the worksheet (Ctrl+S saves)">${icon('file')}<span class="ws-file-name">${script?.name ?? 'Untitled'}</span></summary>
|
|
474
|
+
<div class="menu-panel"><div class="menu-section ws-file-panel">
|
|
475
|
+
<label class="label" for="ws-name">Name</label>
|
|
476
|
+
<input id="ws-name" name="ws_name" value="${script?.name ?? ''}" maxlength="200" autocomplete="off">
|
|
477
|
+
<div class="ws-file-actions">
|
|
478
|
+
<button class="btn btn-sm btn-hot" name="mode" value="save" title="Save (Ctrl+S)">${icon('save')} Save</button>
|
|
479
|
+
${script ? html`<button class="btn btn-sm" name="mode" value="save_as">Save as new</button><button class="btn btn-sm" name="mode" value="rename">Rename</button>` : ''}
|
|
480
|
+
<button class="btn btn-sm" name="mode" value="download">${icon('download')} Download</button>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a></div>
|
|
483
|
+
</div></div></details>
|
|
484
|
+
${toolBtn('statement', 'play', 'Run Statement', 'Ctrl+Enter', true)}
|
|
485
|
+
${toolBtn('script', 'file', 'Run Script', 'F5')}
|
|
486
|
+
${toolBtn('explain', 'route', 'Explain Plan', 'F10')}
|
|
487
|
+
${toolBtn('autotrace', 'activity', 'Autotrace', 'F6')}
|
|
488
|
+
<label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> Continue on error</label>
|
|
489
|
+
<span class="ws-spacer"></span>
|
|
490
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-format title="Format (Ctrl+F7)" hidden>${icon('sliders')}<span class="sr-only">Format</span></button>
|
|
491
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-case title="Convert case of the selection (Ctrl+B)" hidden><span aria-hidden="true" class="ws-aa">Aa</span><span class="sr-only">Convert case</span></button>
|
|
492
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-find title="Find and replace (Ctrl+F, Ctrl+H)" hidden>${icon('search')}<span class="sr-only">Find</span></button>
|
|
493
|
+
<a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="Load data into a table">${icon('upload')}<span class="sr-only">Load data</span></a>
|
|
494
|
+
<details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="Keyboard shortcuts">?<span class="sr-only">Keyboard shortcuts</span></summary>
|
|
495
|
+
<div class="menu-panel align-right"><div class="menu-section"><dl class="ws-keylist">
|
|
496
|
+
<dt>Ctrl+Enter</dt><dd>Run Statement</dd><dt>F5</dt><dd>Run Script</dd><dt>F10</dt><dd>Explain Plan</dd><dt>F6</dt><dd>Autotrace</dd>
|
|
497
|
+
<dt>Ctrl+S</dt><dd>Save</dd><dt>Ctrl+F7</dt><dd>Format</dd><dt>Ctrl+B</dt><dd>Convert case</dd><dt>Ctrl+F / Ctrl+H</dt><dd>Find / replace</dd>
|
|
498
|
+
<dt>Ctrl+G</dt><dd>Go to line</dd><dt>Ctrl+/</dt><dd>Comment the lines</dd><dt>Ctrl+Space</dt><dd>Suggestions</dd><dt>F1</dt><dd>Help</dd><dt>Escape</dt><dd>Leave fullscreen</dd>
|
|
499
|
+
</dl>
|
|
500
|
+
<label class="ws-kwcase" hidden>Format keywords in <select data-ws-kwcase><option value="lower">lower case</option><option value="upper">UPPER CASE</option></select></label>
|
|
501
|
+
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>Help on the SQL Worksheet</a></div></div></details>
|
|
502
|
+
<label class="ws-runas"><span>Run as</span>
|
|
503
|
+
<select name="as_app" title="Who the statements run as">
|
|
504
|
+
<option value="">${db === owner ? 'Owner connection' : 'Workspace login'}</option>
|
|
505
|
+
${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
|
|
506
|
+
</select></label>
|
|
507
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-clear title="Clear the editor" hidden>${icon('trash')}<span class="sr-only">Clear</span></button>
|
|
508
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="Fullscreen (Escape leaves)" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">Fullscreen</span></button>
|
|
509
|
+
</div>
|
|
510
|
+
<p class="ws-runs-as muted small">Runs as ${runsAs}${searchSchema ? html`; <code>${searchSchema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Each statement commits on its own.</p>
|
|
511
|
+
${fileError ? html`<p class="alert alert-error ws-file-error" role="alert">${fileError}</p>` : ''}
|
|
512
|
+
<div class="ws-wtabs" role="tablist" aria-label="Worksheets" hidden></div>
|
|
513
|
+
<div class="ws-findbar" hidden>
|
|
514
|
+
<label class="sr-only" for="ws-find-q">Find</label><input id="ws-find-q" type="search" placeholder="Find" autocomplete="off" data-ws-find-q>
|
|
515
|
+
<label class="sr-only" for="ws-find-r">Replace with</label><input id="ws-find-r" placeholder="Replace with" autocomplete="off" data-ws-find-r>
|
|
516
|
+
<label class="ws-check"><input type="checkbox" data-ws-find-re> Regex</label>
|
|
517
|
+
<label class="ws-check"><input type="checkbox" data-ws-find-cs> Match case</label>
|
|
518
|
+
<button type="button" class="btn btn-sm" data-ws-find-next>Next</button>
|
|
519
|
+
<button type="button" class="btn btn-sm" data-ws-find-replace>Replace</button>
|
|
520
|
+
<button type="button" class="btn btn-sm" data-ws-find-all>Replace all</button>
|
|
521
|
+
<label class="sr-only" for="ws-goto">Go to line</label><input id="ws-goto" type="number" min="1" placeholder="Line" class="ws-goto" data-ws-goto>
|
|
522
|
+
<span class="muted small" data-ws-find-msg></span>
|
|
523
|
+
<button type="button" class="btn btn-sm btn-icon" data-ws-find-close title="Close (Escape)">${icon('x-circle')}<span class="sr-only">Close</span></button>
|
|
524
|
+
</div>
|
|
525
|
+
<div class="ws-editor">
|
|
526
|
+
<textarea id="ws-sql" name="sql" aria-label="SQL" class="code sql-editor" rows="14" spellcheck="false" data-code="plpgsql">${text || (isPost ? '' : 'select current_user, version();')}</textarea>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="ws-split ws-split-h" role="separator" aria-orientation="horizontal" aria-label="Resize the output" tabindex="0" data-ws-split="out" hidden></div>
|
|
529
|
+
<div class="ws-output" data-active="${active}">
|
|
530
|
+
<div class="ws-tabs" role="tablist" aria-label="Output">${TABS.map(([k, label]) => html`<button type="button" role="tab" id="ws-tab-${k}" aria-controls="ws-pane-${k}" aria-selected="${k === active ? 'true' : 'false'}" data-ws-tab="${k}">${label}</button>`)}</div>
|
|
531
|
+
${TABS.map(([k, label]) => html`<section class="ws-pane" id="ws-pane-${k}" role="tabpanel" aria-labelledby="ws-tab-${k}" data-pane="${k}"><h2 class="ws-pane-title">${label}</h2>${panes[k]}</section>`)}
|
|
532
|
+
</div>
|
|
533
|
+
</section>
|
|
534
|
+
<footer class="ws-status" role="status" aria-live="polite">${status}</footer>
|
|
535
|
+
</form>
|
|
536
|
+
${downloadable ? html`<form method="post" action="${BASE}/sql/unload" id="ws-dl" class="ws-dl-form">${csrf(s)}<input type="hidden" name="source" value="query"><input type="hidden" name="query" value="${downloadable}"><input type="hidden" name="header" value="1"><input type="hidden" name="bom" value="1"></form>` : ''}
|
|
537
|
+
</div>`;
|
|
538
|
+
return send(reply, s, shell(s, 'SQL Worksheet', [['SQL Workshop', `${BASE}/sql`], ['SQL Worksheet']], main, 'sql', { full: true }));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function worksheetRoutes(app: FastifyInstance) {
|
|
542
|
+
for (const path of ['/sql/worksheet', '/sql/commands']) {
|
|
543
|
+
app.get(`${BASE}${path}`, worksheet);
|
|
544
|
+
app.post(`${BASE}${path}`, worksheet);
|
|
545
|
+
}
|
|
546
|
+
// (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
|
|
547
|
+
app.post(`${BASE}/sql`, worksheet);
|
|
548
|
+
|
|
549
|
+
// (sprint 93, DA-9) Format: the worksheet's text laid out, keywords in the case asked for
|
|
550
|
+
app.post(`${BASE}/sql/worksheet/format`, async (req: Req, reply) => {
|
|
551
|
+
const s = await developer(req, reply);
|
|
552
|
+
if (!s) return;
|
|
553
|
+
const sql = String(req.body?.sql ?? '');
|
|
554
|
+
if (sql.length > 1_000_000) return reply.code(413).send({ error: 'Too long to format.' });
|
|
555
|
+
return reply.header('Cache-Control', 'no-store').send({ sql: formatSql(sql, req.body?.case === 'upper' ? 'upper' : 'lower') });
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// the navigator for another schema or object type (the page swaps it in without a reload)
|
|
559
|
+
app.get(`${BASE}/sql/worksheet/navigator`, async (req: Req, reply) => {
|
|
560
|
+
const s = await developer(req, reply);
|
|
561
|
+
if (!s) return;
|
|
562
|
+
const db = await workshopDb(s);
|
|
563
|
+
const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
|
|
564
|
+
if (req.query?.schema && !schemas.includes(String(req.query.schema))) return reply.code(404).send('Not found');
|
|
565
|
+
const schema = await pickSchema(s, db, schemas, req.query?.schema);
|
|
566
|
+
const type = NAV_TYPES.some(([k]) => k === req.query?.type) ? String(req.query!.type) : 'all';
|
|
567
|
+
return reply.header('Cache-Control', 'private, no-store').type('text/html').send(String(await navigatorTree(db, schema, type)));
|
|
568
|
+
});
|
|
569
|
+
}
|
|
@@ -213,7 +213,7 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
|
|
|
213
213
|
const check = (name: string, on: boolean, label: string, help: string) =>
|
|
214
214
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
|
|
215
215
|
return region('Workspace preferences', html`<form method="post" action="${BASE}/workspaces/${w.id}/preferences">${csrf(s)}
|
|
216
|
-
${check('sql_workshop', w.sql_workshop, 'Enable the SQL Workshop', 'Off: SQL
|
|
216
|
+
${check('sql_workshop', w.sql_workshop, 'Enable the SQL Workshop', 'Off: the SQL Worksheet, the Object Browser, SQL scripts, data loading and unloading, Quick SQL, the query builder, DDL, Compare and AI are refused while this workspace is current, for every developer.')}
|
|
217
217
|
${check('rest_services', w.rest_services, 'Enable RESTful services', 'Off: the REST modules of this workspace\'s applications answer 404 (Not found) at /a/<alias>/rest/…; they can still be edited.')}
|
|
218
218
|
<div class="buttons"><button class="btn">Save preferences</button></div></form>`, '', 'preferences');
|
|
219
219
|
}
|