pgkiln 0.74.0 → 0.76.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 (56) hide show
  1. package/db/migrations/142_json_sources.sql +426 -0
  2. package/docs/apex-feature-parity.md +7 -6
  3. package/docs/guide/03-builder.md +15 -0
  4. package/docs/guide/04-pages-and-regions.md +24 -2
  5. package/docs/guide/12-development.md +11 -0
  6. package/docs/guide/19-rest-data-sources.md +108 -1
  7. package/examples/hr/hr_77_json_sources.sql +174 -0
  8. package/package.json +1 -1
  9. package/public/app.css +18 -0
  10. package/public/app.js +141 -0
  11. package/src/app.ts +5 -0
  12. package/src/builder/document-view.ts +117 -0
  13. package/src/builder/i18n/de.ts +1 -0
  14. package/src/builder/i18n/en.ts +1 -0
  15. package/src/builder/i18n/es.ts +1 -0
  16. package/src/builder/i18n/fr.ts +1 -0
  17. package/src/builder/i18n/it.ts +1 -0
  18. package/src/builder/i18n/ja.ts +1 -0
  19. package/src/builder/i18n/ko.ts +1 -0
  20. package/src/builder/i18n/nl.ts +1 -0
  21. package/src/builder/i18n/pt.ts +1 -0
  22. package/src/builder/i18n/zh-cn.ts +1 -0
  23. package/src/builder/i18n/zh-tw.ts +1 -0
  24. package/src/builder/json-sources.ts +395 -0
  25. package/src/builder/object-actions.ts +1 -0
  26. package/src/builder/report-settings.ts +9 -0
  27. package/src/builder/shared.ts +1 -1
  28. package/src/builder/table-editor.ts +1 -0
  29. package/src/builder/websources.ts +6 -3
  30. package/src/i18n/ar.ts +24 -0
  31. package/src/i18n/cs.ts +24 -0
  32. package/src/i18n/da.ts +24 -0
  33. package/src/i18n/de.ts +24 -0
  34. package/src/i18n/el.ts +24 -0
  35. package/src/i18n/es.ts +24 -0
  36. package/src/i18n/fi.ts +24 -0
  37. package/src/i18n/fr.ts +24 -0
  38. package/src/i18n/he.ts +24 -0
  39. package/src/i18n/it.ts +24 -0
  40. package/src/i18n/ja.ts +24 -0
  41. package/src/i18n/ko.ts +24 -0
  42. package/src/i18n/nb.ts +24 -0
  43. package/src/i18n/pl.ts +24 -0
  44. package/src/i18n/pt.ts +24 -0
  45. package/src/i18n/ru.ts +24 -0
  46. package/src/i18n/sv.ts +24 -0
  47. package/src/i18n/tr.ts +24 -0
  48. package/src/i18n/uk.ts +24 -0
  49. package/src/i18n/zh.ts +24 -0
  50. package/src/i18n.ts +48 -0
  51. package/src/json-sources.ts +365 -0
  52. package/src/runtime/ai-filter.ts +11 -6
  53. package/src/runtime/ir-plan.ts +3 -3
  54. package/src/runtime/pdf.ts +6 -6
  55. package/src/runtime/render.ts +1 -1
  56. package/src/runtime/report.ts +211 -26
@@ -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
+ }
@@ -13,7 +13,7 @@ import { applyPlan, checkPlan, chipsOf, ChipRefused, editChip, planIsEmpty, remo
13
13
  import { parseQuestion, type Lookup } from './ir-phrases.ts';
14
14
  import { applyFacetSettings, clearFacets, facetSettings } from './ask-facets.ts';
15
15
  import { describeFacetFilter, reportFacetDefs, type FacetFilter } from './facet-state.ts';
16
- import { AGGREGATES, fieldsOf, headingOf, HIGHLIGHT_COLORS, isNumeric, opLabel, OPERATORS, q, reportState, visibleColumns, withComputations } from './report.ts';
16
+ import { AGGREGATES, describeFilter, fieldsOf, listValues, rangeValues, headingOf, HIGHLIGHT_COLORS, HIGHLIGHT_OPERATORS, isNumeric, opLabel, OPERATORS, PERIODS, q, reportState, visibleColumns, withComputations } from './report.ts';
17
17
  import { resolveRestRegion } from './rest-sources.ts';
18
18
  import { loadContext, simplePage, txContext, type Req } from './routes.ts';
19
19
 
@@ -152,7 +152,7 @@ export function planSchema(columns: Column[]) {
152
152
  properties: {
153
153
  column: { type: 'string', enum: names },
154
154
  operator: { type: 'string', enum: ops },
155
- value: { type: 'string', description: 'The value to compare with (a date as YYYY-MM-DD, a number without thousands separators); empty for null and not_null.' },
155
+ value: { type: 'string', description: `The value to compare with (a date as YYYY-MM-DD, a number without thousands separators); empty for null and not_null; for in and not_in the values separated by commas; for between "from,to" (either may be empty); for period one of ${Object.keys(PERIODS).join(', ')} (date columns only).` },
156
156
  },
157
157
  required: ['column', 'operator', 'value'],
158
158
  additionalProperties: false,
@@ -163,7 +163,7 @@ export function planSchema(columns: Column[]) {
163
163
  description: 'Rows to color (not to filter), at most 5.',
164
164
  items: {
165
165
  type: 'object',
166
- properties: { column: { type: 'string', enum: names }, operator: { type: 'string', enum: ops }, value: { type: 'string' }, color: { type: 'string', enum: HIGHLIGHT_COLORS } },
166
+ properties: { column: { type: 'string', enum: names }, operator: { type: 'string', enum: HIGHLIGHT_OPERATORS }, value: { type: 'string' }, color: { type: 'string', enum: HIGHLIGHT_COLORS } },
167
167
  required: ['column', 'operator', 'value', 'color'],
168
168
  additionalProperties: false,
169
169
  },
@@ -246,7 +246,7 @@ const pageFacets = (ctx: PageContext, rid: number) => reportFacetDefs(ctx.page.r
246
246
  function describePlan(p: Plan, t: T, label: (n: string) => string, facets: FacetFilter[] = []) {
247
247
  return [
248
248
  ...(p.reset ? [t('report.reset')] : []),
249
- ...p.filters.map((f) => `${label(f.column)} ${opLabel(t, f.operator)}${f.value ? ` ${f.value}` : ''}`),
249
+ ...p.filters.map((f) => describeFilter(t, label(f.column), f.operator, f.value)),
250
250
  ...facets.map((f) => describeFacetFilter(f, label(f.column), t)),
251
251
  ...(p.search ? [`"${p.search}"`] : []),
252
252
  ...(p.sort ? [t('ai_filter.sort', { column: `${p.sort.label}${p.desc ? ' ↓' : ''}` })] : []),
@@ -261,9 +261,14 @@ function describePlan(p: Plan, t: T, label: (n: string) => string, facets: Facet
261
261
  ].join('; ');
262
262
  }
263
263
 
264
+ /** (143) a filter's value as its Edit field shows it: a list's values and a range's bounds separated by commas */
265
+ const editValue = (op: string, v: string) =>
266
+ OPERATORS[op]?.kind === 'list' ? listValues(v).join(', ') : OPERATORS[op]?.kind === 'range' ? rangeValues(v).join(', ') : v;
267
+
264
268
  /** A chip's text (plain: it is escaped where it is used). */
265
269
  function chipText(chip: Chip, t: T, label: (n: string) => string, byPos: (pos: number) => string) {
266
- const cond = (column: string, op: string, value: string) => `${label(column)} ${opLabel(t, op)}${OPERATORS[op]?.noValue ? '' : ` ${value}`}`;
270
+ // (143) a list's values, a range's bounds and a period in words
271
+ const cond = (column: string, op: string, value: string) => describeFilter(t, label(column), op, value);
267
272
  switch (chip.kind) {
268
273
  case 'search': return `${t('report.search_chip')}${chip.column ? ` ${label(chip.column)}:` : ''} ${chip.value}`;
269
274
  case 'filter': return cond(chip.column, chip.op, chip.value);
@@ -332,7 +337,7 @@ export async function renderAiFilter(ctx: PageContext, r: Region): Promise<Raw |
332
337
  if (!columns.length) return '';
333
338
  switch (ch.kind) {
334
339
  case 'search': return input(i, 'a', t('report.search'), ch.value);
335
- case 'filter': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), ch.value)}`;
340
+ case 'filter': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), editValue(ch.op, ch.value))}`;
336
341
  case 'highlight': return html`${colSelect(i, 'a', t('report.column'), ch.column)}${ops(i, ch.op)}${input(i, 'c', t('report.value'), ch.value)}${listSelect(i, 'd', t('report.color'), ch.color, HIGHLIGHT_COLORS.map((x) => [x, t(`color.${x}`)]))}`;
337
342
  case 'sort': return html`${colSelect(i, 'a', t('report.column'), columns.find((x) => x.pos === ch.pos)?.name ?? '', (x) => x.pos > 0)}${listSelect(i, 'b', t('report.sort'), ch.desc ? 'desc' : 'asc', [['asc', t('report.sort_asc')], ['desc', t('report.sort_desc')]])}`;
338
343
  case 'break': case 'group': return colSelect(i, 'a', t('report.column'), ch.column);
@@ -1,7 +1,7 @@
1
1
  import { REPORT_CHART_KINDS } from './charts.ts';
2
2
  import { ComputeError, computeNameOk, computeSql } from './compute.ts';
3
3
  import { facetFilters, facetKeys, facetParamNames, type FacetDef } from './facet-state.ts';
4
- import { AGGREGATES, HIGHLIGHT_COLORS, MAX_COMPUTATIONS, OPERATORS } from './report.ts';
4
+ import { AGGREGATES, filterOk, HIGHLIGHT_COLORS, HIGHLIGHT_OPERATORS, MAX_COMPUTATIONS, OPERATORS } from './report.ts';
5
5
 
6
6
  // (sprint 112) What "Ask in your own words" may do to an interactive report
7
7
  // (APEX 26.1/26.2: AI interactive reports). An answer, from the AI service's
@@ -122,9 +122,9 @@ export function checkPlan(json: unknown, columns: Column[], fieldCount = columns
122
122
  .filter((f) => col(f.column) && typeof f.operator === 'string' && Object.hasOwn(OPERATORS, f.operator) && typeof f.value === 'string')
123
123
  .slice(0, MAX_FILTERS)
124
124
  .map((f) => ({ column: f.column as string, operator: f.operator as string, value: OPERATORS[f.operator as string].noValue ? '' : text(f.value) }))
125
- .filter((f) => OPERATORS[f.operator].noValue || f.value !== '');
125
+ .filter((f) => OPERATORS[f.operator].noValue || (f.value !== '' && filterOk(f.operator, f.value)));
126
126
  const highlights = list<{ column: unknown; operator: unknown; value: unknown; color: unknown }>(a.highlights)
127
- .filter((h) => col(h.column) && typeof h.operator === 'string' && Object.hasOwn(OPERATORS, h.operator) && typeof h.value === 'string')
127
+ .filter((h) => col(h.column) && typeof h.operator === 'string' && HIGHLIGHT_OPERATORS.includes(h.operator) && typeof h.value === 'string')
128
128
  .slice(0, MAX_HIGHLIGHTS)
129
129
  .map((h) => ({
130
130
  column: h.column as string, operator: h.operator as string, value: OPERATORS[h.operator as string].noValue ? '' : text(h.value),
@@ -6,7 +6,7 @@ import type { Region } from '../metadata.ts';
6
6
  import { runtime, savepoint } from '../db.ts';
7
7
  import { substitute, type PageContext } from './context.ts';
8
8
  import { describeFacetFilter, facetFilters, reportFacetDefs } from './facet-state.ts';
9
- import { buildSql, cell, columnFormats, headingOf, isNumeric, maxRows, OPERATORS, PDF_MAX_ROWS, reportState, visibleColumns } from './report.ts';
9
+ import { buildSql, cell, columnFormats, describeFilter, headingOf, isNumeric, maxRows, orderedColumns, PDF_MAX_ROWS, reportState } from './report.ts';
10
10
 
11
11
  // Report printing: Actions → Download PDF. The same query, filters, sort
12
12
  // and visibility as the report on screen (and as the CSV download), drawn
@@ -134,10 +134,10 @@ export function layoutText(text: string, extra: Record<string, string>, ctx?: Pa
134
134
  return ctx ? substitute(replaced, ctx, (v) => v) : replaced;
135
135
  }
136
136
 
137
- /** The columns a report prints, in order: its "pdf.columns", or those on screen. */
138
- function printColumns(r: Region, fields: pg.FieldDef[]) {
137
+ /** The columns a report prints, in order: its "pdf.columns", or those on screen (143: in the user's order, without the ones they hid). */
138
+ function printColumns(r: Region, fields: pg.FieldDef[], st: { hideCols: string[]; order: string[] }) {
139
139
  const wanted = (r.config.pdf as RegionPdf | undefined)?.columns;
140
- if (!Array.isArray(wanted) || !wanted.length) return visibleColumns(r, fields);
140
+ if (!Array.isArray(wanted) || !wanted.length) return orderedColumns(r, fields, st);
141
141
  const byName = new Map(fields.map((f, i) => [f.name.toLowerCase(), { f, i }]));
142
142
  return wanted.flatMap((n) => {
143
143
  const c = typeof n === 'string' ? byName.get(n.toLowerCase()) : undefined;
@@ -158,7 +158,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
158
158
  await savepoint(c, () => c.query({ text: `declare ${cursor} no scroll cursor for ${query.text}`, values: query.values }));
159
159
  const fetch = () => savepoint(c, () => c.query({ text: `fetch ${PDF_BATCH} from ${cursor}`, rowMode: 'array' }));
160
160
  const first = await fetch();
161
- const cols = printColumns(r, first.fields);
161
+ const cols = printColumns(r, first.fields, st);
162
162
  const limit = Math.min(PDF_MAX_ROWS, maxRows(r, null) ?? PDF_MAX_ROWS);
163
163
  let truncated = false;
164
164
  const fmtOf = columnFormats(ctx, r);
@@ -190,7 +190,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
190
190
  const title = text(layout.title ?? '&REPORT_TITLE.');
191
191
  const filters = layout.show_filters
192
192
  ? [
193
- ...st.filters.map((f) => `${headingOf(r, f.column, ctx.locale.tr)} ${OPERATORS[f.op]?.label ?? f.op} ${OPERATORS[f.op]?.noValue ? '' : f.value}`.trim()),
193
+ ...st.filters.map((f) => describeFilter(t, headingOf(r, f.column, ctx.locale.tr), f.op, f.value)),
194
194
  ...(() => {
195
195
  const defs = reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions);
196
196
  return facetFilters(ctx.params, r.id, defs, undefined, appDateReader(ctx)).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
@@ -327,7 +327,7 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
327
327
  'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied', 'da.wait',
328
328
  'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
329
329
  // (sprint 76) a report column heading's menu
330
- 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
330
+ 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values', 'report.moved',
331
331
  // (sprint 84) the date picker
332
332
  'item.choose_date', 'calendar.previous', 'calendar.next', 'calendar.today', 'facets.clear_short'] as const;
333
333
  export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));