pgkiln 0.46.0 → 0.48.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/db/migrations/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +178 -21
- package/docs/guide/08-security.md +19 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +46 -2
- package/docs/guide/13-rest-api.md +74 -9
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +293 -0
- package/public/worksheet.js +669 -0
- package/src/app.ts +4 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +167 -0
- package/src/builder/object-browser.ts +47 -26
- 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-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +28 -81
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +21 -15
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +592 -0
- package/src/builder/workspaces.ts +5 -3
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
- 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,167 @@
|
|
|
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
|
+
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
36
|
+
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
|
37
|
+
${item(q('download'), 'download', 'Quick DDL as a file')}
|
|
38
|
+
${item(q('drop'), 'trash', 'Drop…')}
|
|
39
|
+
</div></div></details>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The listed object the request names, or undefined. */
|
|
43
|
+
async function findObject(db: Db, schema: unknown, kind: unknown, ref: unknown, name: unknown): Promise<MenuObj | undefined> {
|
|
44
|
+
if (typeof schema !== 'string' || typeof ref !== 'string' || typeof kind !== 'string') return undefined;
|
|
45
|
+
if (SYSTEM_SCHEMAS.includes(schema) || !(await usableSchemas(db)).includes(schema)) return undefined;
|
|
46
|
+
if (kind === 'policy') {
|
|
47
|
+
const p = await db.one<{ name: string }>(
|
|
48
|
+
`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
|
|
49
|
+
where n.nspname = $1 and c.oid::regclass::text = $2 and p.polname || ' on ' || c.relname = $3`, [schema, ref, String(name ?? '')]);
|
|
50
|
+
return p ? { type: 'policy', name: p.name, ref } : undefined;
|
|
51
|
+
}
|
|
52
|
+
return (await listObjects(db, schema, false)).find((o) => o.type === kind && o.ref === ref);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const relnameOf = (o: MenuObj, schema: string) => {
|
|
56
|
+
const t = o.type === 'trigger' ? o.ref.split('|')[0] : o.ref;
|
|
57
|
+
return t.startsWith(`${pg.escapeIdentifier(schema)}.`) || t.startsWith(`${schema}.`) ? t.slice(t.indexOf('.') + 1).replace(/^"|"$/g, '').replace(/""/g, '"') : t.replace(/^"|"$/g, '');
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The DDL of one object: what the Object Browser's DDL tab shows. */
|
|
61
|
+
export async function objectDdl(db: Db, schema: string, o: MenuObj): Promise<string> {
|
|
62
|
+
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() ?? ''};`;
|
|
63
|
+
if (o.type === 'trigger') {
|
|
64
|
+
const [rel, name] = o.ref.split('|');
|
|
65
|
+
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 ?? ''};`;
|
|
66
|
+
}
|
|
67
|
+
if (o.type === 'index') return `${(await db.one<{ def: string }>('select pg_get_indexdef($1::regclass) as def', [o.ref]))?.def ?? ''};`;
|
|
68
|
+
const rel = relnameOf(o, schema);
|
|
69
|
+
const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
|
|
70
|
+
const pick = o.type === 'policy'
|
|
71
|
+
? objects.filter((x) => x.kind === 'policy' && x.table === rel && x.name.endsWith(`.${o.name.slice(0, o.name.lastIndexOf(' on '))}`))
|
|
72
|
+
: o.type === 'sequence' ? objects.filter((x) => x.kind === 'sequence' && x.name === rel)
|
|
73
|
+
: o.type === 'type' ? objects.filter((x) => (x.kind === 'type' || x.kind === 'domain') && x.name === rel)
|
|
74
|
+
: objects.filter((x) => (['table', 'view', 'materialized view'].includes(x.kind) ? x.name === rel : x.table === rel));
|
|
75
|
+
return pick.map((x) => x.ddl).join('\n\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What depends on the object (views, foreign keys, triggers' functions…), as lines for a comment. */
|
|
79
|
+
async function dependents(db: Db, o: MenuObj): Promise<string[]> {
|
|
80
|
+
if (!RELATIONS.has(o.type) && o.type !== 'function' && o.type !== 'type' && o.type !== 'sequence') return [];
|
|
81
|
+
const rows = (await db.query<{ what: string }>(
|
|
82
|
+
o.type === 'function'
|
|
83
|
+
? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
|
|
84
|
+
where d.refclassid = 'pg_proc'::regclass and d.refobjid = $1::regprocedure and d.deptype = 'n' order by 1 limit 50`
|
|
85
|
+
: o.type === 'type'
|
|
86
|
+
? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
|
|
87
|
+
where d.refclassid = 'pg_type'::regclass and d.refobjid = $1::regtype and d.deptype = 'n' order by 1 limit 50`
|
|
88
|
+
: `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
|
|
89
|
+
from pg_depend d left join pg_rewrite r on d.classid = 'pg_rewrite'::regclass and r.oid = d.objid
|
|
90
|
+
where d.refclassid = 'pg_class'::regclass and d.refobjid = $1::regclass and d.deptype = 'n'
|
|
91
|
+
and not (d.classid = 'pg_rewrite'::regclass and r.ev_class = $1::regclass)
|
|
92
|
+
order by 1 limit 50`,
|
|
93
|
+
[o.ref])).rows;
|
|
94
|
+
return rows.map((r) => r.what);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DROP_KIND: Record<string, string> = { table: 'table', view: 'view', matview: 'materialized view', index: 'index', sequence: 'sequence', type: 'type', function: 'function', procedure: 'procedure' };
|
|
98
|
+
|
|
99
|
+
/** The statements a menu entry writes into the worksheet. */
|
|
100
|
+
async function draft(db: Db, schema: string, o: MenuObj, act: string): Promise<string | null> {
|
|
101
|
+
const ident = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : pg.escapeIdentifier(n));
|
|
102
|
+
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';
|
|
103
|
+
const base = relnameOf(o, schema).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase().slice(0, 40);
|
|
104
|
+
switch (act) {
|
|
105
|
+
case 'ddl':
|
|
106
|
+
// the DDL names objects within their schema: the search path makes the statements run there
|
|
107
|
+
return `-- DDL of ${o.ref}\nset search_path to ${ident(schema)}, public;\n\n${await objectDdl(db, schema, o)}`;
|
|
108
|
+
case 'index':
|
|
109
|
+
return `create index ${base}_${(await firstCol()).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase()}_idx on ${o.ref} (${ident(await firstCol())});`;
|
|
110
|
+
case 'trigger':
|
|
111
|
+
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();`;
|
|
112
|
+
case 'policy':
|
|
113
|
+
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);`;
|
|
114
|
+
case 'drop': {
|
|
115
|
+
const deps = await dependents(db, o);
|
|
116
|
+
const note = deps.length
|
|
117
|
+
? `-- These depend on ${o.ref}: drop them first, or add CASCADE to drop them too:\n${deps.map((d) => `-- ${d}`).join('\n')}\n`
|
|
118
|
+
: `-- Nothing else depends on ${o.ref}.\n`;
|
|
119
|
+
if (o.type === 'trigger') {
|
|
120
|
+
const [rel, name] = o.ref.split('|');
|
|
121
|
+
return `drop trigger ${ident(name)} on ${rel};`;
|
|
122
|
+
}
|
|
123
|
+
if (o.type === 'policy') return `drop policy ${ident(o.name.slice(0, o.name.lastIndexOf(' on ')))} on ${o.ref};`;
|
|
124
|
+
return `${note}drop ${DROP_KIND[o.type]} ${o.ref};`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function objectActionRoutes(app: FastifyInstance) {
|
|
131
|
+
// a menu entry: the statement into the worksheet (a new tab there), or the DDL as a file
|
|
132
|
+
app.get(`${BASE}/sql/objects/action`, async (req: Req, reply) => {
|
|
133
|
+
const s = await developer(req, reply);
|
|
134
|
+
if (!s) return;
|
|
135
|
+
const db = await workshopDb(s);
|
|
136
|
+
const q = req.query ?? {};
|
|
137
|
+
const o = await findObject(db, q.schema, q.k, q.o, q.n);
|
|
138
|
+
const act = String(q.do ?? '');
|
|
139
|
+
if (!o || !['ddl', 'download', 'index', 'trigger', 'policy', 'drop'].includes(act)) return reply.code(404).send('Not found');
|
|
140
|
+
if (['index', 'trigger', 'policy'].includes(act) && o.type !== 'table') return reply.code(404).send('Not found');
|
|
141
|
+
if (act === 'download') {
|
|
142
|
+
const file = `${relnameOf(o, String(q.schema))}`.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'ddl';
|
|
143
|
+
return reply.header('content-disposition', `attachment; filename="${file}.sql"`).header('cache-control', 'private, no-store')
|
|
144
|
+
.type('application/sql; charset=utf-8').send(`${await objectDdl(db, String(q.schema), o)}\n`);
|
|
145
|
+
}
|
|
146
|
+
const sql = await draft(db, String(q.schema), o, act);
|
|
147
|
+
s.state.__SQLDRAFT = sql ?? '';
|
|
148
|
+
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.');
|
|
149
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Open: the Object Browser's detail of an object without the page around it (the worksheet shows it in a panel)
|
|
153
|
+
app.get(`${BASE}/sql/objects/panel`, async (req: Req, reply) => {
|
|
154
|
+
const s = await developer(req, reply);
|
|
155
|
+
if (!s) return;
|
|
156
|
+
const db = await workshopDb(s);
|
|
157
|
+
const q = req.query ?? {};
|
|
158
|
+
const o = await findObject(db, q.schema, q.k, q.o, q.n);
|
|
159
|
+
if (!o || o.type === 'policy') return reply.code(404).send('Not found');
|
|
160
|
+
const schema = String(q.schema);
|
|
161
|
+
const link = (extra: Record<string, string>) => `${BASE}/sql/objects/panel?${new URLSearchParams({ schema, ...extra })}`;
|
|
162
|
+
const detail = await objectDetail(db, s, schema, o as Obj, String(q.tab ?? ''), Number(q.p) || 1, link);
|
|
163
|
+
const label = OB_TYPES.find(([t]) => t === o.type)?.[1] ?? o.type;
|
|
164
|
+
return reply.header('Cache-Control', 'private, no-store').type('text/html')
|
|
165
|
+
.send(String(html`<div class="obj-panel" data-label="${label}: ${o.ref}">${detail}</div>`));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -5,12 +5,14 @@ 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';
|
|
9
|
+
import { isPinned, pinButton } from './launchpad.ts';
|
|
8
10
|
import { owner } from '../db.ts';
|
|
9
11
|
import type { Session } from '../session.ts';
|
|
10
12
|
|
|
11
13
|
// (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
|
|
12
14
|
// schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
|
|
13
|
-
// statement in SQL
|
|
15
|
+
// statement in the SQL Worksheet, and create tiles when nothing is selected. pgkiln's own schemas (meta,
|
|
14
16
|
// pgkiln_plugins) and objects that belong to an extension are left out unless "Show system objects" is
|
|
15
17
|
// on. A table opens in tabs: Columns, Data, Indexes, Constraints, Triggers, Grants, Row level security,
|
|
16
18
|
// Dependencies, Statistics and DDL; the column actions write the ALTER TABLE statement into SQL
|
|
@@ -19,7 +21,7 @@ import type { Session } from '../session.ts';
|
|
|
19
21
|
// in the catalog before it is used, quoted.
|
|
20
22
|
|
|
21
23
|
/** pgkiln's own schemas: shown with "Show system objects" only. */
|
|
22
|
-
const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
24
|
+
export const SYSTEM_SCHEMAS = ['meta', 'pgkiln_plugins'];
|
|
23
25
|
|
|
24
26
|
export const OB_TYPES = [
|
|
25
27
|
['table', 'Tables', 'table'],
|
|
@@ -32,9 +34,9 @@ export const OB_TYPES = [
|
|
|
32
34
|
['procedure', 'Procedures', 'code'],
|
|
33
35
|
['trigger', 'Triggers', 'bolt'],
|
|
34
36
|
] as const;
|
|
35
|
-
type ObType = (typeof OB_TYPES)[number][0];
|
|
37
|
+
export type ObType = (typeof OB_TYPES)[number][0];
|
|
36
38
|
|
|
37
|
-
interface Obj {
|
|
39
|
+
export interface Obj {
|
|
38
40
|
type: ObType;
|
|
39
41
|
name: string;
|
|
40
42
|
/** the object as SQL can name it: regclass, regprocedure or regtype text; a trigger: "table" */
|
|
@@ -49,7 +51,7 @@ const TABLE_TABS = [
|
|
|
49
51
|
['grants', 'Grants'], ['rls', 'Row level security'], ['dependencies', 'Dependencies'], ['statistics', 'Statistics'], ['ddl', 'DDL'],
|
|
50
52
|
] as const;
|
|
51
53
|
|
|
52
|
-
/** CREATE statements the + menu and the tiles start with (SQL
|
|
54
|
+
/** CREATE statements the + menu and the tiles start with (the SQL Worksheet shows them; nothing runs until Run). */
|
|
53
55
|
export function createTemplate(kind: string, schema: string): string | null {
|
|
54
56
|
const S = pg.escapeIdentifier(schema);
|
|
55
57
|
switch (kind) {
|
|
@@ -80,6 +82,26 @@ async function parsingSchema(s: Session): Promise<string | null> {
|
|
|
80
82
|
).catch(() => undefined);
|
|
81
83
|
return row?.n ?? null;
|
|
82
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* (sprint 89, SQL-4) The schema to show: the one asked for, else the last one shown (remembered per session, shared
|
|
87
|
+
* by the Object Browser and the worksheet's navigator), else the session's schema of the SQL Worksheet, else the parsing
|
|
88
|
+
* schema of the application changed last, else the first of `schemas`.
|
|
89
|
+
*/
|
|
90
|
+
export async function pickSchema(s: Session, db: Awaited<ReturnType<typeof workshopDb>>, schemas: string[], asked: unknown): Promise<string> {
|
|
91
|
+
const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
|
|
92
|
+
if (usable(asked)) {
|
|
93
|
+
s.state.__OBSCHEMA = asked;
|
|
94
|
+
return asked;
|
|
95
|
+
}
|
|
96
|
+
const last = s.state.__OBSCHEMA;
|
|
97
|
+
if (usable(last)) return last;
|
|
98
|
+
const own = await sessionSchema(s, db);
|
|
99
|
+
const parsing = usable(own) ? null : await parsingSchema(s);
|
|
100
|
+
const schema = usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? '');
|
|
101
|
+
if (schema) s.state.__OBSCHEMA = schema;
|
|
102
|
+
return schema;
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
/** A column type in an action: a type name with an optional size and array brackets. */
|
|
84
106
|
const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
|
|
85
107
|
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
|
|
@@ -100,15 +122,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
100
122
|
[system, SYSTEM_SCHEMAS],
|
|
101
123
|
)
|
|
102
124
|
).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;
|
|
125
|
+
const schema = await pickSchema(s, db, schemas, q.schema);
|
|
112
126
|
const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
|
|
113
127
|
const objects = schema ? await listObjects(db, schema, system) : [];
|
|
114
128
|
const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
|
|
@@ -123,17 +137,23 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
123
137
|
if (!list.length && filter) return '';
|
|
124
138
|
return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
|
|
125
139
|
<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
|
|
140
|
+
<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
141
|
</details>`;
|
|
128
142
|
})}</div>`;
|
|
129
143
|
|
|
130
144
|
let detail: Raw;
|
|
131
145
|
if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
|
|
132
|
-
else if (selected)
|
|
146
|
+
else if (selected) {
|
|
147
|
+
// (sprint 96) pin the object to the SQL Workshop's home
|
|
148
|
+
const href = `${BASE}/sql/objects?${new URLSearchParams({ schema, k: selected.type, o: selected.ref })}`;
|
|
149
|
+
const pin = pinButton(s, { href, label: selected.name, icon: OB_TYPES.find(([t]) => t === selected.type)![2] }, await isPinned(s, href), href);
|
|
150
|
+
detail = html`<div class="ob-pin">${pin}</div>${await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link)}`;
|
|
151
|
+
}
|
|
133
152
|
else
|
|
134
|
-
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles"
|
|
153
|
+
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
|
|
154
|
+
<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
155
|
['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/
|
|
156
|
+
].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
157
|
<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
158
|
<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
159
|
<p class="muted">Select an object in the tree, or filter it by name.</p>`);
|
|
@@ -147,13 +167,13 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
147
167
|
<button class="btn btn-sm">Show</button></form>
|
|
148
168
|
<details class="menu ob-create"><summary class="btn btn-hot">${icon('plus')} Create</summary>
|
|
149
169
|
<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/
|
|
170
|
+
html`<a href="${BASE}/sql/worksheet?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
|
|
151
171
|
</div>
|
|
152
172
|
<div class="designer"><aside class="region region-standard ob-tree" aria-label="Database objects">${tree}</aside><div>${detail}</div></div>`;
|
|
153
173
|
return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
|
|
154
174
|
});
|
|
155
175
|
|
|
156
|
-
// column actions: the ALTER TABLE statement, into SQL
|
|
176
|
+
// column actions: the ALTER TABLE statement, into the SQL Worksheet to review and run
|
|
157
177
|
app.post(`${BASE}/sql/objects/alter`, async (req: Req, reply) => {
|
|
158
178
|
const s = await developer(req, reply);
|
|
159
179
|
if (!s) return;
|
|
@@ -215,11 +235,11 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
215
235
|
}
|
|
216
236
|
s.state.__SQLDRAFT = sql;
|
|
217
237
|
flash(s, 'Review the statement and press Run: nothing has been changed yet.');
|
|
218
|
-
return back(reply, s, `${BASE}/sql/
|
|
238
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
219
239
|
});
|
|
220
240
|
}
|
|
221
241
|
|
|
222
|
-
async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
242
|
+
export async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: string, system: boolean): Promise<Obj[]> {
|
|
223
243
|
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
244
|
const rows = (
|
|
225
245
|
await db.query<Obj>(
|
|
@@ -250,7 +270,7 @@ async function listObjects(db: Awaited<ReturnType<typeof workshopDb>>, schema: s
|
|
|
250
270
|
return system ? rows : rows.filter((o) => !o.ext);
|
|
251
271
|
}
|
|
252
272
|
|
|
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> {
|
|
273
|
+
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
274
|
const table = (rows: Record<string, unknown>[], empty = 'None.') =>
|
|
255
275
|
rows.length
|
|
256
276
|
? 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 +356,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
336
356
|
<details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
|
|
337
357
|
<form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
|
|
338
358
|
${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
|
|
359
|
+
<span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into the SQL Worksheet: review it there and run it.</span>
|
|
340
360
|
</div>`
|
|
341
361
|
: ''}${table(cols)}`;
|
|
342
362
|
break;
|
|
@@ -351,7 +371,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
351
371
|
const p = offset / PAGE_SIZE + 1;
|
|
352
372
|
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
373
|
<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
|
|
374
|
+
<p class="muted">To change rows, use the SQL Worksheet, or an interactive grid on the table in an application.</p>`;
|
|
355
375
|
}
|
|
356
376
|
break;
|
|
357
377
|
}
|
|
@@ -401,5 +421,6 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
|
|
|
401
421
|
default:
|
|
402
422
|
body = await ddlOf((x) => (x.kind === 'table' || x.kind === 'view' || x.kind === 'materialized view') ? x.name === relname : x.table === relname);
|
|
403
423
|
}
|
|
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>` : ''}
|
|
424
|
+
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'
|
|
425
|
+
? 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
426
|
}
|
|
@@ -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(
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { PageCss } from '../css.ts';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import { isNumeric } from '../runtime/report.ts';
|
|
8
|
+
import { renderChartBody, type ChartKind } from '../runtime/charts.ts';
|
|
9
|
+
import type { Session } from '../session.ts';
|
|
10
|
+
import { unloadStatement } from '../unload.ts';
|
|
11
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
12
|
+
import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 97, Database Actions DA-6: Create Chart and Charts) A query drawn as a chart with pgkiln's own
|
|
15
|
+
// server-rendered charts (src/runtime/charts.ts): the worksheet's Chart tab draws the last query, guessing the label
|
|
16
|
+
// column (the first text or date column) and the value columns (the numbers); saved charts (meta.sql_chart,
|
|
17
|
+
// migration 110) are listed under SQL Workshop → Charts, the ones on the dashboard drawn there. A saved chart's
|
|
18
|
+
// SELECT runs read-only, with a statement timeout and at most 500 rows, as the workshop's connection.
|
|
19
|
+
|
|
20
|
+
export const SQL_CHART_KINDS: [ChartKind, string][] = [['bar', 'Bar'], ['column', 'Column'], ['stacked', 'Stacked bar'], ['line', 'Line'], ['area', 'Area'], ['donut', 'Donut'], ['pie', 'Pie'], ['scatter', 'Scatter']];
|
|
21
|
+
const MAX_ROWS = 500;
|
|
22
|
+
|
|
23
|
+
export interface ChartSpec { kind: ChartKind; label: string; values: string[] }
|
|
24
|
+
|
|
25
|
+
/** The columns to draw: the ones asked for when they exist, else a label (first non-number) and the numbers. */
|
|
26
|
+
export function chartSpec(columns: string[], numeric: boolean[], asked: Partial<ChartSpec> = {}): ChartSpec | null {
|
|
27
|
+
const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
|
|
28
|
+
const label = asked.label && columns.includes(asked.label) ? asked.label : (columns.find((_, i) => !numeric[i]) ?? columns[0]);
|
|
29
|
+
const wanted = (asked.values ?? []).filter((v) => columns.includes(v) && v !== label);
|
|
30
|
+
const values = wanted.length ? wanted : columns.filter((c, i) => numeric[i] && c !== label);
|
|
31
|
+
return label && values.length ? { kind, label, values } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A chart of rows (text values from the worksheet, or values from a query), with the rules for the page's <style>. */
|
|
35
|
+
export function drawChart(title: string, columns: string[], rows: unknown[][], spec: ChartSpec): { body: Raw; css: string } {
|
|
36
|
+
const css = new PageCss();
|
|
37
|
+
const li = columns.indexOf(spec.label);
|
|
38
|
+
const vi = spec.values.map((v) => columns.indexOf(v));
|
|
39
|
+
const data = rows.map((r) => [r[li] === null || r[li] === undefined ? '' : String(r[li]), ...vi.map((i) => (r[i] === null || r[i] === undefined || r[i] === '' ? null : Number(r[i])))]);
|
|
40
|
+
const body = renderChartBody(spec.kind, title, data, [{ name: spec.label }, ...spec.values.map((name) => ({ name }))], css);
|
|
41
|
+
return { body, css: css.text };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The controls of a chart (type, label column, value columns), named chart_*. */
|
|
45
|
+
export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch'): Raw {
|
|
46
|
+
return html`<div class="ch-controls">
|
|
47
|
+
<label><span>Type</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${label}</option>`)}</select></label>
|
|
48
|
+
<label><span>Labels</span><select name="chart_label" id="${idPrefix}-label">${columns.map((c) => html`<option value="${c}"${c === spec.label ? raw(' selected') : ''}>${c}</option>`)}</select></label>
|
|
49
|
+
<fieldset><legend>Values</legend>${columns.map((c) => html`<label class="check"><input type="checkbox" name="chart_values" value="${c}"${spec.values.includes(c) ? raw(' checked') : ''}> ${c}</label>`)}</fieldset>
|
|
50
|
+
</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === null || v === '' ? [] : [v]).map(String);
|
|
54
|
+
export const askedSpec = (b: Body): Partial<ChartSpec> => ({ kind: b.chart_kind as ChartKind, label: typeof b.chart_label === 'string' ? b.chart_label : undefined, values: list(b.chart_values) });
|
|
55
|
+
|
|
56
|
+
interface SavedChart { id: number; name: string; sql: string; kind: ChartKind; label_col: string; value_cols: string[]; on_dashboard: boolean; created_by: string | null; updated_at: string }
|
|
57
|
+
|
|
58
|
+
/** A saved chart's SELECT, run read-only: columns, which are numbers, and the rows. */
|
|
59
|
+
async function runChart(s: Session, sql: string) {
|
|
60
|
+
const db = await workshopDb(s);
|
|
61
|
+
const schema = await sessionSchema(s, db);
|
|
62
|
+
const c = await db.pool.connect();
|
|
63
|
+
try {
|
|
64
|
+
await c.query('begin transaction read only');
|
|
65
|
+
await c.query(`select set_config('statement_timeout', '10s', true)`);
|
|
66
|
+
if (schema) await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
67
|
+
const res = await c.query({ text: `select * from (${unloadStatement(sql)}\n) chart_rows limit ${MAX_ROWS}`, rowMode: 'array' });
|
|
68
|
+
return { columns: res.fields.map((f) => f.name), numeric: res.fields.map((f) => isNumeric(f.dataTypeID)), rows: res.rows as unknown[][] };
|
|
69
|
+
} finally {
|
|
70
|
+
await c.query('rollback').catch(() => {});
|
|
71
|
+
c.release(true);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function chartCard(s: Session, ch: SavedChart, full = false): Promise<{ body: Raw; css: string }> {
|
|
76
|
+
try {
|
|
77
|
+
const r = await runChart(s, ch.sql);
|
|
78
|
+
const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
|
|
79
|
+
if (!spec) return { body: html`<p class="muted">The query has no number column to draw.</p>`, css: '' };
|
|
80
|
+
const d = drawChart(ch.name, r.columns, r.rows, spec);
|
|
81
|
+
return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length} row${r.rows.length === 1 ? '' : 's'}${r.rows.length === MAX_ROWS ? ` (the first ${MAX_ROWS})` : ''}.</p>` : ''}`, css: d.css };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { body: html`<p class="alert alert-error">${(e as Error).message}</p>`, css: '' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const style = (nonce: string, css: string) => (css ? html`<style nonce="${nonce}">${raw(css.replace(/</g, ''))}</style>` : '');
|
|
88
|
+
|
|
89
|
+
export async function sqlChartRoutes(app: FastifyInstance) {
|
|
90
|
+
// the list and the dashboard
|
|
91
|
+
app.get(`${BASE}/sql/charts`, async (req: Req, reply: FastifyReply) => {
|
|
92
|
+
const s = await developer(req, reply);
|
|
93
|
+
if (!s) return;
|
|
94
|
+
const charts = (await owner.query<SavedChart>('select * from meta.sql_chart where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
95
|
+
let css = '';
|
|
96
|
+
const cards: Raw[] = [];
|
|
97
|
+
for (const ch of charts.filter((c) => c.on_dashboard)) {
|
|
98
|
+
const card = await chartCard(s, ch);
|
|
99
|
+
css += `\n${card.css}`;
|
|
100
|
+
cards.push(html`<section class="ch-card"><h2><a href="${BASE}/sql/charts/${ch.id}">${ch.name}</a></h2>${card.body}</section>`);
|
|
101
|
+
}
|
|
102
|
+
const main = html`<h1 class="u-mb1">Charts</h1>${workshopTabs('charts')}${style(req.cspNonce, css)}
|
|
103
|
+
<p class="muted">Queries drawn as charts. Make one from a query in the <a href="${BASE}/sql/worksheet">SQL Worksheet</a> (its Chart tab, Ctrl+F11), then save it.</p>
|
|
104
|
+
${cards.length ? html`<div class="ch-dashboard">${cards}</div>` : ''}
|
|
105
|
+
${region('All charts', charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Chart</th><th>Type</th><th>On the dashboard</th><th>Changed</th></tr></thead><tbody>
|
|
106
|
+
${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${SQL_CHART_KINDS.find(([k]) => k === c.kind)?.[1] ?? c.kind}</td><td>${c.on_dashboard ? 'Yes' : 'No'}</td>
|
|
107
|
+
<td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">by ${c.created_by}</span>` : ''}</td></tr>`)}</tbody></table></div>`
|
|
108
|
+
: html`<p class="muted">No charts yet.</p>`)}`;
|
|
109
|
+
return send(reply, s, shell(s, 'Charts', [['SQL Workshop', `${BASE}/sql`], ['Charts']], main, 'sql'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// one chart: drawn, with its settings
|
|
113
|
+
app.get(`${BASE}/sql/charts/:id`, async (req: Req, reply: FastifyReply) => {
|
|
114
|
+
const s = await developer(req, reply);
|
|
115
|
+
if (!s) return;
|
|
116
|
+
const ch = /^\d{1,9}$/.test(String(req.params.id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
117
|
+
if (!ch) return reply.code(404).send('Not found');
|
|
118
|
+
const card = await chartCard(s, ch, true);
|
|
119
|
+
let controls: Raw | '' = '';
|
|
120
|
+
try {
|
|
121
|
+
const r = await runChart(s, ch.sql);
|
|
122
|
+
const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
|
|
123
|
+
if (spec) controls = chartControls(r.columns, spec, 'sch');
|
|
124
|
+
} catch {
|
|
125
|
+
controls = '';
|
|
126
|
+
}
|
|
127
|
+
const main = html`<h1 class="u-mb1">${ch.name}</h1>${workshopTabs('charts')}${style(req.cspNonce, card.css)}
|
|
128
|
+
<div class="ch-view">${card.body}</div>
|
|
129
|
+
<form method="post" action="${BASE}/sql/charts/${ch.id}" class="ch-edit">${csrf(s)}
|
|
130
|
+
<div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
|
|
131
|
+
${controls}
|
|
132
|
+
<label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
|
|
133
|
+
<details class="u-mt1"><summary>The query</summary><pre class="source">${ch.sql}</pre></details>
|
|
134
|
+
<div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
|
|
135
|
+
<button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
|
|
136
|
+
<button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
|
|
137
|
+
</form>`;
|
|
138
|
+
return send(reply, s, shell(s, ch.name, [['SQL Workshop', `${BASE}/sql`], ['Charts', `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// save a chart from the worksheet (a new one), or change, open or delete a saved one
|
|
142
|
+
const save = async (req: Req, reply: FastifyReply) => {
|
|
143
|
+
const s = await developer(req, reply);
|
|
144
|
+
if (!s) return;
|
|
145
|
+
const b = req.body ?? {};
|
|
146
|
+
const ws = currentWorkspace(s);
|
|
147
|
+
const id = req.params?.id;
|
|
148
|
+
const name = String(b.name ?? b.chart_name ?? '').trim();
|
|
149
|
+
const asked = askedSpec(b);
|
|
150
|
+
const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
|
|
151
|
+
if (id !== undefined) {
|
|
152
|
+
const ch = /^\d{1,9}$/.test(String(id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [id, ws]) : undefined;
|
|
153
|
+
if (!ch) return reply.code(404).send('Not found');
|
|
154
|
+
if (b.action === 'delete') {
|
|
155
|
+
await owner.query('delete from meta.sql_chart where id = $1', [ch.id]);
|
|
156
|
+
flash(s, `Deleted the chart ${ch.name}.`);
|
|
157
|
+
return back(reply, s, `${BASE}/sql/charts`);
|
|
158
|
+
}
|
|
159
|
+
if (b.action === 'worksheet') {
|
|
160
|
+
s.state.__SQLDRAFT = ch.sql;
|
|
161
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
162
|
+
}
|
|
163
|
+
if (!name || name.length > 120) {
|
|
164
|
+
flash(s, 'A chart needs a name (at most 120 characters).', 'error');
|
|
165
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await owner.query(`update meta.sql_chart set name = $2, kind = $3, label_col = $4, value_cols = $5, on_dashboard = $6, updated_at = now() where id = $1`,
|
|
169
|
+
[ch.id, name, kind, asked.label ?? ch.label_col, asked.values?.length ? asked.values : ch.value_cols, b.on_dashboard === 'on']);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
172
|
+
flash(s, `A chart named “${name}” exists already.`, 'error');
|
|
173
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
174
|
+
}
|
|
175
|
+
flash(s, `Saved the chart ${name}.`);
|
|
176
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
177
|
+
}
|
|
178
|
+
let sql = '';
|
|
179
|
+
try {
|
|
180
|
+
sql = unloadStatement(String(b.chart_sql ?? ''));
|
|
181
|
+
} catch {
|
|
182
|
+
flash(s, 'Only a chart of one SELECT can be saved.', 'error');
|
|
183
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
184
|
+
}
|
|
185
|
+
if (!name || name.length > 120) {
|
|
186
|
+
flash(s, 'Name the chart to save it (at most 120 characters).', 'error');
|
|
187
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const r = await owner.one<{ id: number }>(
|
|
191
|
+
`insert into meta.sql_chart (workspace_id, name, sql, kind, label_col, value_cols, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
192
|
+
[ws, name, sql, kind, asked.label ?? '', asked.values ?? [], s.username]);
|
|
193
|
+
flash(s, `Saved the chart ${name}: it is on the Charts dashboard.`);
|
|
194
|
+
return back(reply, s, `${BASE}/sql/charts/${r!.id}`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
197
|
+
flash(s, `A chart named “${name}” exists already.`, 'error');
|
|
198
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
app.post(`${BASE}/sql/charts`, save);
|
|
202
|
+
app.post(`${BASE}/sql/charts/:id`, save);
|
|
203
|
+
}
|