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.
Files changed (50) hide show
  1. package/db/migrations/108_sql_history.sql +25 -0
  2. package/db/migrations/109_builder_pins.sql +19 -0
  3. package/db/migrations/110_sql_charts.sql +23 -0
  4. package/db/migrations/111_sql_diagram.sql +24 -0
  5. package/db/migrations/113_rest_objects.sql +49 -0
  6. package/docs/apex-feature-parity.md +28 -2
  7. package/docs/guide/03-builder.md +178 -21
  8. package/docs/guide/08-security.md +19 -1
  9. package/docs/guide/10-tutorial.md +1 -1
  10. package/docs/guide/12-development.md +46 -2
  11. package/docs/guide/13-rest-api.md +74 -9
  12. package/docs/guide/16-files.md +2 -2
  13. package/package.json +1 -1
  14. package/public/builder.css +22 -0
  15. package/public/data-modeler.css +56 -0
  16. package/public/data-modeler.js +367 -0
  17. package/public/palette.js +133 -0
  18. package/public/worksheet.css +293 -0
  19. package/public/worksheet.js +669 -0
  20. package/src/app.ts +4 -0
  21. package/src/builder/ai-builder.ts +3 -3
  22. package/src/builder/autorest.ts +362 -0
  23. package/src/builder/data-modeler.ts +382 -0
  24. package/src/builder/db-users.ts +472 -0
  25. package/src/builder/launchpad.ts +140 -0
  26. package/src/builder/object-actions.ts +167 -0
  27. package/src/builder/object-browser.ts +47 -26
  28. package/src/builder/querybuilder.ts +1 -1
  29. package/src/builder/sampledata.ts +2 -2
  30. package/src/builder/scripts.ts +3 -3
  31. package/src/builder/sql-charts.ts +203 -0
  32. package/src/builder/sql-tools.ts +49 -0
  33. package/src/builder/sql.ts +28 -81
  34. package/src/builder/table-editor.ts +219 -0
  35. package/src/builder/ui.ts +21 -15
  36. package/src/builder/unload.ts +8 -4
  37. package/src/builder/worksheet.ts +592 -0
  38. package/src/builder/workspaces.ts +5 -3
  39. package/src/cli/replace.ts +3 -1
  40. package/src/datamodel.ts +324 -0
  41. package/src/dbroles.ts +215 -0
  42. package/src/runtime/autorest.ts +336 -0
  43. package/src/runtime/rest.ts +4 -4
  44. package/src/session.ts +2 -0
  45. package/src/sqlformat.ts +208 -0
  46. package/src/sqlplan.ts +133 -0
  47. package/src/sqlscript.ts +6 -2
  48. package/src/sqlworksheet.ts +278 -0
  49. package/src/tabledef.ts +226 -0
  50. 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 Commands, and create tiles when nothing is selected. pgkiln's own schemas (meta,
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 Commands shows them; nothing runs until Run). */
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
- // (sprint 89, SQL-4) the schema asked for, else the last one shown (remembered per session), else the session's
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></li>`)}</ul>
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) detail = await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link);
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/commands?${new URLSearchParams({ template: k, schema })}">${icon(ic)}<span><b>${label}</b><small class="muted">Start a CREATE ${label.toUpperCase()} statement</small></span></a>`)}
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/commands?${new URLSearchParams({ template: k, schema })}">${icon('plus')} ${k[0].toUpperCase() + k.slice(1)}</a>`)}</div></div></details>
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 Commands to review and run
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/commands`);
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 Commands: review it there and press Run.</span>
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 Commands, or an interactive grid on the table in an application.</p>`;
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>` : ''}</h2>${nav}${region(tabs.find(([k]) => k === tab)![1], body)}`;
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 Commands</button></div></form>`)}`
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 Commands, with a
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 Commands</a>.</p>`;
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) => {
@@ -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 Commands, on a connection of their own that is
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>File</th></tr></thead><tbody>
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
+ }