pgkiln 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/db/migrations/108_sql_history.sql +25 -0
  4. package/docs/apex-feature-parity.md +33 -4
  5. package/docs/guide/03-builder.md +123 -28
  6. package/docs/guide/04-pages-and-regions.md +74 -2
  7. package/docs/guide/05-items.md +14 -2
  8. package/docs/guide/08-security.md +1 -1
  9. package/docs/guide/09-reference.md +1 -1
  10. package/docs/guide/10-tutorial.md +1 -1
  11. package/docs/guide/11-from-apex.md +2 -1
  12. package/docs/guide/12-development.md +26 -3
  13. package/docs/guide/16-files.md +2 -2
  14. package/examples/hr/hr_61_form_designer.sql +80 -0
  15. package/package.json +1 -1
  16. package/public/app.css +62 -2
  17. package/public/app.js +244 -35
  18. package/public/builder.css +21 -1
  19. package/public/builder.js +67 -0
  20. package/public/sw.js +2 -1
  21. package/public/worksheet.css +244 -0
  22. package/public/worksheet.js +665 -0
  23. package/src/app.ts +9 -1
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/appwizard.ts +22 -6
  26. package/src/builder/arrange.ts +33 -12
  27. package/src/builder/code-editor.ts +1 -1
  28. package/src/builder/components.ts +1 -1
  29. package/src/builder/designer.ts +44 -17
  30. package/src/builder/errors.ts +65 -0
  31. package/src/builder/form-designer.ts +229 -0
  32. package/src/builder/locks.ts +6 -0
  33. package/src/builder/newapp.ts +2 -1
  34. package/src/builder/object-actions.ts +166 -0
  35. package/src/builder/object-browser.ts +111 -33
  36. package/src/builder/querybuilder.ts +1 -1
  37. package/src/builder/region-help.ts +2 -0
  38. package/src/builder/region-settings.ts +2 -0
  39. package/src/builder/reporter.ts +1 -1
  40. package/src/builder/routes.ts +7 -5
  41. package/src/builder/sampledata.ts +2 -2
  42. package/src/builder/scripts.ts +3 -3
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +2 -2
  45. package/src/builder/sql.ts +15 -73
  46. package/src/builder/table-editor.ts +219 -0
  47. package/src/builder/ui.ts +5 -3
  48. package/src/builder/unload.ts +8 -4
  49. package/src/builder/worksheet.ts +569 -0
  50. package/src/builder/workspaces.ts +1 -1
  51. package/src/cli/replace.ts +10 -0
  52. package/src/i18n/ar.ts +62 -0
  53. package/src/i18n/cs.ts +62 -0
  54. package/src/i18n/da.ts +62 -0
  55. package/src/i18n/de.ts +62 -0
  56. package/src/i18n/el.ts +62 -0
  57. package/src/i18n/es.ts +62 -0
  58. package/src/i18n/fi.ts +62 -0
  59. package/src/i18n/fr.ts +62 -0
  60. package/src/i18n/he.ts +62 -0
  61. package/src/i18n/it.ts +62 -0
  62. package/src/i18n/ja.ts +62 -0
  63. package/src/i18n/ko.ts +62 -0
  64. package/src/i18n/nb.ts +62 -0
  65. package/src/i18n/pl.ts +62 -0
  66. package/src/i18n/pt.ts +62 -0
  67. package/src/i18n/ru.ts +62 -0
  68. package/src/i18n/sv.ts +62 -0
  69. package/src/i18n/tr.ts +62 -0
  70. package/src/i18n/uk.ts +62 -0
  71. package/src/i18n/zh.ts +62 -0
  72. package/src/i18n.ts +124 -0
  73. package/src/metadata.ts +4 -2
  74. package/src/runtime/devbar.ts +1 -1
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +15 -8
  79. package/src/runtime/items.ts +33 -21
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/pdf.ts +2 -1
  82. package/src/runtime/regions.ts +4 -0
  83. package/src/runtime/render.ts +7 -3
  84. package/src/runtime/report.ts +2 -2
  85. package/src/runtime/routes.ts +3 -1
  86. package/src/runtime/smart-filters.ts +2 -1
  87. package/src/runtime/tasks.ts +6 -3
  88. package/src/sqlformat.ts +208 -0
  89. package/src/sqlplan.ts +133 -0
  90. package/src/sqlscript.ts +6 -2
  91. package/src/sqlworksheet.ts +278 -0
  92. package/src/tabledef.ts +226 -0
  93. package/src/unload.ts +14 -3
@@ -0,0 +1,229 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { heading } from '../runtime/items.ts';
5
+ import { RESERVED_SCHEMAS, SOURCE_ID } from '../runtime/data-reporter.ts';
6
+ import { fdSourcesOf, type FdColumn, type FdSource } from '../runtime/form-designer.ts';
7
+ import type { Session } from '../session.ts';
8
+ import { findObject, objectList, type DbObject } from './reporter.ts';
9
+ import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
10
+
11
+ // Page designer → a Form Designer region → Settings (sprint 85): the tables
12
+ // and views the region offers (which columns forms may show and change, their
13
+ // labels and lists of values, the key column, whether forms may add, change
14
+ // and delete rows) and who may design forms. Saving replaces only the keys
15
+ // this form knows (sources, design_authz, page_size, empty).
16
+
17
+ type Config = Record<string, any>;
18
+ type Body = Record<string, string | undefined>;
19
+
20
+ export const MAX_FD_SOURCES = 20;
21
+
22
+ /** The single-column primary key of a table (null: none, or a view). */
23
+ export async function primaryKeyOf(schema: string, table: string): Promise<string | null> {
24
+ const row = await owner.one<{ name: string }>(
25
+ `select a.attname as name from pg_index i join pg_class c on c.oid = i.indrelid join pg_namespace n on n.oid = c.relnamespace
26
+ join pg_attribute a on a.attrelid = c.oid and a.attnum = i.indkey[0]
27
+ where i.indisprimary and i.indnatts = 1 and n.nspname = $1 and c.relname = $2`, [schema, table]);
28
+ return row?.name ?? null;
29
+ }
30
+
31
+ const clip = (v: string | undefined, max: number) => (v ?? '').trim().slice(0, max);
32
+ const LOV_REF = /^(LOV:[A-Z0-9_]+|STATIC:.+)$/is;
33
+
34
+ /**
35
+ * The region's settings from the form. Columns and the key must exist in the
36
+ * source's table or view (looked up by `lookup`); a new source is a table or
37
+ * view outside pgkiln's and the system's schemas, with its columns offered
38
+ * except binary and JSON ones, and its primary key as the key. A list of
39
+ * values is a shared one of the application (`lovs`) or STATIC:….
40
+ */
41
+ export async function mergeFormDesignerSettings(
42
+ config: Config, b: Body, a: { authz: Set<string>; lovs: Set<string> },
43
+ lookup: { byName: (schema: string, table: string) => Promise<DbObject | null>; byOid: (oid: number) => Promise<DbObject | null>; key: (schema: string, table: string) => Promise<string | null> } =
44
+ { byName: findObject, byOid: objectByOid, key: primaryKeyOf },
45
+ ): Promise<{ config: Config; errors: string[] }> {
46
+ const out: Config = { ...config };
47
+ const errors: string[] = [];
48
+ const size = Number(b.page_size);
49
+ if (b.page_size && Number.isInteger(size) && size >= 5 && size <= 200 && size !== 25) out.page_size = size;
50
+ else delete out.page_size;
51
+ const empty = clip(b.empty, 200);
52
+ if (empty) out.empty = empty;
53
+ else delete out.empty;
54
+ delete out.design_authz;
55
+ if (b.design_authz && a.authz.has(b.design_authz.toUpperCase())) out.design_authz = b.design_authz;
56
+ else if (b.design_authz) errors.push(`There is no authorization scheme ${b.design_authz}.`);
57
+
58
+ const lovOk = (v: string) => LOV_REF.test(v) && (!/^LOV:/i.test(v) || a.lovs.has(v.slice(4).toUpperCase()));
59
+ const sources: FdSource[] = [];
60
+ const existing = fdSourcesOf({ config });
61
+ for (let i = 0; i < existing.length; i++) {
62
+ const s = existing.find((x) => x.id === b[`s${i}_key`]);
63
+ if (!s || sources.some((x) => x.id === s.id)) continue;
64
+ if (b[`s${i}_remove`] === 'true') continue;
65
+ const obj = await lookup.byName(s.schema, s.table);
66
+ const names = new Set(obj?.columns.map((c) => c.name));
67
+ const columns: FdColumn[] = [];
68
+ for (let j = 0; j < 500 && b[`s${i}_col_${j}`] !== undefined; j++) {
69
+ const name = b[`s${i}_col_${j}`]!;
70
+ if (b[`s${i}_on_${j}`] !== 'true' || columns.some((c) => c.name === name)) continue;
71
+ // a column the table no longer has may stay offered (it is skipped at run time), but none can be added
72
+ if (!names.has(name) && !s.columns.some((c) => c.name === name)) continue;
73
+ const label = clip(b[`s${i}_label_${j}`], 80);
74
+ let lov = clip(b[`s${i}_lov_${j}`], 2000);
75
+ if (lov && !lovOk(lov)) {
76
+ errors.push(`${s.id}.${name}: "${lov.slice(0, 60)}" is not a shared list of values of this application (LOV:NAME) or STATIC:Display;value,….`);
77
+ lov = '';
78
+ }
79
+ columns.push({ name, ...(label ? { label } : {}), ...(lov ? { lov } : {}) });
80
+ }
81
+ if (!columns.length) errors.push(`Data source ${s.id} offers no columns.`);
82
+ const key = clip(b[`s${i}_keycol`], 63);
83
+ if (key && !names.has(key)) errors.push(`${s.id}: the key column ${key} is not in ${s.schema}.${s.table}.`);
84
+ const label = clip(b[`s${i}_label`], 80);
85
+ const description = clip(b[`s${i}_description`], 300);
86
+ const hasKey = !!key && names.has(key);
87
+ sources.push({
88
+ id: s.id, ...(label ? { label } : {}), ...(description ? { description } : {}), schema: s.schema, table: s.table,
89
+ ...(hasKey ? { key } : {}), columns, insert: b[`s${i}_insert`] === 'true', update: hasKey && b[`s${i}_update`] === 'true', delete: hasKey && b[`s${i}_delete`] === 'true',
90
+ });
91
+ }
92
+
93
+ if (b.new_object) {
94
+ const obj = /^\d{1,10}$/.test(b.new_object) ? await lookup.byOid(Number(b.new_object)) : null;
95
+ if (!obj || RESERVED_SCHEMAS.test(obj.schema)) errors.push('Choose a table or view for the new data source.');
96
+ else if (sources.length >= MAX_FD_SOURCES) errors.push(`A region offers at most ${MAX_FD_SOURCES} data sources.`);
97
+ else {
98
+ let id = clip(b.new_id, 40).toLowerCase();
99
+ if (id && !SOURCE_ID.test(id)) {
100
+ errors.push(`"${id}" is not a valid static id: lower case letters, digits and _, starting with a letter.`);
101
+ id = '';
102
+ }
103
+ if (id && sources.some((s) => s.id === id)) {
104
+ errors.push(`There is already a data source ${id}.`);
105
+ id = '';
106
+ }
107
+ if (!id) {
108
+ const base = (obj.table.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z]+/, '') || 'source').slice(0, 36);
109
+ id = base;
110
+ for (let n = 2; sources.some((s) => s.id === id); n++) id = `${base}_${n}`;
111
+ }
112
+ const label = clip(b.new_label, 80) || heading(obj.table);
113
+ const key = await lookup.key(obj.schema, obj.table);
114
+ const columns = obj.columns.filter((c) => !/^(bytea|json|jsonb|xml)$/.test(c.type)).map((c) => ({ name: c.name, label: heading(c.name) }));
115
+ // adding rows at first; changing them when the table has a key; never deleting until the developer says so
116
+ sources.push({ id, label, schema: obj.schema, table: obj.table, ...(key ? { key } : {}), columns, insert: true, update: !!key, delete: false });
117
+ }
118
+ }
119
+ if (sources.length) out.sources = sources;
120
+ else delete out.sources;
121
+ return { config: out, errors };
122
+ }
123
+
124
+ async function objectByOid(oid: number): Promise<DbObject | null> {
125
+ const row = await owner.one<{ schema: string; table: string }>(`select n.nspname as schema, c.relname as table from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.oid = $1`, [oid]);
126
+ return row ? findObject(row.schema, row.table) : null;
127
+ }
128
+
129
+ const opt = (value: string, label: string, current: string) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`;
130
+
131
+ /** The settings form under a Form Designer region in the page designer. */
132
+ export async function formDesignerSettingsForm(pageId: number, appId: number, r: { id: number; config: Config }, s: Session): Promise<Raw> {
133
+ const cfg = r.config ?? {};
134
+ const id = (n: string) => `fds_${r.id}_${n}`;
135
+ const app = await owner.one<{ db_role: string | null }>('select db_role from meta.app where id = $1', [appId]);
136
+ const schemes = (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1 order by name', [appId])).rows.map((x) => x.name);
137
+ const lovs = (await owner.query<{ name: string }>('select name from meta.lov where app_id = $1 order by name', [appId])).rows.map((x) => x.name);
138
+ const objects = await objectList(app?.db_role ?? null);
139
+ const sources = fdSourcesOf({ config: cfg });
140
+ const designAuthz = typeof cfg.design_authz === 'string' ? cfg.design_authz : '';
141
+ const role = app?.db_role ?? null;
142
+ const sourceSets = await Promise.all(
143
+ sources.map(async (src, i) => {
144
+ const obj = await findObject(src.schema, src.table);
145
+ const offered = new Map(src.columns.map((c) => [c.name, c]));
146
+ const all = [...(obj?.columns ?? []).map((c) => ({ name: c.name, type: c.type, missing: false })),
147
+ ...src.columns.filter((c) => !obj?.columns.some((x) => x.name === c.name)).map((c) => ({ name: c.name, type: '', missing: true }))];
148
+ const privileges = obj && role
149
+ ? await owner.one<{ sel: boolean; ins: boolean; upd: boolean; del: boolean }>(
150
+ `select has_table_privilege($1, $2::oid, 'select') as sel, has_table_privilege($1, $2::oid, 'insert') as ins,
151
+ has_table_privilege($1, $2::oid, 'update') as upd, has_table_privilege($1, $2::oid, 'delete') as del`, [role, obj.oid])
152
+ .catch(() => undefined)
153
+ : undefined;
154
+ const missing = privileges ? (['sel', 'ins', 'upd', 'del'] as const).filter((k) => !privileges[k] && (k === 'sel' || src[({ ins: 'insert', upd: 'update', del: 'delete' } as const)[k as 'ins' | 'upd' | 'del']])) : [];
155
+ const grant = { sel: 'SELECT', ins: 'INSERT', upd: 'UPDATE', del: 'DELETE' };
156
+ const check = (k: 'insert' | 'update' | 'delete', label: string, needsKey: boolean) =>
157
+ html`<label class="check"><input type="checkbox" name="s${i}_${k}" value="true"${src[k] ? raw(' checked') : ''}> ${label}${needsKey && !src.key ? ' (needs a key column)' : ''}</label>`;
158
+ return html`<fieldset class="prop-group"><legend>${src.label || src.id} <span class="muted">(${src.schema}.${src.table})</span></legend>
159
+ <input type="hidden" name="s${i}_key" value="${src.id}">
160
+ ${!obj ? html`<div class="alert alert-error" role="alert">The table or view ${src.schema}.${src.table} does not exist (any more).</div>` : ''}
161
+ ${missing.length ? html`<div class="alert alert-error" role="alert">The application's database role lacks ${missing.map((k) => grant[k]).join(', ')} on ${src.schema}.${src.table}: grant it, or forms can't do that.</div>` : ''}
162
+ <div class="form-grid">
163
+ <div class="field"><label class="label" for="${id(`s${i}_id`)}">Static id</label><input id="${id(`s${i}_id`)}" value="${src.id}" readonly></div>
164
+ <div class="field"><label class="label" for="${id(`s${i}_label`)}">Label</label><input id="${id(`s${i}_label`)}" name="s${i}_label" maxlength="80" value="${src.label ?? ''}" placeholder="${heading(src.table)}"></div>
165
+ <div class="field" data-wide><label class="label" for="${id(`s${i}_description`)}">Description (shown to designers)</label><input id="${id(`s${i}_description`)}" name="s${i}_description" maxlength="300" value="${src.description ?? ''}"></div>
166
+ <div class="field"><label class="label" for="${id(`s${i}_keycol`)}">Key column (identifies a row)</label>
167
+ <select id="${id(`s${i}_keycol`)}" name="s${i}_keycol"><option value="">- none: forms only add rows -</option>${(obj?.columns ?? []).map((c) => opt(c.name, c.name, src.key ?? ''))}</select></div>
168
+ <div class="field"><span class="label">Forms on it may</span>
169
+ ${check('insert', 'add rows', false)} ${check('update', 'change rows', true)} ${check('delete', 'delete rows', true)}</div>
170
+ </div>
171
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Label</th><th scope="col">List of values</th></tr></thead><tbody>
172
+ ${all.map((c, j) => {
173
+ const o = offered.get(c.name);
174
+ return html`<tr>
175
+ <td data-label="Offer"><input type="hidden" name="s${i}_col_${j}" value="${c.name}"><input type="checkbox" name="s${i}_on_${j}" value="true"${o ? raw(' checked') : ''} aria-label="Offer ${c.name}"></td>
176
+ <td data-label="Column"><code>${c.name}</code> <span class="muted">${c.type}</span>${c.missing ? html` <span class="tag tag-error">not in the table</span>` : ''}</td>
177
+ <td data-label="Label"><input name="s${i}_label_${j}" maxlength="80" value="${o?.label ?? ''}" placeholder="${heading(c.name)}" aria-label="Label of ${c.name}"></td>
178
+ <td data-label="List of values"><input name="s${i}_lov_${j}" maxlength="2000" value="${o?.lov ?? ''}" placeholder="LOV:NAME" list="${id('lovs')}" aria-label="List of values of ${c.name}"></td>
179
+ </tr>`;
180
+ })}
181
+ </tbody></table></div>
182
+ <div class="field"><label class="check"><input type="checkbox" name="s${i}_remove" value="true"> Remove this data source (forms on it stop working)</label></div>
183
+ </fieldset>`;
184
+ }),
185
+ );
186
+ return html`<h3 class="u-mt15">Form Designer settings</h3>
187
+ <p class="muted u-mt0">Users that pass the designers' authorization scheme build data entry forms on the tables and views below: they pick offered columns, labels, help texts, required fields and defaults, and publish forms for everyone who can see this region. Forms read and write rows as the application's database role, so grants and row level security apply.</p>
188
+ <datalist id="${id('lovs')}">${lovs.map((n) => html`<option value="LOV:${n}">`)}</datalist>
189
+ <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/form-designer" class="component-form">${csrf(s)}
190
+ <fieldset class="prop-group"><legend>Forms</legend><div class="form-grid">
191
+ <div class="field"><label class="label" for="${id('design_authz')}">Who may design forms</label>
192
+ <select id="${id('design_authz')}" name="design_authz">${opt('', 'Nobody (choose an authorization scheme)', designAuthz)}${schemes.map((n) => opt(n, `Authorization: ${n}`, designAuthz))}</select></div>
193
+ <div class="field"><label class="label" for="${id('page_size')}">Rows per page</label>
194
+ <input id="${id('page_size')}" name="page_size" type="number" min="5" max="200" value="${cfg.page_size ?? ''}" placeholder="25"></div>
195
+ <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
196
+ <input id="${id('empty')}" name="empty" maxlength="200" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
197
+ </div></fieldset>
198
+ ${sourceSets}
199
+ <fieldset class="prop-group"><legend>Add a data source</legend><div class="form-grid">
200
+ <div class="field" data-wide><label class="label" for="${id('new_object')}">Table or view</label>
201
+ <select id="${id('new_object')}" name="new_object"><option value="">- none -</option>${objects.map((o) => html`<option value="${o.oid}">${o.name}${o.access ? '' : ' (no access for the app role)'}</option>`)}</select></div>
202
+ <div class="field"><label class="label" for="${id('new_id')}">Static id</label><input id="${id('new_id')}" name="new_id" maxlength="40" pattern="[a-z][a-z0-9_]*" placeholder="from the name"></div>
203
+ <div class="field"><label class="label" for="${id('new_label')}">Label</label><input id="${id('new_label')}" name="new_label" maxlength="80"></div>
204
+ </div>
205
+ <small class="help">All columns are offered at first (except binary and JSON ones), the primary key is the key column, and forms may add and change rows, not delete them. Untick columns designers should not put on forms, such as generated keys and audit columns.</small></fieldset>
206
+ <div class="buttons"><button class="btn btn-hot">Save Form Designer settings</button></div>
207
+ </form>`;
208
+ }
209
+
210
+ export async function formDesignerBuilderRoutes(app: FastifyInstance) {
211
+ app.post(`${BASE}/pages/:pid/region/:rid/form-designer`, async (req: Req, reply) => {
212
+ const s = await developer(req, reply);
213
+ if (!s) return;
214
+ const { pid, rid } = req.params as { pid: string; rid: string };
215
+ const r = /^\d{1,9}$/.test(pid) && /^\d{1,9}$/.test(rid)
216
+ ? await owner.one<{ id: number; config: Config; app_id: number }>(
217
+ `select r.id, r.config, p.app_id from meta.region r join meta.page p on p.id = r.page_id where r.id = $1 and r.page_id = $2 and r.type = 'form_designer'`, [rid, pid])
218
+ : undefined;
219
+ if (!r) return reply.code(404).send('Not found');
220
+ const schemes = (await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [r.app_id])).rows;
221
+ const lovs = (await owner.query<{ name: string }>('select name from meta.lov where app_id = $1', [r.app_id])).rows;
222
+ const { config, errors } = await mergeFormDesignerSettings(r.config ?? {}, (req.body ?? {}) as Body,
223
+ { authz: new Set(schemes.map((x) => x.name.toUpperCase())), lovs: new Set(lovs.map((x) => x.name.toUpperCase())) });
224
+ await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
225
+ if (errors.length) flash(s, errors.join(' '), 'error');
226
+ else flash(s, 'Settings saved.');
227
+ return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
228
+ });
229
+ }
@@ -41,6 +41,12 @@ const RUN_ONLY_EXEMPT = /^\/apps\/\d+\/(availability|backups|backups\/settings|l
41
41
  /** The application (and page) a builder URL under /apps/:id or /pages/:pid is about, or null. */
42
42
  export async function appOfPath(path: string): Promise<{ appId: number; pageNo: number | null; pageId: number | null } | null> {
43
43
  const rel = path.split('?')[0].slice(BASE.length);
44
+ // (sprint 86) the page designer by the page's number
45
+ const byNo = /^\/apps\/(\d{1,9})\/pages\/(\d{1,9})(\/|$)/.exec(rel);
46
+ if (byNo) {
47
+ const p = await owner.one<{ id: number }>('select id from meta.page where app_id = $1 and page_no = $2', [Number(byNo[1]), Number(byNo[2])]);
48
+ return { appId: Number(byNo[1]), pageNo: Number(byNo[2]), pageId: p?.id ?? null };
49
+ }
44
50
  const app = /^\/apps\/(\d{1,9})(\/|$)/.exec(rel);
45
51
  if (app) return { appId: Number(app[1]), pageNo: null, pageId: null };
46
52
  const page = /^\/pages\/(\d{1,9})(\/|$)/.exec(rel);
@@ -268,7 +268,8 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
268
268
  if (base !== 'standard') theme.base = base;
269
269
  if (a.topNav) theme.nav = 'top';
270
270
  if (a.accent) theme.accent = a.accent;
271
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
271
+ // (107) a new application starts at Release 1.0, like APEX's
272
+ const r = await c.query(`insert into meta.app (alias, name, authentication, db_role, theme, language, version) values ($1, $2, $3, $4, $5, $6, 'Release 1.0') returning id`, [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
272
273
  const appId = r.rows[0].id as number;
273
274
  await placeApp(c, appId, a.workspaceId);
274
275
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
@@ -0,0 +1,166 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import pg from 'pg';
3
+ import { html, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { readSchema } from '../ddl.ts';
6
+ import { back, BASE, developer, flash, type Req } from './ui.ts';
7
+ import { listObjects, objectDetail, OB_TYPES, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
8
+ import { usableSchemas, workshopDb } from './workspaces.ts';
9
+
10
+ // (sprint 94, Database Actions DA-8) An object's menu in the SQL Worksheet's navigator and the Object Browser's
11
+ // tree (Database Actions: the navigator's right-click menu): Open (the Object Browser's detail, in a panel beside
12
+ // the worksheet), Quick DDL into the worksheet or as a file, Load data, Add an index / trigger / policy, Use as a
13
+ // template, and Drop with what depends on the object. Nothing here changes the database: every statement goes into
14
+ // the worksheet to review and run. The object is looked up in the catalog with the workshop's connection (its
15
+ // schema, type and reference must name one of the listed objects) before anything is read.
16
+
17
+ type Db = Awaited<ReturnType<typeof workshopDb>>;
18
+ export type MenuObj = Pick<Obj, 'name' | 'ref'> & { type: Obj['type'] | 'policy' };
19
+
20
+ const RELATIONS = new Set(['table', 'view', 'matview']);
21
+
22
+ /** The ⋯ menu of an object (works without JavaScript: a details menu of links). */
23
+ export function objectMenu(schema: string, o: MenuObj): Raw {
24
+ const q = (act: string) => `${BASE}/sql/objects/action?${new URLSearchParams({ schema, k: o.type, o: o.ref, ...(o.type === 'policy' || o.type === 'trigger' ? { n: o.name } : {}), do: act })}`;
25
+ const open = `${BASE}/sql/objects?${new URLSearchParams(o.type === 'policy' ? { schema, k: 'table', o: o.ref, tab: 'rls' } : { schema, k: o.type, o: o.ref })}`;
26
+ const item = (href: string, ic: string, label: string, extra = '') => html`<a href="${href}"${extra ? html` ${extra}` : ''}>${icon(ic)} ${label}</a>`;
27
+ const table = o.type === 'table';
28
+ return html`<details class="menu obj-menu"><summary title="Actions for ${o.name}" aria-label="Actions for ${o.name}">⋯</summary>
29
+ <div class="menu-panel"><div class="menu-section menu-links">
30
+ ${o.type === 'policy' ? '' : html`<a href="${open}" data-obj-open="${`${BASE}/sql/objects/panel?${new URLSearchParams({ schema, k: o.type, o: o.ref })}`}">${icon('eye')} Open</a>`}
31
+ ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', 'Edit…') : ''}
32
+ ${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
33
+ ${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
34
+ ${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
35
+ ${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
36
+ ${item(q('download'), 'download', 'Quick DDL as a file')}
37
+ ${item(q('drop'), 'trash', 'Drop…')}
38
+ </div></div></details>`;
39
+ }
40
+
41
+ /** The listed object the request names, or undefined. */
42
+ async function findObject(db: Db, schema: unknown, kind: unknown, ref: unknown, name: unknown): Promise<MenuObj | undefined> {
43
+ if (typeof schema !== 'string' || typeof ref !== 'string' || typeof kind !== 'string') return undefined;
44
+ if (SYSTEM_SCHEMAS.includes(schema) || !(await usableSchemas(db)).includes(schema)) return undefined;
45
+ if (kind === 'policy') {
46
+ const p = await db.one<{ name: string }>(
47
+ `select p.polname || ' on ' || c.relname as name from pg_policy p join pg_class c on c.oid = p.polrelid join pg_namespace n on n.oid = c.relnamespace
48
+ where n.nspname = $1 and c.oid::regclass::text = $2 and p.polname || ' on ' || c.relname = $3`, [schema, ref, String(name ?? '')]);
49
+ return p ? { type: 'policy', name: p.name, ref } : undefined;
50
+ }
51
+ return (await listObjects(db, schema, false)).find((o) => o.type === kind && o.ref === ref);
52
+ }
53
+
54
+ const relnameOf = (o: MenuObj, schema: string) => {
55
+ const t = o.type === 'trigger' ? o.ref.split('|')[0] : o.ref;
56
+ return t.startsWith(`${pg.escapeIdentifier(schema)}.`) || t.startsWith(`${schema}.`) ? t.slice(t.indexOf('.') + 1).replace(/^"|"$/g, '').replace(/""/g, '"') : t.replace(/^"|"$/g, '');
57
+ };
58
+
59
+ /** The DDL of one object: what the Object Browser's DDL tab shows. */
60
+ export async function objectDdl(db: Db, schema: string, o: MenuObj): Promise<string> {
61
+ if (o.type === 'function' || o.type === 'procedure') return `${(await db.one<{ def: string }>('select pg_get_functiondef($1::regprocedure) as def', [o.ref]).catch(() => undefined))?.def?.trimEnd() ?? ''};`;
62
+ if (o.type === 'trigger') {
63
+ const [rel, name] = o.ref.split('|');
64
+ return `${(await db.one<{ def: string }>('select pg_get_triggerdef(oid, true) as def from pg_trigger where tgrelid = $1::regclass and tgname = $2', [rel, name]))?.def ?? ''};`;
65
+ }
66
+ if (o.type === 'index') return `${(await db.one<{ def: string }>('select pg_get_indexdef($1::regclass) as def', [o.ref]))?.def ?? ''};`;
67
+ const rel = relnameOf(o, schema);
68
+ const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
69
+ const pick = o.type === 'policy'
70
+ ? objects.filter((x) => x.kind === 'policy' && x.table === rel && x.name.endsWith(`.${o.name.slice(0, o.name.lastIndexOf(' on '))}`))
71
+ : o.type === 'sequence' ? objects.filter((x) => x.kind === 'sequence' && x.name === rel)
72
+ : o.type === 'type' ? objects.filter((x) => (x.kind === 'type' || x.kind === 'domain') && x.name === rel)
73
+ : objects.filter((x) => (['table', 'view', 'materialized view'].includes(x.kind) ? x.name === rel : x.table === rel));
74
+ return pick.map((x) => x.ddl).join('\n\n');
75
+ }
76
+
77
+ /** What depends on the object (views, foreign keys, triggers' functions…), as lines for a comment. */
78
+ async function dependents(db: Db, o: MenuObj): Promise<string[]> {
79
+ if (!RELATIONS.has(o.type) && o.type !== 'function' && o.type !== 'type' && o.type !== 'sequence') return [];
80
+ const rows = (await db.query<{ what: string }>(
81
+ o.type === 'function'
82
+ ? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
83
+ where d.refclassid = 'pg_proc'::regclass and d.refobjid = $1::regprocedure and d.deptype = 'n' order by 1 limit 50`
84
+ : o.type === 'type'
85
+ ? `select distinct pg_describe_object(d.classid, d.objid, d.objsubid) as what from pg_depend d
86
+ where d.refclassid = 'pg_type'::regclass and d.refobjid = $1::regtype and d.deptype = 'n' order by 1 limit 50`
87
+ : `select distinct coalesce(case when d.classid = 'pg_rewrite'::regclass then 'view ' || r.ev_class::regclass::text end, pg_describe_object(d.classid, d.objid, d.objsubid)) as what
88
+ from pg_depend d left join pg_rewrite r on d.classid = 'pg_rewrite'::regclass and r.oid = d.objid
89
+ where d.refclassid = 'pg_class'::regclass and d.refobjid = $1::regclass and d.deptype = 'n'
90
+ and not (d.classid = 'pg_rewrite'::regclass and r.ev_class = $1::regclass)
91
+ order by 1 limit 50`,
92
+ [o.ref])).rows;
93
+ return rows.map((r) => r.what);
94
+ }
95
+
96
+ const DROP_KIND: Record<string, string> = { table: 'table', view: 'view', matview: 'materialized view', index: 'index', sequence: 'sequence', type: 'type', function: 'function', procedure: 'procedure' };
97
+
98
+ /** The statements a menu entry writes into the worksheet. */
99
+ async function draft(db: Db, schema: string, o: MenuObj, act: string): Promise<string | null> {
100
+ const ident = (n: string) => (/^[a-z_][a-z0-9_$]*$/.test(n) ? n : pg.escapeIdentifier(n));
101
+ const firstCol = async () => (await db.one<{ n: string }>(`select attname as n from pg_attribute where attrelid = $1::regclass and attnum > 0 and not attisdropped order by attnum limit 1`, [o.ref]))?.n ?? 'id';
102
+ const base = relnameOf(o, schema).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase().slice(0, 40);
103
+ switch (act) {
104
+ case 'ddl':
105
+ // the DDL names objects within their schema: the search path makes the statements run there
106
+ return `-- DDL of ${o.ref}\nset search_path to ${ident(schema)}, public;\n\n${await objectDdl(db, schema, o)}`;
107
+ case 'index':
108
+ return `create index ${base}_${(await firstCol()).replace(/[^A-Za-z0-9_]+/g, '_').toLowerCase()}_idx on ${o.ref} (${ident(await firstCol())});`;
109
+ case 'trigger':
110
+ return `create or replace function ${ident(schema)}.${base}_trg() returns trigger\nlanguage plpgsql as $$\nbegin\n -- new and old are the row after and before the change\n return new;\nend\n$$;\n\ncreate trigger ${base}_trg\n before insert or update on ${o.ref}\n for each row execute function ${ident(schema)}.${base}_trg();`;
111
+ case 'policy':
112
+ return `-- row level security applies once it is enabled on the table\nalter table ${o.ref} enable row level security;\n\ncreate policy ${base}_select on ${o.ref}\n for select\n using (true);`;
113
+ case 'drop': {
114
+ const deps = await dependents(db, o);
115
+ const note = deps.length
116
+ ? `-- These depend on ${o.ref}: drop them first, or add CASCADE to drop them too:\n${deps.map((d) => `-- ${d}`).join('\n')}\n`
117
+ : `-- Nothing else depends on ${o.ref}.\n`;
118
+ if (o.type === 'trigger') {
119
+ const [rel, name] = o.ref.split('|');
120
+ return `drop trigger ${ident(name)} on ${rel};`;
121
+ }
122
+ if (o.type === 'policy') return `drop policy ${ident(o.name.slice(0, o.name.lastIndexOf(' on ')))} on ${o.ref};`;
123
+ return `${note}drop ${DROP_KIND[o.type]} ${o.ref};`;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ export async function objectActionRoutes(app: FastifyInstance) {
130
+ // a menu entry: the statement into the worksheet (a new tab there), or the DDL as a file
131
+ app.get(`${BASE}/sql/objects/action`, async (req: Req, reply) => {
132
+ const s = await developer(req, reply);
133
+ if (!s) return;
134
+ const db = await workshopDb(s);
135
+ const q = req.query ?? {};
136
+ const o = await findObject(db, q.schema, q.k, q.o, q.n);
137
+ const act = String(q.do ?? '');
138
+ if (!o || !['ddl', 'download', 'index', 'trigger', 'policy', 'drop'].includes(act)) return reply.code(404).send('Not found');
139
+ if (['index', 'trigger', 'policy'].includes(act) && o.type !== 'table') return reply.code(404).send('Not found');
140
+ if (act === 'download') {
141
+ const file = `${relnameOf(o, String(q.schema))}`.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'ddl';
142
+ return reply.header('content-disposition', `attachment; filename="${file}.sql"`).header('cache-control', 'private, no-store')
143
+ .type('application/sql; charset=utf-8').send(`${await objectDdl(db, String(q.schema), o)}\n`);
144
+ }
145
+ const sql = await draft(db, String(q.schema), o, act);
146
+ s.state.__SQLDRAFT = sql ?? '';
147
+ flash(s, act === 'ddl' ? `The DDL of ${o.ref}.` : act === 'drop' ? 'Review the DROP statement and run it yourself: nothing has been dropped yet.' : 'Fill in the statement and run it: nothing has been changed yet.');
148
+ return back(reply, s, `${BASE}/sql/worksheet`);
149
+ });
150
+
151
+ // Open: the Object Browser's detail of an object without the page around it (the worksheet shows it in a panel)
152
+ app.get(`${BASE}/sql/objects/panel`, async (req: Req, reply) => {
153
+ const s = await developer(req, reply);
154
+ if (!s) return;
155
+ const db = await workshopDb(s);
156
+ const q = req.query ?? {};
157
+ const o = await findObject(db, q.schema, q.k, q.o, q.n);
158
+ if (!o || o.type === 'policy') return reply.code(404).send('Not found');
159
+ const schema = String(q.schema);
160
+ const link = (extra: Record<string, string>) => `${BASE}/sql/objects/panel?${new URLSearchParams({ schema, ...extra })}`;
161
+ const detail = await objectDetail(db, s, schema, o as Obj, String(q.tab ?? ''), Number(q.p) || 1, link);
162
+ const label = OB_TYPES.find(([t]) => t === o.type)?.[1] ?? o.type;
163
+ return reply.header('Cache-Control', 'private, no-store').type('text/html')
164
+ .send(String(html`<div class="obj-panel" data-label="${label}: ${o.ref}">${detail}</div>`));
165
+ });
166
+ }