pgkiln 0.82.0 → 0.83.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/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
package/src/builder/i18n.ts
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
// A page gets its translator from the session: `const tx = tr(s)`, then `tx('rail.app_builder')`, or with
|
|
7
7
|
// {placeholders}: `tx('ab.count', { n: 3 })`. The result is plain text: the html`` tag escapes it like any value.
|
|
8
8
|
// `tx.rich()` puts markup (links) into a sentence: the message is escaped, Raw parameters are kept.
|
|
9
|
+
// `tx.code()` (sprint 162): a sentence whose `backticked` parts are code (syntax help).
|
|
9
10
|
// `tx.dyn()` takes a key made at run time (a region type's gallery label, a SQL Workshop tool) with a fallback.
|
|
10
11
|
// A text that is not keyed yet simply stays English. Moving a screen over: docs/guide/12-development.md.
|
|
11
12
|
//
|
|
12
13
|
// The developer's choice: meta.developer.language (migration 127), copied into the session (__BLANG) at sign-in;
|
|
13
14
|
// before signing in, the sign-in page's language links set a cookie (LANG_COOKIE). See language.ts.
|
|
14
15
|
|
|
16
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
17
|
import type { FastifyRequest } from 'fastify';
|
|
16
18
|
import { esc, raw, Raw } from '../html.ts';
|
|
17
19
|
import type { Session } from '../session.ts';
|
|
@@ -70,6 +72,8 @@ export interface BuilderTranslate {
|
|
|
70
72
|
dyn(key: string, fallback: string, params?: Params): string;
|
|
71
73
|
/** A sentence with markup: the message is escaped, string parameters too; Raw parameters (links) are kept. */
|
|
72
74
|
rich(key: BuilderKey, params: Record<string, string | number | Raw>): Raw;
|
|
75
|
+
/** (sprint 162) A sentence with `code` in backticks: escaped, each `…` becomes <code>…</code> (syntax help, settings). */
|
|
76
|
+
code(key: BuilderKey, params?: Params): Raw;
|
|
73
77
|
/** An English label passed around as text (a breadcrumb, a page title) in this language, when it is a known one. */
|
|
74
78
|
label(text: string): string;
|
|
75
79
|
}
|
|
@@ -117,6 +121,7 @@ export function builderTranslator(lang: BuilderLang, own: Readonly<Record<string
|
|
|
117
121
|
const v = params[k];
|
|
118
122
|
return v === undefined ? m : v instanceof Raw ? v.value : esc(v);
|
|
119
123
|
}));
|
|
124
|
+
tx.code = (key, params) => raw(esc(fill(text(key) ?? key, params)).replace(/`([^`]+)`/g, '<code>$1</code>'));
|
|
120
125
|
tx.label = (label) => (Object.hasOwn(LABELS, label) ? tx(LABELS[label]) : label);
|
|
121
126
|
return tx;
|
|
122
127
|
}
|
|
@@ -134,3 +139,16 @@ export const tr = (s: Pick<Session, 'state'> | null | undefined) => translatorFo
|
|
|
134
139
|
|
|
135
140
|
/** For tests and the coverage check: the message files. */
|
|
136
141
|
export const builderMessages = (lang: BuilderLang): Readonly<Record<string, string>> => MESSAGES[lang];
|
|
142
|
+
|
|
143
|
+
// (sprint 162, CD-8) Long form builders (region and report settings) take the translator of the request being rendered
|
|
144
|
+
// from the async context instead of a parameter on every helper: withTranslator(tr(s), () => build()), and inside,
|
|
145
|
+
// scopedTx('key') like tx. Outside withTranslator it is English.
|
|
146
|
+
const SCOPE = new AsyncLocalStorage<BuilderTranslate>();
|
|
147
|
+
const current = () => SCOPE.getStore() ?? translatorFor('en');
|
|
148
|
+
export const withTranslator = <T>(t: BuilderTranslate, fn: () => T): T => SCOPE.run(t, fn);
|
|
149
|
+
export const scopedTx = ((key: BuilderKey, params?: Params) => current()(key, params)) as BuilderTranslate;
|
|
150
|
+
Object.defineProperty(scopedTx, 'lang', { get: () => current().lang });
|
|
151
|
+
scopedTx.dyn = (key, fallback, params) => current().dyn(key, fallback, params);
|
|
152
|
+
scopedTx.rich = (key, params) => current().rich(key, params);
|
|
153
|
+
scopedTx.code = (key, params) => current().code(key, params);
|
|
154
|
+
scopedTx.label = (label) => current().label(label);
|
|
@@ -6,6 +6,7 @@ import { readSchema } from '../ddl.ts';
|
|
|
6
6
|
import { back, BASE, developer, flash, type Req } from './ui.ts';
|
|
7
7
|
import { listObjects, objectDetail, OB_TYPES, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
|
|
8
8
|
import { usableSchemas, workshopDb } from './workspaces.ts';
|
|
9
|
+
import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
|
|
9
10
|
|
|
10
11
|
// (sprint 94, Database Actions DA-8) An object's menu in the SQL Worksheet's navigator and the Object Browser's
|
|
11
12
|
// tree (Database Actions: the navigator's right-click menu): Open (the Object Browser's detail, in a panel beside
|
|
@@ -20,26 +21,26 @@ export type MenuObj = Pick<Obj, 'name' | 'ref'> & { type: Obj['type'] | 'policy'
|
|
|
20
21
|
const RELATIONS = new Set(['table', 'view', 'matview']);
|
|
21
22
|
|
|
22
23
|
/** The ⋯ menu of an object (works without JavaScript: a details menu of links). */
|
|
23
|
-
export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
24
|
+
export function objectMenu(schema: string, o: MenuObj, tx: BuilderTranslate = translatorFor('en')): Raw {
|
|
24
25
|
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
26
|
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
27
|
const item = (href: string, ic: string, label: string, extra = '') => html`<a href="${href}"${extra ? html` ${extra}` : ''}>${icon(ic)} ${label}</a>`;
|
|
27
28
|
const table = o.type === 'table';
|
|
28
|
-
return html`<details class="menu obj-menu"><summary title="
|
|
29
|
+
return html`<details class="menu obj-menu"><summary title="${tx('oa.actions_for', { name: o.name })}" aria-label="${tx('oa.actions_for', { name: o.name })}">⋯</summary>
|
|
29
30
|
<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')}
|
|
31
|
-
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', '
|
|
32
|
-
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', '
|
|
33
|
-
${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', '
|
|
34
|
-
${table ? item(`${BASE}/sql/load`, 'upload', '
|
|
35
|
-
${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', '
|
|
36
|
-
${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', '
|
|
37
|
-
${table ? item(`${BASE}/sql/objects/document?${new URLSearchParams({ o: o.ref })}`, 'file', '
|
|
38
|
-
${table ? html`${item(q('index'), 'list', '
|
|
39
|
-
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', '
|
|
40
|
-
${item(q('ddl'), 'code', '
|
|
41
|
-
${item(q('download'), 'download', '
|
|
42
|
-
${item(q('drop'), 'trash', '
|
|
31
|
+
${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')} ${tx('oa.open')}</a>`}
|
|
32
|
+
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, o: o.ref })}`, 'edit', tx('oa.edit')) : ''}
|
|
33
|
+
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', tx('oa.template')) : ''}
|
|
34
|
+
${table ? item(`${BASE}/sql/objects/security?${new URLSearchParams({ o: o.ref })}`, 'shield', tx('oa.security')) : ''}
|
|
35
|
+
${table ? item(`${BASE}/sql/load`, 'upload', tx('oa.load')) : ''}
|
|
36
|
+
${table ? item(`${BASE}/sql/objects/fulltext?${new URLSearchParams({ o: o.ref })}`, 'search', tx('oa.fulltext')) : ''}
|
|
37
|
+
${table ? item(`${BASE}/sql/objects/vector?${new URLSearchParams({ o: o.ref })}`, 'search', tx('oa.vector')) : ''}
|
|
38
|
+
${table ? item(`${BASE}/sql/objects/document?${new URLSearchParams({ o: o.ref })}`, 'file', tx('oa.document')) : ''}
|
|
39
|
+
${table ? html`${item(q('index'), 'list', tx('oa.index'))}${item(q('trigger'), 'bolt', tx('oa.trigger'))}${item(q('policy'), 'shield', tx('oa.policy'))}` : ''}
|
|
40
|
+
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', tx('oa.rest')) : ''}
|
|
41
|
+
${item(q('ddl'), 'code', tx('oa.ddl'))}
|
|
42
|
+
${item(q('download'), 'download', tx('oa.ddl_file'))}
|
|
43
|
+
${item(q('drop'), 'trash', tx('oa.drop'))}
|
|
43
44
|
</div></div></details>`;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -149,7 +150,8 @@ export async function objectActionRoutes(app: FastifyInstance) {
|
|
|
149
150
|
}
|
|
150
151
|
const sql = await draft(db, String(q.schema), o, act);
|
|
151
152
|
s.state.__SQLDRAFT = sql ?? '';
|
|
152
|
-
|
|
153
|
+
const tx = tr(s);
|
|
154
|
+
flash(s, act === 'ddl' ? tx('oa.flash.ddl', { name: o.ref }) : act === 'drop' ? tx('oa.flash.drop') : tx('oa.flash.fill'));
|
|
153
155
|
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
154
156
|
});
|
|
155
157
|
|
|
@@ -140,7 +140,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
140
140
|
if (!list.length && filter) return '';
|
|
141
141
|
return html`<details class="ob-folder"${list.length && (filter || list.length <= 50 || selected?.type === t) ? raw(' open') : ''}>
|
|
142
142
|
<summary>${icon(ic)}<span>${tx.dyn(`ob.type.${t}`, label)}</span><span class="kind">${list.length}</span></summary>
|
|
143
|
-
<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">${tx('ob.extension')}</span>` : ''}</a>${objectMenu(schema, o)}</li>`)}</ul>
|
|
143
|
+
<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">${tx('ob.extension')}</span>` : ''}</a>${objectMenu(schema, o, tx)}</li>`)}</ul>
|
|
144
144
|
</details>`;
|
|
145
145
|
})}</div>`;
|
|
146
146
|
|
|
@@ -160,7 +160,7 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
160
160
|
<a class="create-source" href="${BASE}/sql/load">${icon('upload')}<span><b>${tx('ob.tile.load')}</b><small class="muted">${tx('ob.tile.load.desc')}</small></span></a></div>
|
|
161
161
|
<p class="muted">${tx('ob.select_hint')}</p>`);
|
|
162
162
|
|
|
163
|
-
const main = html`<h1 class="u-mb1"
|
|
163
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('objects', s)}
|
|
164
164
|
<div class="ob-head">
|
|
165
165
|
<form method="get" class="ob-schema">${filter ? html`<input type="hidden" name="q" value="${filter}">` : ''}
|
|
166
166
|
<label class="label" for="ob-schema">${tx('ob.schema')}</label>
|
|
@@ -4,6 +4,7 @@ import { owner } from '../db.ts';
|
|
|
4
4
|
import { html, raw } from '../html.ts';
|
|
5
5
|
import { icon } from '../icons.ts';
|
|
6
6
|
import { BASE, csrf, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
7
|
+
import { tr } from './i18n.ts';
|
|
7
8
|
|
|
8
9
|
// SQL Workshop → Query Builder: pick tables and views of a schema; joins
|
|
9
10
|
// follow their foreign keys, or columns joined by the developer (drawn on
|
|
@@ -90,6 +91,8 @@ export interface BuiltQuery {
|
|
|
90
91
|
/** whether the query groups (a column has a function) */
|
|
91
92
|
grouped: boolean;
|
|
92
93
|
notes: string[];
|
|
94
|
+
/** (sprint 162) the tables of `notes`: no foreign key connects them, so they are cross joined */
|
|
95
|
+
crossJoined: string[];
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
/** Build the SELECT for a spec, using only names that exist in `relations`. */
|
|
@@ -98,6 +101,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
|
|
|
98
101
|
const tables = [...new Set(spec.tables)].filter((t) => byName.has(t));
|
|
99
102
|
if (!tables.length) return null;
|
|
100
103
|
const notes: string[] = [];
|
|
104
|
+
const crossJoined: string[] = [];
|
|
101
105
|
const aliases = new Map(tables.map((t, k) => [t, `t${k + 1}`]));
|
|
102
106
|
const q = (t: string) => `${ident(spec.schema)}.${ident(t)}`;
|
|
103
107
|
|
|
@@ -130,6 +134,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
|
|
|
130
134
|
if (pick < 0) {
|
|
131
135
|
pick = 0;
|
|
132
136
|
notes.push(`No foreign key connects ${pending[0]} to the other tables: it is cross joined. Join two of their columns on the canvas, or under Joins.`);
|
|
137
|
+
crossJoined.push(pending[0]);
|
|
133
138
|
}
|
|
134
139
|
const t = pending.splice(pick, 1)[0];
|
|
135
140
|
joins.push({ alias: aliases.get(t)!, table: t, fk: customFor(t).length ? null : fk, custom: customFor(t) });
|
|
@@ -196,7 +201,7 @@ export function buildQuery(relations: CatalogRelation[], fks: CatalogFk[], spec:
|
|
|
196
201
|
if (groupBy.length) sql += `\n group by ${groupBy.join(', ')}`;
|
|
197
202
|
if (order.length) sql += `\n order by ${order.join(', ')}`;
|
|
198
203
|
if (spec.limit) sql += `\n limit ${spec.limit}`;
|
|
199
|
-
return { sql, aliases, joins, notes, grouped };
|
|
204
|
+
return { sql, aliases, joins, notes, crossJoined, grouped };
|
|
200
205
|
}
|
|
201
206
|
|
|
202
207
|
/** Tables and views of a schema, with their columns, and the foreign keys between its tables. */
|
|
@@ -272,6 +277,7 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
|
|
|
272
277
|
app.get(`${BASE}/sql/query`, async (req: Req, reply) => {
|
|
273
278
|
const s = await developer(req, reply);
|
|
274
279
|
if (!s) return;
|
|
280
|
+
const tx = tr(s);
|
|
275
281
|
const schemas = (
|
|
276
282
|
await owner.query(
|
|
277
283
|
`select nspname as name from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema') order by nspname = 'meta', nspname`,
|
|
@@ -299,72 +305,72 @@ export async function queryBuilderRoutes(app: FastifyInstance) {
|
|
|
299
305
|
const orders = [...spec.order.filter((o) => o.column), { column: '', dir: 'asc' as const }];
|
|
300
306
|
|
|
301
307
|
const form = html`<form method="get" action="${BASE}/sql/query">${chosen.length ? html`<input type="hidden" name="o" value="${chosen.join(',')}">` : ''}
|
|
302
|
-
${region(
|
|
308
|
+
${region(`1. ${tx('qb.tables')}`, html`<div class="form-grid"><div class="field"><label class="label" for="f_schema">${tx('sm.schema')}</label>
|
|
303
309
|
<select id="f_schema" name="schema">${schemas.map((x) => html`<option${sel(x === schema)}>${x}</option>`)}</select></div></div>
|
|
304
310
|
${rels.length
|
|
305
|
-
? html`<fieldset class="field"><legend class="label"
|
|
306
|
-
${rels.map((r) => html`<label class="check"><input type="checkbox" name="t" value="${r.name}"${check(chosen.includes(r.name))}> ${r.name}${r.kind === 'view' ? html` <span class="tag"
|
|
311
|
+
? html`<fieldset class="field"><legend class="label">${tx('qb.tables_of', { schema })}</legend><div class="qb-pick">
|
|
312
|
+
${rels.map((r) => html`<label class="check"><input type="checkbox" name="t" value="${r.name}"${check(chosen.includes(r.name))}> ${r.name}${r.kind === 'view' ? html` <span class="tag">${tx('qb.view')}</span>` : ''}${chosen.includes(r.name) ? html` <span class="muted small">${built!.aliases.get(r.name)}</span>` : ''}</label>`)}
|
|
307
313
|
</div></fieldset>`
|
|
308
|
-
: html`<p class="muted"
|
|
309
|
-
<div class="buttons"><button class="btn">${icon('check')}
|
|
314
|
+
: html`<p class="muted">${tx('qb.no_tables')}</p>`}
|
|
315
|
+
<div class="buttons"><button class="btn">${icon('check')} ${tx('qb.apply')}</button></div>`)}
|
|
310
316
|
${built
|
|
311
317
|
? html`<div class="u-spacer"></div>
|
|
312
|
-
${region(
|
|
313
|
-
<label class="check u-mb1"><input type="checkbox" name="distinct" value="1"${check(spec.distinct)}>
|
|
318
|
+
${region(`2. ${tx('ul.columns')}`, html`<p class="muted u-mt0">${tx('qb.columns_help')}</p>
|
|
319
|
+
<label class="check u-mb1"><input type="checkbox" name="distinct" value="1"${check(spec.distinct)}> ${tx('qb.distinct')}</label>
|
|
314
320
|
<div class="qb-canvas" data-joins="${JSON.stringify(lines)}">${chosen.map((t, k) => {
|
|
315
321
|
const a = built!.aliases.get(t)!;
|
|
316
322
|
const pos = spec.positions[t] ?? { x: 16 + k * 272, y: 16 };
|
|
317
323
|
return html`<div class="qb-table" data-table="${t}" data-alias="${a}" data-x="${pos.x}" data-y="${pos.y}">
|
|
318
324
|
${spec.positions[t] ? html`<input type="hidden" name="p" value="${t}:${pos.x},${pos.y}">` : ''}
|
|
319
|
-
<div class="qb-table-head"><button type="button" class="qb-move" hidden aria-label="
|
|
325
|
+
<div class="qb-table-head"><button type="button" class="qb-move" hidden aria-label="${tx('qb.move', { table: t })}" title="${tx('qb.drag_move')}">${icon('menu')}</button>
|
|
320
326
|
<strong>${t}</strong> <span class="muted small">${a}</span></div>
|
|
321
327
|
<ul class="qb-cols">${rels.find((r) => r.name === t)!.columns.map((c) => {
|
|
322
328
|
const ref = `${a}.${c.name}`;
|
|
323
329
|
return html`<li class="qb-col" data-ref="${ref}">
|
|
324
330
|
<label class="check"><input type="checkbox" name="c" value="${ref}"${check(spec.columns.includes(ref))}> ${c.name}</label>
|
|
325
331
|
<span class="muted small qb-type">${c.type}</span>
|
|
326
|
-
<select name="fn" aria-label="
|
|
327
|
-
${chosen.length > 1 ? html`<button type="button" class="qb-link" hidden data-ref="${ref}" aria-label="
|
|
332
|
+
<select name="fn" aria-label="${tx('qb.function_for', { column: ref })}"><option value="">-</option>${Object.entries(FUNCTIONS).map(([fn, label]) => html`<option value="${ref}:${fn}"${sel(spec.fns[ref] === fn)}>${tx.dyn(`qb.fn.${fn}`, label)}</option>`)}</select>
|
|
333
|
+
${chosen.length > 1 ? html`<button type="button" class="qb-link" hidden data-ref="${ref}" aria-label="${tx('qb.join_label', { column: ref })}" title="${tx('qb.join_title')}"></button>` : ''}
|
|
328
334
|
</li>`;
|
|
329
335
|
})}</ul></div>`;
|
|
330
336
|
})}</div>`)}
|
|
331
337
|
${built.joins.length
|
|
332
|
-
? html`<div class="u-spacer"></div>${region(
|
|
333
|
-
${built.
|
|
338
|
+
? html`<div class="u-spacer"></div>${region(`3. ${tx('qb.joins')}`, html`<p class="muted u-mt0">${tx('qb.joins_help')}</p>
|
|
339
|
+
${built.crossJoined.map((t) => html`<div class="alert alert-error" role="alert">${tx('qb.cross_join', { table: t })}</div>`)}
|
|
334
340
|
<ul class="qb-joins">${built.joins.map((j) => html`<li class="u-mb1">${j.table} <span class="muted">${j.alias}</span>
|
|
335
341
|
${j.custom.length
|
|
336
|
-
? html` on ${j.custom.map((c, k) => html`${k ? '
|
|
337
|
-
: j.fk ? html` on ${j.fk.from}(${j.fk.fromCols.join(', ')}) → ${j.fk.to}(${j.fk.toCols.join(', ')})` : html` (cross
|
|
338
|
-
${j.fk || j.custom.length ? html`<label class="sr-only" for="f_jt_${j.alias}"
|
|
339
|
-
<select id="f_jt_${j.alias}" name="jt_${j.alias}"><option value="inner"
|
|
340
|
-
<fieldset class="field"><legend class="label"
|
|
341
|
-
<select name="ja" aria-label="
|
|
342
|
-
<select name="jb" aria-label="
|
|
343
|
-
<div class="buttons"><button class="btn">${icon('check')}
|
|
342
|
+
? html` ${tx('qb.on')} ${j.custom.map((c, k) => html`${k ? ` ${tx('qb.and')} ` : ''}<label class="check qb-inline"><input type="checkbox" name="j" value="${c.a}=${c.b}" checked> ${c.a} = ${c.b}</label>`)}`
|
|
343
|
+
: j.fk ? html` ${tx('qb.on')} ${j.fk.from}(${j.fk.fromCols.join(', ')}) → ${j.fk.to}(${j.fk.toCols.join(', ')})` : html` (${tx('qb.cross')})`}
|
|
344
|
+
${j.fk || j.custom.length ? html`<label class="sr-only" for="f_jt_${j.alias}">${tx('qb.join_type', { table: j.table })}</label>
|
|
345
|
+
<select id="f_jt_${j.alias}" name="jt_${j.alias}"><option value="inner">${tx('qb.inner')}</option><option value="left"${sel(spec.joinTypes[j.alias] === 'left')}>${tx('qb.left')}</option></select>` : ''}</li>`)}</ul>
|
|
346
|
+
<fieldset class="field"><legend class="label">${tx('qb.join_two')}</legend><div class="qb-cond">
|
|
347
|
+
<select name="ja" aria-label="${tx('qb.join_a')}">${colOptions('')}</select><span class="qb-eq">=</span>
|
|
348
|
+
<select name="jb" aria-label="${tx('qb.join_b')}">${colOptions('')}</select></div></fieldset>
|
|
349
|
+
<div class="buttons"><button class="btn">${icon('check')} ${tx('qb.apply')}</button></div>`)}`
|
|
344
350
|
: ''}
|
|
345
351
|
<div class="u-spacer"></div>
|
|
346
|
-
${region(`${built.joins.length ? 4 : 3}.
|
|
347
|
-
<label class="check"><input type="radio" name="any" value="and"${check(!spec.any)}> all
|
|
348
|
-
<label class="check"><input type="radio" name="any" value="or"${check(spec.any)}> any
|
|
352
|
+
${region(`${built.joins.length ? 4 : 3}. ${tx('qb.conditions')}`, html`<fieldset class="field"><legend class="label">${tx('qb.rows_match')}</legend><div class="radio-group">
|
|
353
|
+
<label class="check"><input type="radio" name="any" value="and"${check(!spec.any)}> ${tx('qb.all')}</label>
|
|
354
|
+
<label class="check"><input type="radio" name="any" value="or"${check(spec.any)}> ${tx('qb.any')}</label></div></fieldset>
|
|
349
355
|
${wheres.map((w, k) => html`<div class="qb-cond">
|
|
350
|
-
<select name="wc" aria-label="
|
|
351
|
-
<select name="wo" aria-label="
|
|
352
|
-
<input name="wv" value="${w.value}" aria-label="
|
|
353
|
-
<div class="buttons"><button class="btn">${icon('plus')}
|
|
356
|
+
<select name="wc" aria-label="${tx('qb.cond_column', { n: k + 1 })}">${colOptions(w.column)}</select>
|
|
357
|
+
<select name="wo" aria-label="${tx('qb.cond_operator', { n: k + 1 })}">${Object.entries(OPERATORS).map(([op, label]) => html`<option value="${op}"${sel(op === w.op)}>${op} · ${tx.dyn(`qb.op.${op.replace(/[^a-z]+/g, '_') || { '=': 'eq', '<>': 'ne', '<': 'lt', '<=': 'le', '>': 'gt', '>=': 'ge' }[op]}`, label)}</option>`)}</select>
|
|
358
|
+
<input name="wv" value="${w.value}" aria-label="${tx('qb.cond_value', { n: k + 1 })}"></div>`)}
|
|
359
|
+
<div class="buttons"><button class="btn">${icon('plus')} ${tx('qb.add_condition')}</button></div>`)}
|
|
354
360
|
<div class="u-spacer"></div>
|
|
355
|
-
${region(`${built.joins.length ? 5 : 4}.
|
|
356
|
-
<select name="oc" aria-label="
|
|
357
|
-
<select name="od" aria-label="
|
|
358
|
-
<div class="form-grid"><div class="field"><label class="label" for="f_limit"
|
|
359
|
-
<div class="buttons"><button class="btn btn-hot">${icon('check')}
|
|
361
|
+
${region(`${built.joins.length ? 5 : 4}. ${tx('qb.sort_limit')}`, html`${orders.map((o, k) => html`<div class="qb-cond">
|
|
362
|
+
<select name="oc" aria-label="${tx('qb.sort_column', { n: k + 1 })}">${colOptions(o.column)}</select>
|
|
363
|
+
<select name="od" aria-label="${tx('qb.sort_dir', { n: k + 1 })}"><option value="asc">${tx('qb.asc')}</option><option value="desc"${sel(o.dir === 'desc')}>${tx('qb.desc')}</option></select><span></span></div>`)}
|
|
364
|
+
<div class="form-grid"><div class="field"><label class="label" for="f_limit">${tx('qb.limit')}</label><input id="f_limit" name="limit" type="number" min="1" max="100000" value="${spec.limit ?? ''}"></div></div>
|
|
365
|
+
<div class="buttons"><button class="btn btn-hot">${icon('check')} ${tx('qb.apply')}</button></div>`)}`
|
|
360
366
|
: ''}
|
|
361
367
|
</form>`;
|
|
362
368
|
|
|
363
369
|
const output = built
|
|
364
|
-
? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="
|
|
370
|
+
? html`<div class="u-spacer"></div>${region('SQL', html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('qs.generated')}">${built.sql}</pre>
|
|
365
371
|
<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')}
|
|
372
|
+
<div class="buttons"><button class="btn btn-hot">${icon('play')} ${tx('qb.run')}</button></div></form>`)}`
|
|
367
373
|
: '';
|
|
368
|
-
return send(reply, s, shell(s, '
|
|
374
|
+
return send(reply, s, shell(s, tx('sql.tool.query'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.query')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('query', s)}${form}${output}`, 'sql'));
|
|
369
375
|
});
|
|
370
376
|
}
|
package/src/builder/quicksql.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { clientIp, type Session } from '../session.ts';
|
|
|
7
7
|
import { runAndRecord, runOptions, SCRIPT_MAX_MB } from './scripts.ts';
|
|
8
8
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
9
9
|
import { currentWorkspace } from './workspaces.ts';
|
|
10
|
+
import { tr } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// SQL Workshop → Quick SQL: shorthand → DDL preview, then save it as a SQL
|
|
12
13
|
// script or run it (as a script, so the results per statement are kept).
|
|
@@ -27,32 +28,29 @@ view department_employees departments employees
|
|
|
27
28
|
|
|
28
29
|
export async function quickSqlRoutes(app: FastifyInstance) {
|
|
29
30
|
const render = (s: Session, reply: FastifyReply, src: string, body: Record<string, string | undefined>, error?: string) => {
|
|
31
|
+
const tx = tr(s);
|
|
30
32
|
const { ddl, warnings } = quickSql(src);
|
|
31
|
-
const syntax = html`<details class="u-mt1"><summary
|
|
32
|
-
|
|
33
|
-
<li>Column types from the name (<code>*_id</code> bigint, <code>*_at</code> timestamptz, <code>*_date</code>/<code>*_on</code> date, <code>is_*</code> boolean, <code>price</code>/<code>amount</code> numeric, otherwise text) or written after it: <code>vc200</code>, <code>num</code>, <code>num(10,2)</code>, <code>int</code>, <code>date</code>, <code>ts</code>, <code>tstz</code>, <code>clob</code>, <code>blob</code>, <code>json</code>, <code>bool</code>, <code>uuid</code>.</li>
|
|
34
|
-
<li>Column directives: <code>/nn</code>, <code>/pk</code>, <code>/unique</code>, <code>/idx</code>, <code>/fk table</code>, <code>/check a, b</code>, <code>/between 1 and 10</code>, <code>/default value</code>, <code>/lower</code>, <code>/upper</code>; <code>[a comment]</code>. Table directive: <code>/auditcols</code>.</li>
|
|
35
|
-
<li>Settings: <code># pk: identity | seq | guid | none</code>, <code># schema: name</code>, <code># prefix: xx</code>, <code># drop: true</code>, <code># auditcols: true</code>, or <code># settings = { pk: "guid", schema: "app" }</code>.</li>
|
|
36
|
-
<li><code>view name table1 table2</code>: a view joining the tables by their foreign keys. <code>--</code> starts a comment.</li></ul></details>`;
|
|
33
|
+
const syntax = html`<details class="u-mt1"><summary>${tx('qs.syntax')}</summary><ul class="small">
|
|
34
|
+
${(['qs.syntax.tables', 'qs.syntax.types', 'qs.syntax.directives', 'qs.syntax.settings', 'qs.syntax.views'] as const).map((k) => html`<li>${tx.code(k)}</li>`)}</ul></details>`;
|
|
37
35
|
const main = html`<form method="post" action="${BASE}/sql/quick">${csrf(s)}
|
|
38
36
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
39
37
|
<div class="qs-columns">
|
|
40
|
-
${region('
|
|
38
|
+
${region(tx('sql.tool.quicksql'), html`<label class="sr-only" for="f_source">${tx('sql.tool.quicksql')}</label>
|
|
41
39
|
<textarea id="f_source" name="source" class="code" rows="22" spellcheck="false">${src}</textarea>
|
|
42
|
-
<div class="buttons"><button class="btn" name="action" value="preview">${icon('play')}
|
|
40
|
+
<div class="buttons"><button class="btn" name="action" value="preview">${icon('play')} ${tx('qs.generate')}</button></div>${syntax}`)}
|
|
43
41
|
${region('PostgreSQL', html`${warnings.length
|
|
44
|
-
? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li
|
|
42
|
+
? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${warnings.map((w) => html`<li>${tx('qs.line', { line: w.line, message: w.message })}</li>`)}</ul></div>`
|
|
45
43
|
: ''}
|
|
46
|
-
<pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="
|
|
44
|
+
<pre class="source ddl" data-code-view="sql" tabindex="0" aria-label="${tx('qs.generated')}">${ddl}</pre>`)}
|
|
47
45
|
</div>
|
|
48
46
|
<div class="u-spacer"></div>
|
|
49
|
-
${region('
|
|
47
|
+
${region(tx('qs.save_or_run'), html`<div class="form-grid"><div class="field"><label class="label" for="f_name">${tx('qs.script_name')}</label>
|
|
50
48
|
<input id="f_name" name="name" value="${body.name ?? 'Quick SQL'}" maxlength="200"></div></div>
|
|
51
|
-
${runOptions(body)}
|
|
52
|
-
<div class="buttons"><button class="btn" name="action" value="save">${icon('file')}
|
|
53
|
-
<button class="btn btn-hot" name="action" value="run">${icon('play')}
|
|
49
|
+
${runOptions(body, tx)}
|
|
50
|
+
<div class="buttons"><button class="btn" name="action" value="save">${icon('file')} ${tx('qs.save_script')}</button>
|
|
51
|
+
<button class="btn btn-hot" name="action" value="run">${icon('play')} ${tx('qs.run')}</button></div>`)}
|
|
54
52
|
</form>`;
|
|
55
|
-
return send(reply, s, shell(s, '
|
|
53
|
+
return send(reply, s, shell(s, tx('sql.tool.quicksql'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.quicksql')]], html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('quicksql', s)}${main}`, 'sql'));
|
|
56
54
|
};
|
|
57
55
|
|
|
58
56
|
app.get(`${BASE}/sql/quick`, async (req: Req, reply) => {
|
|
@@ -68,7 +66,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
|
|
|
68
66
|
const src = (b.source ?? '').replace(/\r\n/g, '\n');
|
|
69
67
|
if (Buffer.byteLength(src) > 1024 * 1024) {
|
|
70
68
|
reply.code(422);
|
|
71
|
-
return render(s, reply, '', b, '
|
|
69
|
+
return render(s, reply, '', b, tr(s)('qs.too_large'));
|
|
72
70
|
}
|
|
73
71
|
if (b.action !== 'save' && b.action !== 'run') return render(s, reply, src, b);
|
|
74
72
|
const { ddl } = quickSql(src);
|
|
@@ -78,7 +76,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
|
|
|
78
76
|
.join('\n')}\n\n${ddl}`;
|
|
79
77
|
if (Buffer.byteLength(content) > SCRIPT_MAX_MB * 1024 * 1024) {
|
|
80
78
|
reply.code(422);
|
|
81
|
-
return render(s, reply, src, b, '
|
|
79
|
+
return render(s, reply, src, b, tr(s)('qs.script_too_large'));
|
|
82
80
|
}
|
|
83
81
|
const name = (b.name ?? '').trim().slice(0, 200) || 'Quick SQL';
|
|
84
82
|
if (b.action === 'run') {
|
|
@@ -88,7 +86,7 @@ export async function quickSqlRoutes(app: FastifyInstance) {
|
|
|
88
86
|
let unique = name;
|
|
89
87
|
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [unique, currentWorkspace(s)]); n++) unique = `${name} (${n})`;
|
|
90
88
|
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [unique, content, s.username, currentWorkspace(s)]);
|
|
91
|
-
flash(s,
|
|
89
|
+
flash(s, tr(s)('qs.saved', { name: unique }));
|
|
92
90
|
return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
|
|
93
91
|
});
|
|
94
92
|
}
|