pgkiln 0.47.0 → 0.49.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/SECURITY.md +2 -2
- 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/db/migrations/116_dump_jobs.sql +41 -0
- package/db/migrations/117_rest_handlers.sql +63 -0
- package/db/migrations/118_change_log.sql +54 -0
- package/docs/apex-feature-parity.md +11 -11
- package/docs/guide/01-installation.md +4 -0
- package/docs/guide/03-builder.md +267 -7
- package/docs/guide/08-security.md +29 -0
- package/docs/guide/12-development.md +70 -6
- package/docs/guide/13-rest-api.md +133 -10
- package/docs/guide/15-extensions.md +1 -1
- package/examples/hr/hr_62_rest_module.sql +39 -0
- package/package.json +1 -1
- package/public/app.css +1 -1
- package/public/builder-icons.svg +2 -0
- package/public/builder.css +442 -173
- package/public/builder.js +48 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/sql-monitor.js +73 -0
- package/public/worksheet.css +49 -0
- package/public/worksheet.js +4 -0
- package/src/app.ts +10 -0
- package/src/automations.ts +6 -0
- package/src/binds.ts +2 -1
- package/src/builder/advisor.ts +24 -2
- package/src/builder/autorest.ts +363 -0
- package/src/builder/changelog.ts +240 -0
- package/src/builder/components.ts +1 -1
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/datapump.ts +364 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/ddl-tools.ts +13 -4
- package/src/builder/find-workspace.ts +2 -2
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +7 -1
- package/src/builder/plan-view.ts +25 -0
- package/src/builder/rest-modules.ts +629 -0
- package/src/builder/rest.ts +1 -0
- package/src/builder/routes.ts +4 -2
- package/src/builder/scheduling.ts +495 -0
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-monitor.ts +397 -0
- package/src/builder/sql-tools.ts +53 -0
- package/src/builder/sql-utilities.ts +7 -39
- package/src/builder/sql.ts +20 -9
- package/src/builder/table-editor.ts +4 -1
- package/src/builder/top-sql.ts +24 -6
- package/src/builder/ui.ts +25 -17
- package/src/builder/worksheet.ts +35 -28
- package/src/builder/workspaces.ts +4 -2
- package/src/changelog.ts +109 -0
- package/src/cli/replace.ts +3 -1
- package/src/cron-schedule.ts +282 -0
- package/src/datamodel.ts +324 -0
- package/src/datapump.ts +546 -0
- package/src/dbroles.ts +215 -0
- package/src/dumpscript.ts +407 -0
- package/src/pgcron.ts +225 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest-handlers.ts +75 -0
- package/src/runtime/rest.ts +120 -26
- package/src/session.ts +2 -0
package/src/datamodel.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { html, raw, type Raw } from './html.ts';
|
|
2
|
+
|
|
3
|
+
// (sprint 98, Database Actions DA-10: Data Modeler) A relational diagram of some tables and views of one schema:
|
|
4
|
+
// the catalog read for them (columns, keys, foreign keys, comments), each box's size, an automatic layout, the
|
|
5
|
+
// foreign key lines with crow's feet, and the whole diagram as one SVG. The same SVG is the page's diagram (the
|
|
6
|
+
// boxes at their saved positions, so it shows without JavaScript), the Export SVG file and the source of the
|
|
7
|
+
// PNG export. It uses SVG presentation attributes only (no style attributes, no <style>): the strict CSP allows
|
|
8
|
+
// them, the builder's stylesheet themes them on the page, and a downloaded file looks the same anywhere.
|
|
9
|
+
// public/data-modeler.js moves the boxes and draws the lines again with the same geometry (fkPath).
|
|
10
|
+
|
|
11
|
+
export interface ModelColumn {
|
|
12
|
+
name: string;
|
|
13
|
+
type: string;
|
|
14
|
+
notNull: boolean;
|
|
15
|
+
default: string | null;
|
|
16
|
+
comment: string | null;
|
|
17
|
+
}
|
|
18
|
+
export interface ModelConstraint {
|
|
19
|
+
name: string;
|
|
20
|
+
/** p primary key, u unique, f foreign key, c check */
|
|
21
|
+
type: 'p' | 'u' | 'f' | 'c';
|
|
22
|
+
cols: string[];
|
|
23
|
+
refSchema: string | null;
|
|
24
|
+
refTable: string | null;
|
|
25
|
+
refCols: string[];
|
|
26
|
+
def: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ModelTable {
|
|
29
|
+
name: string;
|
|
30
|
+
kind: 'table' | 'view' | 'materialized view';
|
|
31
|
+
/** the regclass text (schema-qualified, quoted when needed) */
|
|
32
|
+
ref: string;
|
|
33
|
+
comment: string | null;
|
|
34
|
+
columns: ModelColumn[];
|
|
35
|
+
constraints: ModelConstraint[];
|
|
36
|
+
}
|
|
37
|
+
export interface Placed {
|
|
38
|
+
name: string;
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Q = { query: <T extends Record<string, any> = any>(sql: string, params?: unknown[]) => Promise<{ rows: T[] }> };
|
|
44
|
+
|
|
45
|
+
/** Names of the tables and views of a schema (not partitions), for the Add objects search. */
|
|
46
|
+
export async function modelNames(db: Q, schema: string): Promise<{ name: string; kind: ModelTable['kind'] }[]> {
|
|
47
|
+
return (await db.query<{ name: string; kind: ModelTable['kind'] }>(
|
|
48
|
+
`select c.relname as name, case when c.relkind in ('r', 'p') then 'table' when c.relkind = 'm' then 'materialized view' else 'view' end as kind
|
|
49
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
50
|
+
where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
|
|
51
|
+
order by c.relname limit 5000`, [schema])).rows;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The named tables and views of a schema as the diagram shows them (missing names are left out). */
|
|
55
|
+
export async function readModel(db: Q, schema: string, names: string[]): Promise<ModelTable[]> {
|
|
56
|
+
if (!names.length) return [];
|
|
57
|
+
return (await db.query<ModelTable>(
|
|
58
|
+
`select c.relname as name, case when c.relkind in ('r', 'p') then 'table' when c.relkind = 'm' then 'materialized view' else 'view' end as kind,
|
|
59
|
+
c.oid::regclass::text as ref, obj_description(c.oid, 'pg_class') as comment,
|
|
60
|
+
coalesce((select json_agg(json_build_object('name', a.attname, 'type', format_type(a.atttypid, a.atttypmod), 'notNull', a.attnotnull,
|
|
61
|
+
'default', case when a.attidentity = 'a' then 'generated always as identity' when a.attidentity = 'd' then 'generated by default as identity'
|
|
62
|
+
when a.attgenerated = 's' then 'generated always as (' || pg_get_expr(d.adbin, d.adrelid) || ') stored'
|
|
63
|
+
else pg_get_expr(d.adbin, d.adrelid) end,
|
|
64
|
+
'comment', col_description(c.oid, a.attnum)) order by a.attnum)
|
|
65
|
+
from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
|
|
66
|
+
where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped), '[]') as columns,
|
|
67
|
+
coalesce((select json_agg(json_build_object('name', k.conname, 'type', k.contype,
|
|
68
|
+
'cols', array(select a.attname from unnest(k.conkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.n order by u.i),
|
|
69
|
+
'refSchema', fn.nspname, 'refTable', f.relname,
|
|
70
|
+
'refCols', coalesce(array(select a.attname from unnest(k.confkey) with ordinality u(n, i) join pg_attribute a on a.attrelid = k.confrelid and a.attnum = u.n order by u.i), '{}'),
|
|
71
|
+
'def', pg_get_constraintdef(k.oid)) order by position(k.contype in 'pufc'), k.conname)
|
|
72
|
+
from pg_constraint k left join pg_class f on f.oid = k.confrelid left join pg_namespace fn on fn.oid = f.relnamespace
|
|
73
|
+
where k.conrelid = c.oid and k.contype in ('p', 'u', 'f', 'c')), '[]') as constraints
|
|
74
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
75
|
+
where n.nspname = $1 and c.relname = any ($2::text[]) and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
|
|
76
|
+
order by c.relname`, [schema, names])).rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The names a search picks: `*` all of them; a pattern with `*` (emp*, *_log) matches whole names; other words match
|
|
81
|
+
* a part of a name (any case). Several words or patterns, separated by spaces or commas, add up.
|
|
82
|
+
*/
|
|
83
|
+
export function matchNames(all: string[], q: string, opts: { exact?: boolean } = {}): string[] {
|
|
84
|
+
const terms = q.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean).slice(0, 50);
|
|
85
|
+
if (!terms.length) return [];
|
|
86
|
+
const tests = terms.map((t) => {
|
|
87
|
+
if (t.includes('*')) {
|
|
88
|
+
const re = new RegExp(`^${t.split('*').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`, 'i');
|
|
89
|
+
return (n: string) => re.test(n);
|
|
90
|
+
}
|
|
91
|
+
const low = t.toLowerCase();
|
|
92
|
+
return opts.exact ? (n: string) => n === t || n.toLowerCase() === low : (n: string) => n.toLowerCase().includes(low);
|
|
93
|
+
});
|
|
94
|
+
return all.filter((n) => tests.some((f) => f(n)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A diagram's saved objects, cleaned: names (unique) and positions within 0..20000. */
|
|
98
|
+
export function cleanObjects(v: unknown): Placed[] {
|
|
99
|
+
if (!Array.isArray(v)) return [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
const out: Placed[] = [];
|
|
102
|
+
for (const o of v.slice(0, 500)) {
|
|
103
|
+
if (!o || typeof o !== 'object') continue;
|
|
104
|
+
const name = (o as any).name;
|
|
105
|
+
if (typeof name !== 'string' || !name || name.length > 63 || seen.has(name)) continue;
|
|
106
|
+
seen.add(name);
|
|
107
|
+
const n = (x: unknown) => (Number.isFinite(Number(x)) ? Math.min(20000, Math.max(0, Math.round(Number(x)))) : 0);
|
|
108
|
+
out.push({ name, x: n((o as any).x), y: n((o as any).y) });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- geometry
|
|
114
|
+
|
|
115
|
+
export const HEAD = 30;
|
|
116
|
+
export const ROW = 20;
|
|
117
|
+
const FOOT = 8;
|
|
118
|
+
/** columns drawn per box; the others are counted in a last row */
|
|
119
|
+
export const MAX_ROWS = 40;
|
|
120
|
+
const CHAR = 7;
|
|
121
|
+
const TYPE_CHAR = 6.4;
|
|
122
|
+
const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
|
|
123
|
+
|
|
124
|
+
export interface Box {
|
|
125
|
+
name: string;
|
|
126
|
+
x: number;
|
|
127
|
+
y: number;
|
|
128
|
+
w: number;
|
|
129
|
+
h: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The rows a box shows: its columns (at most MAX_ROWS) and a "more" row. */
|
|
133
|
+
const shownColumns = (t: ModelTable) => t.columns.slice(0, t.columns.length > MAX_ROWS ? MAX_ROWS - 1 : MAX_ROWS);
|
|
134
|
+
|
|
135
|
+
export function boxSize(t: ModelTable): { w: number; h: number } {
|
|
136
|
+
const cols = shownColumns(t);
|
|
137
|
+
const rows = cols.length + (cols.length < t.columns.length ? 1 : 0);
|
|
138
|
+
const widest = Math.max(0, ...cols.map((c) => 30 + cut(c.name, 36).length * CHAR + 18 + cut(c.type, 24).length * TYPE_CHAR + 10));
|
|
139
|
+
const title = 24 + cut(t.name, 48).length * 7.6 + (t.kind === 'table' ? 0 : 50);
|
|
140
|
+
return { w: Math.round(Math.min(480, Math.max(160, widest, title))), h: HEAD + Math.max(1, rows) * ROW + 6 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The y offset (in its box) of a column's row; the header for a column not drawn. */
|
|
144
|
+
export function rowOffset(t: ModelTable, column: string) {
|
|
145
|
+
const i = shownColumns(t).findIndex((c) => c.name === column);
|
|
146
|
+
return i < 0 ? HEAD / 2 : HEAD + i * ROW + ROW / 2;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A foreign key line from a child's row (a, ay) to its parent's row (b, by): an orthogonal path, the crow's foot
|
|
151
|
+
* (many) at the child and a bar (one) at the parent. The same function is in public/data-modeler.js.
|
|
152
|
+
*/
|
|
153
|
+
export function fkPath(a: Box, ay: number, b: Box, by: number) {
|
|
154
|
+
const y1 = a.y + ay;
|
|
155
|
+
const y2 = b.y + by;
|
|
156
|
+
let x1: number, x2: number, d1: number, d2: number, path: string;
|
|
157
|
+
if (a.x + a.w + 40 <= b.x) {
|
|
158
|
+
[x1, x2, d1, d2] = [a.x + a.w, b.x, 1, -1];
|
|
159
|
+
const mx = Math.round((x1 + x2) / 2);
|
|
160
|
+
path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
|
|
161
|
+
} else if (b.x + b.w + 40 <= a.x) {
|
|
162
|
+
[x1, x2, d1, d2] = [a.x, b.x + b.w, -1, 1];
|
|
163
|
+
const mx = Math.round((x1 + x2) / 2);
|
|
164
|
+
path = `M ${x1} ${y1} H ${mx} V ${y2} H ${x2}`;
|
|
165
|
+
} else {
|
|
166
|
+
// one above the other (or the same table): around the right-hand sides
|
|
167
|
+
[x1, x2, d1, d2] = [a.x + a.w, b.x + b.w, 1, 1];
|
|
168
|
+
const lx = Math.max(x1, x2) + (a === b ? 18 : 30);
|
|
169
|
+
const yy = a === b && y1 === y2 ? y2 + 10 : y2;
|
|
170
|
+
path = `M ${x1} ${y1} H ${lx} V ${yy} H ${x2}`;
|
|
171
|
+
}
|
|
172
|
+
const foot = `M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 - 7} M ${x1 + d1 * 14} ${y1} L ${x1} ${y1 + 7} M ${x1 + d1 * 14} ${y1 - 7} V ${y1 + 7}`;
|
|
173
|
+
const bar = `M ${x2 + d2 * 10} ${y2 - 7} V ${y2 + 7}`;
|
|
174
|
+
return { path, ends: `${foot} ${bar}`, circle: { cx: x2 + d2 * 20, cy: y2 } };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface Line {
|
|
178
|
+
from: string;
|
|
179
|
+
to: string;
|
|
180
|
+
name: string;
|
|
181
|
+
fy: number;
|
|
182
|
+
ty: number;
|
|
183
|
+
optional: boolean;
|
|
184
|
+
label: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The foreign key lines between the shown tables. */
|
|
188
|
+
export function lines(schema: string, tables: ModelTable[]): Line[] {
|
|
189
|
+
const by = new Map(tables.map((t) => [t.name, t]));
|
|
190
|
+
return tables.flatMap((t) =>
|
|
191
|
+
t.constraints.filter((k) => k.type === 'f' && k.refSchema === schema && k.refTable && by.has(k.refTable)).map((k) => ({
|
|
192
|
+
from: t.name,
|
|
193
|
+
to: k.refTable!,
|
|
194
|
+
name: k.name,
|
|
195
|
+
fy: rowOffset(t, k.cols[0]),
|
|
196
|
+
ty: rowOffset(by.get(k.refTable!)!, k.refCols[0]),
|
|
197
|
+
optional: k.cols.some((c) => !t.columns.find((x) => x.name === c)?.notNull),
|
|
198
|
+
label: `${t.name}(${k.cols.join(', ')}) → ${k.refTable}(${k.refCols.join(', ')}): ${k.name}`,
|
|
199
|
+
})),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Positions for all tables: parents left of their children (by foreign key levels), each level a column, a column
|
|
205
|
+
* wraps when it gets taller than the others need.
|
|
206
|
+
*/
|
|
207
|
+
export function autoLayout(schema: string, tables: ModelTable[]): Placed[] {
|
|
208
|
+
const size = new Map(tables.map((t) => [t.name, boxSize(t)]));
|
|
209
|
+
const parents = new Map(tables.map((t) => [t.name, [...new Set(t.constraints.filter((k) => k.type === 'f' && k.refSchema === schema && k.refTable !== t.name && size.has(k.refTable!)).map((k) => k.refTable!))]]));
|
|
210
|
+
const level = new Map<string, number>();
|
|
211
|
+
const visiting = new Set<string>();
|
|
212
|
+
const levelOf = (n: string): number => {
|
|
213
|
+
if (level.has(n)) return level.get(n)!;
|
|
214
|
+
if (visiting.has(n)) return 0; // a cycle: this edge doesn't count
|
|
215
|
+
visiting.add(n);
|
|
216
|
+
const l = Math.max(-1, ...parents.get(n)!.map(levelOf)) + 1;
|
|
217
|
+
visiting.delete(n);
|
|
218
|
+
level.set(n, l);
|
|
219
|
+
return l;
|
|
220
|
+
};
|
|
221
|
+
for (const t of tables) levelOf(t.name);
|
|
222
|
+
const total = tables.reduce((s, t) => s + size.get(t.name)!.h + 40, 0);
|
|
223
|
+
const maxH = Math.max(900, Math.sqrt(total * 900), ...tables.map((t) => size.get(t.name)!.h));
|
|
224
|
+
const levels = [...new Set(level.values())].sort((a, b) => a - b);
|
|
225
|
+
const index = new Map<string, number>();
|
|
226
|
+
const out: Placed[] = [];
|
|
227
|
+
let x = 20;
|
|
228
|
+
for (const l of levels) {
|
|
229
|
+
const members = tables.filter((t) => level.get(t.name) === l).map((t) => t.name);
|
|
230
|
+
// near their parents: by the parents' mean position, then by name
|
|
231
|
+
const key = (n: string) => {
|
|
232
|
+
const p = parents.get(n)!.filter((x) => index.has(x));
|
|
233
|
+
return p.length ? p.reduce((s, x) => s + index.get(x)!, 0) / p.length : Number.MAX_SAFE_INTEGER;
|
|
234
|
+
};
|
|
235
|
+
members.sort((a, b) => key(a) - key(b) || a.localeCompare(b));
|
|
236
|
+
let y = 20;
|
|
237
|
+
let colW = 0;
|
|
238
|
+
for (const n of members) {
|
|
239
|
+
const s = size.get(n)!;
|
|
240
|
+
if (y > 20 && y + s.h > maxH) {
|
|
241
|
+
x += colW + 60;
|
|
242
|
+
y = 20;
|
|
243
|
+
colW = 0;
|
|
244
|
+
}
|
|
245
|
+
index.set(n, index.size);
|
|
246
|
+
out.push({ name: n, x, y });
|
|
247
|
+
y += s.h + 40;
|
|
248
|
+
colW = Math.max(colW, s.w);
|
|
249
|
+
}
|
|
250
|
+
x += colW + 100;
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Saved positions for the shown tables; tables without one get the automatic layout's (below the others when some are placed). */
|
|
256
|
+
export function placeAll(schema: string, tables: ModelTable[], saved: Placed[]): Placed[] {
|
|
257
|
+
const known = new Map(saved.map((p) => [p.name, p]));
|
|
258
|
+
const missing = tables.filter((t) => !known.has(t.name));
|
|
259
|
+
if (!missing.length) return tables.map((t) => known.get(t.name)!);
|
|
260
|
+
const placedTables = tables.filter((t) => known.has(t.name));
|
|
261
|
+
if (!placedTables.length) return autoLayout(schema, tables);
|
|
262
|
+
const bottom = Math.max(...placedTables.map((t) => known.get(t.name)!.y + boxSize(t).h)) + 60;
|
|
263
|
+
const extra = autoLayout(schema, missing).map((p) => ({ ...p, y: p.y + bottom - 20 }));
|
|
264
|
+
return tables.map((t) => known.get(t.name) ?? extra.find((p) => p.name === t.name)!);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------- SVG
|
|
268
|
+
|
|
269
|
+
export const COLORS = { bg: '#ffffff', frame: '#94a3b8', head: '#e8eefc', title: '#1e293b', text: '#334155', muted: '#64748b', key: '#b45309', line: '#475569', view: '#f1f5f9' };
|
|
270
|
+
|
|
271
|
+
/** Where a box's title links: Table Properties for a table, the Object Browser for a view. */
|
|
272
|
+
export const objectHref = (schema: string, t: ModelTable) =>
|
|
273
|
+
t.kind === 'table'
|
|
274
|
+
? `/builder/sql/objects/table?${new URLSearchParams({ o: t.ref })}`
|
|
275
|
+
: `/builder/sql/objects?${new URLSearchParams({ schema, k: t.kind === 'view' ? 'view' : 'matview', o: t.ref })}`;
|
|
276
|
+
|
|
277
|
+
/** Key markers of a column: P primary key, F foreign key, U unique. */
|
|
278
|
+
export function markers(t: ModelTable, column: string) {
|
|
279
|
+
const has = (type: string) => t.constraints.some((k) => k.type === type && k.cols.includes(column));
|
|
280
|
+
return `${has('p') ? 'P' : ''}${has('f') ? 'F' : ''}${!has('p') && has('u') ? 'U' : ''}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The whole diagram. `title` names it; `links` makes box titles links (the page; not an exported file); `auto`: each box's automatic position (data-ax/ay, for the page's Auto layout). */
|
|
284
|
+
export function diagramSvg(schema: string, title: string, tables: ModelTable[], placed: Placed[], opts: { links?: boolean; id?: string; auto?: Placed[] } = {}): Raw {
|
|
285
|
+
const autoAt = new Map((opts.auto ?? []).map((p) => [p.name, p]));
|
|
286
|
+
const pos = new Map(placed.map((p) => [p.name, p]));
|
|
287
|
+
const boxes = new Map(tables.map((t) => [t.name, { ...pos.get(t.name)!, ...boxSize(t) } as Box]));
|
|
288
|
+
const all = [...boxes.values()];
|
|
289
|
+
const W = Math.max(400, ...all.map((b) => b.x + b.w + 60));
|
|
290
|
+
const H = Math.max(240, ...all.map((b) => b.y + b.h + 40));
|
|
291
|
+
const c = COLORS;
|
|
292
|
+
const fk = lines(schema, tables).map((l) => {
|
|
293
|
+
const p = fkPath(boxes.get(l.from)!, l.fy, boxes.get(l.to)!, l.ty);
|
|
294
|
+
return html`<g class="dm-fk${l.optional ? ' dm-optional' : ''}" data-from="${l.from}" data-to="${l.to}" data-fy="${l.fy}" data-ty="${l.ty}"><title>${l.label}</title>
|
|
295
|
+
<path class="dm-line" d="${p.path}" fill="none" stroke="${c.line}" stroke-width="1.5"${l.optional ? raw(' stroke-dasharray="6 4"') : ''}/>
|
|
296
|
+
<path class="dm-end" d="${p.ends}" fill="none" stroke="${c.line}" stroke-width="1.5"/>${l.optional ? html`<circle class="dm-opt" cx="${p.circle.cx}" cy="${p.circle.cy}" r="4" fill="${c.bg}" stroke="${c.line}" stroke-width="1.5"/>` : ''}</g>`;
|
|
297
|
+
});
|
|
298
|
+
const box = (t: ModelTable) => {
|
|
299
|
+
const b = boxes.get(t.name)!;
|
|
300
|
+
const cols = shownColumns(t);
|
|
301
|
+
const titleText = html`<text class="dm-title" x="10" y="20" font-weight="600" font-size="13" fill="${c.title}">${cut(t.name, 48)}</text>`;
|
|
302
|
+
return html`<g class="dm-box dm-${t.kind === 'table' ? 'table' : 'view'}" data-name="${t.name}" data-x="${b.x}" data-y="${b.y}" data-w="${b.w}" data-h="${b.h}"${autoAt.has(t.name) ? html` data-ax="${autoAt.get(t.name)!.x}" data-ay="${autoAt.get(t.name)!.y}"` : ''} transform="translate(${b.x} ${b.y})"${opts.links ? raw(' tabindex="0"') : ''} aria-label="${t.kind} ${t.name}">
|
|
303
|
+
<title>${t.kind} ${schema}.${t.name}${t.comment ? `: ${t.comment}` : ''}</title>
|
|
304
|
+
<rect class="dm-frame" width="${b.w}" height="${b.h}" rx="4" fill="${t.kind === 'table' ? c.bg : c.view}" stroke="${c.frame}"/>
|
|
305
|
+
<path class="dm-head" d="M 0.5 4.5 a 4 4 0 0 1 4 -4 H ${b.w - 4.5} a 4 4 0 0 1 4 4 V ${HEAD} H 0.5 Z" fill="${c.head}" stroke="${c.frame}"/>
|
|
306
|
+
${opts.links ? html`<a class="dm-link" href="${objectHref(schema, t)}">${titleText}</a>` : titleText}
|
|
307
|
+
${t.kind === 'table' ? '' : html`<text class="dm-kind" x="${b.w - 8}" y="20" text-anchor="end" font-size="10" fill="${c.muted}">${t.kind === 'view' ? 'view' : 'mat. view'}</text>`}
|
|
308
|
+
${cols.map((col, i) => {
|
|
309
|
+
const y = HEAD + i * ROW + 14;
|
|
310
|
+
return html`<g class="dm-row"><title>${col.name} ${col.type}${col.notNull ? ' not null' : ''}${col.comment ? `: ${col.comment}` : ''}</title>
|
|
311
|
+
<text class="dm-key" x="6" y="${y}" font-size="10" font-weight="700" fill="${c.key}">${markers(t, col.name)}</text>
|
|
312
|
+
<text class="dm-col" x="26" y="${y}" font-size="12" fill="${c.text}"${col.notNull ? raw(' font-weight="600"') : ''}>${col.notNull ? '* ' : ''}${cut(col.name, 36)}</text>
|
|
313
|
+
<text class="dm-type" x="${b.w - 8}" y="${y}" text-anchor="end" font-size="11" fill="${c.muted}">${cut(col.type, 24)}</text></g>`;
|
|
314
|
+
})}
|
|
315
|
+
${cols.length < t.columns.length ? html`<text class="dm-more" x="26" y="${HEAD + cols.length * ROW + 14}" font-size="11" font-style="italic" fill="${c.muted}">… ${t.columns.length - cols.length} more columns</text>` : ''}
|
|
316
|
+
${!t.columns.length ? html`<text class="dm-more" x="26" y="${HEAD + 14}" font-size="11" font-style="italic" fill="${c.muted}">no columns</text>` : ''}
|
|
317
|
+
</g>`;
|
|
318
|
+
};
|
|
319
|
+
return html`<svg xmlns="http://www.w3.org/2000/svg" class="dm-svg"${opts.id ? raw(` id="${opts.id.replace(/[^a-z0-9-]/g, '')}"`) : ''} width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" role="group" aria-label="Diagram ${title}">
|
|
320
|
+
<title>${title}: ${tables.length} objects of schema ${schema}</title>
|
|
321
|
+
<rect class="dm-bg" width="100%" height="100%" fill="${c.bg}"/>
|
|
322
|
+
<g class="dm-content"><g class="dm-lines">${fk}</g><g class="dm-boxes">${tables.map(box)}</g></g>
|
|
323
|
+
</svg>`;
|
|
324
|
+
}
|