pgkiln 0.74.0 → 0.75.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.
@@ -0,0 +1,365 @@
1
+ import pg from 'pg';
2
+
3
+ // (sprint 142, AP-8; APEX: Shared Components → JSON Sources, and JSON Duality Views)
4
+ //
5
+ // A JSON source describes a jsonb column with a data profile: columns at paths in the documents, and arrays (of
6
+ // objects, or of values) with columns of their own. It is a JSON list in meta.app (json_sources, migration 142),
7
+ // exported with the application. From it the builder makes views in the table's schema:
8
+ //
9
+ // <view> the table's key columns and one column per path, typed (a value that doesn't read as the
10
+ // type shows NULL); an UPDATE writes only the changed paths (meta.json_source_write), so keys
11
+ // the profile doesn't know about stay;
12
+ // <array view> per array: the table's key columns, row_key (stable while other elements change), position
13
+ // and the element's columns; UPDATE, INSERT and DELETE change one element.
14
+ //
15
+ // Regions (forms, reports, grids, master-detail), REST and MCP use the views like tables. documentViewDdl makes a
16
+ // document view of a table and its child tables (APEX's JSON Duality View).
17
+
18
+ export const JSON_TYPES = ['text', 'integer', 'bigint', 'numeric', 'boolean', 'date', 'timestamp', 'timestamptz', 'jsonb'] as const;
19
+ export type JsonType = (typeof JSON_TYPES)[number];
20
+
21
+ export interface JsonColumn { name: string; path: string[]; type: JsonType; readonly?: boolean }
22
+ export interface JsonArray { name: string; path: string[]; view: string; columns: JsonColumn[] }
23
+ export interface JsonSource {
24
+ name: string;
25
+ description?: string;
26
+ schema: string;
27
+ table: string;
28
+ key: string[];
29
+ column: string;
30
+ view: string;
31
+ /** a table of documents: the view also adds and deletes the table's rows */
32
+ rows?: boolean;
33
+ columns: JsonColumn[];
34
+ arrays: JsonArray[];
35
+ }
36
+
37
+ export const STATIC_ID = /^[A-Z][A-Z0-9_]{0,59}$/;
38
+ export const NAME = /^[a-z_][a-z0-9_]{0,62}$/;
39
+ export const ROW_KEY = 'row_key';
40
+ export const POSITION = 'position';
41
+ const MAX_DEPTH = 4;
42
+ const MAX_COLUMNS = 200;
43
+
44
+ export const ident = (n: string) => (NAME.test(n) && !RESERVED.has(n) ? n : pg.escapeIdentifier(n));
45
+ // words that can't be a bare column name (PostgreSQL's reserved key words that look like data names)
46
+ const RESERVED = new Set(['all', 'and', 'any', 'array', 'as', 'asc', 'both', 'case', 'cast', 'check', 'column', 'constraint', 'create', 'default', 'desc', 'distinct', 'do', 'else', 'end', 'false', 'for', 'foreign', 'from', 'grant', 'group', 'having', 'in', 'into', 'leading', 'limit', 'not', 'null', 'offset', 'on', 'only', 'or', 'order', 'primary', 'references', 'select', 'table', 'then', 'to', 'true', 'union', 'unique', 'user', 'using', 'when', 'where', 'with', 'position']);
47
+ const lit = (s: string) => pg.escapeLiteral(s);
48
+ const pathSql = (path: string[]) => (path.length ? `array[${path.map(lit).join(', ')}]` : 'array[]::text[]');
49
+
50
+ /** The SQL of one column read from a jsonb expression. */
51
+ export function columnSql(doc: string, c: JsonColumn) {
52
+ const p = pathSql(c.path);
53
+ if (c.type === 'jsonb') return `${doc} #> ${p}`;
54
+ if (c.type === 'text') return `${doc} #>> ${p}`;
55
+ return `case when meta.input_ok(${doc} #>> ${p}, '${c.type}') then (${doc} #>> ${p})::${c.type} end`;
56
+ }
57
+
58
+ /**
59
+ * (142, AP-8) JSON path columns: config.json_columns [{"name", "column", "path", "type"}] add a column read from a
60
+ * json or jsonb column of the source at a path ("address.city", "skills.0.name"; numbers index arrays), typed (a value
61
+ * that doesn't read as the type is NULL). They come before the user's computations, so search, filters, sorting,
62
+ * aggregates and downloads treat them as columns. Names and types are checked; the path is a literal.
63
+ */
64
+ export function withJsonColumns(r: { config?: Record<string, any> | null }, src: string) {
65
+ const list = Array.isArray(r.config?.json_columns) ? (r.config!.json_columns as { name?: unknown; column?: unknown; path?: unknown; type?: unknown }[]) : [];
66
+ const exprs: string[] = [];
67
+ for (const j of list) {
68
+ if (!j || typeof j.name !== 'string' || !NAME.test(j.name) || typeof j.column !== 'string' || !j.column) continue;
69
+ const type = (JSON_TYPES as readonly string[]).includes(String(j.type ?? 'text')) ? (String(j.type ?? 'text') as JsonType) : 'text';
70
+ const path = Array.isArray(j.path) ? j.path.map(String) : String(j.path ?? '').split('.').filter((k) => k !== '');
71
+ exprs.push(`${columnSql(`"__j".${pg.escapeIdentifier(j.column)}::jsonb`, { name: j.name, path, type })} as ${pg.escapeIdentifier(j.name)}`);
72
+ }
73
+ return exprs.length ? `select "__j".*, ${exprs.join(', ')} from (\n${src}\n) "__j"` : src;
74
+ }
75
+
76
+ /** "city=profile:address.city:text; first=profile:skills.0.name" ⇄ config.json_columns (bad entries left out). */
77
+ export function parseJsonColumns(text: string | undefined): { name: string; column: string; path: string; type: JsonType }[] {
78
+ const out: { name: string; column: string; path: string; type: JsonType }[] = [];
79
+ for (const part of (text ?? '').split(';')) {
80
+ const m = /^\s*([a-z_][a-z0-9_]{0,62})\s*=\s*([^:\s]+)\s*:\s*([^:]*?)\s*(?::\s*([a-z]+)\s*)?$/.exec(part);
81
+ if (!m || out.some((x) => x.name === m[1])) continue;
82
+ const type = (JSON_TYPES as readonly string[]).includes(m[4] ?? 'text') ? ((m[4] ?? 'text') as JsonType) : 'text';
83
+ out.push({ name: m[1], column: m[2], path: m[3], type });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export const jsonColumnsText = (v: unknown) =>
89
+ Array.isArray(v) ? v.map((j: any) => `${j.name}=${j.column}:${Array.isArray(j.path) ? j.path.join('.') : j.path ?? ''}${j.type && j.type !== 'text' ? `:${j.type}` : ''}`).join('; ') : '';
90
+
91
+ // ---------------------------------------------------------------- checks
92
+
93
+ function pathProblem(path: unknown): string | null {
94
+ if (!Array.isArray(path) || path.length > MAX_DEPTH + 2) return 'a path is a list of up to 6 keys';
95
+ for (const k of path) if (typeof k !== 'string' || !k.length || k.length > 200) return 'each key of a path is 1 to 200 characters';
96
+ return null;
97
+ }
98
+
99
+ function columnsProblems(cols: unknown, where: string, taken: Set<string>, allowEmptyPath: boolean): string[] {
100
+ const out: string[] = [];
101
+ if (!Array.isArray(cols)) return [`${where}: the columns are a list.`];
102
+ if (cols.length > MAX_COLUMNS) out.push(`${where}: at most ${MAX_COLUMNS} columns.`);
103
+ const names = new Set(taken);
104
+ for (const c of cols as JsonColumn[]) {
105
+ if (!c || typeof c !== 'object') {
106
+ out.push(`${where}: a column is an object with a name, a path and a type.`);
107
+ continue;
108
+ }
109
+ if (typeof c.name !== 'string' || !NAME.test(c.name)) out.push(`${where}: column name "${String(c.name)}" is lower case letters, digits and _, starting with a letter or _.`);
110
+ else if (names.has(c.name)) out.push(`${where}: there are two columns ${c.name}${taken.has(c.name) ? ' (a key column or a column the view adds itself)' : ''}.`);
111
+ else names.add(c.name);
112
+ const pp = pathProblem(c.path);
113
+ if (pp) out.push(`${where}, column ${c.name}: ${pp}.`);
114
+ else if (!allowEmptyPath && !c.path.length) out.push(`${where}, column ${c.name}: the path names at least one key.`);
115
+ if (!(JSON_TYPES as readonly string[]).includes(c.type)) out.push(`${where}, column ${c.name}: the type is one of ${JSON_TYPES.join(', ')}.`);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Problems with a JSON source's definition (empty: fine). The catalog is checked by the builder. */
121
+ export function jsonSourceProblems(x: JsonSource): string[] {
122
+ const out: string[] = [];
123
+ if (typeof x.name !== 'string' || !STATIC_ID.test(x.name)) out.push('The name (static ID) is upper case letters, digits and _, starting with a letter, e.g. EMP_PROFILE.');
124
+ for (const [what, v] of [['schema', x.schema], ['table', x.table], ['jsonb column', x.column]] as const)
125
+ if (typeof v !== 'string' || !v || v.length > 63) out.push(`The ${what} is a name of 1 to 63 characters.`);
126
+ if (typeof x.view !== 'string' || !NAME.test(x.view)) out.push('The view name is lower case letters, digits and _, e.g. emp_profile.');
127
+ if (!Array.isArray(x.key) || !x.key.length || x.key.some((k) => typeof k !== 'string' || !k)) out.push('The table needs a primary key (the views use it to find the row).');
128
+ if (x.description != null && (typeof x.description !== 'string' || x.description.length > 500)) out.push('The description is at most 500 characters.');
129
+ const key = new Set(Array.isArray(x.key) ? x.key : []);
130
+ out.push(...columnsProblems(x.columns, 'The view', key, false));
131
+ if (!Array.isArray(x.arrays)) out.push('The arrays are a list.');
132
+ else {
133
+ const views = new Set([x.view]);
134
+ for (const a of x.arrays) {
135
+ const at = `Array ${a?.name ?? '?'}`;
136
+ if (!a || typeof a.name !== 'string' || !NAME.test(a.name)) out.push(`${at}: the name is lower case letters, digits and _.`);
137
+ const pp = pathProblem(a?.path);
138
+ if (pp || !a.path.length) out.push(`${at}: ${pp ?? 'the path names at least one key'}.`);
139
+ if (typeof a?.view !== 'string' || !NAME.test(a.view)) out.push(`${at}: the view name is lower case letters, digits and _.`);
140
+ else if (views.has(a.view)) out.push(`${at}: the view ${a.view} is used twice.`);
141
+ else views.add(a.view);
142
+ out.push(...columnsProblems(a?.columns, at, new Set([...key, ROW_KEY, POSITION]), true));
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // ---------------------------------------------------------------- the data profile from sample documents
149
+
150
+ interface Seen { types: Set<string>; ints: boolean; dates: boolean; stamps: boolean; count: number; objKeys?: Map<string, Seen>; elems?: Seen }
151
+ const newSeen = (): Seen => ({ types: new Set(), ints: true, dates: true, stamps: true, count: 0 });
152
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
153
+ const STAMP = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}(:?\d{2})?)?$/;
154
+
155
+ function observe(s: Seen, v: unknown, depth: number) {
156
+ s.count++;
157
+ if (v === null) return;
158
+ if (Array.isArray(v)) {
159
+ s.types.add('array');
160
+ if (depth < MAX_DEPTH) for (const e of v) observe((s.elems ??= newSeen()), e, depth + 1);
161
+ return;
162
+ }
163
+ if (typeof v === 'object') {
164
+ s.types.add('object');
165
+ if (depth < MAX_DEPTH) for (const [k, x] of Object.entries(v as object)) {
166
+ s.objKeys ??= new Map();
167
+ if (!s.objKeys.has(k)) s.objKeys.set(k, newSeen());
168
+ observe(s.objKeys.get(k)!, x, depth + 1);
169
+ }
170
+ return;
171
+ }
172
+ s.types.add(typeof v);
173
+ if (typeof v === 'number' && !Number.isInteger(v)) s.ints = false;
174
+ if (typeof v === 'string') {
175
+ if (!DATE.test(v)) s.dates = false;
176
+ if (!STAMP.test(v)) s.stamps = false;
177
+ }
178
+ }
179
+
180
+ function typeOf(s: Seen): JsonType {
181
+ const t = [...s.types];
182
+ if (t.length !== 1) return t.every((x) => x === 'number') ? 'numeric' : t.some((x) => x === 'object' || x === 'array') ? 'jsonb' : 'text';
183
+ switch (t[0]) {
184
+ case 'number': return s.ints ? 'integer' : 'numeric';
185
+ case 'boolean': return 'boolean';
186
+ case 'string': return s.dates ? 'date' : s.stamps ? 'timestamptz' : 'text';
187
+ default: return 'jsonb';
188
+ }
189
+ }
190
+
191
+ const snake = (k: string) => k.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^_+|_+$/g, '').replace(/^(\d)/, '_$1') || 'value';
192
+
193
+ function uniqueName(base: string, taken: Set<string>) {
194
+ let n = base.slice(0, 60);
195
+ for (let i = 2; taken.has(n) || RESERVED.has(n); i++) n = `${base.slice(0, 56)}_${i}`;
196
+ taken.add(n);
197
+ return n;
198
+ }
199
+
200
+ /** Columns for the keys of an object (nested objects flattened into a_b, arrays of objects collected apart). */
201
+ function flatten(s: Seen, prefix: string[], taken: Set<string>, arrays: { path: string[]; s: Seen }[], depth: number): JsonColumn[] {
202
+ const out: JsonColumn[] = [];
203
+ for (const [k, x] of s.objKeys ?? []) {
204
+ const path = [...prefix, k];
205
+ const only = [...x.types];
206
+ if (only.length === 1 && only[0] === 'object' && x.objKeys?.size && depth < MAX_DEPTH) {
207
+ out.push(...flatten(x, path, taken, arrays, depth + 1));
208
+ } else if (only.length === 1 && only[0] === 'array' && x.elems && x.elems.types.size) {
209
+ arrays.push({ path, s: x.elems });
210
+ } else {
211
+ out.push({ name: uniqueName(snake(path.join('_')), taken), path, type: typeOf(x) });
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+
217
+ /**
218
+ * A data profile from sample documents: a column per scalar path (nested objects flattened, a_b), an array view per
219
+ * array (of objects: a column per key; of values: one column "value"). Arrays inside array elements stay jsonb.
220
+ */
221
+ export function proposeProfile(docs: unknown[], o: { key: string[]; view: string }): { columns: JsonColumn[]; arrays: JsonArray[] } {
222
+ const root = newSeen();
223
+ for (const d of docs) if (d && typeof d === 'object' && !Array.isArray(d)) observe(root, d, 0);
224
+ const found: { path: string[]; s: Seen }[] = [];
225
+ const columns = flatten(root, [], new Set([...o.key]), found, 0);
226
+ const views = new Set([o.view]);
227
+ const arrays = found.map(({ path, s }): JsonArray => {
228
+ const name = snake(path.join('_'));
229
+ const view = uniqueName(`${o.view}_${name}`.slice(0, 63), views);
230
+ const taken = new Set([...o.key, ROW_KEY, POSITION]);
231
+ const objects = [...s.types].every((t) => t === 'object');
232
+ const cols = objects
233
+ ? [...(s.objKeys ?? new Map<string, Seen>())].map(([k, x]): JsonColumn => ({ name: uniqueName(snake(k), taken), path: [k], type: typeOf(x) }))
234
+ : [{ name: 'value', path: [], type: typeOf(s) }];
235
+ return { name, path, view, columns: cols };
236
+ });
237
+ return { columns, arrays };
238
+ }
239
+
240
+ // ---------------------------------------------------------------- DDL
241
+
242
+ export interface Grant { grantee: string; privileges: string[] }
243
+
244
+ const triggerArg = (o: object) => lit(JSON.stringify(o));
245
+ const grantsSql = (view: string, grants: Grant[]) =>
246
+ grants.filter((g) => g.privileges.length).map((g) => `grant ${g.privileges.join(', ')} on ${view} to ${g.grantee === 'PUBLIC' ? 'public' : pg.escapeIdentifier(g.grantee)}`);
247
+
248
+ /**
249
+ * The statements that (re)make a source's views: drop each, create it security_invoker, its INSTEAD OF trigger,
250
+ * a comment, and the table's grants (SELECT, INSERT, UPDATE, DELETE) on the view.
251
+ */
252
+ export function jsonSourceDdl(x: JsonSource, o: { app: string; grants: Grant[]; replace: boolean }): string[] {
253
+ const table = `${ident(x.schema)}.${ident(x.table)}`;
254
+ const keySel = x.key.map((k) => `t.${ident(k)}`).join(', ');
255
+ const doc = `t.${ident(x.column)}`;
256
+ const out: string[] = [];
257
+ const view = `${ident(x.schema)}.${ident(x.view)}`;
258
+ const arrays = x.arrays.map((a) => ({ a, view: `${ident(x.schema)}.${ident(a.view)}` }));
259
+ if (o.replace) for (const v of [...arrays.map((y) => y.view), view]) out.push(`drop view if exists ${v}`);
260
+ const cols = x.columns.map((c) => `${columnSql(doc, c)} as ${ident(c.name)}`);
261
+ out.push(`create view ${view} with (security_invoker = true) as\nselect ${[keySel, ...cols].join(',\n ')}\n from ${table} t`);
262
+ out.push(`comment on view ${view} is ${lit(`JSON source ${x.name} of application ${o.app}: ${x.schema}.${x.table}.${x.column} (made by pgkiln; Shared Components → JSON sources makes it again)`)}`);
263
+ out.push(`create trigger json_source_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_source_write(${triggerArg({
264
+ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, rows: !!x.rows,
265
+ columns: x.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
266
+ })})`);
267
+ out.push(...grantsSql(view, o.grants));
268
+ for (const { a, view: av } of arrays) {
269
+ const arr = `${doc} #> ${pathSql(a.path)}`;
270
+ const ecols = a.columns.map((c) => `${columnSql('e.v', c)} as ${ident(c.name)}`);
271
+ out.push(`create view ${av} with (security_invoker = true) as\nselect ${[keySel, `k.key as ${ROW_KEY}`, `k.idx + 1 as ${POSITION}`, ...ecols].join(',\n ')}\n from ${table} t\n cross join lateral meta.jsonb_element_keys(${arr}) k\n cross join lateral (select (${arr}) -> k.idx as v) e`);
272
+ out.push(`comment on view ${av} is ${lit(`JSON source ${x.name} of application ${o.app}: the array ${a.path.join('.')} of ${x.schema}.${x.table}.${x.column}, one row per element (made by pgkiln)`)}`);
273
+ out.push(`create trigger json_source_write\n instead of insert or update or delete on ${av}\n for each row execute function meta.json_array_write(${triggerArg({
274
+ name: x.name, schema: x.schema, table: x.table, key: x.key, column: x.column, array: a.path, row_key: ROW_KEY, position: POSITION,
275
+ columns: a.columns.map((c) => ({ name: c.name, path: c.path, ...(c.readonly ? { readonly: true } : {}) })),
276
+ })})`);
277
+ out.push(...grantsSql(av, o.grants));
278
+ }
279
+ return out;
280
+ }
281
+
282
+ export interface Child { name: string; schema: string; table: string; key: string[]; fk: [string, string][]; omit?: string[] }
283
+
284
+ /**
285
+ * A document view (APEX: JSON Duality View): the table's key and a jsonb document of the row with its children (a
286
+ * list per child table, by foreign key, without the foreign key columns). Writing the document updates the row,
287
+ * upserts the children it lists and deletes the ones it leaves out (meta.json_document_write).
288
+ */
289
+ export function documentViewDdl(o: { schema: string; table: string; key: string[]; view: string; document: string; children: Child[]; omit: string[]; grants: Grant[] }): string[] {
290
+ const table = `${ident(o.schema)}.${ident(o.table)}`;
291
+ const view = `${ident(o.schema)}.${ident(o.view)}`;
292
+ const omit = o.omit.map((c) => ` - ${lit(c)}`).join('');
293
+ const kids = o.children.map((c, i) => {
294
+ const alias = `c${i + 1}`;
295
+ const drop = [...c.fk.map(([col]) => col), ...(c.omit ?? [])].map((col) => ` - ${lit(col)}`).join('');
296
+ const on = c.fk.map(([col, parent]) => `${alias}.${ident(col)} = t.${ident(parent)}`).join(' and ');
297
+ const order = c.key.map((k) => `${alias}.${ident(k)}`).join(', ');
298
+ return `${lit(c.name)}, coalesce((select jsonb_agg(to_jsonb(${alias})${drop} order by ${order}) from ${ident(c.schema)}.${ident(c.table)} ${alias} where ${on}), '[]'::jsonb)`;
299
+ });
300
+ const docSql = kids.length ? `(to_jsonb(t)${omit}) || jsonb_build_object(\n ${kids.join(',\n ')})` : `to_jsonb(t)${omit}`;
301
+ return [
302
+ `create view ${view} with (security_invoker = true) as\nselect ${o.key.map((k) => `t.${ident(k)}`).join(', ')},\n ${docSql} as ${ident(o.document)}\n from ${table} t`,
303
+ `comment on view ${view} is ${lit(`Document view of ${o.schema}.${o.table}${o.children.length ? ` with ${o.children.map((c) => `${c.schema}.${c.table}`).join(', ')}` : ''} (made by pgkiln: SQL Workshop › Table Properties › Document view)`)}`,
304
+ `create trigger json_document_write\n instead of insert or update or delete on ${view}\n for each row execute function meta.json_document_write(${triggerArg({
305
+ schema: o.schema, table: o.table, key: o.key, document: o.document,
306
+ children: o.children.map((c) => ({ name: c.name, schema: c.schema, table: c.table, key: c.key, fk: c.fk })),
307
+ })})`,
308
+ ...grantsSql(view, o.grants),
309
+ ];
310
+ }
311
+
312
+ // ---------------------------------------------------------------- catalog helpers
313
+
314
+ type Q = { query: <T extends pg.QueryResultRow = any>(sql: string, params?: unknown[]) => Promise<pg.QueryResult<T>> };
315
+
316
+ /** The primary key columns of a table, in order (empty: none). */
317
+ export async function primaryKey(db: Q, schema: string, table: string): Promise<string[]> {
318
+ return (await db.query<{ n: string }>(
319
+ `select a.attname as n from pg_index i join pg_class c on c.oid = i.indrelid join pg_namespace s on s.oid = c.relnamespace
320
+ cross join lateral unnest(i.indkey::int2[]) with ordinality k(attnum, ord)
321
+ join pg_attribute a on a.attrelid = c.oid and a.attnum = k.attnum
322
+ where s.nspname = $1 and c.relname = $2 and i.indisprimary order by k.ord`, [schema, table])).rows.map((r) => r.n);
323
+ }
324
+
325
+ /** Who has SELECT, INSERT, UPDATE or DELETE on a table (PUBLIC included), for the same grants on its views. */
326
+ export async function tableGrants(db: Q, schema: string, table: string): Promise<Grant[]> {
327
+ const rows = (await db.query<{ grantee: string; privileges: string[] }>(
328
+ `select case when x.grantee = 0 then 'PUBLIC' else pg_get_userbyid(x.grantee) end as grantee,
329
+ array_agg(lower(x.privilege_type) order by x.privilege_type) as privileges
330
+ from pg_class c join pg_namespace s on s.oid = c.relnamespace, aclexplode(c.relacl) x
331
+ where s.nspname = $1 and c.relname = $2 and x.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') and x.grantee <> c.relowner
332
+ group by 1 order by 1`, [schema, table])).rows;
333
+ return rows;
334
+ }
335
+
336
+ /** A sample of a column's documents (objects only), for proposing a profile. */
337
+ export async function sampleDocuments(db: Q, schema: string, table: string, column: string, limit = 200): Promise<unknown[]> {
338
+ return (await db.query<{ d: unknown }>(
339
+ `select ${ident(column)} as d from ${ident(schema)}.${ident(table)} where jsonb_typeof(${ident(column)}::jsonb) = 'object' limit ${Math.max(1, Math.min(1000, limit | 0))}`)).rows.map((r) => r.d);
340
+ }
341
+
342
+ /** The child tables of a table: foreign keys that reference it, with the child's primary key. */
343
+ export async function childTables(db: Q, schema: string, table: string): Promise<Child[]> {
344
+ const rows = (await db.query<{ schema: string; table: string; fk: [string, string][] }>(
345
+ `select cs.nspname as schema, cc.relname as table,
346
+ array(select array[ca.attname::text, pa.attname::text]
347
+ from unnest(f.conkey, f.confkey) with ordinality u(ck, pk, ord)
348
+ join pg_attribute ca on ca.attrelid = f.conrelid and ca.attnum = u.ck
349
+ join pg_attribute pa on pa.attrelid = f.confrelid and pa.attnum = u.pk order by u.ord) as fk
350
+ from pg_constraint f join pg_class pc on pc.oid = f.confrelid join pg_namespace ps on ps.oid = pc.relnamespace
351
+ join pg_class cc on cc.oid = f.conrelid join pg_namespace cs on cs.oid = cc.relnamespace
352
+ where f.contype = 'f' and ps.nspname = $1 and pc.relname = $2 and f.conrelid <> f.confrelid
353
+ order by cs.nspname, cc.relname, f.conname`, [schema, table])).rows;
354
+ const out: Child[] = [];
355
+ const names = new Set<string>();
356
+ for (const r of rows) {
357
+ const key = await primaryKey(db, r.schema, r.table);
358
+ if (!key.length) continue;
359
+ let name = r.table;
360
+ for (let i = 2; names.has(name); i++) name = `${r.table}_${i}`;
361
+ names.add(name);
362
+ out.push({ name, schema: r.schema, table: r.table, key, fk: r.fk });
363
+ }
364
+ return out;
365
+ }
@@ -1,3 +1,4 @@
1
+ import { withJsonColumns } from '../json-sources.ts';
1
2
  import { componentSetting } from './component-settings.ts';
2
3
  import { icon } from '../icons.ts';
3
4
  import pg from 'pg';
@@ -399,7 +400,8 @@ export const q = (col: string) => `"__q".${pg.escapeIdentifier(col)}`;
399
400
  * A computation that doesn't parse (or whose name is taken) is left out and
400
401
  * reported, so the report keeps working and the user can remove it.
401
402
  */
402
- export async function withComputations(ctx: PageContext, r: Region, src: string, st: ReportState) {
403
+ export async function withComputations(ctx: PageContext, r: Region, src0: string, st: ReportState) {
404
+ const src = withJsonColumns(r, src0);
403
405
  if (!st.computations.length) return { src, errors: [] as { c: Computation; message: string }[] };
404
406
  const columns = await columnsOf(ctx, src);
405
407
  // a computation may only use the shown columns: a hidden one would be shown through it