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.
- package/db/migrations/142_json_sources.sql +426 -0
- package/docs/apex-feature-parity.md +7 -6
- package/docs/guide/03-builder.md +15 -0
- package/docs/guide/04-pages-and-regions.md +24 -2
- package/docs/guide/12-development.md +11 -0
- package/docs/guide/19-rest-data-sources.md +108 -1
- package/examples/hr/hr_77_json_sources.sql +174 -0
- package/package.json +1 -1
- package/public/app.css +18 -0
- package/public/app.js +141 -0
- package/src/app.ts +5 -0
- package/src/builder/document-view.ts +117 -0
- package/src/builder/i18n/de.ts +1 -0
- package/src/builder/i18n/en.ts +1 -0
- package/src/builder/i18n/es.ts +1 -0
- package/src/builder/i18n/fr.ts +1 -0
- package/src/builder/i18n/it.ts +1 -0
- package/src/builder/i18n/ja.ts +1 -0
- package/src/builder/i18n/ko.ts +1 -0
- package/src/builder/i18n/nl.ts +1 -0
- package/src/builder/i18n/pt.ts +1 -0
- package/src/builder/i18n/zh-cn.ts +1 -0
- package/src/builder/i18n/zh-tw.ts +1 -0
- package/src/builder/json-sources.ts +395 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/report-settings.ts +9 -0
- package/src/builder/shared.ts +1 -1
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/websources.ts +6 -3
- package/src/i18n/ar.ts +24 -0
- package/src/i18n/cs.ts +24 -0
- package/src/i18n/da.ts +24 -0
- package/src/i18n/de.ts +24 -0
- package/src/i18n/el.ts +24 -0
- package/src/i18n/es.ts +24 -0
- package/src/i18n/fi.ts +24 -0
- package/src/i18n/fr.ts +24 -0
- package/src/i18n/he.ts +24 -0
- package/src/i18n/it.ts +24 -0
- package/src/i18n/ja.ts +24 -0
- package/src/i18n/ko.ts +24 -0
- package/src/i18n/nb.ts +24 -0
- package/src/i18n/pl.ts +24 -0
- package/src/i18n/pt.ts +24 -0
- package/src/i18n/ru.ts +24 -0
- package/src/i18n/sv.ts +24 -0
- package/src/i18n/tr.ts +24 -0
- package/src/i18n/uk.ts +24 -0
- package/src/i18n/zh.ts +24 -0
- package/src/i18n.ts +48 -0
- package/src/json-sources.ts +365 -0
- package/src/runtime/ai-filter.ts +11 -6
- package/src/runtime/ir-plan.ts +3 -3
- package/src/runtime/pdf.ts +6 -6
- package/src/runtime/render.ts +1 -1
- 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
|
+
}
|
package/src/runtime/ai-filter.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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);
|
package/src/runtime/ir-plan.ts
CHANGED
|
@@ -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' &&
|
|
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),
|
package/src/runtime/pdf.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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) =>
|
|
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)));
|
package/src/runtime/render.ts
CHANGED
|
@@ -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)]));
|