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.
- package/db/migrations/141_workspace_resources.sql +115 -0
- package/db/migrations/142_json_sources.sql +426 -0
- package/docs/apex-feature-parity.md +11 -8
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +62 -4
- package/docs/guide/04-pages-and-regions.md +1 -0
- package/docs/guide/12-development.md +26 -1
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +149 -4
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/examples/hr/hr_77_json_sources.sql +174 -0
- package/package.json +1 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +7 -0
- package/src/builder/advisor.ts +13 -1
- package/src/builder/ai.ts +29 -3
- package/src/builder/designer.ts +1 -1
- package/src/builder/document-view.ts +117 -0
- package/src/builder/home.ts +3 -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/network-access.ts +33 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/report-settings.ts +9 -0
- package/src/builder/rest-catalogs.ts +22 -14
- package/src/builder/routes.ts +5 -1
- package/src/builder/shared.ts +1 -1
- package/src/builder/static-files.ts +14 -4
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/websources.ts +18 -9
- package/src/builder/workflows.ts +2 -1
- package/src/builder/workspace-resources.ts +528 -0
- package/src/json-sources.ts +365 -0
- package/src/metadata.ts +17 -3
- package/src/remote-servers.ts +133 -0
- package/src/restcatalog.ts +21 -10
- package/src/restsync.ts +1 -1
- package/src/runtime/context.ts +2 -0
- package/src/runtime/report.ts +3 -1
- package/src/runtime/static-files.ts +46 -17
- package/src/runtime/template-components.ts +2 -1
- package/src/websources.ts +43 -18
- 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;
|