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
|
@@ -15,7 +15,7 @@ import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
|
15
15
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
16
16
|
// chooses for the builder (meta.builder_ai):
|
|
17
17
|
// - SQL Workshop → AI: SQL from a question (shown, never run: the developer
|
|
18
|
-
// may run it in SQL
|
|
18
|
+
// may run it in the SQL Worksheet), and an explanation of a query or an error;
|
|
19
19
|
// - SQL Workshop → AI → Describe tables: descriptions of tables and columns
|
|
20
20
|
// for the model (meta.ai_table_note, optionally also COMMENT ON), typed or
|
|
21
21
|
// drafted by AI from the names and types (never from the data) and
|
|
@@ -148,7 +148,7 @@ async function workshopPage(s: Session, reply: FastifyReply, out: { question?: s
|
|
|
148
148
|
${out.result ?? ''}
|
|
149
149
|
<div class="columns">
|
|
150
150
|
${region('SQL from a question', html`
|
|
151
|
-
<p class="muted u-mt0">The answer is shown, never run: check it, then run it in SQL
|
|
151
|
+
<p class="muted u-mt0">The answer is shown, never run: check it, then run it in the SQL Worksheet if you want.</p>
|
|
152
152
|
<form method="post" action="${BASE}/sql/ai/sql">${csrf(s)}
|
|
153
153
|
<div class="form-grid">${select('schema', 'Schema', out.schema ?? list[0] ?? '', list)}</div>
|
|
154
154
|
<div class="field"><label class="label" for="f_question">Question</label>
|
|
@@ -324,7 +324,7 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
324
324
|
<form method="post" action="${BASE}/sql">${csrf(s)}
|
|
325
325
|
<div class="field"><label class="label" for="f_ai_sql">SQL (not run)</label>
|
|
326
326
|
<textarea id="f_ai_sql" name="sql" class="code sql-editor" rows="10" spellcheck="false" data-code="plpgsql">${sql}</textarea></div>
|
|
327
|
-
<div class="buttons"><button class="btn">Run in SQL
|
|
327
|
+
<div class="buttons"><button class="btn">Run in the SQL Worksheet</button></div>
|
|
328
328
|
</form>`);
|
|
329
329
|
} catch (e) {
|
|
330
330
|
result = errorBox(e);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { html, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { readSchema } from '../ddl.ts';
|
|
6
|
+
import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
7
|
+
import { listObjects, objectDetail, OB_TYPES, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
|
|
8
|
+
import { usableSchemas, workshopDb } from './workspaces.ts';
|
|
9
|
+
|
|
10
|
+
// (sprint 94, Database Actions DA-8) An object's menu in the SQL Worksheet's navigator and the Object Browser's
|
|
11
|
+
// tree (Database Actions: the navigator's right-click menu): Open (the Object Browser's detail, in a panel beside
|
|
12
|
+
// the worksheet), Quick DDL into the worksheet or as a file, Load data, Add an index / trigger / policy, Use as a
|
|
13
|
+
// template, and Drop with what depends on the object. Nothing here changes the database: every statement goes into
|
|
14
|
+
// the worksheet to review and run. The object is looked up in the catalog with the workshop's connection (its
|
|
15
|
+
// schema, type and reference must name one of the listed objects) before anything is read.
|
|
16
|
+
|
|
17
|
+
type Db = Awaited<ReturnType<typeof workshopDb>>;
|
|
18
|
+
export type MenuObj = Pick<Obj, 'name' | 'ref'> & { type: Obj['type'] | 'policy' };
|
|
19
|
+
|
|
20
|
+
const RELATIONS = new Set(['table', 'view', 'matview']);
|
|
21
|
+
|
|
22
|
+
/** The ⋯ menu of an object (works without JavaScript: a details menu of links). */
|
|
23
|
+
export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
24
|
+
const q = (act: string) => `${BASE}/sql/objects/action?${new URLSearchParams({ schema, k: o.type, o: o.ref, ...(o.type === 'policy' || o.type === 'trigger' ? { n: o.name } : {}), do: act })}`;
|
|
25
|
+
const open = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
|
|
26
|
+
const item = (href: string, ic: string, label: string, extra = '') => html`<a href="${href}"${extra ? html` ${extra}` : ''}>${icon(ic)} ${label}</a>`;
|
|
27
|
+
const table = o.type === 'table';
|
|
28
|
+
return html`<details class="menu obj-menu"><summary title="Actions for ${o.name}" aria-label="Actions for ${o.name}">⋯</summary>
|
|
29
|
+
<div class="menu-panel"><div class="menu-section menu-links">
|
|
30
|
+
${o.type === 'policy' ? '' : html`<a href="${open}" data-obj-open="${`${BASE}/sql/objects/panel?${new URLSearchParams({ schema, k: o.type, o: o.ref })}`}">${icon('eye')} Open</a>`}
|
|
31
|
+
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', 'Edit…') : ''}
|
|
32
|
+
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
|
|
33
|
+
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
34
|
+
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
35
|
+
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
|
36
|
+
${item(q('download'), 'download', 'Quick DDL as a file')}
|
|
37
|
+
${item(q('drop'), 'trash', 'Drop…')}
|
|
38
|
+
</div></div></details>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The listed object the request names, or undefined. */
|
|
42
|
+
async function findObject(db: Db, schema: unknown, kind: unknown, ref: unknown, name: unknown): Promise<MenuObj | undefined> {
|
|
43
|
+
if (typeof schema !== 'string' || typeof ref !== 'string' || typeof kind !== 'string') return undefined;
|
|
44
|
+
if (SYSTEM_SCHEMAS.includes(schema) || !(await usableSchemas(db)).includes(schema)) return undefined;
|
|
45
|
+
if (kind === 'policy') {
|
|
46
|
+
const p = await db.one<{ name: string }>(
|
|
47
|
+
`select p.polname || ' on ' || c.relname as name from pg_policy p join pg_class c on c.oid = p.polrelid join pg_namespace n on n.oid = c.relnamespace
|
|
48
|
+
where n.nspname = $1 and c.oid::regclass::text = $2 and p.polname || ' on ' || c.relname = $3`, [schema, ref, String(name ?? '')]);
|
|
49
|
+
return p ? { type: 'policy', name: p.name, ref } : undefined;
|
|
50
|
+
}
|
|
51
|
+
return (await listObjects(db, schema, false)).find((o) => o.type === kind && o.ref === ref);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const relnameOf = (o: MenuObj, schema: string) => {
|
|
55
|
+
const t = o.type === 'trigger' ? o.ref.split('|')[0] : o.ref;
|
|
56
|
+
return t.startsWith(`${pg.escapeIdentifier(schema)}.`) || t.startsWith(`${schema}.`) ? t.slice(t.indexOf('.') + 1).replace(/^"|"$/g, '').replace(/""/g, '"') : t.replace(/^"|"$/g, '');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The DDL of one object: what the Object Browser's DDL tab shows. */
|
|
60
|
+
export async function objectDdl(db: Db, schema: string, o: MenuObj): Promise<string> {
|
|
61
|
+
if (o.type === 'function' || o.type === 'procedure') return `${(await db.one<{ def: string }>('select pg_get_functiondef($1::regprocedure) as def', [o.ref]).catch(() => undefined))?.def?.trimEnd() ?? ''};`;
|
|
62
|
+
if (o.type === 'trigger') {
|
|
63
|
+
const [rel, name] = o.ref.split('|');
|
|
64
|
+
return `${(await db.one<{ def: string }>('select pg_get_triggerdef(oid, true) as def from pg_trigger where tgrelid = $1::regclass and tgname = $2', [rel, name]))?.def ?? ''};`;
|
|
65
|
+
}
|
|
66
|
+
if (o.type === 'index') return `${(await db.one<{ def: string }>('select pg_get_indexdef($1::regclass) as def', [o.ref]))?.def ?? ''};`;
|
|
67
|
+
const rel = relnameOf(o, schema);
|
|
68
|
+
const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
|
|
69
|
+
const pick = o.type === 'policy'
|
|
70
|
+
? objects.filter((x) => x.kind === 'policy' && x.table === rel && x.name.endsWith(`.${o.name.slice(0, o.name.lastIndexOf(' on '))}`))
|
|
71
|
+
: o.type === 'sequence' ? objects.filter((x) => x.kind === 'sequence' && x.name === rel)
|
|
72
|
+
: o.type === 'type' ? objects.filter((x) => (x.kind === 'type' || x.kind === 'domain') && x.name === rel)
|
|
73
|
+
: objects.filter((x) => (['table', 'view', 'materialized view'].includes(x.kind) ? x.name === rel : x.table === rel));
|
|
74
|
+
return pick.map((x) => x.ddl).join('\n\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What depends on the object (views, foreign keys, triggers' functions…), as lines for a comment. */
|
|
78
|
+
async function dependents(db: Db, o: MenuObj): Promise<string[]> {
|
|
79
|
+
if (!RELATIONS.has(o.type) && o.type !== 'function' && o.type !== 'type' && o.type !== 'sequence') return [];
|
|
80
|
+
const rows = (await db.query<{ what: string }>(
|
|
81
|
+
o.type === 'function'
|
|
82
|
+
? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
|
|
83
|
+
where d.refclassid = 'pg_proc'::regclass and d.refobjid = $1::regprocedure and d.deptype = 'n' order by 1 limit 50`
|
|
84
|
+
: o.type === 'type'
|
|
85
|
+
? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
|
|
86
|
+
where d.refclassid = 'pg_type'::regclass and d.refobjid = $1::regtype and d.deptype = 'n' order by 1 limit 50`
|
|
87
|
+
: `select distinct coalesce(case when d.classid = 'pg_rewrite'::regclass then 'view ' || r.ev_class::regclass::text end, pg_describe_object(d.classid, d.objid, d.objsubid)) as what
|
|
88
|
+
from pg_depend d left join pg_rewrite r on d.classid = 'pg_rewrite'::regclass and r.oid = d.objid
|
|
89
|
+
where d.refclassid = 'pg_class'::regclass and d.refobjid = $1::regclass and d.deptype = 'n'
|
|
90
|
+
and not (d.classid = 'pg_rewrite'::regclass and r.ev_class = $1::regclass)
|
|
91
|
+
order by 1 limit 50`,
|
|
92
|
+
[o.ref])).rows;
|
|
93
|
+
return rows.map((r) => r.what);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DROP_KIND: Record<string, string> = { table: 'table', view: 'view', matview: 'materialized view', index: 'index', sequence: 'sequence', type: 'type', function: 'function', procedure: 'procedure' };
|
|
97
|
+
|
|
98
|
+
/** The statements a menu entry writes into the worksheet. */
|
|
99
|
+
async function draft(db: Db, schema: string, o: MenuObj, act: string): Promise<string | null> {
|
|
100
|
+
const ident = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : pg.escapeIdentifier(n));
|
|
101
|
+
const firstCol = async () => (await db.one<{ n: string }>(`select attname as n from pg_attribute where attrelid = $1::regclass and attnum > 0 and not attisdropped order by attnum limit 1`, [o.ref]))?.n ?? 'id';
|
|
102
|
+
const base = relnameOf(o, schema).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase().slice(0, 40);
|
|
103
|
+
switch (act) {
|
|
104
|
+
case 'ddl':
|
|
105
|
+
// the DDL names objects within their schema: the search path makes the statements run there
|
|
106
|
+
return `-- DDL of ${o.ref}\nset search_path to ${ident(schema)}, public;\n\n${await objectDdl(db, schema, o)}`;
|
|
107
|
+
case 'index':
|
|
108
|
+
return `create index ${base}_${(await firstCol()).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase()}_idx on ${o.ref} (${ident(await firstCol())});`;
|
|
109
|
+
case 'trigger':
|
|
110
|
+
return `create or replace function ${ident(schema)}.${base}_trg() returns trigger\nlanguage plpgsql as $$\nbegin\n -- new and old are the row after and before the change\n return new;\nend\n$$;\n\ncreate trigger ${base}_trg\n before insert or update on ${o.ref}\n for each row execute function ${ident(schema)}.${base}_trg();`;
|
|
111
|
+
case 'policy':
|
|
112
|
+
return `-- row level security applies once it is enabled on the table\nalter table ${o.ref} enable row level security;\n\ncreate policy ${base}_select on ${o.ref}\n for select\n using (true);`;
|
|
113
|
+
case 'drop': {
|
|
114
|
+
const deps = await dependents(db, o);
|
|
115
|
+
const note = deps.length
|
|
116
|
+
? `-- These depend on ${o.ref}: drop them first, or add CASCADE to drop them too:\n${deps.map((d) => `-- ${d}`).join('\n')}\n`
|
|
117
|
+
: `-- Nothing else depends on ${o.ref}.\n`;
|
|
118
|
+
if (o.type === 'trigger') {
|
|
119
|
+
const [rel, name] = o.ref.split('|');
|
|
120
|
+
return `drop trigger ${ident(name)} on ${rel};`;
|
|
121
|
+
}
|
|
122
|
+
if (o.type === 'policy') return `drop policy ${ident(o.name.slice(0, o.name.lastIndexOf(' on ')))} on ${o.ref};`;
|
|
123
|
+
return `${note}drop ${DROP_KIND[o.type]} ${o.ref};`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function objectActionRoutes(app: FastifyInstance) {
|
|
130
|
+
// a menu entry: the statement into the worksheet (a new tab there), or the DDL as a file
|
|
131
|
+
app.get(`${BASE}/sql/objects/action`, async (req: Req, reply) => {
|
|
132
|
+
const s = await developer(req, reply);
|
|
133
|
+
if (!s) return;
|
|
134
|
+
const db = await workshopDb(s);
|
|
135
|
+
const q = req.query ?? {};
|
|
136
|
+
const o = await findObject(db, q.schema, q.k, q.o, q.n);
|
|
137
|
+
const act = String(q.do ?? '');
|
|
138
|
+
if (!o || !['ddl', 'download', 'index', 'trigger', 'policy', 'drop'].includes(act)) return reply.code(404).send('Not found');
|
|
139
|
+
if (['index', 'trigger', 'policy'].includes(act) && o.type !== 'table') return reply.code(404).send('Not found');
|
|
140
|
+
if (act === 'download') {
|
|
141
|
+
const file = `${relnameOf(o, String(q.schema))}`.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'ddl';
|
|
142
|
+
return reply.header('content-disposition', `attachment; filename="${file}.sql"`).header('cache-control', 'private, no-store')
|
|
143
|
+
.type('application/sql; charset=utf-8').send(`${await objectDdl(db, String(q.schema), o)}\n`);
|
|
144
|
+
}
|
|
145
|
+
const sql = await draft(db, String(q.schema), o, act);
|
|
146
|
+
s.state.__SQLDRAFT = sql ?? '';
|
|
147
|
+
flash(s, act === 'ddl' ? `The DDL of ${o.ref}.` : act === 'drop' ? 'Review the DROP statement and run it yourself: nothing has been dropped yet.' : 'Fill in the statement and run it: nothing has been changed yet.');
|
|
148
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Open: the Object Browser's detail of an object without the page around it (the worksheet shows it in a panel)
|
|
152
|
+
app.get(`${BASE}/sql/objects/panel`, async (req: Req, reply) => {
|
|
153
|
+
const s = await developer(req, reply);
|
|
154
|
+
if (!s) return;
|
|
155
|
+
const db = await workshopDb(s);
|
|
156
|
+
const q = req.query ?? {};
|
|
157
|
+
const o = await findObject(db, q.schema, q.k, q.o, q.n);
|
|
158
|
+
if (!o || o.type === 'policy') return reply.code(404).send('Not found');
|
|
159
|
+
const schema = String(q.schema);
|
|
160
|
+
const link = (extra: Record<string, string>) => `${BASE}/sql/objects/panel?${new URLSearchParams({ schema, ...extra })}`;
|
|
161
|
+
const detail = await objectDetail(db, s, schema, o as Obj, String(q.tab ?? ''), Number(q.p) || 1, link);
|
|
162
|
+
const label = OB_TYPES.find(([t]) => t === o.type)?.[1] ?? o.type;
|
|
163
|
+
return reply.header('Cache-Control', 'private, no-store').type('text/html')
|
|
164
|
+
.send(String(html`<div class="obj-panel" data-label="${label}: ${o.ref}">${detail}</div>`));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -5,12 +5,13 @@ import { icon } from '../icons.ts';
|
|
|
5
5
|
import { readSchema } from '../ddl.ts';
|
|
6
6
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
7
7
|
import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
|
|
8
|
+
import { objectMenu } from './object-actions.ts';
|
|
8
9
|
import { owner } from '../db.ts';
|
|
9
10
|
import type { Session } from '../session.ts';
|
|
10
11
|
|
|
11
12
|
// (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
|
|
12
13
|
// schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
|
|
13
|
-
// statement in SQL
|
|
14
|
+
// statement in the SQL Worksheet, and create tiles when nothing is selected. pgkiln's own schemas (meta,
|
|
14
15
|
// pgkiln_plugins) and objects that belong to an extension are left out unless "Show system objects" is
|
|
15
16
|
// on. A table opens in tabs: Columns, Data, Indexes, Constraints, Triggers, Grants, Row level security,
|
|
16
17
|
// Dependencies, Statistics and DDL; the column actions write the ALTER TABLE statement into SQL
|
|
@@ -19,7 +20,7 @@ import type { Session } from '../session.ts';
|
|
|
19
20
|
// in the catalog before it is used, quoted.
|
|
20
21
|
|
|
21
22
|
/** pgkiln's own schemas: shown with "Show system objects" only. */
|
|
22
|
-
const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
23
|
+
export const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
23
24
|
|
|
24
25
|
export const OB_TYPES = [
|
|
25
26
|
['table', 'Tables', 'table'],
|
|
@@ -32,9 +33,9 @@ export const OB_TYPES = [
|
|
|
32
33
|
['procedure', 'Procedures', 'code'],
|
|
33
34
|
['trigger', 'Triggers', 'bolt'],
|
|
34
35
|
] as const;
|
|
35
|
-
type ObType = (typeof OB_TYPES)[number][0];
|
|
36
|
+
export type ObType = (typeof OB_TYPES)[number][0];
|
|
36
37
|
|
|
37
|
-
interface Obj {
|
|
38
|
+
export interface Obj {
|
|
38
39
|
type: ObType;
|
|
39
40
|
name: string;
|
|
40
41
|
/** the object as SQL can name it: regclass, regprocedure or regtype text; a trigger: "table" */
|
|
@@ -49,7 +50,7 @@ const TABLE_TABS = [
|
|
|
49
50
|
['grants', 'Grants'], ['rls', 'Row level security'], ['dependencies', 'Dependencies'], ['statistics', 'Statistics'], ['ddl', 'DDL'],
|
|
50
51
|
] as const;
|
|
51
52
|
|
|
52
|
-
/** CREATE statements the + menu and the tiles start with (SQL
|
|
53
|
+
/** CREATE statements the + menu and the tiles start with (the SQL Worksheet shows them; nothing runs until Run). */
|
|
53
54
|
export function createTemplate(kind: string, schema: string): string | null {
|
|
54
55
|
const S = pg.escapeIdentifier(schema);
|
|
55
56
|
switch (kind) {
|
|
@@ -80,6 +81,26 @@ async function parsingSchema(s: Session): Promise<string | null> {
|
|
|
80
81
|
).catch(() => undefined);
|
|
81
82
|
return row?.n ?? null;
|
|
82
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* (sprint 89, SQL-4) The schema to show: the one asked for, else the last one shown (remembered per session, shared
|
|
86
|
+
* by the Object Browser and the worksheet's navigator), else the session's schema of the SQL Worksheet, else the parsing
|
|
87
|
+
* schema of the application changed last, else the first of `schemas`.
|
|
88
|
+
*/
|
|
89
|
+
export async function pickSchema(s: Session, db: Awaited<ReturnType<typeof workshopDb>>, schemas: string[], asked: unknown): Promise<string> {
|
|
90
|
+
const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
|
|
91
|
+
if (usable(asked)) {
|
|
92
|
+
s.state.__OBSCHEMA = asked;
|
|
93
|
+
return asked;
|
|
94
|
+
}
|
|
95
|
+
const last = s.state.__OBSCHEMA;
|
|
96
|
+
if (usable(last)) return last;
|
|
97
|
+
const own = await sessionSchema(s, db);
|
|
98
|
+
const parsing = usable(own) ? null : await parsingSchema(s);
|
|
99
|
+
const schema = usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? '');
|
|
100
|
+
if (schema) s.state.__OBSCHEMA = schema;
|
|
101
|
+
return schema;
|
|
102
|
+
}
|
|
103
|
+
|
|
83
104
|
/** A column type in an action: a type name with an optional size and array brackets. */
|
|
84
105
|
const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
|
|
85
106
|
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
@@ -100,15 +121,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
100
121
|
[system, SYSTEM_SCHEMAS],
|
|
101
122
|
)
|
|
102
123
|
).rows.map((r) => r.n);
|
|
103
|
-
|
|
104
|
-
// schema of SQL Commands, else the parsing schema of the application changed last, else the first
|
|
105
|
-
const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
|
|
106
|
-
const asked = usable(q.schema) ? q.schema : null;
|
|
107
|
-
const last = s.state.__OBSCHEMA;
|
|
108
|
-
const own = asked || usable(last) ? null : await sessionSchema(s, db);
|
|
109
|
-
const parsing = asked || usable(last) || usable(own) ? null : await parsingSchema(s);
|
|
110
|
-
const schema = asked ?? (usable(last) ? last : usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? ''));
|
|
111
|
-
if (schema && schema !== last) s.state.__OBSCHEMA = schema;
|
|
124
|
+
const schema = await pickSchema(s, db, schemas, q.schema);
|
|
112
125
|
const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
|
|
113
126
|
const objects = schema ? await listObjects(db, schema, system) : [];
|
|
114
127
|
const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
|
|
@@ -123,7 +136,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
123
136
|
if (!list.length && filter) return '';
|
|
124
137
|
return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
|
|
125
138
|
<summary>${icon(ic)}<span>${label}</span><span class="kind">${list.length}</span></summary>
|
|
126
|
-
<ul class="tree">${list.map((o) => html`<li><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a
|
|
139
|
+
<ul class="tree">${list.map((o) => html`<li class="ob-item"><a href="${link({ k: o.type, o: o.ref })}"${selected === o ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${o.name}</span>${o.rls ? html`<span class="tag">RLS</span>` : ''}${o.ext ? html`<span class="tag">extension</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
|
|
127
140
|
</details>`;
|
|
128
141
|
})}</div>`;
|
|
129
142
|
|
|
@@ -131,9 +144,10 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
131
144
|
if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
|
|
132
145
|
else if (selected) detail = await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link);
|
|
133
146
|
else
|
|
134
|
-
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles"
|
|
147
|
+
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
|
|
148
|
+
<a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>Table (form)</b><small class="muted">Columns, keys, indexes and security in a form</small></span></a>${[
|
|
135
149
|
['table', 'table', 'Table'], ['view', 'layers', 'View'], ['function', 'code', 'Function'], ['sequence', 'hash', 'Sequence'], ['index', 'list', 'Index'],
|
|
136
|
-
].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/
|
|
150
|
+
].map(([k, ic, label]) => html`<a class="create-source" href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
|
|
137
151
|
<a class="create-source" href="${BASE}/sql/quick">${icon('bolt')}<span><b>Quick SQL</b><small class="muted">A data model in shorthand</small></span></a>
|
|
138
152
|
<a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>Load data</b><small class="muted">A table from a file</small></span></a></div>
|
|
139
153
|
<p class="muted">Select an object in the tree, or filter it by name.</p>`);
|
|
@@ -147,13 +161,13 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
147
161
|
<button class="btn btn-sm">Show</button></form>
|
|
148
162
|
<details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} Create</summary>
|
|
149
163
|
<div class="menu-panel align-right"><div class="menu-section menu-links">${['table', 'view', 'function', 'sequence', 'index'].map((k) =>
|
|
150
|
-
html`<a href="${BASE}/sql/
|
|
164
|
+
html`<a href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
|
|
151
165
|
</div>
|
|
152
166
|
<div class="designer"><aside class="region region-standard ob-tree" aria-label="Database objects">${tree}</aside><div>${detail}</div></div>`;
|
|
153
167
|
return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
|
|
154
168
|
});
|
|
155
169
|
|
|
156
|
-
// column actions: the ALTER TABLE statement, into SQL
|
|
170
|
+
// column actions: the ALTER TABLE statement, into the SQL Worksheet to review and run
|
|
157
171
|
app.post(`${BASE}/sql/objects/alter`, async (req: Req, reply) => {
|
|
158
172
|
const s = await developer(req, reply);
|
|
159
173
|
if (!s) return;
|
|
@@ -215,11 +229,11 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
215
229
|
}
|
|
216
230
|
s.state.__SQLDRAFT = sql;
|
|
217
231
|
flash(s, 'Review the statement and press Run: nothing has been changed yet.');
|
|
218
|
-
return back(reply, s, `${BASE}/sql/
|
|
232
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
219
233
|
});
|
|
220
234
|
}
|
|
221
235
|
|
|
222
|
-
async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
236
|
+
export async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
223
237
|
const ext = (cls: string, oid: string) => `exists (select 1 from pg_depend d where d.classid = '${cls}'::regclass and d.objid = ${oid} and d.deptype = 'e')`;
|
|
224
238
|
const rows = (
|
|
225
239
|
await db.query<Obj>(
|
|
@@ -250,7 +264,7 @@ async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: s
|
|
|
250
264
|
return system ? rows : rows.filter((o) => !o.ext);
|
|
251
265
|
}
|
|
252
266
|
|
|
253
|
-
async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
|
|
267
|
+
export async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csrf_token: string }, schema: string, o: Obj, tabAsked: string, page: number, link: (x: Record<string, string>) => string): Promise<Raw> {
|
|
254
268
|
const table = (rows: Record<string, unknown>[], empty = 'None.') =>
|
|
255
269
|
rows.length
|
|
256
270
|
? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
|
|
@@ -336,7 +350,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
336
350
|
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
|
|
337
351
|
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
|
|
338
352
|
${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">Show the SQL</button></form></div></details>
|
|
339
|
-
<span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into SQL
|
|
353
|
+
<span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into the SQL Worksheet: review it there and run it.</span>
|
|
340
354
|
</div>`
|
|
341
355
|
: ''}${table(cols)}`;
|
|
342
356
|
break;
|
|
@@ -351,7 +365,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
351
365
|
const p = offset / PAGE_SIZE + 1;
|
|
352
366
|
body = html`${table(rows, 'No rows.')}<nav class="pager" aria-label="Pages">${p > 1 ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p - 1) })}">‹ Previous</a>` : ''}
|
|
353
367
|
<span>Rows ${rows.length ? offset + 1 : 0}–${offset + rows.length}</span>${more ? html`<a href="${link({ k: o.type, o: o.ref, tab: 'data', p: String(p + 1) })}">Next ›</a>` : ''}</nav>
|
|
354
|
-
<p class="muted">To change rows, use SQL
|
|
368
|
+
<p class="muted">To change rows, use the SQL Worksheet, or an interactive grid on the table in an application.</p>`;
|
|
355
369
|
}
|
|
356
370
|
break;
|
|
357
371
|
}
|
|
@@ -401,5 +415,6 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
401
415
|
default:
|
|
402
416
|
body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
|
|
403
417
|
}
|
|
404
|
-
return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}
|
|
418
|
+
return html`<h2 class="ob-title">${icon(OB_TYPES.find(([t]) => t === o.type)![2])} ${o.ref}${o.rls ? html` <span class="tag">RLS</span>` : ''}${o.type === 'table'
|
|
419
|
+
? html` <a class="btn btn-sm" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}">${icon('edit')} Edit table</a>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
|
|
405
420
|
}
|
|
@@ -363,7 +363,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
|
|
|
363
363
|
const output = built
|
|
364
364
|
? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" tabindex="0" aria-label="Generated SQL">${built.sql}</pre>
|
|
365
365
|
<form method="post" action="${BASE}/sql">${csrf(s)}<input type="hidden" name="sql" value="${built.sql}">
|
|
366
|
-
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run in SQL
|
|
366
|
+
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run in the SQL Worksheet</button></div></form>`)}`
|
|
367
367
|
: '';
|
|
368
368
|
return send(reply, s, shell(s, 'Query Builder', [['SQL Workshop', `${BASE}/sql`], ['Query Builder']], html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('query')}${form}${output}`, 'sql'));
|
|
369
369
|
});
|
|
@@ -40,7 +40,7 @@ import { currentWorkspace, workshopDb } from './workspaces.ts';
|
|
|
40
40
|
// nulls, all proposed from the catalog. The form posts (CSRF) to preview
|
|
41
41
|
// (insert and roll back), insert (one transaction, parents first), download
|
|
42
42
|
// as SQL or CSV, or save the definition (meta.data_generator) to rerun.
|
|
43
|
-
// Runs on a connection of its own as the owner, like SQL
|
|
43
|
+
// Runs on a connection of its own as the owner, like the SQL Worksheet, with a
|
|
44
44
|
// statement timeout; the connection is closed afterwards. No JavaScript needed.
|
|
45
45
|
|
|
46
46
|
/** The statement timeout of a run (PostgreSQL interval syntax). */
|
|
@@ -109,7 +109,7 @@ export async function sampleDataRoutes(app: FastifyInstance) {
|
|
|
109
109
|
|
|
110
110
|
const intro = html`<p class="muted">Generate realistic rows for the tables of a schema: names, e-mail addresses, dates and numbers in a range, values from a list,
|
|
111
111
|
foreign keys that pick existing parent rows. Preview them, insert them in one transaction (parents first) or download them as SQL or CSV.
|
|
112
|
-
Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like <a href="${BASE}/sql">SQL
|
|
112
|
+
Runs as the builder's owner connection (an isolated workspace's developer login for its developers), like the <a href="${BASE}/sql/worksheet">SQL Worksheet</a>.</p>`;
|
|
113
113
|
|
|
114
114
|
// ------------------------------------------------------------ step 1 and 2: schema and tables
|
|
115
115
|
const start = async (s: Session, reply: FastifyReply, schema: string | undefined) => {
|
package/src/builder/scripts.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
|
12
12
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
13
13
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
14
14
|
// optionally in one transaction), and the run history. Scripts run on the
|
|
15
|
-
// owner connection, like SQL
|
|
15
|
+
// owner connection, like the SQL Worksheet, on a connection of their own that is
|
|
16
16
|
// closed afterwards (a script may SET ROLE or change settings). Scripts and runs belong
|
|
17
17
|
// to the current workspace; an isolated workspace's run on its developer login (090).
|
|
18
18
|
|
|
@@ -139,11 +139,11 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
139
139
|
).rows;
|
|
140
140
|
const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
|
|
141
141
|
const list = scripts.length
|
|
142
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>
|
|
142
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Script</th><th>Updated</th><th class="num">Size</th><th>Last run</th><th>Open</th></tr></thead><tbody>
|
|
143
143
|
${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
144
144
|
<td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small">by ${x.updated_by}</span>` : ''}</td><td class="num">${bytes(Number(x.size))}</td>
|
|
145
145
|
<td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error">Errors</span>` : html`<span class="tag tag-ok">OK</span>`}` : html`<span class="muted">never</span>`}</td>
|
|
146
|
-
<td><a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} Download</a></td></tr>`)}
|
|
146
|
+
<td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')} Worksheet</a> <a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} Download</a></td></tr>`)}
|
|
147
147
|
</tbody></table></div>`
|
|
148
148
|
: html`<p class="muted">No scripts yet. Create one, upload a .sql file, or generate one with Quick SQL.</p>`;
|
|
149
149
|
return page(
|
package/src/builder/sql.ts
CHANGED
|
@@ -7,12 +7,15 @@ import { owner } from '../db.ts';
|
|
|
7
7
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { scriptRoutes } from './scripts.ts';
|
|
10
|
+
import { worksheetRoutes } from './worksheet.ts';
|
|
10
11
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
11
12
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
13
|
import { unloadRoutes } from './unload.ts';
|
|
13
14
|
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
14
15
|
import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
15
16
|
import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
17
|
+
import { objectActionRoutes } from './object-actions.ts';
|
|
18
|
+
import { tableEditorRoutes } from './table-editor.ts';
|
|
16
19
|
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
17
20
|
|
|
18
21
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
@@ -53,29 +56,29 @@ async function workshopHome(req: Req, reply: FastifyReply) {
|
|
|
53
56
|
]);
|
|
54
57
|
const tile = (href: string, ic: string, title: string, text: string) =>
|
|
55
58
|
html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
56
|
-
const list = (title: string, rows: Raw[], empty: string) =>
|
|
59
|
+
const list = (title: string | Raw, rows: Raw[], empty: string) =>
|
|
57
60
|
html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
|
|
58
61
|
const main = html`<div class="ab-home">
|
|
59
62
|
<div class="ab-main">
|
|
60
63
|
<h1 class="sr-only">SQL Workshop</h1>
|
|
61
64
|
${workshopTabs('home')}
|
|
62
65
|
<div class="ab-tiles sw-tiles">
|
|
66
|
+
${tile('/sql/worksheet', 'code', 'SQL Worksheet', 'Run statements and scripts, with a navigator, plans and history.')}
|
|
63
67
|
${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
|
|
64
|
-
${tile('/sql/commands', 'code', 'SQL Commands', 'Run SQL statements against your schemas.')}
|
|
65
68
|
${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
|
|
66
69
|
${tile('/sql/utilities', 'wrench', 'Utilities', 'Load and unload data, sample data, DDL, schema comparison, object reports and the database.')}
|
|
67
70
|
${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
|
|
68
71
|
</div>
|
|
69
72
|
<div class="sw-recents">
|
|
70
73
|
${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
|
|
71
|
-
${list(
|
|
74
|
+
${list(html`<a href="${BASE}/sql/worksheet?tab=history" title="Open SQL History">Recent SQL commands</a>`, commands.rows.map((c) => html`<li><a href="${BASE}/sql/worksheet?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
|
|
72
75
|
${list('Recent SQL scripts', scripts.rows.map((x) => html`<li><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a><span class="muted">${ago(x.updated_at)}</span></li>`), 'No scripts in this workspace yet.')}
|
|
73
76
|
</div>
|
|
74
77
|
</div>
|
|
75
78
|
<aside class="ab-side" aria-label="About, schema and tasks">
|
|
76
79
|
<section><h2>About</h2><p>The SQL Workshop is a collection of tools to manage database objects. It runs as ${workshopRunsAs(db)}.</p>
|
|
77
80
|
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener">Learn more …</a></section>
|
|
78
|
-
<section><h2>Schema</h2><p>The default schema of this session: first in the search path of SQL
|
|
81
|
+
<section><h2>Schema</h2><p>The default schema of this session: first in the search path of the SQL Worksheet and SQL Scripts.</p>
|
|
79
82
|
<form method="post" action="${BASE}/sql/schema" class="sw-schema">${csrf(s)}
|
|
80
83
|
<label class="sr-only" for="f_schema">Schema</label>
|
|
81
84
|
<select id="f_schema" name="schema" data-autosubmit><option value="">(the default search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
@@ -102,76 +105,11 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
102
105
|
// Generate DDL and Schema Comparison (sprint 56)
|
|
103
106
|
await app.register(ddlToolRoutes);
|
|
104
107
|
await app.register(sqlUtilityRoutes);
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
const rows = (res.rows ?? []).slice(0, limit);
|
|
108
|
-
return html`<div class="table-wrap"><table class="report"><thead><tr>${res.fields.map((f) => html`<th>${f.name}</th>`)}</tr></thead>
|
|
109
|
-
<tbody>${rows.map((r) => html`<tr>${r.map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const sqlWorkshop = async (req: Req, reply: FastifyReply) => {
|
|
113
|
-
const s = await developer(req, reply);
|
|
114
|
-
if (!s) return;
|
|
115
|
-
const sql = req.body?.sql ?? '';
|
|
116
|
-
let result: Raw | '' = '';
|
|
117
|
-
const db = await workshopDb(s);
|
|
118
|
-
// (sprint 61) a command from the home page's Recent SQL Commands (the developer's own)
|
|
119
|
-
let text = sql;
|
|
120
|
-
if (req.method === 'GET' && /^\d{1,18}$/.test(String(req.query?.recent ?? ''))) {
|
|
121
|
-
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]);
|
|
122
|
-
if (r) text = r.detail;
|
|
123
|
-
}
|
|
124
|
-
const schema = await sessionSchema(s, db);
|
|
125
|
-
if (req.method === 'GET') {
|
|
126
|
-
// (sprint 75) a statement to review: from the Object Browser's column actions, or a CREATE template
|
|
127
|
-
if (typeof s.state.__SQLDRAFT === 'string') {
|
|
128
|
-
text = s.state.__SQLDRAFT;
|
|
129
|
-
delete s.state.__SQLDRAFT;
|
|
130
|
-
} else if (typeof req.query?.template === 'string') {
|
|
131
|
-
const into = typeof req.query.schema === 'string' && (await usableSchemas(db)).includes(req.query.schema) ? req.query.schema : (schema ?? 'public');
|
|
132
|
-
text = createTemplate(req.query.template, into) ?? text;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (req.method === 'POST' && sql.trim()) {
|
|
136
|
-
const started = performance.now();
|
|
137
|
-
try {
|
|
138
|
-
// a connection of its own, closed afterwards: the SQL may SET ROLE or change settings,
|
|
139
|
-
// which must not leak into the builder's pooled owner connections
|
|
140
|
-
const c = await db.pool.connect();
|
|
141
|
-
let out;
|
|
142
|
-
try {
|
|
143
|
-
if (schema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
144
|
-
out = await c.query({ text: sql, rowMode: 'array' });
|
|
145
|
-
} finally {
|
|
146
|
-
c.release(true);
|
|
147
|
-
}
|
|
148
|
-
const res = Array.isArray(out) ? out[out.length - 1] : out;
|
|
149
|
-
const ms = (performance.now() - started).toFixed(0);
|
|
150
|
-
result = res.fields?.length
|
|
151
|
-
? html`<p class="muted">${res.rowCount} row(s) in ${ms} ms${res.rows.length > 500 ? ' (showing 500)' : ''}</p>${resultTable(res)}`
|
|
152
|
-
: html`<div class="alert alert-success">${res.command ?? 'Statement'} completed${res.rowCount !== null ? `, ${res.rowCount} row(s)` : ''} in ${ms} ms.</div>`;
|
|
153
|
-
} catch (e) {
|
|
154
|
-
result = html`<div class="alert alert-error"><strong>Error:</strong> ${(e as Error).message}</div>`;
|
|
155
|
-
}
|
|
156
|
-
clearCompletions(); // the script may have changed tables or grants
|
|
157
|
-
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
|
|
158
|
-
}
|
|
159
|
-
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
|
|
160
|
-
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role)${schema ? html`, schema <code>${schema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Multiple statements are allowed; results of the last one are shown.</p>
|
|
161
|
-
<form method="post" action="${BASE}/sql/commands">${csrf(s)}
|
|
162
|
-
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${text || 'select current_user, version();'}</textarea>
|
|
163
|
-
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
|
|
164
|
-
</form>
|
|
165
|
-
<div class="u-mt1">${result}</div>`;
|
|
166
|
-
return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
|
|
167
|
-
};
|
|
168
|
-
app.get(`${BASE}/sql/commands`, sqlWorkshop);
|
|
169
|
-
app.post(`${BASE}/sql/commands`, sqlWorkshop);
|
|
170
|
-
// (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
|
|
171
|
-
app.post(`${BASE}/sql`, sqlWorkshop);
|
|
108
|
+
// (sprint 91) the SQL Worksheet (was SQL Commands; /sql/commands and a post to /sql are the same page)
|
|
109
|
+
await app.register(worksheetRoutes);
|
|
172
110
|
app.get(`${BASE}/sql`, workshopHome);
|
|
173
111
|
|
|
174
|
-
// (sprint 61) the session's default schema: first in the search path of SQL
|
|
112
|
+
// (sprint 61) the session's default schema: first in the search path of the SQL Worksheet and SQL Scripts
|
|
175
113
|
app.post(`${BASE}/sql/schema`, async (req: Req, reply) => {
|
|
176
114
|
const s = await developer(req, reply);
|
|
177
115
|
if (!s) return;
|
|
@@ -180,10 +118,14 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
180
118
|
if (wanted && !usable.includes(wanted)) return reply.code(404).send('Not found');
|
|
181
119
|
if (wanted) s.state.__SQLSCHEMA = wanted;
|
|
182
120
|
else delete s.state.__SQLSCHEMA;
|
|
183
|
-
flash(s, wanted ? `Schema ${wanted} first in the search path of SQL
|
|
121
|
+
flash(s, wanted ? `Schema ${wanted} first in the search path of the SQL Worksheet and SQL Scripts.` : 'The default search path again.');
|
|
184
122
|
return back(reply, s, `${BASE}/sql`);
|
|
185
123
|
});
|
|
186
124
|
|
|
187
125
|
// (sprint 75) the Object Browser: object-browser.ts
|
|
188
126
|
await app.register(objectBrowserRoutes);
|
|
127
|
+
// (sprint 94) an object's menu: Quick DDL, Drop, Add index / trigger / policy, Open in a panel
|
|
128
|
+
await app.register(objectActionRoutes);
|
|
129
|
+
// (sprint 95) Table Properties: create, edit or copy a table in a form
|
|
130
|
+
await app.register(tableEditorRoutes);
|
|
189
131
|
}
|