pgkiln 0.70.0 → 0.72.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 (65) hide show
  1. package/db/migrations/137_create_page_types.sql +904 -0
  2. package/db/migrations/138_app_utilities.sql +153 -0
  3. package/db/migrations/139_opentelemetry.sql +36 -0
  4. package/docs/apex-feature-parity.md +9 -7
  5. package/docs/guide/01-installation.md +108 -0
  6. package/docs/guide/03-builder.md +116 -4
  7. package/docs/guide/04-pages-and-regions.md +3 -1
  8. package/docs/guide/05-items.md +1 -1
  9. package/docs/guide/06-processing.md +18 -0
  10. package/docs/guide/09-reference.md +18 -1
  11. package/docs/guide/12-development.md +35 -1
  12. package/docs/guide/16-files.md +1 -1
  13. package/examples/hr/hr_73_configuration_options.sql +17 -0
  14. package/examples/hr/hr_74_app_utilities.sql +22 -0
  15. package/package.json +1 -1
  16. package/public/app.css +9 -0
  17. package/public/app.js +50 -0
  18. package/public/builder.css +48 -0
  19. package/src/ai/service.ts +14 -0
  20. package/src/app.ts +20 -0
  21. package/src/builder/advisor.ts +2 -2
  22. package/src/builder/app-updates.ts +3 -0
  23. package/src/builder/components.ts +3 -1
  24. package/src/builder/create-page.ts +259 -0
  25. package/src/builder/dependencies.ts +311 -0
  26. package/src/builder/features.ts +313 -0
  27. package/src/builder/home.ts +3 -0
  28. package/src/builder/i18n/de.ts +6 -0
  29. package/src/builder/i18n/en.ts +6 -0
  30. package/src/builder/i18n/es.ts +6 -0
  31. package/src/builder/i18n/fr.ts +6 -0
  32. package/src/builder/i18n/it.ts +6 -0
  33. package/src/builder/i18n/ja.ts +6 -0
  34. package/src/builder/i18n/ko.ts +6 -0
  35. package/src/builder/i18n/nl.ts +6 -0
  36. package/src/builder/i18n/pt.ts +6 -0
  37. package/src/builder/i18n/zh-cn.ts +6 -0
  38. package/src/builder/i18n/zh-tw.ts +6 -0
  39. package/src/builder/login-page.ts +17 -14
  40. package/src/builder/newapp.ts +41 -23
  41. package/src/builder/routes.ts +13 -2
  42. package/src/builder/telemetry.ts +206 -0
  43. package/src/builder/ui-defaults.ts +161 -0
  44. package/src/builder/ui.ts +2 -1
  45. package/src/builder/upgrade.ts +154 -0
  46. package/src/builder/utilities.ts +181 -0
  47. package/src/builder/wizards.ts +211 -26
  48. package/src/builder/workspaces.ts +2 -1
  49. package/src/cli/replace.ts +9 -1
  50. package/src/history.ts +155 -0
  51. package/src/instance-checks.ts +11 -4
  52. package/src/layout.ts +2 -1
  53. package/src/mail/mail.ts +14 -0
  54. package/src/runtime/ai-agents.ts +9 -0
  55. package/src/runtime/builtin-components.ts +23 -2
  56. package/src/runtime/items.ts +4 -2
  57. package/src/runtime/routes.ts +3 -0
  58. package/src/session.ts +26 -0
  59. package/src/telemetry/browser.ts +91 -0
  60. package/src/telemetry/config.ts +342 -0
  61. package/src/telemetry/export.ts +484 -0
  62. package/src/telemetry/http.ts +126 -0
  63. package/src/telemetry/trace.ts +456 -0
  64. package/src/webclient.ts +25 -0
  65. package/src/workflow.ts +14 -7
@@ -0,0 +1,904 @@
1
+ -- =====================================================================
2
+ -- 137: Create Page like APEX's (sprint 137, parity card AP-3)
3
+ --
4
+ -- 1. More page types in meta.generate_page (the Create Page grid's
5
+ -- Component tab; APEX: Create a Page → Component):
6
+ -- - on a table or view: report (an interactive report alone), smart_filters
7
+ -- (a report with smart filters above it), tree (id / parent / label, the
8
+ -- parent proposed from a self-referencing foreign key), timeline,
9
+ -- media_list, content_row, comments (the built-in template components
10
+ -- ut_timeline, ut_media_list, ut_content_row, ut_comments), data_load (a
11
+ -- data load definition and a page with a file item, a Load button and a
12
+ -- data_load process) and wizard (a page per step with a wizard progress
13
+ -- list, Previous / Next and an INSERT on Finish);
14
+ -- - without a table: search (a search region over chosen search
15
+ -- configurations, or a new configuration on a table), tasks (a unified
16
+ -- task list) and workflows (the workflow console).
17
+ -- The page types of 047–100 are unchanged: the new meta.generate_page hands
18
+ -- them to the former definition, renamed meta.generate_page_100;
19
+ -- meta.wizard_defaults likewise (meta.wizard_defaults_097). A dashboard is
20
+ -- made by the builder (it reads the data to choose its charts).
21
+ --
22
+ -- 2. Configuration options (the Configuration Options feature): build options
23
+ -- marked configurable (meta.build_option.configurable, exported with the
24
+ -- application) can be switched by the application's administrators at
25
+ -- run time: meta.configurable_options() and
26
+ -- meta.set_configurable_options(names), security definer, only as the
27
+ -- application's own role, for a user with the admin role, and only for
28
+ -- configurable options.
29
+ --
30
+ -- 3. E-mail reporting (the Email Reporting feature):
31
+ -- meta.send_activity_report(to, days) queues a summary of the current
32
+ -- application's activity with the rows as a CSV attachment; for the
33
+ -- application's automations and its administrators.
34
+ --
35
+ -- 4. Create page as copy: meta.page_shared_refs(page id, target app) lists the
36
+ -- shared components a page refers to by name; meta.copy_page(...) copies a
37
+ -- page (regions, items, buttons, dynamic actions, validations, processes,
38
+ -- computations, branches) into the same or another application under a new
39
+ -- number, renaming P<old>_ items to P<new>_, with the shared components
40
+ -- mapped to the target's or copied. Owner only (the builder checks the
41
+ -- developer's workspaces).
42
+ -- =====================================================================
43
+
44
+ -- ---------------------------------------------------------------- 1. page types
45
+ alter function meta.generate_page(text, text, regclass, int, jsonb) rename to generate_page_100;
46
+ alter function meta.wizard_defaults(text, regclass) rename to wizard_defaults_097;
47
+
48
+ -- the application's own data: not pgkiln's or the system's tables (as 047's generator checks)
49
+ create function meta.wizard_check_table(p_table regclass) returns void
50
+ language plpgsql stable as $$
51
+ begin
52
+ if p_table is null then
53
+ raise exception 'choose a table or view';
54
+ end if;
55
+ if (select n.nspname ~ '^pg_' or n.nspname in ('information_schema', 'meta')
56
+ from pg_class rel join pg_namespace n on n.oid = rel.relnamespace where rel.oid = p_table) then
57
+ raise exception 'pages can''t be generated on %', p_table;
58
+ end if;
59
+ if (select relkind from pg_class where oid = p_table) not in ('r', 'p', 'v', 'm') then
60
+ raise exception '% is not a table or view', p_table;
61
+ end if;
62
+ end
63
+ $$;
64
+
65
+ create function meta.wizard_defaults(p_kind text, p_table regclass) returns jsonb
66
+ language plpgsql stable as $$
67
+ declare
68
+ v_pk text;
69
+ v_display text;
70
+ v_skip text[];
71
+ v_fk text;
72
+ v_date text;
73
+ v_text text;
74
+ v_n int;
75
+ v_out jsonb;
76
+ begin
77
+ if p_kind in ('report_form', 'grid', 'form', 'cards', 'calendar', 'chart', 'map', 'facets', 'master_detail') then
78
+ return meta.wizard_defaults_097(p_kind, p_table);
79
+ end if;
80
+ if p_kind in ('search', 'tasks', 'workflows', 'dashboard') then
81
+ v_out := jsonb_build_object('nav', true,
82
+ 'label', case p_kind when 'search' then 'Search' when 'tasks' then 'My Tasks' when 'workflows' then 'Workflows' else 'Dashboard' end,
83
+ 'icon', case p_kind when 'search' then 'search' when 'tasks' then 'check-circle' when 'workflows' then 'route' else 'dashboard' end);
84
+ return v_out || case p_kind
85
+ when 'search' then jsonb_build_object('configs', '[]'::jsonb, 'form_page', null)
86
+ when 'tasks' then jsonb_build_object('context', 'my', 'completed', false)
87
+ when 'workflows' then jsonb_build_object('context', 'initiator')
88
+ else '{}'::jsonb end;
89
+ end if;
90
+ if p_kind not in ('report', 'smart_filters', 'tree', 'timeline', 'media_list', 'content_row', 'comments', 'data_load', 'wizard') then
91
+ raise exception 'unknown page type "%"', p_kind;
92
+ end if;
93
+ perform meta.wizard_check_table(p_table);
94
+ v_pk := meta.wizard_pk(p_table);
95
+ v_display := meta.wizard_display(p_table);
96
+ v_skip := meta.wizard_sidecars(p_table);
97
+ -- the first foreign key to another table
98
+ select column_name into v_fk from meta.wizard_catalog(p_table)
99
+ where fk_table is not null and not is_pk and fk_table <> p_table order by ordinal limit 1;
100
+ -- a date: one named like a date or a time of an event first
101
+ select column_name into v_date from meta.wizard_catalog(p_table)
102
+ where kind in ('date', 'timestamp') and not (column_name = any (v_skip))
103
+ order by (column_name ~ '(date|_at$|_on$|when|time|created|posted)') desc, ordinal limit 1;
104
+ -- a longer text: a comment, body, note or description first
105
+ select column_name into v_text from meta.wizard_catalog(p_table)
106
+ where kind = 'text' and not is_pk and fk_table is null and column_name is distinct from v_display and not (column_name = any (v_skip))
107
+ order by (column_name ~ '(comment|body|text|note|descr|summary|message|remark|detail)') desc, ordinal limit 1;
108
+
109
+ v_out := jsonb_build_object('nav', p_kind <> 'wizard',
110
+ 'label', case p_kind
111
+ when 'wizard' then 'Create ' || meta.wizard_table_label(p_table, false)
112
+ when 'data_load' then 'Load ' || meta.wizard_table_label(p_table)
113
+ else meta.wizard_table_label(p_table) end,
114
+ 'icon', case p_kind when 'smart_filters' then 'filter' when 'tree' then 'org' when 'timeline' then 'clock'
115
+ when 'media_list' then 'image' when 'content_row' then 'list' when 'comments' then 'comments'
116
+ when 'data_load' then 'upload' when 'wizard' then 'file' else 'table' end);
117
+ case p_kind
118
+ when 'report' then
119
+ v_out := v_out || jsonb_build_object('form_page', null);
120
+ when 'smart_filters' then
121
+ v_out := v_out || jsonb_build_object('form_page', null, 'facets', coalesce(meta.wizard_defaults_097('facets', p_table) -> 'facets', '[]'));
122
+ when 'tree' then
123
+ v_out := v_out || jsonb_build_object('form_page', null, 'expanded', 1,
124
+ 'id_column', v_pk,
125
+ -- the parent: a column with a foreign key to the table itself (mgr → empno)
126
+ 'parent_column', (select column_name from meta.wizard_catalog(p_table) where fk_table = p_table and not is_pk order by ordinal limit 1),
127
+ 'label_column', coalesce(v_display, v_pk));
128
+ when 'timeline' then
129
+ v_out := v_out || jsonb_build_object('form_page', null, 'title', coalesce(v_display, v_pk), 'when', v_date,
130
+ 'who', coalesce((select column_name from meta.wizard_catalog(p_table)
131
+ where kind = 'text' and column_name ~ '(user|author|by$|owner|who|person)' and not (column_name = any (v_skip))
132
+ order by ordinal limit 1), v_fk),
133
+ 'body', v_text);
134
+ when 'media_list' then
135
+ v_out := v_out || jsonb_build_object('form_page', null, 'title', coalesce(v_display, v_pk), 'description', coalesce(v_fk, v_text), 'badge', null);
136
+ when 'content_row' then
137
+ v_out := v_out || jsonb_build_object('form_page', null, 'title', coalesce(v_display, v_pk), 'description', coalesce(v_text, v_fk),
138
+ 'misc', coalesce(v_date, case when v_text is not null then v_fk end), 'badge', null);
139
+ when 'comments' then
140
+ v_out := v_out || jsonb_build_object(
141
+ 'user', coalesce((select column_name from meta.wizard_catalog(p_table)
142
+ where (kind = 'text' or fk_table is not null) and column_name ~ '(user|author|by$|owner|who|person|name)'
143
+ and not is_pk and not (column_name = any (v_skip)) order by (fk_table is not null) desc, ordinal limit 1), v_fk, v_display),
144
+ 'date', v_date, 'comment', coalesce(v_text, v_display));
145
+ when 'data_load' then
146
+ v_out := v_out || jsonb_build_object('mode', case when v_pk is not null then 'merge' else 'append' end, 'skip_errors', false,
147
+ 'definition', left(upper(regexp_replace((select relname from pg_class where oid = p_table), '[^A-Za-z0-9_]', '_', 'g')), 55) || '_LOAD');
148
+ when 'wizard' then
149
+ select count(*) into v_n from meta.wizard_catalog(p_table)
150
+ where not generated and not is_pk and kind not in ('binary', 'geometry') and not (column_name = any (v_skip));
151
+ v_out := v_out || jsonb_build_object('return_page', null,
152
+ 'columns', (select coalesce(jsonb_agg(column_name order by ordinal), '[]') from meta.wizard_catalog(p_table)
153
+ where not generated and not is_pk and kind not in ('binary', 'geometry') and not (column_name = any (v_skip))),
154
+ 'steps', least(greatest(ceil(v_n / 4.0)::int, 2), 5));
155
+ end case;
156
+ return v_out;
157
+ end
158
+ $$;
159
+
160
+ -- the steps of a wizard page type: a page per step with a wizard progress list, the step's fields,
161
+ -- Cancel / Previous / Next, and on the last one Finish with an INSERT of all steps' values
162
+ create function meta.wizard_steps(p_app meta.app, p_table regclass, p_page int, p_label text, o jsonb) returns int
163
+ language plpgsql as $$
164
+ declare
165
+ v_skip text[] := meta.wizard_sidecars(p_table);
166
+ v_return int := coalesce(nullif(o ->> 'return_page', '')::int, p_app.home_page, p_page);
167
+ v_cols text[];
168
+ v_steps int;
169
+ v_list text := 'WIZARD_' || p_page;
170
+ v_first int;
171
+ v_page int;
172
+ v_region int;
173
+ v_seq int;
174
+ v_type text;
175
+ v_lov text;
176
+ v_names text := '';
177
+ v_values text := '';
178
+ i int;
179
+ c record;
180
+ begin
181
+ -- the chosen columns, and every column a row can't be saved without
182
+ select array_agg(w.column_name order by w.ordinal) into v_cols
183
+ from meta.wizard_catalog(p_table) w
184
+ where not w.generated and w.kind not in ('binary', 'geometry') and not (w.column_name = any (v_skip))
185
+ and (w.column_name in (select meta.wizard_col(p_table, x, 'wizard column') from jsonb_array_elements_text(coalesce(o -> 'columns', '[]')) x)
186
+ or (w.not_null and not w.has_default));
187
+ if v_cols is null then
188
+ raise exception 'choose the columns of the wizard';
189
+ end if;
190
+ v_steps := least(greatest(coalesce(nullif(o ->> 'steps', '')::int, 2), 1), 10, cardinality(v_cols));
191
+ for i in 0 .. v_steps - 1 loop
192
+ if exists (select 1 from meta.page where app_id = p_app.id and page_no = p_page + i) then
193
+ raise exception 'page % already exists (the wizard needs pages % to %)', p_page + i, p_page, p_page + v_steps - 1;
194
+ end if;
195
+ end loop;
196
+ if exists (select 1 from meta.list where app_id = p_app.id and name = v_list) then
197
+ raise exception 'the list % exists already', v_list;
198
+ end if;
199
+ insert into meta.list (app_id, name, type, description) values (p_app.id, v_list, 'static', 'The steps of the wizard on pages ' || p_page || '–' || (p_page + v_steps - 1));
200
+ for i in 0 .. v_steps - 1 loop
201
+ insert into meta.list_entry (app_id, list_name, seq, label, target_page) values (p_app.id, v_list, (i + 1) * 10, 'Step ' || (i + 1), p_page + i);
202
+ end loop;
203
+
204
+ for i in 0 .. v_steps - 1 loop
205
+ v_page := meta.wizard_new_page(p_app, p_page + i, p_label || case when v_steps > 1 then format(' (%s/%s)', i + 1, v_steps) else '' end, p_app.home_page);
206
+ update meta.page set title = p_label where id = v_page;
207
+ if i = 0 then
208
+ v_first := v_page;
209
+ end if;
210
+ insert into meta.region (page_id, seq, title, type, template, config)
211
+ values (v_page, 10, 'Steps', 'list', 'plain', jsonb_build_object('list', v_list, 'template', 'wizard_progress'));
212
+ insert into meta.region (page_id, seq, title, type, template)
213
+ values (v_page, 20, 'Step ' || (i + 1), 'form', 'wizard') returning id into v_region;
214
+ v_seq := 10;
215
+ -- this step's share of the columns, in order
216
+ for c in
217
+ select w.* from unnest(v_cols) with ordinality u(col, n) join meta.wizard_catalog(p_table) w on w.column_name = u.col
218
+ where (u.n - 1) * v_steps / cardinality(v_cols) = i
219
+ order by u.n
220
+ loop
221
+ v_lov := null;
222
+ v_type := case
223
+ when c.fk_table is not null then 'select'
224
+ when c.kind = 'number' then 'number'
225
+ when c.kind = 'date' then 'date'
226
+ when c.kind = 'timestamp' then 'datetime'
227
+ when c.kind = 'boolean' then 'switch'
228
+ else 'text' end;
229
+ if v_type = 'select' then
230
+ v_lov := format('select %I as d, %I as r from %s order by 1', coalesce(c.fk_display, c.fk_column), c.fk_column, meta.wizard_qname(c.fk_table));
231
+ end if;
232
+ insert into meta.item (page_id, region_id, seq, name, label, type, lov, required)
233
+ values (v_page, v_region, v_seq, meta.wizard_item(p_page + i, c.column_name), meta.wizard_column_label(p_table, c.column_name),
234
+ v_type, v_lov, c.not_null and not c.has_default and v_type <> 'switch');
235
+ v_seq := v_seq + 10;
236
+ v_names := v_names || case when v_names = '' then '' else ', ' end || quote_ident(c.column_name);
237
+ v_values := v_values || case when v_values = '' then '' else ', ' end || format(':%s::%s', meta.wizard_item(p_page + i, c.column_name), c.type_sql);
238
+ end loop;
239
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot, slot)
240
+ values (v_page, v_region, 10, 'CANCEL', 'Cancel', 'redirect', v_return, false, 'close');
241
+ if i > 0 then
242
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot, slot)
243
+ values (v_page, v_region, 20, 'PREVIOUS', 'Previous', 'redirect', p_page + i - 1, false, 'previous');
244
+ end if;
245
+ if i < v_steps - 1 then
246
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot, slot)
247
+ values (v_page, v_region, 30, 'NEXT', 'Next', 'submit', p_page + i + 1, true, 'next');
248
+ end if;
249
+ end loop;
250
+ -- the last step: Finish saves the row (as the application's role)
251
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot, slot)
252
+ values (v_page, v_region, 30, 'FINISH', 'Finish', 'submit', v_return, true, 'next');
253
+ insert into meta.process (page_id, seq, name, type, code, when_button, success_message)
254
+ values (v_page, 10, 'Create ' || meta.wizard_table_label(p_table, false), 'sql',
255
+ format('insert into %s (%s)%svalues (%s)', meta.wizard_qname(p_table), v_names, E'\n', v_values), 'FINISH',
256
+ meta.wizard_table_label(p_table, false) || ' created.');
257
+ return v_first;
258
+ end
259
+ $$;
260
+
261
+ create function meta.generate_page(
262
+ p_app text,
263
+ p_kind text,
264
+ p_table regclass,
265
+ p_page int,
266
+ p_options jsonb default '{}'
267
+ ) returns int
268
+ language plpgsql as $$
269
+ declare
270
+ v_app meta.app;
271
+ o jsonb;
272
+ v_label text;
273
+ v_flabel text;
274
+ v_icon text;
275
+ v_pk text;
276
+ v_page int;
277
+ v_region int;
278
+ v_report int;
279
+ v_fpage int;
280
+ v_link jsonb;
281
+ v_rep jsonb;
282
+ v_src text;
283
+ v_cfg jsonb;
284
+ v_a text;
285
+ v_b text;
286
+ v_c text;
287
+ v_d text;
288
+ v_name text;
289
+ v_facets jsonb := '[]';
290
+ v_names text[];
291
+ v_n int;
292
+ c record;
293
+ begin
294
+ if p_kind in ('report_form', 'grid', 'form', 'cards', 'calendar', 'chart', 'map', 'facets', 'master_detail') then
295
+ return meta.generate_page_100(p_app, p_kind, p_table, p_page, p_options);
296
+ end if;
297
+ select * into v_app from meta.app where alias = p_app;
298
+ if v_app.id is null then
299
+ raise exception 'application "%" does not exist', p_app;
300
+ end if;
301
+ if p_kind = 'dashboard' then
302
+ raise exception 'a dashboard is made by the builder (Create page → Dashboard): it reads the data to choose its charts';
303
+ end if;
304
+ if p_kind not in ('report', 'smart_filters', 'tree', 'timeline', 'media_list', 'content_row', 'comments', 'data_load', 'wizard',
305
+ 'search', 'tasks', 'workflows') then
306
+ raise exception 'unknown page type "%"', p_kind;
307
+ end if;
308
+ if p_kind in ('tasks', 'workflows') then
309
+ p_table := null;
310
+ elsif p_kind <> 'search' or p_table is not null then
311
+ perform meta.wizard_check_table(p_table);
312
+ end if;
313
+ if p_options is null or jsonb_typeof(p_options) <> 'object' then
314
+ p_options := '{}';
315
+ end if;
316
+ -- an option given as JSON null or '' means "none"; a missing one takes the default
317
+ o := meta.wizard_defaults(p_kind, case when p_kind = 'search' then null else p_table end) || p_options;
318
+ v_pk := case when p_table is not null then meta.wizard_pk(p_table) end;
319
+ v_label := coalesce(nullif(btrim(o ->> 'label'), ''),
320
+ case when p_table is not null then meta.wizard_table_label(p_table) else initcap(replace(p_kind, '_', ' ')) end);
321
+ v_flabel := case when p_table is not null then meta.wizard_table_label(p_table, false) end;
322
+ v_icon := nullif(o ->> 'icon', '');
323
+ v_fpage := case when p_kind in ('report', 'smart_filters', 'tree', 'timeline', 'media_list', 'content_row', 'search')
324
+ then nullif(o ->> 'form_page', '')::int end;
325
+ if v_fpage = p_page then
326
+ raise exception 'the form page needs a page number of its own';
327
+ end if;
328
+ if v_fpage is not null and (p_table is null or v_pk is null) then
329
+ raise exception 'a form page needs a table with a single-column primary key%', coalesce(format(' (%s has none)', p_table), '');
330
+ end if;
331
+ if v_fpage is not null then
332
+ v_link := jsonb_build_object('page', v_fpage, 'items', jsonb_build_object(meta.wizard_item(v_fpage, v_pk), '#' || v_pk || '#'));
333
+ end if;
334
+
335
+ if p_kind = 'wizard' then
336
+ v_page := meta.wizard_steps(v_app, p_table, p_page, v_label, o);
337
+
338
+ elsif p_kind = 'data_load' then
339
+ v_name := upper(btrim(coalesce(o ->> 'definition', '')));
340
+ if v_name !~ '^[A-Z][A-Z0-9_]{0,59}$' then
341
+ raise exception 'the data load definition''s name is upper case letters, digits and _ (at most 60), e.g. ORDERS_LOAD';
342
+ end if;
343
+ if coalesce(o ->> 'mode', 'append') not in ('append', 'merge', 'replace') then
344
+ raise exception 'the mode is append, merge or replace';
345
+ end if;
346
+ if o ->> 'mode' = 'merge' and v_pk is null then
347
+ raise exception 'merge needs a table with a single-column primary key (% has none)', p_table;
348
+ end if;
349
+ if meta.wizard_qname(p_table) !~ '^[A-Za-z_][A-Za-z0-9_$]*([.][A-Za-z_][A-Za-z0-9_$]*)?$' then
350
+ raise exception 'a data load definition needs a table name without quotes (% has one)', p_table;
351
+ end if;
352
+ -- an existing definition of that name is used as it is
353
+ if not exists (select 1 from meta.data_load_def where app_id = v_app.id and name = v_name) then
354
+ insert into meta.data_load_def (app_id, name, description, table_name, mode, skip_errors)
355
+ values (v_app.id, v_name, 'Made by Create page → Data loading', meta.wizard_qname(p_table),
356
+ coalesce(o ->> 'mode', 'append'), coalesce((o ->> 'skip_errors')::boolean, false));
357
+ end if;
358
+ v_page := meta.wizard_new_page(v_app, p_page, v_label, v_app.home_page);
359
+ insert into meta.region (page_id, seq, title, type)
360
+ values (v_page, 10, v_label, 'form') returning id into v_region;
361
+ insert into meta.item (page_id, region_id, seq, name, label, type, required, help)
362
+ values (v_page, v_region, 10, format('P%s_FILE', p_page), 'File', 'file', true,
363
+ 'A CSV, Excel, JSON or XML file whose column names match the table''s columns.');
364
+ insert into meta.button (page_id, region_id, seq, name, label, action, hot)
365
+ values (v_page, v_region, 10, 'LOAD', 'Load', 'submit', true);
366
+ insert into meta.process (page_id, seq, name, type, when_button, success_message, config)
367
+ values (v_page, 10, 'Load ' || meta.wizard_table_label(p_table), 'data_load', 'LOAD', '{inserted} added, {updated} updated.',
368
+ jsonb_build_object('file_item', format('P%s_FILE', p_page), 'definition', v_name));
369
+
370
+ else
371
+ v_page := meta.wizard_new_page(v_app, p_page, v_label, v_app.home_page);
372
+ if p_kind in ('report', 'smart_filters') then
373
+ v_rep := meta.wizard_report(p_table);
374
+ insert into meta.region (page_id, seq, title, type, source, config)
375
+ values (v_page, 20, v_label, 'report', v_rep ->> 'source',
376
+ jsonb_build_object('headings', v_rep -> 'headings')
377
+ || case when v_link is not null then jsonb_build_object('link', v_link || jsonb_build_object('column', v_pk, 'icon', true)) else '{}' end
378
+ || case when jsonb_array_length(v_rep -> 'hidden') > 0 then jsonb_build_object('hidden', v_rep -> 'hidden') else '{}' end
379
+ || case when v_rep -> 'formats' <> '{}' then jsonb_build_object('formats', v_rep -> 'formats') else '{}' end)
380
+ returning id into v_region;
381
+ if p_kind = 'smart_filters' then
382
+ for c in
383
+ select w.* from jsonb_array_elements_text(coalesce(o -> 'facets', '[]')) with ordinality x(col, n)
384
+ join meta.wizard_catalog(p_table) w on w.column_name = meta.wizard_col(p_table, x.col, 'facet column')
385
+ order by x.n
386
+ loop
387
+ continue when c.kind in ('binary', 'geometry', 'point', 'other');
388
+ -- a foreign key filters by the parent's name, the column wizard_report shows instead of the key
389
+ v_facets := v_facets || jsonb_build_array(jsonb_strip_nulls(jsonb_build_object(
390
+ 'column', case when (v_rep -> 'hidden') ? c.column_name
391
+ then (select k from jsonb_object_keys(v_rep -> 'headings') k where k like c.column_name || '\_display%' order by length(k) limit 1)
392
+ else c.column_name end,
393
+ 'label', meta.wizard_column_label(p_table, c.column_name),
394
+ 'type', case when c.kind in ('number', 'date', 'timestamp') and c.fk_table is null then 'range' end)));
395
+ end loop;
396
+ insert into meta.region (page_id, seq, title, type, columns, template, config)
397
+ values (v_page, 10, 'Filters', 'smart_filters', 12, 'plain',
398
+ jsonb_build_object('report', v_region, 'facets', v_facets,
399
+ 'placeholder', 'Search or filter ' || lower(meta.wizard_table_label(p_table)) || '…'));
400
+ end if;
401
+
402
+ elsif p_kind = 'tree' then
403
+ v_a := meta.wizard_col(p_table, o ->> 'id_column', 'id column');
404
+ v_b := meta.wizard_col(p_table, o ->> 'parent_column', 'parent column');
405
+ v_c := meta.wizard_col(p_table, o ->> 'label_column', 'label column');
406
+ if v_a is null then
407
+ raise exception 'choose the column that identifies a node (usually the primary key)';
408
+ end if;
409
+ if v_b is null then
410
+ raise exception 'choose the parent column: % has no foreign key to itself', p_table;
411
+ end if;
412
+ v_c := coalesce(v_c, v_a);
413
+ v_cfg := jsonb_build_object('expanded', least(greatest(coalesce(nullif(o ->> 'expanded', '')::int, 1), 0), 20));
414
+ if v_link is not null then
415
+ -- the tree's link takes #id#: the node's id column is the key here
416
+ v_cfg := v_cfg || jsonb_build_object('link', jsonb_build_object('page', v_fpage,
417
+ 'items', jsonb_build_object(meta.wizard_item(v_fpage, v_pk), case when v_a = v_pk then '#id#' else '#' || v_pk || '#' end)));
418
+ end if;
419
+ insert into meta.region (page_id, seq, title, type, source, config)
420
+ values (v_page, 10, v_label, 'tree',
421
+ format(E'select t.%I as id,\n t.%I as parent_id,\n %s as label%s\n from %s t\n order by 3',
422
+ v_a, v_b, meta.wizard_expr(p_table, v_c),
423
+ case when v_link is not null and v_a <> v_pk then format(E',\n t.%I', v_pk) else '' end,
424
+ meta.wizard_qname(p_table)),
425
+ v_cfg)
426
+ returning id into v_region;
427
+
428
+ elsif p_kind in ('timeline', 'media_list', 'content_row', 'comments') then
429
+ -- a built-in template component, one row per instance, all rows in its wrapper ("multiple")
430
+ if p_kind = 'timeline' then
431
+ v_a := meta.wizard_col(p_table, o ->> 'title', 'title column');
432
+ v_b := meta.wizard_col(p_table, o ->> 'when', 'date column');
433
+ if v_a is null then
434
+ raise exception 'choose the column for the titles';
435
+ end if;
436
+ v_src := concat_ws(E',\n ',
437
+ meta.wizard_expr(p_table, v_a) || ' as title',
438
+ case when v_b is not null then format('t.%I as "when"', v_b) end,
439
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'who', 'who column')) || ' as who',
440
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'body', 'text column')) || ' as body',
441
+ case when v_pk is not null then format('t.%I', v_pk) end);
442
+ v_d := case when v_b is not null then format('t.%I desc nulls last', v_b) else '1' end;
443
+ elsif p_kind in ('media_list', 'content_row') then
444
+ v_a := meta.wizard_col(p_table, o ->> 'title', 'title column');
445
+ if v_a is null then
446
+ raise exception 'choose the column for the titles';
447
+ end if;
448
+ v_src := concat_ws(E',\n ',
449
+ meta.wizard_expr(p_table, v_a) || ' as title',
450
+ format('upper(left(%s::text, 2)) as initials', meta.wizard_expr(p_table, v_a)),
451
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'description', 'description column')) || ' as description',
452
+ case when p_kind = 'content_row' then meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'misc', 'secondary column')) || ' as misc' end,
453
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'badge', 'badge column')) || ' as badge',
454
+ -- the badge's colour from its value (approved, pending, late…)
455
+ meta.wizard_expr(p_table, meta.wizard_col(p_table, o ->> 'badge', 'badge column')) || ' as state',
456
+ case when v_pk is not null then format('t.%I', v_pk) end);
457
+ v_d := '1';
458
+ else
459
+ v_a := meta.wizard_col(p_table, o ->> 'comment', 'comment column');
460
+ v_b := meta.wizard_col(p_table, o ->> 'date', 'date column');
461
+ v_c := meta.wizard_col(p_table, o ->> 'user', 'user column');
462
+ if v_a is null then
463
+ raise exception 'choose the column with the comment';
464
+ end if;
465
+ v_src := concat_ws(E',\n ',
466
+ coalesce(meta.wizard_expr(p_table, v_c), quote_literal('')) || ' as "user"',
467
+ case when v_c is not null then format('upper(left(%s::text, 2)) as initials', meta.wizard_expr(p_table, v_c)) end,
468
+ case when v_b is not null then format('t.%I as date', v_b) end,
469
+ meta.wizard_expr(p_table, v_a) || ' as comment',
470
+ case when v_pk is not null then format('t.%I', v_pk) end);
471
+ v_d := case when v_b is not null then format('t.%I desc nulls last', v_b) else '1' end;
472
+ end if;
473
+ insert into meta.region (page_id, seq, title, type, source, config)
474
+ values (v_page, 10, v_label, 'template_component',
475
+ format(E'select %s\n from %s t\n order by %s', v_src, meta.wizard_qname(p_table), v_d),
476
+ jsonb_strip_nulls(jsonb_build_object('component', 'ut_' || p_kind, 'display', 'multiple', 'max_rows', 100, 'link', v_link)))
477
+ returning id into v_region;
478
+
479
+ elsif p_kind = 'search' then
480
+ select array_agg(upper(x)) into v_names from jsonb_array_elements_text(coalesce(o -> 'configs', '[]')) x where btrim(x) <> '';
481
+ for v_name in select unnest(coalesce(v_names, '{}')) loop
482
+ if not exists (select 1 from meta.search_config where app_id = v_app.id and name = v_name) then
483
+ raise exception 'the application has no search configuration %', v_name;
484
+ end if;
485
+ end loop;
486
+ if p_table is not null then
487
+ -- a new configuration on the table: its text columns searched, its name or first text column as the title
488
+ v_name := left(upper(regexp_replace((select relname from pg_class where oid = p_table), '[^A-Za-z0-9_]', '_', 'g')), 50);
489
+ v_n := 1;
490
+ while exists (select 1 from meta.search_config where app_id = v_app.id and name = v_name || case when v_n > 1 then '_' || v_n else '' end) loop
491
+ v_n := v_n + 1;
492
+ end loop;
493
+ v_name := v_name || case when v_n > 1 then '_' || v_n else '' end;
494
+ v_a := coalesce(meta.wizard_display(p_table), v_pk);
495
+ if v_a is null then
496
+ raise exception '% has no text column to show as the results'' titles', p_table;
497
+ end if;
498
+ insert into meta.search_config (app_id, name, label, seq, source_type, source, searchable_columns, title_column, description_column, link)
499
+ values (v_app.id, v_name, meta.wizard_table_label(p_table), 10 * (1 + (select count(*) from meta.search_config where app_id = v_app.id)),
500
+ 'table', meta.wizard_qname(p_table),
501
+ coalesce((select array_agg(column_name order by ordinal) from meta.wizard_catalog(p_table)
502
+ where kind = 'text' and not (column_name = any (meta.wizard_sidecars(p_table)))), '{}'),
503
+ v_a,
504
+ (select column_name from meta.wizard_catalog(p_table) where kind = 'text' and column_name <> v_a
505
+ and not (column_name = any (meta.wizard_sidecars(p_table))) order by ordinal limit 1),
506
+ v_link);
507
+ v_names := coalesce(v_names, '{}') || v_name;
508
+ end if;
509
+ if coalesce(cardinality(v_names), 0) = 0 then
510
+ raise exception 'choose at least one search configuration, or a table to search';
511
+ end if;
512
+ insert into meta.region (page_id, seq, title, type, columns, config)
513
+ values (v_page, 10, v_label, 'search', 12, jsonb_build_object('configs', to_jsonb(v_names)))
514
+ returning id into v_region;
515
+ -- the form page belongs to the new configuration's table
516
+ if p_table is null then
517
+ v_fpage := null;
518
+ end if;
519
+
520
+ elsif p_kind = 'tasks' then
521
+ insert into meta.region (page_id, seq, title, type, columns, config)
522
+ values (v_page, 10, v_label, 'tasks', 12,
523
+ jsonb_build_object('context', case when o ->> 'context' in ('my', 'initiated', 'admin') then o ->> 'context' else 'my' end,
524
+ 'completed', coalesce((o ->> 'completed')::boolean, false)));
525
+ elsif p_kind = 'workflows' then
526
+ insert into meta.region (page_id, seq, title, type, columns, config)
527
+ values (v_page, 10, v_label, 'workflows', 12,
528
+ case when o ->> 'context' = 'admin' then '{"context": "admin"}'::jsonb else '{}'::jsonb end);
529
+ end if;
530
+
531
+ if v_fpage is not null then
532
+ perform meta.wizard_form(v_app, p_table, v_fpage, v_flabel, p_page, true, null);
533
+ if p_kind <> 'search' then
534
+ insert into meta.button (page_id, region_id, seq, name, label, action, target_page, hot)
535
+ values (v_page, v_region, 10, 'CREATE', 'Create', 'redirect', v_fpage, true);
536
+ end if;
537
+ end if;
538
+ end if;
539
+
540
+ if coalesce((o ->> 'nav')::boolean, false) then
541
+ perform meta.wizard_nav(v_app, v_label, v_icon, p_page);
542
+ end if;
543
+ return v_page;
544
+ end
545
+ $$;
546
+
547
+ revoke all on function meta.generate_page_100(text, text, regclass, int, jsonb), meta.wizard_defaults_097(text, regclass),
548
+ meta.generate_page(text, text, regclass, int, jsonb), meta.wizard_defaults(text, regclass),
549
+ meta.wizard_check_table(regclass), meta.wizard_steps(meta.app, regclass, int, text, jsonb)
550
+ from public;
551
+
552
+ -- ---------------------------------------------------------------- 2. configuration options
553
+ alter table meta.build_option add column configurable boolean not null default false;
554
+ comment on column meta.build_option.configurable is 'The application''s administrators may switch it at run time (the Configuration Options feature; meta.set_configurable_options)';
555
+
556
+ -- an export made before 137 leaves the flag out
557
+ create function meta.build_option_defaults() returns trigger
558
+ language plpgsql set search_path = pg_catalog as $$
559
+ begin
560
+ new.configurable := coalesce(new.configurable, false);
561
+ return new;
562
+ end $$;
563
+ create trigger build_option_defaults before insert on meta.build_option
564
+ for each row execute function meta.build_option_defaults();
565
+
566
+ -- the configurable build options of the current application, for its administrators
567
+ create function meta.configurable_options()
568
+ returns table (name text, description text, enabled boolean)
569
+ language plpgsql stable security definer set search_path = meta, pg_catalog as $$
570
+ declare
571
+ v_app int := meta.caller_app('meta.configurable_options');
572
+ begin
573
+ if not meta.has_role('admin') then
574
+ raise exception 'meta.configurable_options: for the application''s administrators' using errcode = '42501';
575
+ end if;
576
+ return query
577
+ select o.name, coalesce(nullif(btrim(o.description), ''), initcap(replace(o.name, '_', ' '))), o.status = 'include'
578
+ from meta.build_option o
579
+ where o.app_id = v_app and o.configurable
580
+ order by o.name;
581
+ end
582
+ $$;
583
+ grant execute on function meta.configurable_options() to public;
584
+
585
+ -- switch the configurable build options: those named in p_enabled (colon-separated, as a checkbox group
586
+ -- posts them) are included, the other configurable ones excluded. Returns how many changed.
587
+ create function meta.set_configurable_options(p_enabled text) returns int
588
+ language plpgsql volatile security definer set search_path = meta, pg_catalog as $$
589
+ declare
590
+ v_app int := meta.caller_app('meta.set_configurable_options');
591
+ v_on text[] := coalesce(string_to_array(nullif(p_enabled, ''), ':'), '{}');
592
+ v_n int;
593
+ begin
594
+ if not meta.has_role('admin') then
595
+ raise exception 'meta.set_configurable_options: for the application''s administrators' using errcode = '42501';
596
+ end if;
597
+ if exists (select 1 from unnest(v_on) x
598
+ where not exists (select 1 from meta.build_option o where o.app_id = v_app and o.configurable and o.name = upper(x))) then
599
+ raise exception 'meta.set_configurable_options: only configurable build options can be switched' using errcode = '42501';
600
+ end if;
601
+ update meta.build_option o
602
+ set status = case when o.name = any (select upper(x) from unnest(v_on) x) then 'include' else 'exclude' end
603
+ where o.app_id = v_app and o.configurable
604
+ and o.status <> case when o.name = any (select upper(x) from unnest(v_on) x) then 'include' else 'exclude' end;
605
+ get diagnostics v_n = row_count;
606
+ return v_n;
607
+ end
608
+ $$;
609
+ grant execute on function meta.set_configurable_options(text) to public;
610
+
611
+ -- ---------------------------------------------------------------- 3. e-mail reporting
612
+ create function meta.send_activity_report(p_to text, p_days int default 7) returns bigint
613
+ language plpgsql volatile security definer set search_path = meta, pg_catalog as $$
614
+ declare
615
+ v_app int := meta.caller_app('meta.send_activity_report');
616
+ v_auto int := nullif(current_setting('pgkiln.automation_id', true), '')::int;
617
+ v_session uuid := nullif(current_setting('pgkiln.session_id', true), '')::uuid;
618
+ v_days int := least(greatest(coalesce(p_days, 7), 1), 366);
619
+ v_name text;
620
+ v_body text;
621
+ v_mail bigint;
622
+ r record;
623
+ begin
624
+ -- the application's own automations (the scheduler sets the automation), or a signed-in administrator
625
+ if not ((v_auto is not null and exists (select 1 from meta.automation a where a.id = v_auto and a.app_id = v_app))
626
+ or (v_session is not null and exists (select 1 from meta.session s where s.id = v_session and s.app_id = v_app)
627
+ and meta.has_role('admin'))) then
628
+ raise exception 'meta.send_activity_report: for the application''s automations and administrators' using errcode = '42501';
629
+ end if;
630
+ if coalesce(btrim(p_to), '') = '' then
631
+ return null;
632
+ end if;
633
+ select a.name into v_name from meta.app a where a.id = v_app;
634
+ select count(*) filter (where l.event = 'page_view') as views,
635
+ count(distinct l.username) filter (where l.event = 'page_view') as users,
636
+ count(*) filter (where l.event = 'login') as logins,
637
+ count(*) filter (where l.event = 'login_failed') as failed,
638
+ count(*) filter (where l.event ~ 'error') as errors
639
+ into r
640
+ from meta.activity_log l
641
+ where l.app_id = v_app and l.at > now() - make_interval(days => v_days);
642
+ v_body := format(E'%s: the last %s days\n\nPage views: %s\nUsers: %s\nSign-ins: %s (failed: %s)\nErrors: %s\n',
643
+ v_name, v_days, r.views, r.users, r.logins, r.failed, r.errors);
644
+ v_body := v_body || E'\nMost viewed pages:\n' || coalesce((
645
+ select string_agg(format(' page %s: %s', x.page_no, x.n), E'\n' order by x.n desc, x.page_no)
646
+ from (select l.page_no, count(*) as n from meta.activity_log l
647
+ where l.app_id = v_app and l.event = 'page_view' and l.at > now() - make_interval(days => v_days)
648
+ group by 1 order by 2 desc, 1 limit 5) x), ' (none)')
649
+ || E'\n\nThe activity is attached as a CSV file.\n';
650
+ v_mail := meta.send_mail(p_to, format('%s: activity of the last %s days', v_name, v_days), v_body);
651
+ perform meta.add_mail_attachment(v_mail,
652
+ meta.data_export(format('select at, username, event, page_no, elapsed_ms from meta.activity_log where app_id = %s and at > now() - make_interval(days => %s) order by at desc limit 10000', v_app, v_days), 'csv'),
653
+ 'activity.csv', 'text/csv');
654
+ return v_mail;
655
+ end
656
+ $$;
657
+ revoke all on function meta.send_activity_report(text, int) from public;
658
+ grant execute on function meta.send_activity_report(text, int) to public;
659
+ comment on function meta.send_activity_report(text, int) is 'Mail a summary of the current application''s activity with a CSV (137; the Email Reporting feature). For its automations and administrators.';
660
+
661
+ -- ---------------------------------------------------------------- 4. copy page
662
+ -- does the application have a shared component of this kind and name?
663
+ create function meta.shared_component_exists(p_kind text, p_app int, p_name text) returns boolean
664
+ language sql stable as $$
665
+ select case p_kind
666
+ when 'authz_scheme' then exists (select 1 from meta.authz_scheme s where s.app_id = p_app and s.name = p_name)
667
+ when 'build_option' then exists (select 1 from meta.build_option s where s.app_id = p_app and s.name = p_name)
668
+ when 'lov' then exists (select 1 from meta.lov s where s.app_id = p_app and s.name = p_name)
669
+ when 'list' then exists (select 1 from meta.list s where s.app_id = p_app and s.name = p_name)
670
+ when 'template_component' then exists (select 1 from meta.template_component s where s.app_id = p_app and s.static_id = p_name)
671
+ when 'search_config' then exists (select 1 from meta.search_config s where s.app_id = p_app and s.name = p_name)
672
+ when 'data_load_def' then exists (select 1 from meta.data_load_def s where s.app_id = p_app and s.name = p_name)
673
+ else false end
674
+ $$;
675
+
676
+ -- the shared components a page refers to by name, that the source application has:
677
+ -- (kind, name, whether the target application has one of that name)
678
+ create function meta.page_shared_refs(p_page_id int, p_target_app int)
679
+ returns table (kind text, name text, in_target boolean)
680
+ language sql stable as $$
681
+ with pg as (select * from meta.page where id = p_page_id),
682
+ refs (kind, name) as (
683
+ select 'authz_scheme', x.authz from (
684
+ select authz from pg union all select authz from meta.region where page_id = p_page_id
685
+ union all select authz from meta.item where page_id = p_page_id union all select authz from meta.button where page_id = p_page_id
686
+ union all select authz from meta.process where page_id = p_page_id union all select authz from meta.dynamic_action where page_id = p_page_id
687
+ union all select authz from meta.branch where page_id = p_page_id union all select authz from meta.computation where page_id = p_page_id) x
688
+ union
689
+ select 'build_option', ltrim(x.build_option, '!') from (
690
+ select build_option from pg union all select build_option from meta.region where page_id = p_page_id
691
+ union all select build_option from meta.item where page_id = p_page_id union all select build_option from meta.button where page_id = p_page_id
692
+ union all select build_option from meta.process where page_id = p_page_id union all select build_option from meta.dynamic_action where page_id = p_page_id
693
+ union all select build_option from meta.validation where page_id = p_page_id union all select build_option from meta.branch where page_id = p_page_id
694
+ union all select build_option from meta.computation where page_id = p_page_id) x
695
+ union
696
+ select 'lov', substr(i.lov, 5) from meta.item i where i.page_id = p_page_id and i.lov ~ '^LOV:'
697
+ union
698
+ select 'list', r.config ->> 'list' from meta.region r where r.page_id = p_page_id and r.type = 'list'
699
+ union
700
+ select 'template_component', r.config ->> 'component' from meta.region r where r.page_id = p_page_id and r.type = 'template_component'
701
+ union
702
+ select 'search_config', upper(x) from meta.region r, jsonb_array_elements_text(case when jsonb_typeof(r.config -> 'configs') = 'array' then r.config -> 'configs' else '[]' end) x
703
+ where r.page_id = p_page_id and r.type = 'search'
704
+ union
705
+ select 'data_load_def', p.config ->> 'definition' from meta.process p where p.page_id = p_page_id and p.type = 'data_load'
706
+ )
707
+ select r.kind, r.name,
708
+ meta.shared_component_exists(r.kind, p_target_app, r.name)
709
+ from refs r, pg
710
+ -- a built-in template component (ut_…) or a name the application doesn't have is left as it is
711
+ where coalesce(r.name, '') <> '' and meta.shared_component_exists(r.kind, pg.app_id, r.name)
712
+ order by 1, 2
713
+ $$;
714
+
715
+ -- {"page": <old>} → {"page": <new>} anywhere in a configuration (a page's links to itself)
716
+ create function meta.copy_page_json(p jsonb, p_old int, p_new int) returns jsonb
717
+ language plpgsql immutable as $$
718
+ declare
719
+ v jsonb;
720
+ k text;
721
+ e jsonb;
722
+ begin
723
+ if p is null or p_old = p_new then
724
+ return p;
725
+ end if;
726
+ case jsonb_typeof(p)
727
+ when 'object' then
728
+ v := '{}';
729
+ for k, e in select * from jsonb_each(p) loop
730
+ if k = 'page' and jsonb_typeof(e) = 'number' and (e #>> '{}')::numeric = p_old then
731
+ v := v || jsonb_build_object(k, p_new);
732
+ else
733
+ v := v || jsonb_build_object(k, meta.copy_page_json(e, p_old, p_new));
734
+ end if;
735
+ end loop;
736
+ return v;
737
+ when 'array' then
738
+ return coalesce((select jsonb_agg(meta.copy_page_json(x, p_old, p_new) order by n) from jsonb_array_elements(p) with ordinality a(x, n)), '[]');
739
+ else
740
+ return p;
741
+ end case;
742
+ end
743
+ $$;
744
+
745
+ /*
746
+ * Copy page p_page of application p_from into application p_to (the same or another) as page p_new.
747
+ * p_map: {"<kind>:<NAME>": "<target name>"} maps a shared component to one of the target's (by name);
748
+ * without an entry, a component the target has by the same name is used, else it is copied.
749
+ * Returns the new page's id.
750
+ */
751
+ create function meta.copy_page(p_from int, p_page int, p_to int, p_new int, p_name text default null, p_map jsonb default '{}')
752
+ returns int
753
+ language plpgsql as $$
754
+ declare
755
+ v_src meta.page;
756
+ v_page_id int;
757
+ v_old text;
758
+ v_new text;
759
+ v_rmap jsonb := '{}';
760
+ v_map jsonb := '{}';
761
+ v_target text;
762
+ v_id int;
763
+ v_newid int;
764
+ v_e jsonb;
765
+ v_lmap jsonb;
766
+ t text;
767
+ r record;
768
+ x record;
769
+ begin
770
+ select * into v_src from meta.page where app_id = p_from and page_no = p_page;
771
+ if v_src.id is null then
772
+ raise exception 'page % does not exist', p_page;
773
+ end if;
774
+ if not exists (select 1 from meta.app where id = p_to) then
775
+ raise exception 'the target application does not exist';
776
+ end if;
777
+ if p_new is null or p_new < 1 then
778
+ raise exception 'a page number must be a positive whole number';
779
+ end if;
780
+ if exists (select 1 from meta.page where app_id = p_to and page_no = p_new) then
781
+ raise exception 'page % already exists', p_new;
782
+ end if;
783
+ if p_map is null or jsonb_typeof(p_map) <> 'object' then
784
+ p_map := '{}';
785
+ end if;
786
+
787
+ -- the shared components: mapped, the same name, or copied
788
+ for r in select * from meta.page_shared_refs(v_src.id, p_to) loop
789
+ v_target := nullif(p_map ->> (r.kind || ':' || r.name), '');
790
+ if v_target is null and (p_from = p_to or r.in_target) then
791
+ v_target := r.name;
792
+ end if;
793
+ if v_target is not null then
794
+ if not meta.shared_component_exists(r.kind, p_to, v_target) then
795
+ raise exception 'the target application has no % %', replace(r.kind, '_', ' '), v_target;
796
+ end if;
797
+ v_map := v_map || jsonb_build_object(r.kind || ':' || r.name, v_target);
798
+ continue;
799
+ end if;
800
+ -- copy the component into the target application, under its own name
801
+ t := case r.kind when 'authz_scheme' then 'authz_scheme' when 'build_option' then 'build_option' when 'lov' then 'lov'
802
+ when 'list' then 'list' when 'template_component' then 'template_component'
803
+ when 'search_config' then 'search_config' when 'data_load_def' then 'data_load_def' end;
804
+ execute format(
805
+ 'insert into meta.%1$I select (jsonb_populate_record(null::meta.%1$I, to_jsonb(x) || jsonb_build_object(''id'', nextval(''meta.%1$s_id_seq''), ''app_id'', $2))).* from meta.%1$I x where x.app_id = $1 and x.%2$I = $3',
806
+ t, case when t = 'template_component' then 'static_id' else 'name' end)
807
+ using p_from, p_to, r.name;
808
+ if r.kind = 'list' then
809
+ -- and its entries, with their parents
810
+ v_lmap := '{}';
811
+ for x in select * from meta.list_entry where app_id = p_from and list_name = r.name order by parent_id nulls first, id loop
812
+ v_e := to_jsonb(x) || jsonb_build_object('id', nextval('meta.list_entry_id_seq'), 'app_id', p_to,
813
+ 'parent_id', v_lmap ->> x.parent_id::text);
814
+ insert into meta.list_entry select (jsonb_populate_record(null::meta.list_entry, v_e)).* returning id into v_id;
815
+ v_lmap := v_lmap || jsonb_build_object(x.id::text, v_id);
816
+ end loop;
817
+ end if;
818
+ v_map := v_map || jsonb_build_object(r.kind || ':' || r.name, r.name);
819
+ end loop;
820
+
821
+ -- the page
822
+ insert into meta.page
823
+ select (jsonb_populate_record(null::meta.page, to_jsonb(v_src) || jsonb_build_object(
824
+ 'id', nextval('meta.page_id_seq'), 'app_id', p_to, 'page_no', p_new,
825
+ 'name', coalesce(nullif(btrim(p_name), ''), v_src.name),
826
+ 'parent_page', case when v_src.parent_page = p_page then null
827
+ when exists (select 1 from meta.page where app_id = p_to and page_no = v_src.parent_page) then v_src.parent_page
828
+ else (select home_page from meta.app where id = p_to) end,
829
+ 'authz', v_map ->> ('authz_scheme:' || v_src.authz),
830
+ 'build_option', case when v_src.build_option is not null
831
+ then case when left(v_src.build_option, 1) = '!' then '!' else '' end
832
+ || (v_map ->> ('build_option:' || ltrim(v_src.build_option, '!'))) end))).*
833
+ returning id into v_page_id;
834
+ -- an authorization or build option that is not a shared component (e.g. a built-in) stays as it was
835
+ update meta.page p set authz = coalesce(p.authz, v_src.authz), build_option = coalesce(p.build_option, v_src.build_option) where p.id = v_page_id;
836
+ -- a page group the target application doesn't have is left out
837
+ update meta.page p set page_group = null
838
+ where p.id = v_page_id and p.page_group is not null and p_from <> p_to
839
+ and not exists (select 1 from meta.page_group g where g.app_id = p_to and g.name = p.page_group);
840
+
841
+ v_old := 'P' || p_page || '_';
842
+ v_new := 'P' || p_new || '_';
843
+ -- every component: items renamed P<old>_ → P<new>_ (also where code refers to them), links to the page itself
844
+ -- to the copy, shared components mapped
845
+ for t in select unnest(array['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch']) loop
846
+ for v_e in execute format('select to_jsonb(x) from meta.%I x where x.page_id = $1 order by x.id', t) using v_src.id loop
847
+ v_id := (v_e ->> 'id')::int;
848
+ if p_page <> p_new then
849
+ v_e := regexp_replace(v_e::text, '\m' || v_old, v_new, 'g')::jsonb;
850
+ end if;
851
+ if v_e ? 'config' then
852
+ v_e := jsonb_set(v_e, '{config}', coalesce(meta.copy_page_json(v_e -> 'config', p_page, p_new), 'null'));
853
+ end if;
854
+ if v_e ? 'target_page' and (v_e ->> 'target_page')::int = p_page and coalesce(v_e ->> 'target_type', 'page') = 'page' then
855
+ v_e := v_e || jsonb_build_object('target_page', p_new);
856
+ end if;
857
+ if v_e ->> 'authz' is not null and v_map ? ('authz_scheme:' || (v_e ->> 'authz')) then
858
+ v_e := v_e || jsonb_build_object('authz', v_map ->> ('authz_scheme:' || (v_e ->> 'authz')));
859
+ end if;
860
+ if v_e ->> 'build_option' is not null and v_map ? ('build_option:' || ltrim(v_e ->> 'build_option', '!')) then
861
+ v_e := v_e || jsonb_build_object('build_option', case when left(v_e ->> 'build_option', 1) = '!' then '!' else '' end
862
+ || (v_map ->> ('build_option:' || ltrim(v_e ->> 'build_option', '!'))));
863
+ end if;
864
+ if t = 'item' and v_e ->> 'lov' ~ '^LOV:' and v_map ? ('lov:' || substr(v_e ->> 'lov', 5)) then
865
+ v_e := v_e || jsonb_build_object('lov', 'LOV:' || (v_map ->> ('lov:' || substr(v_e ->> 'lov', 5))));
866
+ end if;
867
+ if t = 'region' and v_e ->> 'type' = 'list' and v_map ? ('list:' || (v_e #>> '{config,list}')) then
868
+ v_e := jsonb_set(v_e, '{config,list}', to_jsonb(v_map ->> ('list:' || (v_e #>> '{config,list}'))));
869
+ end if;
870
+ if t = 'region' and v_e ->> 'type' = 'template_component' and v_map ? ('template_component:' || (v_e #>> '{config,component}')) then
871
+ v_e := jsonb_set(v_e, '{config,component}', to_jsonb(v_map ->> ('template_component:' || (v_e #>> '{config,component}'))));
872
+ end if;
873
+ if t = 'region' and v_e ->> 'type' = 'search' and jsonb_typeof(v_e #> '{config,configs}') = 'array' then
874
+ v_e := jsonb_set(v_e, '{config,configs}', (select coalesce(jsonb_agg(coalesce(v_map ->> ('search_config:' || upper(c)), c) order by n), '[]')
875
+ from jsonb_array_elements_text(v_e #> '{config,configs}') with ordinality a(c, n)));
876
+ end if;
877
+ if t = 'process' and v_e ->> 'type' = 'data_load' and v_map ? ('data_load_def:' || (v_e #>> '{config,definition}')) then
878
+ v_e := jsonb_set(v_e, '{config,definition}', to_jsonb(v_map ->> ('data_load_def:' || (v_e #>> '{config,definition}'))));
879
+ end if;
880
+ v_e := v_e || jsonb_build_object('id', null, 'page_id', v_page_id);
881
+ if v_e ? 'region_id' then
882
+ v_e := v_e || jsonb_build_object('region_id', v_rmap -> (v_e ->> 'region_id'));
883
+ end if;
884
+ if v_e ? 'affected_region_id' then
885
+ v_e := v_e || jsonb_build_object('affected_region_id', v_rmap -> (v_e ->> 'affected_region_id'));
886
+ end if;
887
+ execute format('insert into meta.%1$I select (jsonb_populate_record(null::meta.%1$I, $1 || jsonb_build_object(''id'', nextval(''meta.%1$s_id_seq'')))).* returning id', t)
888
+ using v_e into v_newid;
889
+ if t = 'region' then
890
+ v_rmap := v_rmap || jsonb_build_object(v_id::text, v_newid);
891
+ end if;
892
+ end loop;
893
+ if t = 'region' then
894
+ -- facets, smart filters and maps point at their report region by id
895
+ update meta.region g
896
+ set config = jsonb_set(g.config, '{report}', v_rmap -> (g.config ->> 'report'))
897
+ where g.page_id = v_page_id and g.config ? 'report' and v_rmap ? (g.config ->> 'report');
898
+ end if;
899
+ end loop;
900
+ return v_page_id;
901
+ end
902
+ $$;
903
+ revoke all on function meta.shared_component_exists(text, int, text), meta.page_shared_refs(int, int), meta.copy_page_json(jsonb, int, int),
904
+ meta.copy_page(int, int, int, int, text, jsonb) from public;