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,49 @@
|
|
|
1
|
+
// (sprint 96, Database Actions DA-15) The SQL Workshop's tools in one list: the home page's Launchpad tabs
|
|
2
|
+
// (Development, Administration, Monitoring), the More menu's headings and the builder's search (Ctrl+K) read it.
|
|
3
|
+
// `tab` is the SQL Workshop tab a tool's own pages mark (workshopTabs in ui.ts); tools without one are pages
|
|
4
|
+
// elsewhere in the builder.
|
|
5
|
+
|
|
6
|
+
export type ToolGroup = 'development' | 'administration' | 'monitoring';
|
|
7
|
+
export interface SqlTool {
|
|
8
|
+
key: string;
|
|
9
|
+
href: string;
|
|
10
|
+
icon: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description: string;
|
|
13
|
+
group: ToolGroup;
|
|
14
|
+
/** the SQL Workshop tab its pages mark */
|
|
15
|
+
tab?: string;
|
|
16
|
+
/** one of the main tabs (not in More) */
|
|
17
|
+
main?: boolean;
|
|
18
|
+
/** administrators only */
|
|
19
|
+
admin?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const TOOL_GROUPS: [ToolGroup, string][] = [['development', 'Development'], ['administration', 'Administration'], ['monitoring', 'Monitoring']];
|
|
23
|
+
|
|
24
|
+
export const SQL_TOOLS: SqlTool[] = [
|
|
25
|
+
{ key: 'worksheet', href: '/builder/sql/worksheet', icon: 'code', label: 'SQL Worksheet', description: 'Run statements and scripts with a navigator, plans, results and history.', group: 'development', tab: 'sql', main: true },
|
|
26
|
+
{ key: 'objects', href: '/builder/sql/objects', icon: 'database', label: 'Object Browser', description: 'Tables, views and functions with their columns, data, keys, policies and DDL.', group: 'development', tab: 'objects', main: true },
|
|
27
|
+
{ key: 'scripts', href: '/builder/sql/scripts', icon: 'file', label: 'SQL Scripts', description: 'Saved scripts of the workspace: upload, run, download, run history.', group: 'development', tab: 'scripts', main: true },
|
|
28
|
+
{ key: 'table', href: '/builder/sql/objects/table', icon: 'table', label: 'Create Table', description: 'A new table in a form: columns, keys, indexes and security.', group: 'development' },
|
|
29
|
+
{ key: 'charts', href: '/builder/sql/charts', icon: 'chart', label: 'Charts', description: 'Queries drawn as charts, and a dashboard of them.', group: 'development', tab: 'charts' },
|
|
30
|
+
{ key: 'quicksql', href: '/builder/sql/quick', icon: 'bolt', label: 'Quick SQL', description: 'A data model in shorthand, turned into DDL.', group: 'development', tab: 'quicksql' },
|
|
31
|
+
{ key: 'query', href: '/builder/sql/query', icon: 'filter', label: 'Query Builder', description: 'A query from tables and their joins, drawn.', group: 'development', tab: 'query' },
|
|
32
|
+
{ key: 'load', href: '/builder/sql/load', icon: 'upload', label: 'Load Data', description: 'CSV, Excel, JSON or XML into a new or existing table.', group: 'development', tab: 'load' },
|
|
33
|
+
{ key: 'unload', href: '/builder/sql/unload', icon: 'download', label: 'Unload Data', description: 'A table, view or query as CSV, JSON, Excel, XML or SQL inserts.', group: 'development', tab: 'unload' },
|
|
34
|
+
{ key: 'sample', href: '/builder/sql/sample-data', icon: 'layers', label: 'Sample Data', description: 'Rows that look real, for development.', group: 'development', tab: 'sample' },
|
|
35
|
+
{ key: 'ddl', href: '/builder/sql/ddl', icon: 'file', label: 'Generate DDL', description: 'A schema\'s objects as one script.', group: 'development', tab: 'ddl' },
|
|
36
|
+
{ key: 'compare', href: '/builder/sql/compare', icon: 'layers', label: 'Schema Comparison', description: 'The differences between two schemas, and the SQL to align them.', group: 'development', tab: 'compare' },
|
|
37
|
+
{ key: 'modeler', href: '/builder/sql/modeler', icon: 'org', label: 'Data Modeler', description: 'Relational diagrams of tables and their foreign keys; DDL and a data dictionary from them.', group: 'development', tab: 'modeler' },
|
|
38
|
+
{ key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'Tables and views as REST endpoints (AutoREST): URLs, examples, OpenAPI.', group: 'development', tab: 'rest' },
|
|
39
|
+
{ key: 'ai', href: '/builder/sql/ai', icon: 'bolt', label: 'AI', description: 'SQL from a question, explanations and table descriptions.', group: 'development', tab: 'ai' },
|
|
40
|
+
{ key: 'utilities', href: '/builder/sql/utilities', icon: 'wrench', label: 'Utilities', description: 'Object reports, About Database and the other utilities.', group: 'administration', tab: 'utilities', main: true },
|
|
41
|
+
{ key: 'backups', href: '/builder/backups', icon: 'archive', label: 'Manage Backups', description: 'Backups of the workspace\'s applications.', group: 'administration' },
|
|
42
|
+
{ key: 'workspaces', href: '/builder/workspaces', icon: 'layers', label: 'Workspaces', description: 'Workspaces, their developers and database roles.', group: 'administration', admin: true },
|
|
43
|
+
{ key: 'dbusers', href: '/builder/db-users', icon: 'key', label: 'Database Users', description: 'PostgreSQL roles: sign-in, expiry, memberships and schema privileges.', group: 'administration', admin: true },
|
|
44
|
+
{ key: 'developers', href: '/builder/developers', icon: 'users', label: 'Developers', description: 'Builder accounts and their workspaces.', group: 'administration' },
|
|
45
|
+
{ key: 'monitor', href: '/builder/sql/utilities/monitor', icon: 'activity', label: 'Database Monitor', description: 'Sessions, what they run and what blocks them.', group: 'monitoring', admin: true },
|
|
46
|
+
{ key: 'about', href: '/builder/sql/utilities/about', icon: 'info', label: 'About Database', description: 'The server: version, settings, extensions and sizes.', group: 'monitoring' },
|
|
47
|
+
{ key: 'reports', href: '/builder/sql/utilities/reports', icon: 'list', label: 'Object Reports', description: 'Tables without keys, unindexed foreign keys, row level security gaps and more.', group: 'monitoring' },
|
|
48
|
+
{ key: 'activity', href: '/builder/dashboard', icon: 'chart', label: 'Monitor Activity', description: 'Page views, errors and slow pages of the applications.', group: 'monitoring' },
|
|
49
|
+
];
|
package/src/builder/sql.ts
CHANGED
|
@@ -7,12 +7,20 @@ import { owner } from '../db.ts';
|
|
|
7
7
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { scriptRoutes } from './scripts.ts';
|
|
10
|
+
import { worksheetRoutes } from './worksheet.ts';
|
|
11
|
+
import { launchpad, launchpadRoutes } from './launchpad.ts';
|
|
12
|
+
import { sqlChartRoutes } from './sql-charts.ts';
|
|
13
|
+
import { isAdmin } from './locks.ts';
|
|
10
14
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
11
15
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
16
|
import { unloadRoutes } from './unload.ts';
|
|
13
17
|
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
14
18
|
import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
15
19
|
import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
20
|
+
import { objectActionRoutes } from './object-actions.ts';
|
|
21
|
+
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
|
+
import { dataModelerRoutes } from './data-modeler.ts';
|
|
23
|
+
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
16
24
|
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
17
25
|
|
|
18
26
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
@@ -51,31 +59,23 @@ async function workshopHome(req: Req, reply: FastifyReply) {
|
|
|
51
59
|
[currentWorkspace(s)],
|
|
52
60
|
),
|
|
53
61
|
]);
|
|
54
|
-
const
|
|
55
|
-
html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
56
|
-
const list = (title: string, rows: Raw[], empty: string) =>
|
|
62
|
+
const list = (title: string | Raw, rows: Raw[], empty: string) =>
|
|
57
63
|
html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
|
|
58
64
|
const main = html`<div class="ab-home">
|
|
59
65
|
<div class="ab-main">
|
|
60
66
|
<h1 class="sr-only">SQL Workshop</h1>
|
|
61
67
|
${workshopTabs('home')}
|
|
62
|
-
|
|
63
|
-
${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
|
|
64
|
-
${tile('/sql/commands', 'code', 'SQL Commands', 'Run SQL statements against your schemas.')}
|
|
65
|
-
${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
|
|
66
|
-
${tile('/sql/utilities', 'wrench', 'Utilities', 'Load and unload data, sample data, DDL, schema comparison, object reports and the database.')}
|
|
67
|
-
${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
|
|
68
|
-
</div>
|
|
68
|
+
${await launchpad(s, await isAdmin(s.username))}
|
|
69
69
|
<div class="sw-recents">
|
|
70
70
|
${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
|
|
71
|
-
${list(
|
|
71
|
+
${list(html`<a href="${BASE}/sql/worksheet?tab=history" title="Open SQL History">Recent SQL commands</a>`, commands.rows.map((c) => html`<li><a href="${BASE}/sql/worksheet?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
|
|
72
72
|
${list('Recent SQL scripts', scripts.rows.map((x) => html`<li><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a><span class="muted">${ago(x.updated_at)}</span></li>`), 'No scripts in this workspace yet.')}
|
|
73
73
|
</div>
|
|
74
74
|
</div>
|
|
75
75
|
<aside class="ab-side" aria-label="About, schema and tasks">
|
|
76
76
|
<section><h2>About</h2><p>The SQL Workshop is a collection of tools to manage database objects. It runs as ${workshopRunsAs(db)}.</p>
|
|
77
77
|
<a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener">Learn more …</a></section>
|
|
78
|
-
<section><h2>Schema</h2><p>The default schema of this session: first in the search path of SQL
|
|
78
|
+
<section><h2>Schema</h2><p>The default schema of this session: first in the search path of the SQL Worksheet and SQL Scripts.</p>
|
|
79
79
|
<form method="post" action="${BASE}/sql/schema" class="sw-schema">${csrf(s)}
|
|
80
80
|
<label class="sr-only" for="f_schema">Schema</label>
|
|
81
81
|
<select id="f_schema" name="schema" data-autosubmit><option value="">(the default search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
@@ -102,76 +102,15 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
102
102
|
// Generate DDL and Schema Comparison (sprint 56)
|
|
103
103
|
await app.register(ddlToolRoutes);
|
|
104
104
|
await app.register(sqlUtilityRoutes);
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const sqlWorkshop = async (req: Req, reply: FastifyReply) => {
|
|
113
|
-
const s = await developer(req, reply);
|
|
114
|
-
if (!s) return;
|
|
115
|
-
const sql = req.body?.sql ?? '';
|
|
116
|
-
let result: Raw | '' = '';
|
|
117
|
-
const db = await workshopDb(s);
|
|
118
|
-
// (sprint 61) a command from the home page's Recent SQL Commands (the developer's own)
|
|
119
|
-
let text = sql;
|
|
120
|
-
if (req.method === 'GET' && /^\d{1,18}$/.test(String(req.query?.recent ?? ''))) {
|
|
121
|
-
const r = await owner.one<{ detail: string }>(`select detail from meta.activity_log where id = $1 and username = $2 and event = 'sql_command'`, [req.query!.recent, s.username]);
|
|
122
|
-
if (r) text = r.detail;
|
|
123
|
-
}
|
|
124
|
-
const schema = await sessionSchema(s, db);
|
|
125
|
-
if (req.method === 'GET') {
|
|
126
|
-
// (sprint 75) a statement to review: from the Object Browser's column actions, or a CREATE template
|
|
127
|
-
if (typeof s.state.__SQLDRAFT === 'string') {
|
|
128
|
-
text = s.state.__SQLDRAFT;
|
|
129
|
-
delete s.state.__SQLDRAFT;
|
|
130
|
-
} else if (typeof req.query?.template === 'string') {
|
|
131
|
-
const into = typeof req.query.schema === 'string' && (await usableSchemas(db)).includes(req.query.schema) ? req.query.schema : (schema ?? 'public');
|
|
132
|
-
text = createTemplate(req.query.template, into) ?? text;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
if (req.method === 'POST' && sql.trim()) {
|
|
136
|
-
const started = performance.now();
|
|
137
|
-
try {
|
|
138
|
-
// a connection of its own, closed afterwards: the SQL may SET ROLE or change settings,
|
|
139
|
-
// which must not leak into the builder's pooled owner connections
|
|
140
|
-
const c = await db.pool.connect();
|
|
141
|
-
let out;
|
|
142
|
-
try {
|
|
143
|
-
if (schema) await c.query(`select set_config('search_path', $1, false)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
144
|
-
out = await c.query({ text: sql, rowMode: 'array' });
|
|
145
|
-
} finally {
|
|
146
|
-
c.release(true);
|
|
147
|
-
}
|
|
148
|
-
const res = Array.isArray(out) ? out[out.length - 1] : out;
|
|
149
|
-
const ms = (performance.now() - started).toFixed(0);
|
|
150
|
-
result = res.fields?.length
|
|
151
|
-
? html`<p class="muted">${res.rowCount} row(s) in ${ms} ms${res.rows.length > 500 ? ' (showing 500)' : ''}</p>${resultTable(res)}`
|
|
152
|
-
: html`<div class="alert alert-success">${res.command ?? 'Statement'} completed${res.rowCount !== null ? `, ${res.rowCount} row(s)` : ''} in ${ms} ms.</div>`;
|
|
153
|
-
} catch (e) {
|
|
154
|
-
result = html`<div class="alert alert-error"><strong>Error:</strong> ${(e as Error).message}</div>`;
|
|
155
|
-
}
|
|
156
|
-
clearCompletions(); // the script may have changed tables or grants
|
|
157
|
-
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), elapsedMs: Math.round(performance.now() - started), detail: sql.length > 2000 ? `${sql.slice(0, 2000)}…` : sql });
|
|
158
|
-
}
|
|
159
|
-
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('sql')}
|
|
160
|
-
<p class="muted">Runs as ${workshopRunsAs(db)} (not as an application role)${schema ? html`, schema <code>${schema}</code> first in the search path (<a href="${BASE}/sql">change</a>)` : ''}. Multiple statements are allowed; results of the last one are shown.</p>
|
|
161
|
-
<form method="post" action="${BASE}/sql/commands">${csrf(s)}
|
|
162
|
-
<textarea name="sql" class="code sql-editor" rows="12" spellcheck="false" aria-label="SQL" data-code="plpgsql">${text || 'select current_user, version();'}</textarea>
|
|
163
|
-
<div class="buttons"><button class="btn btn-hot">${icon('play')} Run (Ctrl+Enter)</button></div>
|
|
164
|
-
</form>
|
|
165
|
-
<div class="u-mt1">${result}</div>`;
|
|
166
|
-
return send(reply, s, shell(s, 'SQL Workshop', [['SQL Workshop']], main, 'sql'));
|
|
167
|
-
};
|
|
168
|
-
app.get(`${BASE}/sql/commands`, sqlWorkshop);
|
|
169
|
-
app.post(`${BASE}/sql/commands`, sqlWorkshop);
|
|
170
|
-
// (before sprint 61 SQL Commands was /builder/sql: a post there still runs)
|
|
171
|
-
app.post(`${BASE}/sql`, sqlWorkshop);
|
|
105
|
+
// (sprint 97) Charts: saved charts and their dashboard
|
|
106
|
+
await app.register(sqlChartRoutes);
|
|
107
|
+
// (sprint 96) the Launchpad's pins and the builder's search (Ctrl+K)
|
|
108
|
+
await app.register(launchpadRoutes);
|
|
109
|
+
// (sprint 91) the SQL Worksheet (was SQL Commands; /sql/commands and a post to /sql are the same page)
|
|
110
|
+
await app.register(worksheetRoutes);
|
|
172
111
|
app.get(`${BASE}/sql`, workshopHome);
|
|
173
112
|
|
|
174
|
-
// (sprint 61) the session's default schema: first in the search path of SQL
|
|
113
|
+
// (sprint 61) the session's default schema: first in the search path of the SQL Worksheet and SQL Scripts
|
|
175
114
|
app.post(`${BASE}/sql/schema`, async (req: Req, reply) => {
|
|
176
115
|
const s = await developer(req, reply);
|
|
177
116
|
if (!s) return;
|
|
@@ -180,10 +119,18 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
180
119
|
if (wanted && !usable.includes(wanted)) return reply.code(404).send('Not found');
|
|
181
120
|
if (wanted) s.state.__SQLSCHEMA = wanted;
|
|
182
121
|
else delete s.state.__SQLSCHEMA;
|
|
183
|
-
flash(s, wanted ? `Schema ${wanted} first in the search path of SQL
|
|
122
|
+
flash(s, wanted ? `Schema ${wanted} first in the search path of the SQL Worksheet and SQL Scripts.` : 'The default search path again.');
|
|
184
123
|
return back(reply, s, `${BASE}/sql`);
|
|
185
124
|
});
|
|
186
125
|
|
|
187
126
|
// (sprint 75) the Object Browser: object-browser.ts
|
|
188
127
|
await app.register(objectBrowserRoutes);
|
|
128
|
+
// (sprint 94) an object's menu: Quick DDL, Drop, Add index / trigger / policy, Open in a panel
|
|
129
|
+
await app.register(objectActionRoutes);
|
|
130
|
+
// (sprint 95) Table Properties: create, edit or copy a table in a form
|
|
131
|
+
await app.register(tableEditorRoutes);
|
|
132
|
+
// (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
|
|
133
|
+
await app.register(dataModelerRoutes);
|
|
134
|
+
// (sprint 100) RESTful Services: AutoREST (autorest.ts)
|
|
135
|
+
await app.register(autoRestBuilderRoutes);
|
|
189
136
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { esc, html, raw, type Raw } from '../html.ts';
|
|
3
|
+
import { icon } from '../icons.ts';
|
|
4
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
5
|
+
import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
6
|
+
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
7
|
+
import { clearCompletions } from './code-editor.ts';
|
|
8
|
+
import { usableSchemas, workshopDb } from './workspaces.ts';
|
|
9
|
+
import { checkTable, emptyTable, ON_DELETE, readTable, tableSql, type ColumnDef, type TableDef } from '../tabledef.ts';
|
|
10
|
+
|
|
11
|
+
// (sprint 95, Database Actions DA-8: Table Properties) Create a table, edit one, or start from one as a template, in
|
|
12
|
+
// a form: columns (name, type, not null, default, identity, comment, primary key), unique keys, foreign keys,
|
|
13
|
+
// checks, indexes, the comment, row level security and, for a new table, partitioning. Preview DDL shows the exact
|
|
14
|
+
// CREATE or ALTER statements; Apply runs them in one transaction with the workshop's connection (nothing is
|
|
15
|
+
// changed when one fails); Open in the worksheet puts them there to review. The form posts the whole definition;
|
|
16
|
+
// readTable() gives the table as it is now, so the statements are always made against the catalog, not against
|
|
17
|
+
// what the page showed. Without JavaScript the sections stack and spare empty rows add columns and keys.
|
|
18
|
+
|
|
19
|
+
const SPARE = 3; // empty rows for new columns, keys, checks and indexes (the page adds more with script)
|
|
20
|
+
const TYPES = ['bigint', 'integer', 'smallint', 'numeric', 'numeric(12,2)', 'real', 'double precision', 'text', 'varchar(200)', 'char(1)', 'boolean', 'date',
|
|
21
|
+
'timestamp', 'timestamptz', 'time', 'interval', 'uuid', 'jsonb', 'json', 'bytea', 'inet', 'text[]', 'integer[]'];
|
|
22
|
+
|
|
23
|
+
const rows = (b: Body, prefix: string) => {
|
|
24
|
+
const ix = [...new Set(Object.keys(b).map((k) => new RegExp(`^${prefix}\\.(\\d{1,3})\\.`).exec(k)?.[1]).filter((x): x is string => !!x))].map(Number).sort((x, y) => x - y);
|
|
25
|
+
return ix.map((i) => (f: string) => String(b[`${prefix}.${i}.${f}`] ?? '').trim());
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The definition the form posted; generated expressions and indexes the form can't edit come from the table as it is. */
|
|
29
|
+
function fromForm(b: Body, schema: string, old: TableDef | null): TableDef {
|
|
30
|
+
const def = emptyTable(schema);
|
|
31
|
+
def.name = String(b.name ?? '').trim();
|
|
32
|
+
def.comment = String(b.comment ?? '').trim();
|
|
33
|
+
def.pkName = String(b.pk_name ?? '').trim();
|
|
34
|
+
def.rls = b.rls === 'on';
|
|
35
|
+
def.forceRls = b.force_rls === 'on';
|
|
36
|
+
if (!old) {
|
|
37
|
+
def.partitionBy = (['range', 'list', 'hash'] as const).find((x) => x === b.part_by) ?? '';
|
|
38
|
+
def.partitionKey = def.partitionBy ? String(b.part_key ?? '').trim() : '';
|
|
39
|
+
}
|
|
40
|
+
for (const r of rows(b, 'col')) {
|
|
41
|
+
if (r('drop') === 'on' || (!r('name') && !r('type'))) continue;
|
|
42
|
+
const orig = old?.columns.find((c) => c.orig === r('orig')) ? r('orig') : '';
|
|
43
|
+
const was = old?.columns.find((c) => c.orig === orig);
|
|
44
|
+
const identity = r('identity') === 'always' || r('identity') === 'by default' ? (r('identity') as ColumnDef['identity']) : '';
|
|
45
|
+
def.columns.push({ name: r('name'), orig, type: r('type'), notNull: r('nn') === 'on', default: r('def'), identity, generated: was?.generated ?? '', comment: r('comment'), pk: r('pk') === 'on' });
|
|
46
|
+
}
|
|
47
|
+
for (const r of rows(b, 'uk')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.uniques.push({ name: r('name'), columns: r('cols') });
|
|
48
|
+
for (const r of rows(b, 'fk')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.fks.push({ name: r('name'), columns: r('cols'), refTable: r('ref'), refColumns: r('refcols'), onDelete: r('del') || 'no action' });
|
|
49
|
+
for (const r of rows(b, 'ck')) if (r('drop') !== 'on' && (r('name') || r('expr'))) def.checks.push({ name: r('name'), expr: r('expr') });
|
|
50
|
+
for (const r of rows(b, 'ix')) if (r('drop') !== 'on' && (r('name') || r('cols'))) def.indexes.push({ name: r('name'), columns: r('cols'), unique: r('unique') === 'on' });
|
|
51
|
+
// indexes the form can't express stay as they are
|
|
52
|
+
for (const i of old?.indexes ?? []) if (i.fixed) def.indexes.push(i);
|
|
53
|
+
return def;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A copy of a table under a new name, for "Use as template": new names for its constraints and indexes too. */
|
|
57
|
+
function asTemplate(t: TableDef): TableDef {
|
|
58
|
+
const name = `${t.name}_copy`.slice(0, 63);
|
|
59
|
+
const rename = (n: string) => (n.includes(t.name) ? n.replace(t.name, name) : `${n}_copy`).slice(0, 63);
|
|
60
|
+
return {
|
|
61
|
+
...structuredClone(t), name, pkName: t.pkName ? rename(t.pkName) : '',
|
|
62
|
+
columns: t.columns.map((c) => ({ ...c, orig: '' })),
|
|
63
|
+
uniques: t.uniques.map((k) => ({ ...k, name: rename(k.name) })),
|
|
64
|
+
fks: t.fks.map((k) => ({ ...k, name: rename(k.name) })),
|
|
65
|
+
checks: t.checks.map((k) => ({ ...k, name: rename(k.name) })),
|
|
66
|
+
indexes: t.indexes.filter((i) => !i.fixed).map((i) => ({ ...i, name: rename(i.name) })),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function tables(db: Awaited<ReturnType<typeof workshopDb>>) {
|
|
71
|
+
return (await db.query<{ q: string }>(
|
|
72
|
+
`select c.oid::regclass::text as q from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
73
|
+
where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname <> 'information_schema' and not (n.nspname = any ($1))
|
|
74
|
+
and has_schema_privilege(n.oid, 'USAGE') order by 1 limit 2000`, [SYSTEM_SCHEMAS])).rows.map((r) => r.q);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function form(s: Session, def: TableDef, opts: { editing: TableDef | null; schemas: string[]; refTables: string[]; ddl: string[]; errors: string[]; output: Raw | ''; tab: string; ref: string }): Raw {
|
|
78
|
+
const { editing } = opts;
|
|
79
|
+
const chk = (on: boolean) => (on ? raw(' checked') : '');
|
|
80
|
+
const sel = (v: string, cur: string) => (v === cur ? raw(' selected') : '');
|
|
81
|
+
const typeList = html`<datalist id="te-types">${TYPES.map((t) => html`<option value="${t}">`)}</datalist>`;
|
|
82
|
+
const colRow = (c: ColumnDef | null, i: number) => html`<tr class="te-row${c ? '' : ' te-spare'}">
|
|
83
|
+
<td class="te-move"><button type="button" class="btn btn-sm btn-icon" data-te-up title="Move up" hidden>↑</button><button type="button" class="btn btn-sm btn-icon" data-te-down title="Move down" hidden>↓</button></td>
|
|
84
|
+
<td><input type="hidden" name="col.${i}.orig" value="${c?.orig ?? ''}"><input id="te-c${i}-name" aria-label="Name" name="col.${i}.name" value="${c?.name ?? ''}" autocomplete="off" spellcheck="false"></td>
|
|
85
|
+
<td><input id="te-c${i}-type" aria-label="Type" name="col.${i}.type" value="${c?.type ?? ''}" list="te-types" autocomplete="off" spellcheck="false"></td>
|
|
86
|
+
<td class="te-c"><input type="checkbox" aria-label="Primary key" id="te-c${i}-pk" name="col.${i}.pk"${chk(!!c?.pk)}></td>
|
|
87
|
+
<td class="te-c"><input type="checkbox" aria-label="Not null" id="te-c${i}-nn" name="col.${i}.nn"${chk(!!c?.notNull)}></td>
|
|
88
|
+
<td>${c?.generated
|
|
89
|
+
? html`<code class="te-generated" title="Generated (stored)">= ${c.generated}</code><input type="hidden" name="col.${i}.def" value="">`
|
|
90
|
+
: html`<input id="te-c${i}-def" aria-label="Default" name="col.${i}.def" value="${c?.default ?? ''}" class="code" spellcheck="false">`}</td>
|
|
91
|
+
<td><select id="te-c${i}-id" aria-label="Identity" name="col.${i}.identity"><option value="">-</option><option value="by default"${sel('by default', c?.identity ?? '')}>by default</option><option value="always"${sel('always', c?.identity ?? '')}>always</option></select></td>
|
|
92
|
+
<td><input id="te-c${i}-cm" aria-label="Comment" name="col.${i}.comment" value="${c?.comment ?? ''}"></td>
|
|
93
|
+
<td class="te-c"><label class="te-drop"><input type="checkbox" name="col.${i}.drop"><span>Remove</span></label></td></tr>`;
|
|
94
|
+
const simpleRows = <T,>(list: T[], prefix: string, cells: (x: T | null, i: number) => Raw) =>
|
|
95
|
+
[...list, ...Array<null>(SPARE).fill(null)].map((x, i) => html`<tr class="te-row${x ? '' : ' te-spare'}">${cells(x, i)}<td class="te-c"><label class="te-drop"><input type="checkbox" name="${prefix}.${i}.drop"><span>Remove</span></label></td></tr>`);
|
|
96
|
+
// extra: attributes written by this file (constants, or values escaped with esc())
|
|
97
|
+
const input = (name: string, value: string, label: string, extra = '') => html`<input id="te-${name}" aria-label="${label}" name="${name}" value="${value}" autocomplete="off" spellcheck="false"${extra ? raw(` ${extra}`) : ''}>`;
|
|
98
|
+
const sections: [string, string, Raw][] = [
|
|
99
|
+
['columns', 'Columns', html`<div class="table-wrap"><table class="report te-table" data-te-grid="col"><thead><tr><th></th><th>Name</th><th>Type</th><th>Key</th><th>Not null</th><th>Default</th><th>Identity</th><th>Comment</th><th></th></tr></thead>
|
|
100
|
+
<tbody>${[...def.columns, ...Array<null>(SPARE).fill(null)].map((c, i) => colRow(c, i))}</tbody></table></div>
|
|
101
|
+
<div class="buttons"><button type="button" class="btn btn-sm" data-te-add="col" hidden>${icon('plus')} Add column</button></div>
|
|
102
|
+
<div class="field u-mt1"><label class="label" for="te-pk_name">Primary key name</label>${input('pk_name', def.pkName, 'Primary key name', `placeholder="${esc(def.name ? `${def.name}_pkey` : 'table_pkey')}"`)}<small class="help">Tick Key on the columns of the primary key.</small></div>`],
|
|
103
|
+
['keys', 'Unique keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="uk"><thead><tr><th>Name</th><th>Columns (comma-separated)</th><th></th></tr></thead><tbody>
|
|
104
|
+
${simpleRows(def.uniques, 'uk', (k, i) => html`<td>${input(`uk.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`uk.${i}.cols`, k?.columns ?? '', 'Columns')}</td>`)}</tbody></table></div>`],
|
|
105
|
+
['fks', 'Foreign keys', html`<div class="table-wrap"><table class="report te-table" data-te-grid="fk"><thead><tr><th>Name</th><th>Columns</th><th>References</th><th>Their columns</th><th>On delete</th><th></th></tr></thead><tbody>
|
|
106
|
+
${simpleRows(def.fks, 'fk', (f, i) => html`<td>${input(`fk.${i}.name`, f?.name ?? '', 'Name')}</td><td>${input(`fk.${i}.cols`, f?.columns ?? '', 'Columns')}</td>
|
|
107
|
+
<td><select id="te-fk${i}-ref" aria-label="References" name="fk.${i}.ref"><option value="">-</option>${opts.refTables.map((t) => html`<option value="${t}"${sel(t, f?.refTable ?? '')}>${t}</option>`)}</select></td>
|
|
108
|
+
<td>${input(`fk.${i}.refcols`, f?.refColumns ?? '', 'Their columns', 'placeholder="its primary key"')}</td>
|
|
109
|
+
<td><select id="te-fk${i}-del" aria-label="On delete" name="fk.${i}.del">${ON_DELETE.map((d) => html`<option value="${d}"${sel(d, f?.onDelete ?? 'no action')}>${d}</option>`)}</select></td>`)}</tbody></table></div>`],
|
|
110
|
+
['checks', 'Checks', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ck"><thead><tr><th>Name</th><th>Condition</th><th></th></tr></thead><tbody>
|
|
111
|
+
${simpleRows(def.checks, 'ck', (k, i) => html`<td>${input(`ck.${i}.name`, k?.name ?? '', 'Name')}</td><td>${input(`ck.${i}.expr`, k?.expr ?? '', 'Condition', 'class="code" placeholder="salary >= 0"')}</td>`)}</tbody></table></div>`],
|
|
112
|
+
['indexes', 'Indexes', html`<div class="table-wrap"><table class="report te-table" data-te-grid="ix"><thead><tr><th>Name</th><th>Columns or expressions</th><th>Unique</th><th></th></tr></thead><tbody>
|
|
113
|
+
${simpleRows(def.indexes.filter((i) => !i.fixed), 'ix', (x, i) => html`<td>${input(`ix.${i}.name`, x?.name ?? '', 'Name')}</td><td>${input(`ix.${i}.cols`, x?.columns ?? '', 'Columns', 'class="code" placeholder="lower(name), created_at desc"')}</td>
|
|
114
|
+
<td class="te-c"><input type="checkbox" aria-label="Unique" id="te-ix${i}-u" name="ix.${i}.unique"${chk(!!x?.unique)}></td>`)}</tbody></table></div>
|
|
115
|
+
${def.indexes.some((i) => i.fixed) ? html`<p class="muted small">Kept as they are (another method or a condition): ${def.indexes.filter((i) => i.fixed).map((i) => html`<code>${i.fixed}</code> `)}</p>` : ''}`],
|
|
116
|
+
['more', 'Comment and security', html`<div class="field"><label class="label" for="te-comment">Comment</label><textarea id="te-comment" name="comment" rows="2">${def.comment}</textarea></div>
|
|
117
|
+
<label class="check"><input type="checkbox" name="rls"${chk(def.rls)}> Row level security: rows only through policies</label>
|
|
118
|
+
<label class="check"><input type="checkbox" name="force_rls"${chk(def.forceRls)}> Force it for the table's owner too</label>
|
|
119
|
+
${editing ? '' : html`<fieldset class="prop-group u-mt1"><legend>Partitioning</legend><div class="form-grid">
|
|
120
|
+
<div class="field"><label class="label" for="te-part_by">Partition by</label><select id="te-part_by" name="part_by"><option value="">Not partitioned</option>${(['range', 'list', 'hash'] as const).map((p) => html`<option value="${p}"${sel(p, def.partitionBy)}>${p}</option>`)}</select></div>
|
|
121
|
+
<div class="field"><label class="label" for="te-part_key">Key</label>${input('part_key', def.partitionKey, 'Key', 'class="code" placeholder="created_at"')}</div></div>
|
|
122
|
+
<small class="help">The partitions themselves are tables made with CREATE TABLE … PARTITION OF in the worksheet.</small></fieldset>`}`],
|
|
123
|
+
['ddl', 'DDL', opts.ddl.length ? html`<pre class="source te-ddl">${opts.ddl.join('\n\n')}</pre>` : html`<p class="muted">${editing ? 'No changes yet: the table is as the form shows it.' : 'Fill in the table and press Preview DDL.'}</p>`],
|
|
124
|
+
['output', 'Output', opts.output || html`<p class="muted">Apply shows what happened here.</p>`],
|
|
125
|
+
];
|
|
126
|
+
const tab = sections.some(([k]) => k === opts.tab) ? opts.tab : opts.errors.length ? 'columns' : 'columns';
|
|
127
|
+
return html`<form method="post" action="${BASE}/sql/objects/table" class="te" data-te-tab="${tab}">${csrf(s)}
|
|
128
|
+
<input type="hidden" name="ref" value="${opts.ref}">${typeList}
|
|
129
|
+
${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
|
|
130
|
+
<div class="form-grid te-head">
|
|
131
|
+
<div class="field"><label class="label" for="te-schema">Schema</label>${editing
|
|
132
|
+
? html`<input id="te-schema" value="${def.schema}" readonly><input type="hidden" name="schema" value="${def.schema}">`
|
|
133
|
+
: html`<select id="te-schema" name="schema">${opts.schemas.map((n) => html`<option value="${n}"${sel(n, def.schema)}>${n}</option>`)}</select>`}</div>
|
|
134
|
+
<div class="field"><label class="label" for="te-name">Table name</label><input id="te-name" name="name" value="${def.name}" required autocomplete="off" spellcheck="false"></div>
|
|
135
|
+
</div>
|
|
136
|
+
<nav class="te-tabs" role="tablist" aria-label="Table properties" hidden>${sections.map(([k, label]) => html`<button type="button" role="tab" aria-selected="${k === tab ? 'true' : 'false'}" data-te-show="${k}">${label}</button>`)}</nav>
|
|
137
|
+
${sections.map(([k, label, body]) => html`<section class="te-section" data-te-section="${k}"><h2>${label}</h2>${body}</section>`)}
|
|
138
|
+
<div class="buttons te-buttons">
|
|
139
|
+
<button class="btn" name="mode" value="preview">${icon('eye')} Preview DDL</button>
|
|
140
|
+
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
|
|
141
|
+
<button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
|
|
142
|
+
<a class="btn" href="${editing ? `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema, k: 'table', o: opts.ref })}` : `${BASE}/sql/objects?${new URLSearchParams({ schema: def.schema })}`}">Cancel</a>
|
|
143
|
+
</div></form>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function page(req: Req, reply: FastifyReply) {
|
|
147
|
+
const s = await developer(req, reply);
|
|
148
|
+
if (!s) return;
|
|
149
|
+
const db = await workshopDb(s);
|
|
150
|
+
const isPost = req.method === 'POST';
|
|
151
|
+
const b: Body = isPost ? (req.body ?? {}) : (req.query ?? {});
|
|
152
|
+
const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
|
|
153
|
+
const refTables = await tables(db);
|
|
154
|
+
// the table being edited, read again from the catalog on every request
|
|
155
|
+
const ref = String((isPost ? b.ref : b.o) ?? '');
|
|
156
|
+
const editing = ref ? await readTable(db, ref) : null;
|
|
157
|
+
if (ref && (!editing || SYSTEM_SCHEMAS.includes(editing.schema) || !schemas.includes(editing.schema))) return reply.code(404).send('Not found');
|
|
158
|
+
let def: TableDef;
|
|
159
|
+
let errors: string[] = [];
|
|
160
|
+
let ddl: string[] = [];
|
|
161
|
+
let output: Raw | '' = '';
|
|
162
|
+
let tab = 'columns';
|
|
163
|
+
if (!isPost) {
|
|
164
|
+
const template = typeof b.template === 'string' && b.template ? await readTable(db, b.template) : null;
|
|
165
|
+
if (b.template && (!template || !schemas.includes(template.schema))) return reply.code(404).send('Not found');
|
|
166
|
+
def = editing ?? (template ? asTemplate(template) : emptyTable(schemas.includes(String(b.schema)) ? String(b.schema) : (schemas[0] ?? 'public')));
|
|
167
|
+
} else {
|
|
168
|
+
const schema = editing ? editing.schema : String(b.schema ?? '');
|
|
169
|
+
if (!schemas.includes(schema)) return reply.code(404).send('Not found');
|
|
170
|
+
def = fromForm(b, schema, editing);
|
|
171
|
+
errors = checkTable(def, editing);
|
|
172
|
+
if (!errors.length) ddl = tableSql(def, editing);
|
|
173
|
+
tab = 'ddl';
|
|
174
|
+
if (!errors.length && b.mode === 'worksheet') {
|
|
175
|
+
s.state.__SQLDRAFT = ddl.join('\n\n') || '-- no changes';
|
|
176
|
+
flash(s, 'Review the statements and run them: nothing has been changed yet.');
|
|
177
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
178
|
+
}
|
|
179
|
+
if (!errors.length && b.mode === 'apply' && ddl.length) {
|
|
180
|
+
const c = await db.pool.connect();
|
|
181
|
+
let failed = '';
|
|
182
|
+
let at = '';
|
|
183
|
+
try {
|
|
184
|
+
await c.query('begin');
|
|
185
|
+
for (const x of ddl) {
|
|
186
|
+
at = x;
|
|
187
|
+
await c.query(x);
|
|
188
|
+
}
|
|
189
|
+
await c.query('commit');
|
|
190
|
+
} catch (e) {
|
|
191
|
+
failed = (e as Error).message;
|
|
192
|
+
await c.query('rollback').catch(() => {});
|
|
193
|
+
} finally {
|
|
194
|
+
c.release(true);
|
|
195
|
+
}
|
|
196
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: ddl.join(';\n').slice(0, 2000) });
|
|
197
|
+
clearCompletions();
|
|
198
|
+
if (!failed) {
|
|
199
|
+
flash(s, `${editing ? 'Changed' : 'Created'} ${def.schema}.${def.name}: ${ddl.length} statement${ddl.length === 1 ? '' : 's'}.`);
|
|
200
|
+
const now = await readTable(db, `${pgIdent(def.schema)}.${pgIdent(def.name)}`);
|
|
201
|
+
return back(reply, s, `${BASE}/sql/objects/table?${new URLSearchParams({ o: now ? `${pgIdent(now.schema)}.${pgIdent(now.name)}` : '' })}`);
|
|
202
|
+
}
|
|
203
|
+
output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source">${at}</pre></div>`;
|
|
204
|
+
tab = 'output';
|
|
205
|
+
}
|
|
206
|
+
if (errors.length) tab = 'columns';
|
|
207
|
+
}
|
|
208
|
+
const title = editing ? `Table ${editing.schema}.${editing.name}` : 'Create table';
|
|
209
|
+
const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}
|
|
210
|
+
${form(s, def, { editing, schemas, refTables, ddl, errors, output, tab, ref: editing ? ref : '' })}`;
|
|
211
|
+
return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [title]], main, 'sql'));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const pgIdent = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : `"${n.replace(/"/g, '""')}"`);
|
|
215
|
+
|
|
216
|
+
export async function tableEditorRoutes(app: FastifyInstance) {
|
|
217
|
+
app.get(`${BASE}/sql/objects/table`, page);
|
|
218
|
+
app.post(`${BASE}/sql/objects/table`, page);
|
|
219
|
+
}
|
package/src/builder/ui.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { documentShell } from '../layout.ts';
|
|
9
9
|
import { getSession, saveState, takeFlash, type Session } from '../session.ts';
|
|
10
|
+
import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
|
|
10
11
|
import { ensureDevbar } from '../runtime/devbar.ts';
|
|
11
12
|
|
|
12
13
|
// Shared building blocks of the builder UI.
|
|
@@ -51,8 +52,8 @@ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
|
|
|
51
52
|
// Builder-only stylesheets and scripts, loaded on every builder page after
|
|
52
53
|
// app.css / app.js (the runtime never loads them). Add new builder assets
|
|
53
54
|
// here, and only here.
|
|
54
|
-
export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css'];
|
|
55
|
-
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js'];
|
|
55
|
+
export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css', '/static/data-modeler.css'];
|
|
56
|
+
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js', '/static/palette.js', '/static/data-modeler.js'];
|
|
56
57
|
export const builderHead = () =>
|
|
57
58
|
html`${BUILDER_STYLES.map((href) => html`<link rel="stylesheet" href="${href}">\n`)}${BUILDER_SCRIPTS.map((src) => html`<script src="${src}" defer></script>\n`)}`;
|
|
58
59
|
|
|
@@ -139,6 +140,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
139
140
|
<a href="${BASE}/users">${icon('user')} Manage users and groups</a>
|
|
140
141
|
<a href="${BASE}/developers">${icon('users')} Developers</a>
|
|
141
142
|
<a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
|
|
143
|
+
${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} Database users</a>` : ''}
|
|
142
144
|
<a href="${BASE}/developers">${icon('key')} Change my password</a>
|
|
143
145
|
</div>
|
|
144
146
|
</div></details></li>
|
|
@@ -170,6 +172,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
170
172
|
${back ? html`<a class="ide-back" href="${back[1]}" title="Back to ${back[0]}">${bicon('back')}<span class="sr-only">Back to ${back[0]}</span></a>` : ''}
|
|
171
173
|
<nav class="ide-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(([label, href], i) =>
|
|
172
174
|
html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
|
|
175
|
+
<form method="get" action="${BASE}/palette" class="ide-search" role="search" data-palette>
|
|
176
|
+
<label class="sr-only" for="ide-search-q">Search the builder</label>
|
|
177
|
+
<input id="ide-search-q" name="q" type="search" placeholder="Search (Ctrl+K)" autocomplete="off" aria-keyshortcuts="Control+K"></form>
|
|
173
178
|
<div class="ide-tools">${opts.toolbar ?? ''}</div>
|
|
174
179
|
</header>
|
|
175
180
|
${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
|
|
@@ -247,22 +252,23 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
247
252
|
};
|
|
248
253
|
|
|
249
254
|
/** Tabs of the SQL Workshop. */
|
|
250
|
-
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities';
|
|
255
|
+
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest';
|
|
251
256
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
252
|
-
const tab = (key:
|
|
253
|
-
html`<a class="ide-tab" href="${
|
|
254
|
-
// (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
['compare', '/sql/compare', 'layers', 'Compare'], ['ai', '/sql/ai', 'bolt', 'AI'],
|
|
259
|
-
];
|
|
260
|
-
const open = more.find(([k]) => k === active);
|
|
257
|
+
const tab = (key: string, href: string, ic: string, label: string) =>
|
|
258
|
+
html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
259
|
+
// (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
|
|
260
|
+
// scrolls; (sprint 96) the More menu under the Launchpad's headings, from SQL_TOOLS
|
|
261
|
+
const main = SQL_TOOLS.filter((t) => t.main);
|
|
262
|
+
const open = SQL_TOOLS.find((t) => !t.main && t.tab === active);
|
|
261
263
|
return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
|
|
262
|
-
${tab('home',
|
|
263
|
-
${open ? tab(
|
|
264
|
+
${tab('home', `${BASE}/sql`, 'home', 'Home')}${main.map((t) => tab(t.tab!, t.href, t.icon, t.label))}
|
|
265
|
+
${open ? tab(open.tab!, open.href, open.icon, open.label) : ''}
|
|
264
266
|
<details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
|
|
265
|
-
<div class="menu-panel align-right"
|
|
267
|
+
<div class="menu-panel align-right">${TOOL_GROUPS.map(([g, heading]) => {
|
|
268
|
+
// administrators' tools are on the Launchpad and the Utilities page, not in every developer's menu
|
|
269
|
+
const list = SQL_TOOLS.filter((t) => t.group === g && !t.main && !t.admin);
|
|
270
|
+
return list.length ? html`<div class="menu-section menu-links"><span class="small muted">${heading}</span>${list.map((t) => html`<a href="${t.href}"${t.tab && t.tab === active ? raw(' aria-current="page"') : ''}>${icon(t.icon)} ${t.label}</a>`)}</div>` : '';
|
|
271
|
+
})}</div></details></nav>`;
|
|
266
272
|
};
|
|
267
273
|
|
|
268
274
|
/** Number of app tile colours (.app-color-0 … -7 in app.css). */
|
package/src/builder/unload.ts
CHANGED
|
@@ -21,12 +21,12 @@ import {
|
|
|
21
21
|
type UnloadOptions,
|
|
22
22
|
} from '../unload.ts';
|
|
23
23
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
24
|
-
import { workshopDb } from './workspaces.ts';
|
|
24
|
+
import { sessionSchema, workshopDb } from './workspaces.ts';
|
|
25
25
|
|
|
26
26
|
// SQL Workshop → Unload Data (APEX: Data Workshop → Unload Data): a table or
|
|
27
27
|
// view (chosen columns, an optional WHERE and ORDER BY) or a query, to CSV,
|
|
28
28
|
// JSON, Excel or XML, streamed from a cursor (src/unload.ts). Runs on a
|
|
29
|
-
// connection of its own as the owner, like SQL
|
|
29
|
+
// connection of its own as the owner, like the SQL Worksheet, but in a READ ONLY
|
|
30
30
|
// transaction with a statement timeout; the connection is closed afterwards
|
|
31
31
|
// so nothing the query sets can leak into the pool. The table step is a GET
|
|
32
32
|
// form and the download a POST form (CSRF), so it works without JavaScript.
|
|
@@ -100,7 +100,7 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
100
100
|
return html`
|
|
101
101
|
<fieldset class="prop-group u-mt125"><legend>Format</legend>
|
|
102
102
|
<div class="unload-formats" role="radiogroup" aria-label="Format">${UNLOAD_FORMATS.map(
|
|
103
|
-
(f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML' }[f]}</label>`,
|
|
103
|
+
(f) => html`<label class="check"><input type="radio" name="format" value="${f}"${fmt === f ? raw(' checked') : ''}> ${{ csv: 'CSV', json: 'JSON', xlsx: 'Excel (.xlsx)', xml: 'XML', sql: 'SQL inserts' }[f]}</label>`,
|
|
104
104
|
)}</div>
|
|
105
105
|
</fieldset>
|
|
106
106
|
<fieldset class="prop-group"><legend>CSV</legend><div class="form-grid">
|
|
@@ -213,11 +213,15 @@ export async function unloadRoutes(app: FastifyInstance) {
|
|
|
213
213
|
if (typeof opts === 'string') return refuse(opts);
|
|
214
214
|
|
|
215
215
|
const started = performance.now();
|
|
216
|
-
const
|
|
216
|
+
const db = await workshopDb(s);
|
|
217
|
+
const schema = await sessionSchema(s, db);
|
|
218
|
+
const c = await db.pool.connect();
|
|
217
219
|
let opened;
|
|
218
220
|
try {
|
|
219
221
|
await c.query('begin transaction read only');
|
|
220
222
|
await c.query(`select set_config('statement_timeout', $1, true)`, [timeout()]);
|
|
223
|
+
// (sprint 92) the session's schema first in the search path, like the SQL Worksheet (its downloads come here)
|
|
224
|
+
if (schema) await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
221
225
|
opened = await openUnload(c, sql, opts);
|
|
222
226
|
} catch (e) {
|
|
223
227
|
await c.query('rollback').catch(() => {});
|