pgkiln 0.73.0 → 0.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/db/migrations/141_workspace_resources.sql +115 -0
  2. package/db/migrations/142_json_sources.sql +426 -0
  3. package/docs/apex-feature-parity.md +11 -8
  4. package/docs/guide/01-installation.md +1 -0
  5. package/docs/guide/03-builder.md +62 -4
  6. package/docs/guide/04-pages-and-regions.md +1 -0
  7. package/docs/guide/12-development.md +26 -1
  8. package/docs/guide/16-files.md +29 -0
  9. package/docs/guide/19-rest-data-sources.md +149 -4
  10. package/examples/hr/hr_76_remote_servers.sql +48 -0
  11. package/examples/hr/hr_77_json_sources.sql +174 -0
  12. package/package.json +1 -1
  13. package/src/ai/service.ts +10 -1
  14. package/src/app.ts +7 -0
  15. package/src/builder/advisor.ts +13 -1
  16. package/src/builder/ai.ts +29 -3
  17. package/src/builder/designer.ts +1 -1
  18. package/src/builder/document-view.ts +117 -0
  19. package/src/builder/home.ts +3 -0
  20. package/src/builder/i18n/de.ts +1 -0
  21. package/src/builder/i18n/en.ts +1 -0
  22. package/src/builder/i18n/es.ts +1 -0
  23. package/src/builder/i18n/fr.ts +1 -0
  24. package/src/builder/i18n/it.ts +1 -0
  25. package/src/builder/i18n/ja.ts +1 -0
  26. package/src/builder/i18n/ko.ts +1 -0
  27. package/src/builder/i18n/nl.ts +1 -0
  28. package/src/builder/i18n/pt.ts +1 -0
  29. package/src/builder/i18n/zh-cn.ts +1 -0
  30. package/src/builder/i18n/zh-tw.ts +1 -0
  31. package/src/builder/json-sources.ts +395 -0
  32. package/src/builder/network-access.ts +33 -0
  33. package/src/builder/object-actions.ts +1 -0
  34. package/src/builder/report-settings.ts +9 -0
  35. package/src/builder/rest-catalogs.ts +22 -14
  36. package/src/builder/routes.ts +5 -1
  37. package/src/builder/shared.ts +1 -1
  38. package/src/builder/static-files.ts +14 -4
  39. package/src/builder/table-editor.ts +1 -0
  40. package/src/builder/websources.ts +18 -9
  41. package/src/builder/workflows.ts +2 -1
  42. package/src/builder/workspace-resources.ts +528 -0
  43. package/src/json-sources.ts +365 -0
  44. package/src/metadata.ts +17 -3
  45. package/src/remote-servers.ts +133 -0
  46. package/src/restcatalog.ts +21 -10
  47. package/src/restsync.ts +1 -1
  48. package/src/runtime/context.ts +2 -0
  49. package/src/runtime/report.ts +3 -1
  50. package/src/runtime/static-files.ts +46 -17
  51. package/src/runtime/template-components.ts +2 -1
  52. package/src/websources.ts +43 -18
  53. package/src/workspace-export.ts +80 -1
@@ -0,0 +1,115 @@
1
+ -- Sprint 141 (AP-7): what a workspace's applications share (APEX: Static Workspace Files, Remote Servers,
2
+ -- workspace-level Web Credentials).
3
+ --
4
+ -- meta.workspace_file a workspace's CSS, JavaScript, images and fonts (never HTML), served to each of its
5
+ -- applications at /a/<alias>/workspace-files/<name> (the application's own origin, so the
6
+ -- Content-Security-Policy stays script-src 'self') and named #WORKSPACE_FILES#<name> in the
7
+ -- "every page loads" lists and in templates. The runtime role reads neither the table nor
8
+ -- the content: meta.workspace_file_versions() gives an application its own workspace's
9
+ -- names and versions; the server sends the content with the owner pool.
10
+ -- meta.remote_server a named base URL per workspace (static id, e.g. CRM): this installation's URL, or an
11
+ -- environment variable PGKILN_SERVER_<...> that wins when it is set, and an optional
12
+ -- default web credential. REST data sources (meta.rest_source.server), REST source catalogs
13
+ -- (meta.rest_catalog.server) and AI services (meta.ai_service.server_id) refer to it; the
14
+ -- URL it resolves to goes through the same checks as before (allow-list, the workspace's
15
+ -- network access list, address checks at connect time). Not readable by the runtime role.
16
+ -- meta.workspace_credential web credentials of a workspace, used by its applications by name when an application has
17
+ -- none of that name. Secrets (*_enc) are encrypted by the server with PGKILN_SECRET_KEY
18
+ -- (src/secrets.ts), write-only, never exported; no grant to the runtime role at all.
19
+ --
20
+ -- All new columns on existing tables are nullable: exports of older versions import as they are, and export_app /
21
+ -- import_app copy every column of meta.rest_source (no redefinition needed).
22
+
23
+ -- ---------------------------------------------------------------- workspace files
24
+ create table meta.workspace_file (
25
+ id serial primary key,
26
+ workspace_id int not null references meta.workspace on delete cascade,
27
+ name text not null check (name ~ '^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$' and name !~ '\.\.' and lower(name) !~ '\.(html?|xhtml|xml|php|exe)$'),
28
+ mime text not null check (mime ~ '^[a-z]+/[a-z0-9.+-]+$'),
29
+ content bytea not null check (octet_length(content) <= 5 * 1024 * 1024),
30
+ updated_by text,
31
+ updated_at timestamptz not null default now(),
32
+ unique (workspace_id, name)
33
+ );
34
+ revoke all on meta.workspace_file from public;
35
+ comment on table meta.workspace_file is 'Static workspace files (141): served to the workspace''s applications at /a/<alias>/workspace-files/<name>; in the workspace export';
36
+
37
+ -- The names and versions (milliseconds) of the workspace files an application may load: its own workspace's only.
38
+ create function meta.workspace_file_versions(p_app_id int) returns jsonb
39
+ language sql stable security definer set search_path = meta, pg_catalog as $$
40
+ select coalesce(jsonb_object_agg(f.name, floor(extract(epoch from f.updated_at))::bigint), '{}')
41
+ from meta.workspace_file f
42
+ where f.workspace_id = meta.app_workspace(meta.checked_app(p_app_id))
43
+ $$;
44
+ revoke all on function meta.workspace_file_versions(int) from public;
45
+ grant execute on function meta.workspace_file_versions(int) to pgkiln_runtime;
46
+
47
+ -- ---------------------------------------------------------------- remote servers
48
+ create table meta.remote_server (
49
+ id serial primary key,
50
+ workspace_id int not null references meta.workspace on delete cascade,
51
+ static_id text not null check (static_id ~ '^[A-Z][A-Z0-9_]{0,59}$'),
52
+ name text not null check (name ~ '^\S(.{0,98}\S)?$'),
53
+ description text check (length(description) <= 2000),
54
+ -- this installation's base URL, e.g. https://crm.example.com/api (no trailing slash, query or fragment)
55
+ base_url text check (base_url ~ '^https?://[^\s<>"{}?#]+$' and base_url !~ '/$' and length(base_url) <= 2000),
56
+ -- or the server's environment variable that holds it (wins when set): PGKILN_SERVER_<NAME> only
57
+ env_var text check (env_var ~ '^PGKILN_SERVER_[A-Z0-9_]{1,60}$'),
58
+ -- the web credential sources on this server use when they name none of their own
59
+ credential text check (credential ~ '^[A-Z][A-Z0-9_]{0,59}$'),
60
+ updated_by text,
61
+ created_at timestamptz not null default now(),
62
+ updated_at timestamptz not null default now(),
63
+ unique (workspace_id, static_id),
64
+ check (base_url is not null or env_var is not null)
65
+ );
66
+ revoke all on meta.remote_server from public;
67
+ comment on table meta.remote_server is 'Remote servers (141): a named base URL per workspace for REST data sources, catalogs and AI services; in the workspace export';
68
+
69
+ -- a REST data source on a server: its URL is then a path after the server's base URL ('' is the base URL itself)
70
+ alter table meta.rest_source add column server text check (server ~ '^[A-Z][A-Z0-9_]{0,59}$');
71
+ alter table meta.rest_source drop constraint rest_source_url_check;
72
+ alter table meta.rest_source add constraint rest_source_url_check
73
+ check (url ~ '^https?://' or (server is not null and url ~ '^([/?][^\s]*)?$'));
74
+ comment on column meta.rest_source.server is 'A remote server of the application''s workspace (static id): the URL is then a path after its base URL';
75
+
76
+ -- a catalog on a server: services are paths after the server's base URL; base_url is then optional
77
+ alter table meta.rest_catalog add column server text check (server ~ '^[A-Z][A-Z0-9_]{0,59}$');
78
+ alter table meta.rest_catalog alter column base_url drop not null;
79
+ alter table meta.rest_catalog add constraint rest_catalog_base_or_server check (base_url is not null or server is not null);
80
+ comment on column meta.rest_catalog.server is 'A remote server of the workspace (static id): data sources made from the catalog use it';
81
+
82
+ -- an AI service on a server (administrators choose one of any workspace): its base URL is the server's plus server_path
83
+ alter table meta.ai_service
84
+ add column server_id int references meta.remote_server on delete set null,
85
+ add column server_path text check (server_path ~ '^/[^\s]*$' and length(server_path) <= 500);
86
+ alter table meta.ai_service drop constraint ai_service_compatible_url;
87
+ alter table meta.ai_service add constraint ai_service_compatible_url
88
+ check (provider <> 'openai_compatible' or base_url is not null or server_id is not null);
89
+ comment on column meta.ai_service.server_id is 'A remote server whose base URL (plus server_path) the service calls instead of base_url';
90
+
91
+ -- ---------------------------------------------------------------- workspace web credentials
92
+ create table meta.workspace_credential (
93
+ id serial primary key,
94
+ workspace_id int not null references meta.workspace on delete cascade,
95
+ name text not null check (name ~ '^[A-Z][A-Z0-9_]{0,59}$'),
96
+ description text check (length(description) <= 2000),
97
+ type text not null default 'basic' check (type in ('basic', 'header', 'bearer', 'oauth2', 'aws_sigv4')),
98
+ username text,
99
+ header_name text check (header_name ~ '^[A-Za-z0-9!#$%&''*+.^_`|~-]{1,64}$'),
100
+ token_url text check (token_url ~ '^https?://'),
101
+ scope text,
102
+ valid_for text[] not null default '{}',
103
+ grant_type text not null default 'client_credentials' check (grant_type in ('client_credentials', 'password', 'refresh_token')),
104
+ oauth_username text,
105
+ -- "v1:" + base64(iv, tag, ciphertext); written by the server only (src/secrets.ts)
106
+ secret_enc text,
107
+ password_enc text,
108
+ refresh_token_enc text,
109
+ token_refreshed_at timestamptz,
110
+ updated_by text,
111
+ updated_at timestamptz not null default now(),
112
+ unique (workspace_id, name)
113
+ );
114
+ revoke all on meta.workspace_credential from public;
115
+ comment on table meta.workspace_credential is 'Web credentials of a workspace (141): its applications use them by name when they have none of that name; secrets encrypted, never exported';
@@ -0,0 +1,426 @@
1
+ -- Sprint 142 (AP-8): JSON sources and document views (APEX: Shared Components → JSON Sources, JSON Duality Views).
2
+ --
3
+ -- meta.app.json_sources an application's JSON sources, a JSON list exported with the application (like
4
+ -- vector_providers, migration 136). A source names a table, its key and a jsonb column
5
+ -- (a column of a relational table, or the one document column of a table of documents)
6
+ -- and a data profile: columns at paths in the document, and arrays of objects (or of
7
+ -- values) with columns of their own. The builder (Shared Components → JSON sources)
8
+ -- proposes the profile from sample rows and creates views from it in the application's
9
+ -- schema; regions, REST and MCP use those views like tables.
10
+ --
11
+ -- The views are ordinary schema objects, made by the builder with the developer's connection and recorded in the
12
+ -- Change Log (like Table Properties › Add full-text search, migration 135). They are security_invoker (the
13
+ -- table's row level security and grants apply to whoever reads them) and get INSTEAD OF triggers that call the
14
+ -- functions below, with the source's description as the trigger argument:
15
+ --
16
+ -- meta.json_source_write() a source's view: an UPDATE changes only the paths whose values changed, in one
17
+ -- UPDATE of the table row (meta.jsonb_patch), so keys the profile doesn't know about
18
+ -- stay; an INSERT or DELETE adds or removes the table row only when the source says
19
+ -- so (a table of documents), else it is refused.
20
+ -- meta.json_array_write() an array's view (one row per element, keyed by the element's contents): UPDATE
21
+ -- patches one element, INSERT appends one, DELETE removes one, each in one UPDATE of
22
+ -- the table row (meta.jsonb_array_change). An element that changed meanwhile is not
23
+ -- found and the statement fails, so nothing is overwritten.
24
+ -- meta.json_document_write() a document view (SQL Workshop › Table Properties › Document view: APEX's JSON
25
+ -- Duality View): a table row and its child rows (foreign keys) as one jsonb document;
26
+ -- writing the document updates the row, upserts the children it lists and deletes the
27
+ -- children it leaves out.
28
+ --
29
+ -- All of them are SECURITY INVOKER with search_path pg_catalog: they write as the role that writes the view, so its
30
+ -- grants and the table's row level security apply; identifiers from the trigger argument are quoted (format %I).
31
+
32
+ alter table meta.app add column json_sources jsonb not null default '[]'
33
+ check (jsonb_typeof(json_sources) = 'array');
34
+ comment on column meta.app.json_sources is 'JSON sources (142): a jsonb column described by a data profile, presented as updatable views; exported with the application';
35
+
36
+ -- an export made before 142 has no json_sources
37
+ create or replace function meta.app_ai_default() returns trigger
38
+ language plpgsql set search_path = pg_catalog as $$
39
+ begin
40
+ new.ai_tools := coalesce(new.ai_tools, '[]');
41
+ new.ai_agents := coalesce(new.ai_agents, '[]');
42
+ new.vector_providers := coalesce(new.vector_providers, '[]');
43
+ new.json_sources := coalesce(new.json_sources, '[]');
44
+ return new;
45
+ end $$;
46
+
47
+ -- ---------------------------------------------------------------- reading values
48
+
49
+ -- Whether a text reads as a value of a type (integer, numeric, date …): a document with a wrong value shows NULL
50
+ -- in that column instead of failing the whole view. pg_input_is_valid on PostgreSQL 16 and later.
51
+ do $do$
52
+ begin
53
+ if current_setting('server_version_num')::int >= 160000 then
54
+ execute $f$
55
+ create function meta.input_ok(p_value text, p_type text) returns boolean
56
+ language sql stable parallel safe set search_path = pg_catalog as
57
+ $$ select p_value is null or pg_input_is_valid(p_value, p_type) $$$f$;
58
+ else
59
+ execute $f$
60
+ create function meta.input_ok(p_value text, p_type text) returns boolean
61
+ language plpgsql stable parallel safe set search_path = pg_catalog as $$
62
+ begin
63
+ if p_value is null then
64
+ return true;
65
+ end if;
66
+ execute format('select %L::%s', p_value, p_type::regtype);
67
+ return true;
68
+ exception when others then
69
+ return false;
70
+ end $$$f$;
71
+ end if;
72
+ end
73
+ $do$;
74
+
75
+ -- ---------------------------------------------------------------- changing documents
76
+
77
+ -- Set a value at a path of keys, making the objects on the way (jsonb_set only makes the last key).
78
+ create function meta.jsonb_put(p_doc jsonb, p_path text[], p_value jsonb) returns jsonb
79
+ language plpgsql immutable parallel safe set search_path = pg_catalog as $$
80
+ declare
81
+ d jsonb := p_doc;
82
+ i int;
83
+ begin
84
+ if coalesce(cardinality(p_path), 0) = 0 then
85
+ return p_value;
86
+ end if;
87
+ if d is null or jsonb_typeof(d) <> 'object' then
88
+ d := '{}';
89
+ end if;
90
+ for i in 1 .. cardinality(p_path) - 1 loop
91
+ if jsonb_typeof(d #> p_path[1:i]) is distinct from 'object' then
92
+ d := jsonb_set(d, p_path[1:i], '{}', true);
93
+ end if;
94
+ end loop;
95
+ return jsonb_set(d, p_path, p_value, true);
96
+ end $$;
97
+
98
+ -- Apply changes [{"p": [keys], "v": value}] to a document: a value is set at its path, a null (or JSON null)
99
+ -- removes the key. Keys the changes don't name stay as they are.
100
+ create function meta.jsonb_patch(p_doc jsonb, p_changes jsonb) returns jsonb
101
+ language plpgsql immutable parallel safe set search_path = pg_catalog as $$
102
+ declare
103
+ d jsonb := p_doc;
104
+ c jsonb;
105
+ p text[];
106
+ begin
107
+ for c in select x from jsonb_array_elements(coalesce(p_changes, '[]')) x loop
108
+ p := array(select jsonb_array_elements_text(c->'p'));
109
+ if c->'v' is null or jsonb_typeof(c->'v') = 'null' then
110
+ if cardinality(p) = 0 then
111
+ d := null;
112
+ elsif d is not null then
113
+ d := d #- p;
114
+ end if;
115
+ else
116
+ d := meta.jsonb_put(d, p, c->'v');
117
+ end if;
118
+ end loop;
119
+ return d;
120
+ end $$;
121
+
122
+ -- The key of each element of an array as the array views show it: the md5 of the element's text and its
123
+ -- occurrence among equal elements (1, 2 …), so a key stays the same when other elements are added or removed.
124
+ create function meta.jsonb_element_keys(p_array jsonb)
125
+ returns table (idx int, key text)
126
+ language sql immutable parallel safe set search_path = pg_catalog as $$
127
+ select (x.ord - 1)::int, md5(x.e::text) || '.' || row_number() over (partition by md5(x.e::text) order by x.ord)
128
+ from jsonb_array_elements(case when jsonb_typeof(p_array) = 'array' then p_array else '[]' end) with ordinality x(e, ord)
129
+ $$;
130
+
131
+ -- Change one element of the array at p_path: 'update' patches the element with the given key, 'delete' removes
132
+ -- it, 'insert' appends a new element made of the changes. An element that is not there (changed or removed
133
+ -- meanwhile) raises an error.
134
+ create function meta.jsonb_array_change(p_doc jsonb, p_path text[], p_op text, p_key text, p_changes jsonb) returns jsonb
135
+ language plpgsql immutable set search_path = pg_catalog as $$
136
+ declare
137
+ arr jsonb := p_doc #> p_path;
138
+ i int;
139
+ e jsonb;
140
+ begin
141
+ if arr is null or jsonb_typeof(arr) <> 'array' then
142
+ arr := '[]';
143
+ end if;
144
+ if p_op = 'insert' then
145
+ arr := arr || jsonb_build_array(coalesce(meta.jsonb_patch('{}', p_changes), 'null'));
146
+ else
147
+ select k.idx into i from meta.jsonb_element_keys(arr) k where k.key = p_key;
148
+ if i is null then
149
+ raise exception 'The element % of % is no longer there: someone changed the document meanwhile. Reload and try again.',
150
+ p_key, array_to_string(p_path, '.') using errcode = '40001';
151
+ end if;
152
+ if p_op = 'delete' then
153
+ arr := arr - i;
154
+ elsif p_op = 'update' then
155
+ e := arr -> i;
156
+ if jsonb_typeof(e) <> 'object' and exists (select from jsonb_array_elements(p_changes) c where jsonb_array_length(c->'p') > 0) then
157
+ e := '{}';
158
+ end if;
159
+ arr := jsonb_set(arr, array[i::text], coalesce(meta.jsonb_patch(e, p_changes), 'null'));
160
+ else
161
+ raise exception 'meta.jsonb_array_change: unknown operation %', p_op;
162
+ end if;
163
+ end if;
164
+ return meta.jsonb_put(p_doc, p_path, arr);
165
+ end $$;
166
+
167
+ -- The WHERE condition on a table's key columns, with the values from $1 (a jsonb row): typed by the table's row type.
168
+ create function meta.json_key_where(p_schema text, p_table text, p_key jsonb, p_alias text default 't', p_param int default 1) returns text
169
+ language sql immutable set search_path = pg_catalog as $$
170
+ select string_agg(format('%I.%I = (jsonb_populate_record(null::%I.%I, $%s)).%I', p_alias, k, p_schema, p_table, p_param, k), ' and ')
171
+ from jsonb_array_elements_text(p_key) k
172
+ $$;
173
+
174
+ -- The changes of the profile's columns between two rows of a view (to_jsonb(old), to_jsonb(new)).
175
+ create function meta.json_column_changes(p_columns jsonb, p_old jsonb, p_new jsonb) returns jsonb
176
+ language sql immutable parallel safe set search_path = pg_catalog as $$
177
+ select coalesce(jsonb_agg(jsonb_build_object('p', c->'path', 'v', p_new -> (c->>'name'))), '[]')
178
+ from jsonb_array_elements(p_columns) c
179
+ where coalesce(c->>'readonly', 'false') <> 'true'
180
+ and (p_old is null or (p_new -> (c->>'name')) is distinct from (p_old -> (c->>'name')))
181
+ and (p_old is not null or jsonb_typeof(p_new -> (c->>'name')) is distinct from 'null')
182
+ $$;
183
+
184
+ -- INSTEAD OF trigger of a JSON source's view. Argument: {"schema", "table", "key": [columns], "column",
185
+ -- "columns": [{"name", "path"}], "rows": true|false, "name"}.
186
+ create function meta.json_source_write() returns trigger
187
+ language plpgsql security invoker set search_path = pg_catalog as $$
188
+ declare
189
+ s jsonb := tg_argv[0]::jsonb;
190
+ o jsonb := case when tg_op <> 'INSERT' then to_jsonb(old) end;
191
+ n jsonb := case when tg_op <> 'DELETE' then to_jsonb(new) end;
192
+ k text;
193
+ ch jsonb;
194
+ cnt int;
195
+ cols text := '';
196
+ vals text := '';
197
+ r jsonb;
198
+ begin
199
+ if tg_op = 'UPDATE' then
200
+ for k in select jsonb_array_elements_text(s->'key') loop
201
+ if (n->k) is distinct from (o->k) then
202
+ raise exception 'JSON source %: the key column % is not changed through the view', s->>'name', k;
203
+ end if;
204
+ end loop;
205
+ ch := meta.json_column_changes(s->'columns', o, n);
206
+ if jsonb_array_length(ch) > 0 then
207
+ execute format('update %I.%I t set %I = meta.jsonb_patch(t.%I, $2) where %s',
208
+ s->>'schema', s->>'table', s->>'column', s->>'column', meta.json_key_where(s->>'schema', s->>'table', s->'key'))
209
+ using o, ch;
210
+ get diagnostics cnt = row_count;
211
+ if cnt = 0 then
212
+ return null;
213
+ end if;
214
+ end if;
215
+ return new;
216
+ end if;
217
+ if coalesce(s->>'rows', 'false') <> 'true' then
218
+ raise exception 'JSON source % changes the documents of existing rows of %.%: % through its view is not allowed',
219
+ s->>'name', s->>'schema', s->>'table', lower(tg_op) using errcode = '42501';
220
+ end if;
221
+ if tg_op = 'DELETE' then
222
+ execute format('delete from %I.%I t where %s', s->>'schema', s->>'table', meta.json_key_where(s->>'schema', s->>'table', s->'key')) using o;
223
+ get diagnostics cnt = row_count;
224
+ return case when cnt = 0 then null else old end;
225
+ end if;
226
+ -- INSERT: the key columns given (others take their defaults) and the document made of the given values
227
+ for k in select jsonb_array_elements_text(s->'key') loop
228
+ if jsonb_typeof(n->k) is distinct from 'null' and (n->k) is not null then
229
+ cols := cols || format('%I, ', k);
230
+ vals := vals || format('(jsonb_populate_record(null::%I.%I, $1)).%I, ', s->>'schema', s->>'table', k);
231
+ end if;
232
+ end loop;
233
+ execute format('insert into %I.%I as t (%s%I) values (%s$2) returning to_jsonb(t)',
234
+ s->>'schema', s->>'table', cols, s->>'column', vals)
235
+ into r using n, coalesce(meta.jsonb_patch('{}', meta.json_column_changes(s->'columns', null, n)), '{}');
236
+ new := jsonb_populate_record(new, r);
237
+ return new;
238
+ end $$;
239
+
240
+ -- INSTEAD OF trigger of an array's view. Argument: {"schema", "table", "key": [the table's key columns, also
241
+ -- columns of the view], "column", "array": [path], "columns": [{"name", "path"}], "row_key", "position", "name"}.
242
+ create function meta.json_array_write() returns trigger
243
+ language plpgsql security invoker set search_path = pg_catalog as $$
244
+ declare
245
+ s jsonb := tg_argv[0]::jsonb;
246
+ o jsonb := case when tg_op <> 'INSERT' then to_jsonb(old) end;
247
+ n jsonb := case when tg_op <> 'DELETE' then to_jsonb(new) end;
248
+ rk text := coalesce(s->>'row_key', 'row_key');
249
+ k text;
250
+ ch jsonb;
251
+ e jsonb;
252
+ doc jsonb;
253
+ cnt int;
254
+ arr text[] := array(select jsonb_array_elements_text(s->'array'));
255
+ begin
256
+ if tg_op = 'UPDATE' then
257
+ for k in select jsonb_array_elements_text(s->'key') loop
258
+ if (n->k) is distinct from (o->k) then
259
+ raise exception 'JSON source %: an element does not move to another row (column %)', s->>'name', k;
260
+ end if;
261
+ end loop;
262
+ ch := meta.json_column_changes(s->'columns', o, n);
263
+ if jsonb_array_length(ch) = 0 then
264
+ return new;
265
+ end if;
266
+ elsif tg_op = 'INSERT' then
267
+ for k in select jsonb_array_elements_text(s->'key') loop
268
+ if n->k is null or jsonb_typeof(n->k) = 'null' then
269
+ raise exception 'JSON source %: a new element needs the row it belongs to (column %)', s->>'name', k using errcode = '23502';
270
+ end if;
271
+ end loop;
272
+ ch := meta.json_column_changes(s->'columns', null, n);
273
+ end if;
274
+ execute format('update %I.%I t set %I = meta.jsonb_array_change(t.%I, $2, $3, $4, $5) where %s returning t.%I',
275
+ s->>'schema', s->>'table', s->>'column', s->>'column', meta.json_key_where(s->>'schema', s->>'table', s->'key'), s->>'column')
276
+ into doc using coalesce(o, n), arr, lower(tg_op), o->>rk, ch;
277
+ get diagnostics cnt = row_count;
278
+ if cnt = 0 then
279
+ return null;
280
+ end if;
281
+ if tg_op = 'DELETE' then
282
+ return old;
283
+ end if;
284
+ -- a new element: its key and position as the view now shows them (the last one)
285
+ if tg_op = 'INSERT' then
286
+ select jsonb_build_object(rk, k3.key, coalesce(s->>'position', 'position'), k3.idx + 1) into e
287
+ from meta.jsonb_element_keys(doc #> arr) k3
288
+ order by k3.idx desc limit 1;
289
+ new := jsonb_populate_record(new, e);
290
+ end if;
291
+ return new;
292
+ end $$;
293
+
294
+ -- INSTEAD OF trigger of a document view. Argument: {"schema", "table", "key": [columns], "document": "doc",
295
+ -- "children": [{"name", "schema", "table", "key": [columns], "fk": [[child column, parent column], …]}]}.
296
+ create function meta.json_document_write() returns trigger
297
+ language plpgsql security invoker set search_path = pg_catalog as $$
298
+ declare
299
+ s jsonb := tg_argv[0]::jsonb;
300
+ dcol text := coalesce(s->>'document', 'doc');
301
+ o jsonb := case when tg_op <> 'INSERT' then to_jsonb(old) end;
302
+ d jsonb := case when tg_op <> 'DELETE' then to_jsonb(new) -> dcol end;
303
+ pk jsonb; -- the parent row's key columns and values
304
+ ch jsonb;
305
+ c jsonb;
306
+ e jsonb;
307
+ row_ jsonb;
308
+ kept jsonb[];
309
+ cols text[];
310
+ sets text;
311
+ ins text;
312
+ fkw text;
313
+ k text;
314
+ cnt int;
315
+ childnames text[] := array(select x->>'name' from jsonb_array_elements(coalesce(s->'children', '[]')) x);
316
+ begin
317
+ if tg_op <> 'DELETE' and (d is null or jsonb_typeof(d) <> 'object') then
318
+ raise exception 'The document of %.% is a JSON object', s->>'schema', s->>'table' using errcode = '22023';
319
+ end if;
320
+ -- the parent row
321
+ if tg_op = 'DELETE' then
322
+ pk := (select jsonb_object_agg(k2, o->k2) from jsonb_array_elements_text(s->'key') k2);
323
+ elsif tg_op = 'UPDATE' then
324
+ pk := (select jsonb_object_agg(k2, o->k2) from jsonb_array_elements_text(s->'key') k2);
325
+ for k in select jsonb_array_elements_text(s->'key') loop
326
+ if d ? k and (d->k) is distinct from (o->k) and (d->>k) is distinct from (o->>k) then
327
+ raise exception 'The key column % of %.% is not changed through its document', k, s->>'schema', s->>'table';
328
+ end if;
329
+ end loop;
330
+ cols := array(
331
+ select a.attname::text from pg_attribute a
332
+ where a.attrelid = format('%I.%I', s->>'schema', s->>'table')::regclass and a.attnum > 0 and not a.attisdropped
333
+ and a.attgenerated = '' and a.attidentity <> 'a' and d ? a.attname
334
+ and a.attname <> all (array(select jsonb_array_elements_text(s->'key'))) and a.attname <> all (childnames));
335
+ if cardinality(cols) > 0 then
336
+ select string_agg(format('%I = r.%I', x, x), ', ') into sets from unnest(cols) x;
337
+ execute format('update %I.%I t set %s from jsonb_populate_record(null::%I.%I, $2) r where %s',
338
+ s->>'schema', s->>'table', sets, s->>'schema', s->>'table', meta.json_key_where(s->>'schema', s->>'table', s->'key'))
339
+ using pk, d;
340
+ get diagnostics cnt = row_count;
341
+ if cnt = 0 then
342
+ return null;
343
+ end if;
344
+ end if;
345
+ else
346
+ cols := array(
347
+ select a.attname::text from pg_attribute a
348
+ where a.attrelid = format('%I.%I', s->>'schema', s->>'table')::regclass and a.attnum > 0 and not a.attisdropped
349
+ and a.attgenerated = '' and a.attidentity <> 'a' and d ? a.attname and jsonb_typeof(d->a.attname) <> 'null'
350
+ and a.attname <> all (childnames));
351
+ select string_agg(format('%I', x), ', '), string_agg(format('r.%I', x), ', ') into sets, ins from unnest(cols) x;
352
+ if sets is null then
353
+ execute format('insert into %I.%I as t default values returning to_jsonb(t)', s->>'schema', s->>'table') into row_;
354
+ else
355
+ execute format('insert into %I.%I as t (%s) select %s from jsonb_populate_record(null::%I.%I, $1) r returning to_jsonb(t)',
356
+ s->>'schema', s->>'table', sets, ins, s->>'schema', s->>'table')
357
+ into row_ using d;
358
+ end if;
359
+ pk := (select jsonb_object_agg(k2, row_->k2) from jsonb_array_elements_text(s->'key') k2);
360
+ end if;
361
+
362
+ -- the children
363
+ for c in select x from jsonb_array_elements(coalesce(s->'children', '[]')) x loop
364
+ select string_agg(format('x.%I = (jsonb_populate_record(null::%I.%I, $1)).%I', f->>0, s->>'schema', s->>'table', f->>1), ' and ')
365
+ into fkw from jsonb_array_elements(c->'fk') f;
366
+ if tg_op = 'DELETE' then
367
+ execute format('delete from %I.%I x where %s', c->>'schema', c->>'table', fkw) using pk;
368
+ continue;
369
+ end if;
370
+ if not (d ? (c->>'name')) then
371
+ continue;
372
+ end if;
373
+ if jsonb_typeof(d->(c->>'name')) <> 'array' then
374
+ raise exception 'In the document of %.%, % is an array', s->>'schema', s->>'table', c->>'name' using errcode = '22023';
375
+ end if;
376
+ kept := '{}';
377
+ for e in select x from jsonb_array_elements(d->(c->>'name')) x loop
378
+ if jsonb_typeof(e) <> 'object' then
379
+ raise exception 'In the document of %.%, the elements of % are objects', s->>'schema', s->>'table', c->>'name' using errcode = '22023';
380
+ end if;
381
+ -- the foreign key columns come from the parent
382
+ e := e || (select jsonb_object_agg(f->>0, pk->(f->>1)) from jsonb_array_elements(c->'fk') f);
383
+ row_ := null;
384
+ if not exists (select from jsonb_array_elements_text(c->'key') k2 where e->k2 is null or jsonb_typeof(e->k2) = 'null') then
385
+ cols := array(
386
+ select a.attname::text from pg_attribute a
387
+ where a.attrelid = format('%I.%I', c->>'schema', c->>'table')::regclass and a.attnum > 0 and not a.attisdropped
388
+ and a.attgenerated = '' and a.attidentity <> 'a' and e ? a.attname
389
+ and a.attname <> all (array(select jsonb_array_elements_text(c->'key'))));
390
+ select string_agg(format('%I = r.%I', x, x), ', ') into sets from unnest(cols) x;
391
+ execute format('update %I.%I x set %s from jsonb_populate_record(null::%I.%I, $2) r where %s and %s returning to_jsonb(x)',
392
+ c->>'schema', c->>'table', coalesce(sets, format('%I = x.%I', c->'key'->>0, c->'key'->>0)), c->>'schema', c->>'table',
393
+ fkw, meta.json_key_where(c->>'schema', c->>'table', c->'key', 'x', 2))
394
+ into row_ using pk, e;
395
+ end if;
396
+ if row_ is null then
397
+ cols := array(
398
+ select a.attname::text from pg_attribute a
399
+ where a.attrelid = format('%I.%I', c->>'schema', c->>'table')::regclass and a.attnum > 0 and not a.attisdropped
400
+ and a.attgenerated = '' and a.attidentity <> 'a' and e ? a.attname and jsonb_typeof(e->a.attname) <> 'null');
401
+ select string_agg(format('%I', x), ', '), string_agg(format('r.%I', x), ', ') into sets, ins from unnest(cols) x;
402
+ execute format('insert into %I.%I as x (%s) select %s from jsonb_populate_record(null::%I.%I, $1) r returning to_jsonb(x)',
403
+ c->>'schema', c->>'table', sets, ins, c->>'schema', c->>'table')
404
+ into row_ using e;
405
+ end if;
406
+ kept := kept || (select jsonb_object_agg(k2, row_->k2) from jsonb_array_elements_text(c->'key') k2);
407
+ end loop;
408
+ -- the children the document leaves out
409
+ execute format('delete from %I.%I x where %s and not ((select jsonb_object_agg(k, to_jsonb(x)->k) from jsonb_array_elements_text($3) k) = any ($2))',
410
+ c->>'schema', c->>'table', fkw)
411
+ using pk, kept, c->'key';
412
+ end loop;
413
+
414
+ if tg_op = 'DELETE' then
415
+ execute format('delete from %I.%I t where %s', s->>'schema', s->>'table', meta.json_key_where(s->>'schema', s->>'table', s->'key')) using pk;
416
+ get diagnostics cnt = row_count;
417
+ return case when cnt = 0 then null else old end;
418
+ end if;
419
+ new := jsonb_populate_record(new, pk);
420
+ return new;
421
+ end $$;
422
+
423
+ grant execute on function meta.input_ok(text, text), meta.jsonb_put(jsonb, text[], jsonb), meta.jsonb_patch(jsonb, jsonb),
424
+ meta.jsonb_element_keys(jsonb), meta.jsonb_array_change(jsonb, text[], text, text, jsonb),
425
+ meta.json_key_where(text, text, jsonb, text, int), meta.json_column_changes(jsonb, jsonb, jsonb),
426
+ meta.json_source_write(), meta.json_array_write(), meta.json_document_write() to public;