letopis 0.21.0 → 1.0.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/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +313 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3469
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +362 -563
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -252
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1369 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -352
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -685
- package/sql/seed.auth.sql +0 -73
package/sql/60-write.sql
ADDED
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
-- letopis 1.0 — конвейер записи (план, §2.5, §2.7, §2.10, этап 1, п. 7–11).
|
|
2
|
+
--
|
|
3
|
+
-- Приложение пишет обычными insert, update, delete; всё проверяют триггеры entity. У каждого
|
|
4
|
+
-- триггера условие when (current_user <> владелец): изменения, которые делает функция владельца
|
|
5
|
+
-- (в том числе сами триггерные функции — они security definer), триггеры не перепроверяют,
|
|
6
|
+
-- поэтому функции владельца обязаны проверять всё сами, сами писать журнал и сами посылать NOTIFY.
|
|
7
|
+
|
|
8
|
+
-- Время версии: обёртка над clock_timestamp(); тест монотонности подменяет её в своей схеме.
|
|
9
|
+
create or replace function @SCHEMA@.now_ts() returns timestamptz
|
|
10
|
+
language sql volatile
|
|
11
|
+
return clock_timestamp();
|
|
12
|
+
|
|
13
|
+
-- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня
|
|
14
|
+
-- семейства, ужесточение описания — исключительную. Форма с двумя int4 — отдельное от
|
|
15
|
+
-- пользовательского lock() (один bigint) пространство ключей advisory-блокировок. p_cls — строка
|
|
16
|
+
-- Class класса семейства. Ключ — арендатор корневого класса и имя корня (итоговое ревью
|
|
17
|
+
-- безопасности): ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не
|
|
18
|
+
-- останавливает; корень из System (общий для всех арендаторов) — ключ System.
|
|
19
|
+
create or replace function @SCHEMA@.family_lock(p_cls @SCHEMA@.entity, p_exclusive boolean) returns void
|
|
20
|
+
language plpgsql volatile
|
|
21
|
+
as $$
|
|
22
|
+
declare
|
|
23
|
+
root text := p_cls.data->'resolved'->'ancestors'->>-1;
|
|
24
|
+
k int;
|
|
25
|
+
begin
|
|
26
|
+
k := hashtext('@SCHEMA_NAME@:' || (case when p_cls.tenant = '@SYSTEM@'::uuid or p_cls.data->>'name' = root then p_cls.tenant
|
|
27
|
+
else coalesce((@SCHEMA@.class_row(root, p_cls.tenant)).tenant, p_cls.tenant) end)::text || ':' || root);
|
|
28
|
+
if p_exclusive then
|
|
29
|
+
perform pg_advisory_xact_lock(19540, k);
|
|
30
|
+
else
|
|
31
|
+
perform pg_advisory_xact_lock_shared(19540, k);
|
|
32
|
+
end if;
|
|
33
|
+
end $$;
|
|
34
|
+
-- до итогового ревью безопасности ключ был без арендатора
|
|
35
|
+
drop function if exists @SCHEMA@.family_lock(text, boolean);
|
|
36
|
+
|
|
37
|
+
-- Список классов конца: строка или массив строк → отсортированный jsonb-массив.
|
|
38
|
+
create or replace function @SCHEMA@.class_set(p jsonb) returns jsonb
|
|
39
|
+
language sql immutable parallel safe
|
|
40
|
+
return (
|
|
41
|
+
select coalesce(jsonb_agg(x order by x), '[]'::jsonb)
|
|
42
|
+
from jsonb_array_elements_text(case jsonb_typeof(p) when 'array' then p else jsonb_build_array(p) end) x
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
-- Входит ли класс цели (с учётом его предков) в список допустимых классов конца.
|
|
46
|
+
create or replace function @SCHEMA@.class_accepts(p_allowed jsonb, p_target_class text, p_target_tenant uuid) returns boolean
|
|
47
|
+
language sql stable
|
|
48
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
49
|
+
as $$
|
|
50
|
+
select exists (
|
|
51
|
+
select 1
|
|
52
|
+
from jsonb_array_elements_text(@SCHEMA@.class_set(p_allowed)) a(name)
|
|
53
|
+
where a.name in (select jsonb_array_elements_text(
|
|
54
|
+
(@SCHEMA@.class_row(p_target_class, p_target_tenant)).data->'resolved'->'ancestors'))
|
|
55
|
+
)
|
|
56
|
+
$$;
|
|
57
|
+
|
|
58
|
+
-- Принимает ли роль ref_role класса ref_class цель класса target_class (с учётом предков цели).
|
|
59
|
+
create or replace function @SCHEMA@.role_accepts(p_ref_class text, p_ref_tenant uuid, p_role text,
|
|
60
|
+
p_target_class text, p_target_tenant uuid) returns boolean
|
|
61
|
+
language sql stable
|
|
62
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
63
|
+
as $$
|
|
64
|
+
select exists (
|
|
65
|
+
select 1
|
|
66
|
+
from jsonb_array_elements_text(@SCHEMA@.class_set(
|
|
67
|
+
(@SCHEMA@.class_row(p_ref_class, p_ref_tenant)).data->'resolved'->'ends'->p_role->'class')) a(name)
|
|
68
|
+
where a.name in (select jsonb_array_elements_text(
|
|
69
|
+
(@SCHEMA@.class_row(p_target_class, p_target_tenant)).data->'resolved'->'ancestors'))
|
|
70
|
+
)
|
|
71
|
+
$$;
|
|
72
|
+
|
|
73
|
+
-- Умолчания из JSON Schema: на каждом уровне для отсутствующих полей; во вложенные объекты —
|
|
74
|
+
-- только если объект уже есть.
|
|
75
|
+
create or replace function @SCHEMA@.apply_defaults(p_schema jsonb, p_data jsonb) returns jsonb
|
|
76
|
+
language plpgsql immutable
|
|
77
|
+
as $$
|
|
78
|
+
declare
|
|
79
|
+
p record;
|
|
80
|
+
d jsonb := p_data;
|
|
81
|
+
begin
|
|
82
|
+
if jsonb_typeof(d) is distinct from 'object' or jsonb_typeof(p_schema->'properties') is distinct from 'object' then
|
|
83
|
+
return d;
|
|
84
|
+
end if;
|
|
85
|
+
for p in select key, value from jsonb_each(p_schema->'properties') loop
|
|
86
|
+
if not (d ? p.key) then
|
|
87
|
+
if p.value ? 'default' then
|
|
88
|
+
d := d || jsonb_build_object(p.key, p.value->'default');
|
|
89
|
+
end if;
|
|
90
|
+
elsif jsonb_typeof(d->p.key) = 'object' then
|
|
91
|
+
d := jsonb_set(d, array[p.key], @SCHEMA@.apply_defaults(p.value, d->p.key));
|
|
92
|
+
end if;
|
|
93
|
+
end loop;
|
|
94
|
+
return d;
|
|
95
|
+
end $$;
|
|
96
|
+
|
|
97
|
+
-- Разбор и нормализация концов (этап 1, п. 7): объявлены, обязательные на месте, у множественных
|
|
98
|
+
-- нет повторов, соблюдены min и max; uuid — в каноническом виде строчными. К таблицам не обращается:
|
|
99
|
+
-- цели проверяет targets_bad() — триггером после оператора, одним запросом на все строки.
|
|
100
|
+
-- Ошибку не бросает: возвращает код и сообщение.
|
|
101
|
+
create or replace function @SCHEMA@.links_shape(p_res jsonb, p_links jsonb, out links jsonb, out code text, out msg text)
|
|
102
|
+
language plpgsql immutable
|
|
103
|
+
as $$
|
|
104
|
+
#variable_conflict use_variable
|
|
105
|
+
declare
|
|
106
|
+
l record;
|
|
107
|
+
e record;
|
|
108
|
+
v jsonb;
|
|
109
|
+
arr jsonb;
|
|
110
|
+
n int;
|
|
111
|
+
begin
|
|
112
|
+
links := '{}'::jsonb;
|
|
113
|
+
if p_links is null or p_links = 'null'::jsonb then
|
|
114
|
+
p_links := '{}'::jsonb;
|
|
115
|
+
end if;
|
|
116
|
+
if jsonb_typeof(p_links) <> 'object' then
|
|
117
|
+
code := 'invalid_data'; msg := 'links — не объект'; return;
|
|
118
|
+
end if;
|
|
119
|
+
for l in select key, value from jsonb_each(p_links) loop
|
|
120
|
+
if not coalesce(p_res->'ends' ? l.key, false) then
|
|
121
|
+
code := 'undeclared_end'; msg := format('конец %s не объявлен в классе %s', l.key, p_res->>'name'); return;
|
|
122
|
+
end if;
|
|
123
|
+
continue when l.value = 'null'::jsonb;
|
|
124
|
+
if coalesce((p_res->'ends'->l.key->>'many')::boolean, false) then
|
|
125
|
+
if jsonb_typeof(l.value) <> 'array' then
|
|
126
|
+
code := 'invalid_data'; msg := format('конец %s — список id', l.key); return;
|
|
127
|
+
end if;
|
|
128
|
+
arr := '[]'::jsonb;
|
|
129
|
+
for v in select value from jsonb_array_elements(l.value) loop
|
|
130
|
+
if jsonb_typeof(v) <> 'string' or not pg_input_is_valid(v #>> '{}', 'uuid') then
|
|
131
|
+
code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
|
|
132
|
+
end if;
|
|
133
|
+
v := to_jsonb((v #>> '{}')::uuid::text);
|
|
134
|
+
if arr @> jsonb_build_array(v) then
|
|
135
|
+
code := 'invalid_data'; msg := format('конец %s: цель повторяется', l.key); return;
|
|
136
|
+
end if;
|
|
137
|
+
arr := arr || jsonb_build_array(v);
|
|
138
|
+
end loop;
|
|
139
|
+
links := links || jsonb_build_object(l.key, arr);
|
|
140
|
+
else
|
|
141
|
+
if jsonb_typeof(l.value) <> 'string' or not pg_input_is_valid(l.value #>> '{}', 'uuid') then
|
|
142
|
+
code := 'invalid_data'; msg := format('конец %s: значение не uuid', l.key); return;
|
|
143
|
+
end if;
|
|
144
|
+
links := links || jsonb_build_object(l.key, (l.value #>> '{}')::uuid::text);
|
|
145
|
+
end if;
|
|
146
|
+
end loop;
|
|
147
|
+
for e in select key, value from jsonb_each(coalesce(p_res->'ends', '{}'::jsonb)) loop
|
|
148
|
+
if coalesce((e.value->>'many')::boolean, false) then
|
|
149
|
+
n := coalesce(jsonb_array_length(links->e.key), 0);
|
|
150
|
+
if n < greatest(coalesce((e.value->>'min')::int, 0), case when coalesce((e.value->>'required')::boolean, false) then 1 else 0 end) then
|
|
151
|
+
code := 'invalid_data'; msg := format('у конца %s слишком мало целей', e.key); return;
|
|
152
|
+
end if;
|
|
153
|
+
if e.value ? 'max' and n > (e.value->>'max')::int then
|
|
154
|
+
code := 'invalid_data'; msg := format('у конца %s слишком много целей', e.key); return;
|
|
155
|
+
end if;
|
|
156
|
+
elsif coalesce((e.value->>'required')::boolean, false) and not links ? e.key then
|
|
157
|
+
code := 'invalid_data'; msg := format('обязательный конец %s не задан', e.key); return;
|
|
158
|
+
end if;
|
|
159
|
+
end loop;
|
|
160
|
+
end $$;
|
|
161
|
+
|
|
162
|
+
-- Цели концов набора строк (решение 2 точки А): существуют, принадлежат арендатору строки или
|
|
163
|
+
-- System и относятся к классу конца или его потомку. p_rows — [{class, tenant, links[, ends]}]
|
|
164
|
+
-- (ends — описание концов, если его ещё нет в строке Class). При p_lock цели сначала блокируются
|
|
165
|
+
-- for key share в порядке id — каждая один раз на оператор, только свои и системные: строку чужого
|
|
166
|
+
-- арендатора не заблокировать и по ожиданию не обнаружить. Проверка идёт после блокировки, по
|
|
167
|
+
-- свежим строкам: цель, которую успели удалить или перевести в чужой класс, не пройдёт. Класс цели
|
|
168
|
+
-- прямо в списке конца — без поиска описания; иначе — предки из строки Class цели.
|
|
169
|
+
-- При p_acl ({tenant, account, plan} — план READ актора) цель ещё и должна быть видна актору (этап 5,
|
|
170
|
+
-- п. 6): строку, которую он не видит, нельзя использовать как цель — ответ тот же, что для
|
|
171
|
+
-- несуществующей. Цели-аккаунты (Account) не проверяются: на них ссылаются членства и владельцы.
|
|
172
|
+
-- Возвращает роль первой непрошедшей цели или null.
|
|
173
|
+
create or replace function @SCHEMA@.targets_bad(p_rows jsonb, p_lock boolean, p_acl jsonb default null) returns text
|
|
174
|
+
language plpgsql volatile
|
|
175
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
176
|
+
as $$
|
|
177
|
+
declare
|
|
178
|
+
bad text;
|
|
179
|
+
begin
|
|
180
|
+
if p_rows is null or jsonb_array_length(p_rows) = 0 then
|
|
181
|
+
return null;
|
|
182
|
+
end if;
|
|
183
|
+
if p_lock then
|
|
184
|
+
perform 1 from @SCHEMA@.entity t
|
|
185
|
+
where t.id in (select (v #>> '{}')::uuid from jsonb_array_elements(p_rows) r
|
|
186
|
+
cross join lateral jsonb_path_query(r->'links', 'lax $.*[*]') v)
|
|
187
|
+
and t.tenant in (select (r->>'tenant')::uuid from jsonb_array_elements(p_rows) r union all select '@SYSTEM@'::uuid)
|
|
188
|
+
order by t.id for key share;
|
|
189
|
+
end if;
|
|
190
|
+
with r as (
|
|
191
|
+
select x.value as item, (x.value->>'tenant')::uuid as tenant from jsonb_array_elements(p_rows) x
|
|
192
|
+
), c as (
|
|
193
|
+
select d.cls, d.tenant, coalesce(d.ends, (@SCHEMA@.class_row(d.cls, d.tenant)).data->'resolved'->'ends') as ends
|
|
194
|
+
from (select distinct r.item->>'class' as cls, r.tenant, r.item->'ends' as ends from r) d
|
|
195
|
+
), p as (
|
|
196
|
+
select r.item->>'class' as cls, r.tenant, r.item->'ends' as ends, k.key as role,
|
|
197
|
+
(case jsonb_typeof(k.value) when 'array' then a.value else k.value end #>> '{}')::uuid as tid
|
|
198
|
+
from r
|
|
199
|
+
cross join lateral jsonb_each(r.item->'links') k
|
|
200
|
+
left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
|
|
201
|
+
where jsonb_typeof(k.value) = 'string' or a.value is not null
|
|
202
|
+
)
|
|
203
|
+
select p.role into bad
|
|
204
|
+
from p
|
|
205
|
+
join c on c.cls = p.cls and c.tenant = p.tenant and c.ends is not distinct from coalesce(p.ends, c.ends)
|
|
206
|
+
left join @SCHEMA@.entity t on t.id = p.tid
|
|
207
|
+
where t.id is null
|
|
208
|
+
or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
|
|
209
|
+
or not coalesce(@SCHEMA@.class_set(c.ends->p.role->'class') ? t.class
|
|
210
|
+
or @SCHEMA@.class_set(c.ends->p.role->'class') ?|
|
|
211
|
+
(select array(select jsonb_array_elements_text(k.data->'resolved'->'ancestors'))
|
|
212
|
+
from @SCHEMA@.entity k
|
|
213
|
+
where k.class = 'Class' and k.data->>'name' = t.class and k.tenant in (t.tenant, '@SYSTEM@'::uuid)
|
|
214
|
+
order by k.tenant = '@SYSTEM@'::uuid limit 1), false)
|
|
215
|
+
or (p_acl is not null and t.class <> 'Account'
|
|
216
|
+
and not @SCHEMA@.row_visible(t.tenant, t.class, t.id, t.owner, t.tags, t.data, t.links,
|
|
217
|
+
(p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan'))
|
|
218
|
+
limit 1;
|
|
219
|
+
return bad;
|
|
220
|
+
end $$;
|
|
221
|
+
-- до этапа 5 цели не сверялись с правами актора
|
|
222
|
+
drop function if exists @SCHEMA@.targets_bad(jsonb, boolean);
|
|
223
|
+
|
|
224
|
+
-- Разбор концов и проверка целей одной строки — для функций владельца (reclass, проверка строк
|
|
225
|
+
-- семейства), которые пишут мимо триггеров. При p_lock цели блокируются for key share; p_acl — как
|
|
226
|
+
-- у targets_bad (цели видны актору).
|
|
227
|
+
create or replace function @SCHEMA@.check_links(p_res jsonb, p_links jsonb, p_tenant uuid, p_lock boolean, p_acl jsonb,
|
|
228
|
+
out links jsonb, out code text, out msg text)
|
|
229
|
+
language plpgsql volatile
|
|
230
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
231
|
+
as $$
|
|
232
|
+
#variable_conflict use_variable
|
|
233
|
+
declare
|
|
234
|
+
bad text;
|
|
235
|
+
begin
|
|
236
|
+
select s.links, s.code, s.msg into links, code, msg from @SCHEMA@.links_shape(p_res, p_links) s;
|
|
237
|
+
if code is not null or links = '{}'::jsonb then
|
|
238
|
+
return;
|
|
239
|
+
end if;
|
|
240
|
+
bad := @SCHEMA@.targets_bad(jsonb_build_array(jsonb_build_object(
|
|
241
|
+
'class', p_res->>'name', 'tenant', p_tenant, 'links', links, 'ends', coalesce(p_res->'ends', '{}'::jsonb))), p_lock, p_acl);
|
|
242
|
+
if bad is not null then
|
|
243
|
+
code := 'target_not_found'; msg := format('цель конца %s не найдена', bad);
|
|
244
|
+
end if;
|
|
245
|
+
end $$;
|
|
246
|
+
drop function if exists @SCHEMA@.check_links(jsonb, jsonb, uuid, boolean);
|
|
247
|
+
|
|
248
|
+
-- id класса с ключом — v5 от арендатора, класса и значений ключа (§2.3); у класса без ключа — null.
|
|
249
|
+
create or replace function @SCHEMA@.key_id(p_res jsonb, p_class text, p_tenant uuid, p_data jsonb, p_links jsonb) returns uuid
|
|
250
|
+
language sql immutable
|
|
251
|
+
return case when jsonb_array_length(coalesce(p_res->'key', '[]'::jsonb)) = 0 then null else
|
|
252
|
+
@SCHEMA@.v5_id(p_tenant, p_class, array(
|
|
253
|
+
select case when p_res->'schema'->'properties' ? u.k then p_data->>u.k else lower(p_links->>u.k) end
|
|
254
|
+
from jsonb_array_elements_text(p_res->'key') with ordinality as u(k, i)
|
|
255
|
+
order by u.i)) end;
|
|
256
|
+
|
|
257
|
+
-- Ведёт ли класс журнал (history: false — нет).
|
|
258
|
+
create or replace function @SCHEMA@.history_on(p_class text, p_tenant uuid) returns boolean
|
|
259
|
+
language sql stable
|
|
260
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
261
|
+
return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
|
|
262
|
+
|
|
263
|
+
-- Сигнал с именами изменённых классов (§2.12, решение 9 точки А). Транзакция с NOTIFY фиксируется
|
|
264
|
+
-- под общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
|
|
265
|
+
-- letopis.notify = 'deferred': имена копятся в letopis.changed, а библиотека после фиксации
|
|
266
|
+
-- отправляет их одной транзакцией без данных. Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией.
|
|
267
|
+
create or replace function @SCHEMA@.signal(p_classes text[]) returns void
|
|
268
|
+
language plpgsql volatile
|
|
269
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
270
|
+
as $$
|
|
271
|
+
declare
|
|
272
|
+
acc text[];
|
|
273
|
+
cl text;
|
|
274
|
+
begin
|
|
275
|
+
if p_classes is null or cardinality(p_classes) = 0 then
|
|
276
|
+
return;
|
|
277
|
+
end if;
|
|
278
|
+
if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
|
|
279
|
+
acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
|
|
280
|
+
if not (acc @> p_classes) then
|
|
281
|
+
perform set_config('letopis.changed',
|
|
282
|
+
array_to_string(array(select distinct x from unnest(acc || p_classes) x order by x), ','), true);
|
|
283
|
+
end if;
|
|
284
|
+
else
|
|
285
|
+
foreach cl in array p_classes loop
|
|
286
|
+
perform pg_notify('@SCHEMA_NAME@', cl);
|
|
287
|
+
end loop;
|
|
288
|
+
end if;
|
|
289
|
+
end $$;
|
|
290
|
+
|
|
291
|
+
-- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
|
|
292
|
+
-- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
|
|
293
|
+
-- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
|
|
294
|
+
create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
|
|
295
|
+
language plpgsql volatile
|
|
296
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
297
|
+
as $$
|
|
298
|
+
begin
|
|
299
|
+
if @SCHEMA@.history_on(e.class, e.tenant) then
|
|
300
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
301
|
+
values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash);
|
|
302
|
+
end if;
|
|
303
|
+
perform @SCHEMA@.signal(array[e.class]);
|
|
304
|
+
end $$;
|
|
305
|
+
|
|
306
|
+
-- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
|
|
307
|
+
-- служебные колонки, журнал, сигнал. Триггеры при этом не срабатывают.
|
|
308
|
+
create or replace function @SCHEMA@.sys_update(p_id uuid, p_data jsonb, p_links jsonb, p_validator jsonpath, p_op text,
|
|
309
|
+
p_class text default null)
|
|
310
|
+
returns @SCHEMA@.entity
|
|
311
|
+
language plpgsql volatile
|
|
312
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
313
|
+
as $$
|
|
314
|
+
declare
|
|
315
|
+
e @SCHEMA@.entity;
|
|
316
|
+
c record;
|
|
317
|
+
begin
|
|
318
|
+
select * into c from @SCHEMA@.ctx();
|
|
319
|
+
update @SCHEMA@.entity x
|
|
320
|
+
set class = coalesce(p_class, x.class), data = p_data, links = p_links, validator = p_validator,
|
|
321
|
+
rev = x.rev + 1, prev_hash = x.hash,
|
|
322
|
+
at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
|
|
323
|
+
author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = p_op,
|
|
324
|
+
reason = nullif(current_setting('letopis.reason', true), ''), moved = null,
|
|
325
|
+
cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
|
|
326
|
+
where x.id = p_id
|
|
327
|
+
returning x.* into e;
|
|
328
|
+
perform @SCHEMA@.log_version(e);
|
|
329
|
+
return e;
|
|
330
|
+
end $$;
|
|
331
|
+
|
|
332
|
+
-- Надгробие: последний снимок удалённой строки следующей версией с видом delete; при переносе на
|
|
333
|
+
-- новый id (reclass, rekey) moved ссылается на новый id.
|
|
334
|
+
create or replace function @SCHEMA@.tombstone(e @SCHEMA@.entity, p_op text default 'delete', p_moved uuid default null) returns void
|
|
335
|
+
language plpgsql volatile
|
|
336
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
337
|
+
as $$
|
|
338
|
+
declare
|
|
339
|
+
c record;
|
|
340
|
+
begin
|
|
341
|
+
select * into c from @SCHEMA@.ctx();
|
|
342
|
+
e.rev := e.rev + 1;
|
|
343
|
+
e.prev_hash := e.hash;
|
|
344
|
+
e.at := greatest(@SCHEMA@.now_ts(), e.at + interval '1 microsecond');
|
|
345
|
+
e.author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
346
|
+
e.agent := c.agent;
|
|
347
|
+
e.op := p_op;
|
|
348
|
+
e.reason := nullif(current_setting('letopis.reason', true), '');
|
|
349
|
+
e.moved := p_moved;
|
|
350
|
+
perform @SCHEMA@.log_version(e);
|
|
351
|
+
end $$;
|
|
352
|
+
|
|
353
|
+
-- Входящие ссылки на строку p_id, роли которых не принимают класс p_class (смена класса, смена родителя).
|
|
354
|
+
create or replace function @SCHEMA@.incoming_conflicts(p_id uuid, p_class text, p_tenant uuid) returns jsonb
|
|
355
|
+
language sql stable
|
|
356
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
357
|
+
as $$
|
|
358
|
+
select coalesce(jsonb_agg(jsonb_build_object('id', r.id, 'class', r.class, 'role', k.key, 'tenant', r.tenant)), '[]'::jsonb)
|
|
359
|
+
from @SCHEMA@.entity r
|
|
360
|
+
cross join lateral jsonb_each(r.links) k
|
|
361
|
+
where r.ends @> array[p_id]
|
|
362
|
+
and (k.value = to_jsonb(p_id::text) or (jsonb_typeof(k.value) = 'array' and k.value @> jsonb_build_array(p_id::text)))
|
|
363
|
+
and not @SCHEMA@.role_accepts(r.class, r.tenant, k.key, p_class, p_tenant)
|
|
364
|
+
$$;
|
|
365
|
+
|
|
366
|
+
-- Перечень видимых актору строк и число остальных — для ошибок restrict, смены класса, ужесточения.
|
|
367
|
+
-- Видимость — та же, что у чтения (row_visible с планом READ актора), а не только арендатор.
|
|
368
|
+
create or replace function @SCHEMA@.visible_detail(p_rows jsonb, p_tenant uuid) returns jsonb
|
|
369
|
+
language plpgsql stable
|
|
370
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
371
|
+
as $$
|
|
372
|
+
declare
|
|
373
|
+
a jsonb := @SCHEMA@.acl_actor('READ');
|
|
374
|
+
vis jsonb;
|
|
375
|
+
begin
|
|
376
|
+
select coalesce(jsonb_agg(r - 'tenant'), '[]'::jsonb) into vis
|
|
377
|
+
from jsonb_array_elements(p_rows) r
|
|
378
|
+
join @SCHEMA@.entity e on e.id = (r->>'id')::uuid
|
|
379
|
+
where (r->>'tenant')::uuid = p_tenant and a is not null
|
|
380
|
+
and @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links,
|
|
381
|
+
(a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan');
|
|
382
|
+
return jsonb_build_object('rows', vis, 'others', jsonb_array_length(p_rows) - jsonb_array_length(vis));
|
|
383
|
+
end $$;
|
|
384
|
+
|
|
385
|
+
-- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
|
|
386
|
+
-- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк.
|
|
387
|
+
create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
|
|
388
|
+
language sql immutable
|
|
389
|
+
return
|
|
390
|
+
o->'key' = n->'key'
|
|
391
|
+
and o->'ancestors' = n->'ancestors'
|
|
392
|
+
and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
|
|
393
|
+
and coalesce(o->'schema'->'required', '[]'::jsonb) @> coalesce(n->'schema'->'required', '[]'::jsonb)
|
|
394
|
+
and not exists (select 1 from jsonb_each(coalesce(o->'schema'->'properties', '{}'::jsonb)) p
|
|
395
|
+
where n->'schema'->'properties'->p.key is distinct from p.value)
|
|
396
|
+
and not exists (select 1 from jsonb_each(coalesce(o->'ends', '{}'::jsonb)) e
|
|
397
|
+
where n->'ends'->e.key is distinct from e.value)
|
|
398
|
+
and not exists (select 1 from jsonb_each(coalesce(n->'ends', '{}'::jsonb)) e
|
|
399
|
+
where not coalesce(o->'ends' ? e.key, false)
|
|
400
|
+
and (coalesce((e.value->>'required')::boolean, false) or coalesce((e.value->>'min')::int, 0) > 0));
|
|
401
|
+
|
|
402
|
+
-- Строки семейства, которые не проходят текущие описания своих классов (после ужесточения).
|
|
403
|
+
-- Не больше 100 строк; p_incoming — проверять ещё и входящие ссылки (смена родителя).
|
|
404
|
+
create or replace function @SCHEMA@.family_conflicts(p_classes text[], p_tenant uuid, p_incoming boolean) returns jsonb
|
|
405
|
+
language plpgsql volatile
|
|
406
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
407
|
+
as $$
|
|
408
|
+
declare
|
|
409
|
+
r @SCHEMA@.entity;
|
|
410
|
+
cls @SCHEMA@.entity;
|
|
411
|
+
res jsonb;
|
|
412
|
+
bad jsonb;
|
|
413
|
+
lk record;
|
|
414
|
+
problem text;
|
|
415
|
+
inc jsonb;
|
|
416
|
+
outv jsonb := '[]'::jsonb;
|
|
417
|
+
begin
|
|
418
|
+
for r in select e.* from @SCHEMA@.entity e
|
|
419
|
+
where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
420
|
+
order by e.id loop
|
|
421
|
+
cls := @SCHEMA@.class_row(r.class, r.tenant);
|
|
422
|
+
res := cls.data->'resolved';
|
|
423
|
+
problem := null;
|
|
424
|
+
if coalesce((res->>'abstract')::boolean, false) then
|
|
425
|
+
problem := 'класс абстрактный';
|
|
426
|
+
else
|
|
427
|
+
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', r.data);
|
|
428
|
+
if bad <> '[]'::jsonb then
|
|
429
|
+
problem := 'данные: ' || bad::text;
|
|
430
|
+
else
|
|
431
|
+
select * into lk from @SCHEMA@.check_links(res, r.links, r.tenant, false, null);
|
|
432
|
+
if lk.code is not null then
|
|
433
|
+
problem := lk.msg;
|
|
434
|
+
elsif coalesce(@SCHEMA@.key_id(res, r.class, r.tenant, r.data, r.links), r.id) <> r.id then
|
|
435
|
+
problem := 'id не соответствует ключу класса';
|
|
436
|
+
end if;
|
|
437
|
+
end if;
|
|
438
|
+
end if;
|
|
439
|
+
if problem is null and p_incoming then
|
|
440
|
+
inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
|
|
441
|
+
if inc <> '[]'::jsonb then
|
|
442
|
+
problem := format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id');
|
|
443
|
+
end if;
|
|
444
|
+
end if;
|
|
445
|
+
if problem is not null then
|
|
446
|
+
outv := outv || jsonb_build_object('id', r.id, 'class', r.class, 'tenant', r.tenant, 'problem', problem);
|
|
447
|
+
exit when jsonb_array_length(outv) >= 100;
|
|
448
|
+
end if;
|
|
449
|
+
end loop;
|
|
450
|
+
return outv;
|
|
451
|
+
end $$;
|
|
452
|
+
|
|
453
|
+
-- Методы цепочки — зарезервированные имена (§2.14). Список подставляет установщик из
|
|
454
|
+
-- RESERVED_CLASS_NAMES (lib/src/types.ts): при изменении списка функция пересоздаётся накатом.
|
|
455
|
+
create or replace function @SCHEMA@.reserved_names() returns text[]
|
|
456
|
+
language sql immutable parallel safe
|
|
457
|
+
return array[@RESERVED_NAMES@]::text[];
|
|
458
|
+
|
|
459
|
+
-- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
|
|
460
|
+
-- имена связей против методов цепочки, однозначность имени конца во всей схеме.
|
|
461
|
+
create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
|
|
462
|
+
language plpgsql volatile
|
|
463
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
464
|
+
as $$
|
|
465
|
+
#variable_conflict use_variable
|
|
466
|
+
declare
|
|
467
|
+
e record;
|
|
468
|
+
o record;
|
|
469
|
+
begin
|
|
470
|
+
if p_tenant <> '@SYSTEM@'::uuid then
|
|
471
|
+
if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
|
|
472
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('имя %s зарезервировано за системным классом', p_data->>'name'));
|
|
473
|
+
end if;
|
|
474
|
+
if coalesce((p_data->>'system')::boolean, false) then
|
|
475
|
+
perform @SCHEMA@.lt_fail('invalid_class', 'признак system задаёт только установка');
|
|
476
|
+
end if;
|
|
477
|
+
if exists (select 1 from @SCHEMA@.entity s
|
|
478
|
+
where s.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'Class', array[p_data->>'name']) and s.class = 'Class') then
|
|
479
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
|
|
480
|
+
end if;
|
|
481
|
+
end if;
|
|
482
|
+
select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
|
|
483
|
+
if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
|
|
484
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
|
|
485
|
+
end if;
|
|
486
|
+
for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
|
|
487
|
+
select x.data->>'name' as name into o
|
|
488
|
+
from @SCHEMA@.entity x
|
|
489
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid)
|
|
490
|
+
and x.data->>'name' <> data->>'name'
|
|
491
|
+
and x.data->'resolved'->'ends' ? e.key
|
|
492
|
+
and @SCHEMA@.class_set(x.data->'resolved'->'ends'->e.key->'class') <> @SCHEMA@.class_set(e.value->'class')
|
|
493
|
+
limit 1;
|
|
494
|
+
if o.name is not null then
|
|
495
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
|
|
496
|
+
end if;
|
|
497
|
+
end loop;
|
|
498
|
+
end $$;
|
|
499
|
+
|
|
500
|
+
-- ========================= триггеры =========================
|
|
501
|
+
|
|
502
|
+
create or replace function @SCHEMA@.trg_entity_bi() returns trigger
|
|
503
|
+
language plpgsql security definer
|
|
504
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
505
|
+
as $$
|
|
506
|
+
declare
|
|
507
|
+
c record;
|
|
508
|
+
cls @SCHEMA@.entity;
|
|
509
|
+
res jsonb;
|
|
510
|
+
bad jsonb;
|
|
511
|
+
lk record;
|
|
512
|
+
kid uuid;
|
|
513
|
+
last record;
|
|
514
|
+
ts timestamptz;
|
|
515
|
+
begin
|
|
516
|
+
select * into c from @SCHEMA@.ctx();
|
|
517
|
+
if c.account is null then
|
|
518
|
+
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
519
|
+
end if;
|
|
520
|
+
NEW.tenant := coalesce(NEW.tenant, c.tenant);
|
|
521
|
+
if NEW.tenant <> c.tenant then
|
|
522
|
+
perform @SCHEMA@.lt_fail('tenant_denied', 'запись в чужого арендатора');
|
|
523
|
+
end if;
|
|
524
|
+
-- аккаунты (с их категориями) — только в System: их пишет системный администратор (этап 5)
|
|
525
|
+
if NEW.class = 'Account' and NEW.tenant <> '@SYSTEM@'::uuid then
|
|
526
|
+
perform @SCHEMA@.lt_fail('tenant_denied', 'аккаунты лежат в арендаторе System');
|
|
527
|
+
end if;
|
|
528
|
+
cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
|
|
529
|
+
if cls.id is null then
|
|
530
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
|
|
531
|
+
end if;
|
|
532
|
+
-- разделяемая блокировка корня семейства, затем описание перечитывается под ней
|
|
533
|
+
perform @SCHEMA@.family_lock(cls, false);
|
|
534
|
+
cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
|
|
535
|
+
res := cls.data->'resolved';
|
|
536
|
+
if coalesce((res->>'abstract')::boolean, false) then
|
|
537
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
538
|
+
end if;
|
|
539
|
+
if NEW.class = 'Class' then
|
|
540
|
+
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
|
|
541
|
+
else
|
|
542
|
+
NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
|
|
543
|
+
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
544
|
+
if bad <> '[]'::jsonb then
|
|
545
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
|
|
546
|
+
end if;
|
|
547
|
+
NEW.validator := null;
|
|
548
|
+
end if;
|
|
549
|
+
-- цели проверяет и блокирует триггер после оператора (targets_bad) — один раз на оператор
|
|
550
|
+
select * into lk from @SCHEMA@.links_shape(res, NEW.links);
|
|
551
|
+
if lk.code is not null then
|
|
552
|
+
perform @SCHEMA@.lt_fail(lk.code, lk.msg);
|
|
553
|
+
end if;
|
|
554
|
+
NEW.links := lk.links;
|
|
555
|
+
if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from NEW.tenant::text then
|
|
556
|
+
perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
|
|
557
|
+
end if;
|
|
558
|
+
kid := @SCHEMA@.key_id(res, NEW.class, NEW.tenant, NEW.data, NEW.links);
|
|
559
|
+
if kid is null then
|
|
560
|
+
if NEW.id is not null then
|
|
561
|
+
perform @SCHEMA@.lt_fail('id_from_db', format('id класса без ключа %s выдаёт база', NEW.class));
|
|
562
|
+
end if;
|
|
563
|
+
NEW.id := @SCHEMA@.v5_id(NEW.tenant, NEW.class, array[gen_random_uuid()::text]);
|
|
564
|
+
else
|
|
565
|
+
if NEW.id is not null and NEW.id <> kid then
|
|
566
|
+
perform @SCHEMA@.lt_fail('id_mismatch', format('id не равен v5 от ключа класса %s', NEW.class), jsonb_build_object('expected', kid));
|
|
567
|
+
end if;
|
|
568
|
+
NEW.id := kid;
|
|
569
|
+
end if;
|
|
570
|
+
-- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
|
|
571
|
+
-- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
|
|
572
|
+
-- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
|
|
573
|
+
select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
|
|
574
|
+
ts := @SCHEMA@.now_ts();
|
|
575
|
+
NEW.rev := coalesce(last.rev, 0) + 1;
|
|
576
|
+
NEW.prev_hash := last.hash;
|
|
577
|
+
NEW.at := greatest(ts, last.at + interval '1 microsecond');
|
|
578
|
+
NEW.created_at := NEW.at;
|
|
579
|
+
NEW.cv := cls.rev;
|
|
580
|
+
if NEW.owner is null then
|
|
581
|
+
NEW.owner := case res->>'ownerDefault' when 'actor' then c.account else NEW.tenant end;
|
|
582
|
+
elsif NEW.owner not in (NEW.tenant, c.account)
|
|
583
|
+
and not exists (select 1 from @SCHEMA@.entity a where a.id = NEW.owner and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
|
|
584
|
+
-- явный владелец — существующий аккаунт; разрешает ли его условие ACL, решает политика вставки
|
|
585
|
+
perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден');
|
|
586
|
+
end if;
|
|
587
|
+
NEW.author := c.author;
|
|
588
|
+
NEW.agent := c.agent;
|
|
589
|
+
NEW.op := 'create';
|
|
590
|
+
NEW.reason := nullif(current_setting('letopis.reason', true), '');
|
|
591
|
+
NEW.moved := null;
|
|
592
|
+
NEW.tags := coalesce(NEW.tags, '{}');
|
|
593
|
+
return NEW;
|
|
594
|
+
end $$;
|
|
595
|
+
|
|
596
|
+
create or replace function @SCHEMA@.trg_entity_bu() returns trigger
|
|
597
|
+
language plpgsql security definer
|
|
598
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
599
|
+
as $$
|
|
600
|
+
declare
|
|
601
|
+
c record;
|
|
602
|
+
cls @SCHEMA@.entity;
|
|
603
|
+
res jsonb;
|
|
604
|
+
old_res jsonb;
|
|
605
|
+
bad jsonb;
|
|
606
|
+
lk record;
|
|
607
|
+
kid uuid;
|
|
608
|
+
op text := 'update';
|
|
609
|
+
inc jsonb;
|
|
610
|
+
begin
|
|
611
|
+
select * into c from @SCHEMA@.ctx();
|
|
612
|
+
if c.account is null then
|
|
613
|
+
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
614
|
+
end if;
|
|
615
|
+
if NEW.id <> OLD.id or NEW.tenant <> OLD.tenant or NEW.owner <> OLD.owner or NEW.created_at <> OLD.created_at then
|
|
616
|
+
perform @SCHEMA@.lt_fail('immutable_key', 'id, арендатора, владельца и время создания менять нельзя');
|
|
617
|
+
end if;
|
|
618
|
+
if (NEW.class = 'Class') <> (OLD.class = 'Class') then
|
|
619
|
+
perform @SCHEMA@.lt_fail('invalid_class', 'строку Class нельзя превратить в данные и наоборот');
|
|
620
|
+
end if;
|
|
621
|
+
-- строки прав не получаются сменой класса: аккаунт с категориями — только в System (ревью этапа 5)
|
|
622
|
+
if NEW.class <> OLD.class and (NEW.class in ('Account', 'member', 'Resource', 'rule') or OLD.class in ('Account', 'member', 'Resource', 'rule')) then
|
|
623
|
+
perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
|
|
624
|
+
end if;
|
|
625
|
+
cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
|
|
626
|
+
if cls.id is null then
|
|
627
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
|
|
628
|
+
end if;
|
|
629
|
+
perform @SCHEMA@.family_lock(cls, false);
|
|
630
|
+
cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
|
|
631
|
+
res := cls.data->'resolved';
|
|
632
|
+
if coalesce((res->>'abstract')::boolean, false) then
|
|
633
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
634
|
+
end if;
|
|
635
|
+
if NEW.class <> OLD.class then
|
|
636
|
+
-- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
|
|
637
|
+
if current_setting('transaction_isolation') <> 'read committed' then
|
|
638
|
+
perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
|
|
639
|
+
end if;
|
|
640
|
+
old_res := (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved';
|
|
641
|
+
if jsonb_array_length(coalesce(old_res->'key', '[]'::jsonb)) > 0 or jsonb_array_length(coalesce(res->'key', '[]'::jsonb)) > 0 then
|
|
642
|
+
perform @SCHEMA@.lt_fail('use_reclass', format('у класса %s или %s есть ключ: id меняется, используйте reclass()', OLD.class, NEW.class));
|
|
643
|
+
end if;
|
|
644
|
+
-- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
|
|
645
|
+
-- транзакций, которые как раз пишут ссылку на эту строку
|
|
646
|
+
perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
|
|
647
|
+
inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
|
|
648
|
+
if inc <> '[]'::jsonb then
|
|
649
|
+
perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
|
|
650
|
+
@SCHEMA@.visible_detail(inc, c.tenant));
|
|
651
|
+
end if;
|
|
652
|
+
op := 'reclass';
|
|
653
|
+
end if;
|
|
654
|
+
if NEW.class = 'Class' then
|
|
655
|
+
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
|
|
656
|
+
else
|
|
657
|
+
NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
|
|
658
|
+
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
659
|
+
if bad <> '[]'::jsonb then
|
|
660
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', NEW.class), jsonb_build_object('issues', bad));
|
|
661
|
+
end if;
|
|
662
|
+
NEW.validator := null;
|
|
663
|
+
end if;
|
|
664
|
+
select * into lk from @SCHEMA@.links_shape(res, NEW.links);
|
|
665
|
+
if lk.code is not null then
|
|
666
|
+
perform @SCHEMA@.lt_fail(lk.code, lk.msg);
|
|
667
|
+
end if;
|
|
668
|
+
NEW.links := lk.links;
|
|
669
|
+
if NEW.class = 'member' and NEW.links->>'Tenant' is distinct from OLD.tenant::text then
|
|
670
|
+
perform @SCHEMA@.lt_fail('invalid_data', 'конец Tenant членства — арендатор строки');
|
|
671
|
+
end if;
|
|
672
|
+
kid := @SCHEMA@.key_id(res, NEW.class, OLD.tenant, NEW.data, NEW.links);
|
|
673
|
+
if kid is not null and kid <> OLD.id then
|
|
674
|
+
perform @SCHEMA@.lt_fail('immutable_key', format('ключевое поле класса %s менять нельзя: id изменился бы (rekey)', NEW.class));
|
|
675
|
+
end if;
|
|
676
|
+
-- ничего не изменилось: строка возвращается как есть, версии нет, оператор считает её затронутой
|
|
677
|
+
if NEW.class = OLD.class and NEW.data = OLD.data and NEW.links = OLD.links and NEW.tags = OLD.tags then
|
|
678
|
+
return OLD;
|
|
679
|
+
end if;
|
|
680
|
+
if NEW.class = 'Class' and not @SCHEMA@.class_change_safe(OLD.data->'resolved', NEW.data->'resolved') then
|
|
681
|
+
-- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
|
|
682
|
+
perform @SCHEMA@.family_lock(OLD, true);
|
|
683
|
+
perform @SCHEMA@.family_lock(NEW, true);
|
|
684
|
+
end if;
|
|
685
|
+
NEW.rev := OLD.rev + 1;
|
|
686
|
+
NEW.prev_hash := OLD.hash;
|
|
687
|
+
NEW.at := greatest(@SCHEMA@.now_ts(), OLD.at + interval '1 microsecond');
|
|
688
|
+
NEW.cv := cls.rev;
|
|
689
|
+
NEW.author := c.author;
|
|
690
|
+
NEW.agent := c.agent;
|
|
691
|
+
NEW.op := op;
|
|
692
|
+
NEW.reason := nullif(current_setting('letopis.reason', true), '');
|
|
693
|
+
NEW.moved := null;
|
|
694
|
+
return NEW;
|
|
695
|
+
end $$;
|
|
696
|
+
|
|
697
|
+
-- После оператора insert (решение 2 точки А): все версии оператора — одной вставкой в журнал
|
|
698
|
+
-- (по переходной таблице), сигнал — по одному на класс.
|
|
699
|
+
create or replace function @SCHEMA@.trg_entity_ai() returns trigger
|
|
700
|
+
language plpgsql security definer
|
|
701
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
702
|
+
as $$
|
|
703
|
+
declare
|
|
704
|
+
cls text[];
|
|
705
|
+
bad text;
|
|
706
|
+
begin
|
|
707
|
+
if exists (select 1 from added n where n.links <> '{}'::jsonb) then
|
|
708
|
+
bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
|
|
709
|
+
from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
|
|
710
|
+
end if;
|
|
711
|
+
if bad is not null then
|
|
712
|
+
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
713
|
+
end if;
|
|
714
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
715
|
+
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
|
|
716
|
+
from added n
|
|
717
|
+
join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
|
|
718
|
+
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
719
|
+
select array_agg(distinct a.class) into cls from added a;
|
|
720
|
+
perform @SCHEMA@.signal(cls);
|
|
721
|
+
if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
722
|
+
perform @SCHEMA@.acl_bump();
|
|
723
|
+
end if;
|
|
724
|
+
return null;
|
|
725
|
+
end $$;
|
|
726
|
+
|
|
727
|
+
-- После оператора update: версии строк, у которых вырос номер версии (пустая правка версии не
|
|
728
|
+
-- даёт), — одной вставкой; сигнал; для строк Class — пересчёт потомков и проверка строк семейства
|
|
729
|
+
-- при ужесточении.
|
|
730
|
+
create or replace function @SCHEMA@.trg_entity_au() returns trigger
|
|
731
|
+
language plpgsql security definer
|
|
732
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
733
|
+
as $$
|
|
734
|
+
declare
|
|
735
|
+
cls text[];
|
|
736
|
+
o_row @SCHEMA@.entity;
|
|
737
|
+
n_row @SCHEMA@.entity;
|
|
738
|
+
cid uuid;
|
|
739
|
+
bad text;
|
|
740
|
+
begin
|
|
741
|
+
-- цели проверяются у строк, у которых изменились концы или класс
|
|
742
|
+
if exists (select 1 from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
743
|
+
where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)) then
|
|
744
|
+
bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
|
|
745
|
+
from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
746
|
+
where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
|
|
747
|
+
end if;
|
|
748
|
+
if bad is not null then
|
|
749
|
+
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
750
|
+
end if;
|
|
751
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
752
|
+
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
|
|
753
|
+
from new_rows n
|
|
754
|
+
join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
755
|
+
join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
|
|
756
|
+
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
757
|
+
select array_agg(distinct n.class) into cls from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev;
|
|
758
|
+
perform @SCHEMA@.signal(cls);
|
|
759
|
+
if cls && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
760
|
+
perform @SCHEMA@.acl_bump();
|
|
761
|
+
end if;
|
|
762
|
+
for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
763
|
+
where n.class = 'Class' order by n.id loop
|
|
764
|
+
select * into o_row from old_rows x where x.id = cid;
|
|
765
|
+
select * into n_row from new_rows x where x.id = cid;
|
|
766
|
+
perform @SCHEMA@.class_evolve(o_row, n_row);
|
|
767
|
+
end loop;
|
|
768
|
+
-- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
|
|
769
|
+
for cid in select n.id from new_rows n join old_rows o on o.id = n.id
|
|
770
|
+
where n.class = 'Account' and coalesce((o.data->>'enabled')::boolean, true)
|
|
771
|
+
and not coalesce((n.data->>'enabled')::boolean, true) loop
|
|
772
|
+
perform @SCHEMA@.auth_kill(cid, null);
|
|
773
|
+
end loop;
|
|
774
|
+
return null;
|
|
775
|
+
end $$;
|
|
776
|
+
|
|
777
|
+
create or replace function @SCHEMA@.class_evolve(o @SCHEMA@.entity, n @SCHEMA@.entity) returns void
|
|
778
|
+
language plpgsql volatile
|
|
779
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
780
|
+
as $$
|
|
781
|
+
declare
|
|
782
|
+
d @SCHEMA@.entity;
|
|
783
|
+
prep record;
|
|
784
|
+
classes text[] := array[n.data->>'name'];
|
|
785
|
+
conflicts jsonb;
|
|
786
|
+
c record;
|
|
787
|
+
begin
|
|
788
|
+
-- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
|
|
789
|
+
for d in select x.* from @SCHEMA@.entity x
|
|
790
|
+
where x.class = 'Class' and x.id <> n.id
|
|
791
|
+
and (x.tenant = n.tenant or n.tenant = '@SYSTEM@'::uuid)
|
|
792
|
+
and x.data->'resolved'->'ancestors' ? (n.data->>'name')
|
|
793
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
|
|
794
|
+
select p.data, p.validator into prep from @SCHEMA@.class_prepare(d.data, d.tenant) p;
|
|
795
|
+
if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
|
|
796
|
+
perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
|
|
797
|
+
end if;
|
|
798
|
+
classes := classes || (d.data->>'name');
|
|
799
|
+
end loop;
|
|
800
|
+
if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
|
|
801
|
+
conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
|
|
802
|
+
if conflicts <> '[]'::jsonb then
|
|
803
|
+
select * into c from @SCHEMA@.ctx();
|
|
804
|
+
perform @SCHEMA@.lt_fail('tightening_conflict', format('новому описанию класса %s не соответствуют строки', n.data->>'name'),
|
|
805
|
+
@SCHEMA@.visible_detail(conflicts, c.tenant));
|
|
806
|
+
end if;
|
|
807
|
+
end if;
|
|
808
|
+
end $$;
|
|
809
|
+
|
|
810
|
+
-- Перед оператором delete: только read committed (§2.10).
|
|
811
|
+
create or replace function @SCHEMA@.trg_entity_bd() returns trigger
|
|
812
|
+
language plpgsql security definer
|
|
813
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
814
|
+
as $$
|
|
815
|
+
begin
|
|
816
|
+
if current_setting('transaction_isolation') <> 'read committed' then
|
|
817
|
+
perform @SCHEMA@.lt_fail('isolation_level', 'удаление разрешено только в read committed');
|
|
818
|
+
end if;
|
|
819
|
+
return null;
|
|
820
|
+
end $$;
|
|
821
|
+
|
|
822
|
+
-- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
|
|
823
|
+
-- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
|
|
824
|
+
-- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
|
|
825
|
+
create or replace function @SCHEMA@.ref_action(p_ends jsonb, p_links jsonb, p_ids uuid[]) returns text
|
|
826
|
+
language sql immutable parallel safe
|
|
827
|
+
return (
|
|
828
|
+
select case when bool_or(x.a = 'cascade') then 'cascade' when bool_or(x.a = 'restrict') then 'restrict'
|
|
829
|
+
when bool_or(x.a = 'unset') then 'unset' end
|
|
830
|
+
from (select coalesce(p_ends->k.key->>'onDelete', 'restrict') as a
|
|
831
|
+
from jsonb_each(p_links) k
|
|
832
|
+
where exists (select 1 from jsonb_array_elements_text(case jsonb_typeof(k.value) when 'array' then k.value
|
|
833
|
+
else jsonb_build_array(k.value) end) t
|
|
834
|
+
where t::uuid = any (p_ids))) x
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
-- Строки арендатора, которыми владел пользователь, переходят арендатору (выход из членства, §2.10):
|
|
838
|
+
-- новая версия каждой строки с журналом. Индекс entity_owner — только строки с владельцем-не арендатором.
|
|
839
|
+
create or replace function @SCHEMA@.owner_to_tenant(p_tenant uuid, p_user uuid) returns int
|
|
840
|
+
language plpgsql volatile
|
|
841
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
842
|
+
as $$
|
|
843
|
+
declare
|
|
844
|
+
c record;
|
|
845
|
+
r @SCHEMA@.entity;
|
|
846
|
+
n int := 0;
|
|
847
|
+
begin
|
|
848
|
+
if p_tenant is null or p_user is null or p_tenant = p_user then
|
|
849
|
+
return 0;
|
|
850
|
+
end if;
|
|
851
|
+
select * into c from @SCHEMA@.ctx();
|
|
852
|
+
for r in select x.* from @SCHEMA@.entity x where x.owner = p_user and x.owner <> x.tenant and x.tenant = p_tenant
|
|
853
|
+
order by x.id for update loop
|
|
854
|
+
update @SCHEMA@.entity x
|
|
855
|
+
set owner = p_tenant, rev = x.rev + 1, prev_hash = x.hash,
|
|
856
|
+
at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
|
|
857
|
+
author = coalesce(c.author, '@SYSTEM@'::uuid), agent = c.agent, op = 'update',
|
|
858
|
+
reason = nullif(current_setting('letopis.reason', true), ''), moved = null
|
|
859
|
+
where x.id = r.id
|
|
860
|
+
returning x.* into r;
|
|
861
|
+
perform @SCHEMA@.log_version(r);
|
|
862
|
+
n := n + 1;
|
|
863
|
+
end loop;
|
|
864
|
+
return n;
|
|
865
|
+
end $$;
|
|
866
|
+
|
|
867
|
+
-- После оператора delete (§2.5, этап 1, п. 8): надгробия, каскад по уровням, restrict по итогу.
|
|
868
|
+
-- Строки, которые удалил сам оператор, каскад уже не видит, поэтому сырой delete любого набора
|
|
869
|
+
-- связанных строк проходит без ошибки 27000. Надгробия уровня пишутся одной вставкой.
|
|
870
|
+
create or replace function @SCHEMA@.trg_entity_ad() returns trigger
|
|
871
|
+
language plpgsql security definer
|
|
872
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
873
|
+
as $$
|
|
874
|
+
declare
|
|
875
|
+
c record;
|
|
876
|
+
r record;
|
|
877
|
+
lvl uuid[];
|
|
878
|
+
all_ids uuid[];
|
|
879
|
+
casc uuid[];
|
|
880
|
+
unsets uuid[];
|
|
881
|
+
classes text[];
|
|
882
|
+
lvl_classes text[];
|
|
883
|
+
v_author uuid;
|
|
884
|
+
v_reason text := nullif(current_setting('letopis.reason', true), '');
|
|
885
|
+
newlinks jsonb;
|
|
886
|
+
k record;
|
|
887
|
+
blockers jsonb;
|
|
888
|
+
dplan jsonb;
|
|
889
|
+
wplan jsonb;
|
|
890
|
+
denied text;
|
|
891
|
+
accs uuid[];
|
|
892
|
+
mems jsonb;
|
|
893
|
+
lvl_accs uuid[];
|
|
894
|
+
lvl_mems jsonb;
|
|
895
|
+
begin
|
|
896
|
+
select * into c from @SCHEMA@.ctx();
|
|
897
|
+
-- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
|
|
898
|
+
-- у которой снимается конец
|
|
899
|
+
dplan := @SCHEMA@.acl_plan('DELETE');
|
|
900
|
+
wplan := @SCHEMA@.acl_plan('WRITE');
|
|
901
|
+
if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
|
|
902
|
+
perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
|
|
903
|
+
end if;
|
|
904
|
+
for r in select g.id, g.tenant, g.data->>'name' as name from gone g where g.class = 'Class' loop
|
|
905
|
+
if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
|
|
906
|
+
or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
|
|
907
|
+
and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
|
|
908
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
|
|
909
|
+
end if;
|
|
910
|
+
end loop;
|
|
911
|
+
v_author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
912
|
+
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
|
|
913
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
914
|
+
select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
|
|
915
|
+
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
|
|
916
|
+
from gone g
|
|
917
|
+
join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
|
|
918
|
+
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
|
|
919
|
+
select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
|
|
920
|
+
coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
|
|
921
|
+
coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
|
|
922
|
+
into lvl, classes, accs, mems from gone g;
|
|
923
|
+
all_ids := lvl;
|
|
924
|
+
while cardinality(lvl) > 0 loop
|
|
925
|
+
-- кто ссылается на уровень: действие по правилам концов (общее с превью)
|
|
926
|
+
with refs as (
|
|
927
|
+
select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x where x.ends && lvl
|
|
928
|
+
), cls as (
|
|
929
|
+
select d.class, d.tenant, (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved'->'ends' as ends
|
|
930
|
+
from (select distinct refs.class, refs.tenant from refs) d
|
|
931
|
+
), act as (
|
|
932
|
+
select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
|
|
933
|
+
from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
|
|
934
|
+
)
|
|
935
|
+
select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}'),
|
|
936
|
+
coalesce(array_agg(act.id order by act.id) filter (where act.a = 'unset'), '{}')
|
|
937
|
+
into casc, unsets from act;
|
|
938
|
+
-- каскад: удаление в порядке id берёт блокировки и ждёт тех, кто как раз пишет ссылки
|
|
939
|
+
with d as (
|
|
940
|
+
delete from @SCHEMA@.entity x
|
|
941
|
+
where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
|
|
942
|
+
returning x.*
|
|
943
|
+
), t as (
|
|
944
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
945
|
+
select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
|
|
946
|
+
greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
|
|
947
|
+
from d
|
|
948
|
+
join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
|
|
949
|
+
where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
|
|
950
|
+
)
|
|
951
|
+
select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
|
|
952
|
+
min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
|
|
953
|
+
coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
|
|
954
|
+
coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
|
|
955
|
+
into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
|
|
956
|
+
if denied is not null then
|
|
957
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
|
|
958
|
+
end if;
|
|
959
|
+
accs := accs || lvl_accs;
|
|
960
|
+
mems := mems || lvl_mems;
|
|
961
|
+
classes := classes || lvl_classes;
|
|
962
|
+
-- unset: снять конец, новая версия
|
|
963
|
+
for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
|
|
964
|
+
if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
965
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s: снятие конца при удалении', r.class));
|
|
966
|
+
end if;
|
|
967
|
+
newlinks := '{}'::jsonb;
|
|
968
|
+
for k in select key, value from jsonb_each(r.links) loop
|
|
969
|
+
if jsonb_typeof(k.value) = 'array' then
|
|
970
|
+
newlinks := newlinks || jsonb_build_object(k.key, coalesce(
|
|
971
|
+
(select jsonb_agg(t) from jsonb_array_elements_text(k.value) t where not (t::uuid = any (all_ids))), '[]'::jsonb));
|
|
972
|
+
elsif not ((k.value #>> '{}')::uuid = any (all_ids)) then
|
|
973
|
+
newlinks := newlinks || jsonb_build_object(k.key, k.value);
|
|
974
|
+
end if;
|
|
975
|
+
end loop;
|
|
976
|
+
perform @SCHEMA@.sys_update(r.id, r.data, newlinks, r.validator, 'update');
|
|
977
|
+
end loop;
|
|
978
|
+
all_ids := all_ids || lvl;
|
|
979
|
+
end loop;
|
|
980
|
+
-- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
|
|
981
|
+
-- владел участник, переходят арендатору (§2.10) — новой версией
|
|
982
|
+
for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
|
|
983
|
+
perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
|
|
984
|
+
end loop;
|
|
985
|
+
-- удалённый аккаунт: сессии гаснут, креды удаляются (идентификаторы освобождаются)
|
|
986
|
+
for r in select distinct u as acc from unnest(accs) u loop
|
|
987
|
+
perform @SCHEMA@.auth_kill(r.acc, null);
|
|
988
|
+
update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
|
|
989
|
+
end loop;
|
|
990
|
+
perform @SCHEMA@.signal(array(select distinct x from unnest(classes) x));
|
|
991
|
+
if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
992
|
+
perform @SCHEMA@.acl_bump();
|
|
993
|
+
end if;
|
|
994
|
+
-- restrict — одним запросом после всего каскада
|
|
995
|
+
select coalesce(jsonb_agg(jsonb_build_object('id', x.id, 'class', x.class, 'tenant', x.tenant)), '[]'::jsonb) into blockers
|
|
996
|
+
from (select x.id, x.class, x.tenant from @SCHEMA@.entity x where x.ends && all_ids order by x.id limit 1000) x;
|
|
997
|
+
if blockers <> '[]'::jsonb then
|
|
998
|
+
perform @SCHEMA@.lt_fail('delete_restricted', 'на удаляемые строки ссылаются другие строки (onDelete: restrict)',
|
|
999
|
+
@SCHEMA@.visible_detail(blockers, c.tenant));
|
|
1000
|
+
end if;
|
|
1001
|
+
return null;
|
|
1002
|
+
end $$;
|
|
1003
|
+
|
|
1004
|
+
-- Превью удаления (этап 4, п. 6): что удалит delete этих строк — те же уровни и то же правило
|
|
1005
|
+
-- ref_action(), что у триггера, но без изменений. Строки: action delete (цели — depth 0, каскад —
|
|
1006
|
+
-- уровень), unset (конец будет снят) и restrict (мешает удалению). Функция владельца ищет по
|
|
1007
|
+
-- индексу ends без RLS (§3 «Индексы под RLS»), поэтому и цели, и выдачу пропускает через
|
|
1008
|
+
-- row_visible() — то же условие, что у политики чтения (как find_*): невидимых строк не раскрывает
|
|
1009
|
+
-- ни id, ни классом. О невидимых мешающих строках скажет само удаление (visible_detail: число).
|
|
1010
|
+
create or replace function @SCHEMA@.delete_preview(p_ids uuid[]) returns table(id uuid, class text, action text, depth int)
|
|
1011
|
+
language plpgsql stable security definer
|
|
1012
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1013
|
+
as $$
|
|
1014
|
+
#variable_conflict use_column
|
|
1015
|
+
declare
|
|
1016
|
+
a jsonb := @SCHEMA@.acl_actor('READ');
|
|
1017
|
+
c_tenant uuid := (a->>'tenant')::uuid;
|
|
1018
|
+
c_account uuid := (a->>'account')::uuid;
|
|
1019
|
+
c_plan jsonb := a->'plan';
|
|
1020
|
+
lvl uuid[];
|
|
1021
|
+
all_ids uuid[];
|
|
1022
|
+
casc uuid[];
|
|
1023
|
+
d int := 0;
|
|
1024
|
+
out_rows jsonb := '[]'::jsonb;
|
|
1025
|
+
begin
|
|
1026
|
+
if c_account is null then
|
|
1027
|
+
return;
|
|
1028
|
+
end if;
|
|
1029
|
+
-- цели — только видимые: превью не подтверждает существование чужих строк
|
|
1030
|
+
select coalesce(array_agg(x.id order by x.id), '{}') into lvl
|
|
1031
|
+
from @SCHEMA@.entity x
|
|
1032
|
+
where x.id = any (p_ids) and @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c_tenant, c_account, c_plan);
|
|
1033
|
+
out_rows := (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', 0)), '[]'::jsonb) from unnest(lvl) u);
|
|
1034
|
+
all_ids := lvl;
|
|
1035
|
+
while cardinality(lvl) > 0 loop
|
|
1036
|
+
d := d + 1;
|
|
1037
|
+
with refs as (
|
|
1038
|
+
select x.id, x.class, x.tenant, x.links from @SCHEMA@.entity x
|
|
1039
|
+
where x.ends && lvl and not (x.id = any (all_ids))
|
|
1040
|
+
), cls as (
|
|
1041
|
+
select dd.class, dd.tenant, (@SCHEMA@.class_row(dd.class, dd.tenant)).data->'resolved'->'ends' as ends
|
|
1042
|
+
from (select distinct refs.class, refs.tenant from refs) dd
|
|
1043
|
+
), act as (
|
|
1044
|
+
select refs.id, @SCHEMA@.ref_action(cls.ends, refs.links, all_ids) as a
|
|
1045
|
+
from refs join cls on cls.class = refs.class and cls.tenant = refs.tenant
|
|
1046
|
+
)
|
|
1047
|
+
select coalesce(array_agg(act.id order by act.id) filter (where act.a = 'cascade'), '{}') into casc from act;
|
|
1048
|
+
out_rows := out_rows || (select coalesce(jsonb_agg(jsonb_build_array(u, 'delete', d)), '[]'::jsonb) from unnest(casc) u);
|
|
1049
|
+
all_ids := all_ids || casc;
|
|
1050
|
+
lvl := casc;
|
|
1051
|
+
end loop;
|
|
1052
|
+
-- кто останется ссылаться на удалённое: снимаемые концы и мешающие строки
|
|
1053
|
+
out_rows := out_rows || (
|
|
1054
|
+
select coalesce(jsonb_agg(jsonb_build_array(r.id, case when r.a = 'unset' then 'unset' else 'restrict' end, null)), '[]'::jsonb)
|
|
1055
|
+
from (select x.id, @SCHEMA@.ref_action((@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved'->'ends', x.links, all_ids) as a
|
|
1056
|
+
from @SCHEMA@.entity x where x.ends && all_ids and not (x.id = any (all_ids))) r);
|
|
1057
|
+
return query
|
|
1058
|
+
select (o->>0)::uuid, e.class, o->>1, (o->>2)::int
|
|
1059
|
+
from jsonb_array_elements(out_rows) o
|
|
1060
|
+
join @SCHEMA@.entity e on e.id = (o->>0)::uuid
|
|
1061
|
+
where @SCHEMA@.row_visible(e.tenant, e.class, e.id, e.owner, e.tags, e.data, e.links, c_tenant, c_account, c_plan);
|
|
1062
|
+
end $$;
|
|
1063
|
+
|
|
1064
|
+
create or replace trigger entity_bi before insert on @SCHEMA@.entity
|
|
1065
|
+
for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bi();
|
|
1066
|
+
create or replace trigger entity_bu before update on @SCHEMA@.entity
|
|
1067
|
+
for each row when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bu();
|
|
1068
|
+
-- до ревизии 2 журнал писал строковый триггер entity_ai после insert или update
|
|
1069
|
+
drop trigger if exists entity_ai on @SCHEMA@.entity;
|
|
1070
|
+
create trigger entity_ai after insert on @SCHEMA@.entity referencing new table as added
|
|
1071
|
+
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ai();
|
|
1072
|
+
create or replace trigger entity_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
|
|
1073
|
+
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
|
|
1074
|
+
create or replace trigger entity_bd before delete on @SCHEMA@.entity
|
|
1075
|
+
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
|
|
1076
|
+
create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
|
|
1077
|
+
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
|
|
1078
|
+
-- в режиме репликации (session_replication_role = replica) триггеры тоже работают
|
|
1079
|
+
alter table @SCHEMA@.entity enable always trigger entity_bi;
|
|
1080
|
+
alter table @SCHEMA@.entity enable always trigger entity_bu;
|
|
1081
|
+
alter table @SCHEMA@.entity enable always trigger entity_ai;
|
|
1082
|
+
alter table @SCHEMA@.entity enable always trigger entity_au;
|
|
1083
|
+
alter table @SCHEMA@.entity enable always trigger entity_bd;
|
|
1084
|
+
alter table @SCHEMA@.entity enable always trigger entity_ad;
|
|
1085
|
+
|
|
1086
|
+
-- ========================= смена класса с ключом =========================
|
|
1087
|
+
|
|
1088
|
+
-- Замена id цели во всех концах.
|
|
1089
|
+
create or replace function @SCHEMA@.links_replace(p_links jsonb, p_old uuid, p_new uuid) returns jsonb
|
|
1090
|
+
language sql immutable parallel safe
|
|
1091
|
+
return (
|
|
1092
|
+
select coalesce(jsonb_object_agg(k.key, case jsonb_typeof(k.value)
|
|
1093
|
+
when 'array' then coalesce((select jsonb_agg(case when x = p_old::text then to_jsonb(p_new::text) else to_jsonb(x) end)
|
|
1094
|
+
from jsonb_array_elements_text(k.value) x), '[]'::jsonb)
|
|
1095
|
+
else case when k.value #>> '{}' = p_old::text then to_jsonb(p_new::text) else k.value end end), '{}'::jsonb)
|
|
1096
|
+
from jsonb_each(p_links) k
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
-- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
|
|
1100
|
+
-- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
|
|
1101
|
+
-- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
|
|
1102
|
+
-- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция.
|
|
1103
|
+
create or replace function @SCHEMA@.move_row(o @SCHEMA@.entity, p_new_id uuid, p_class text, p_data jsonb, p_links jsonb, p_op text)
|
|
1104
|
+
returns @SCHEMA@.entity
|
|
1105
|
+
language plpgsql volatile
|
|
1106
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1107
|
+
as $$
|
|
1108
|
+
declare
|
|
1109
|
+
c record;
|
|
1110
|
+
last record;
|
|
1111
|
+
ts timestamptz;
|
|
1112
|
+
n @SCHEMA@.entity;
|
|
1113
|
+
r @SCHEMA@.entity;
|
|
1114
|
+
nl jsonb;
|
|
1115
|
+
rid uuid;
|
|
1116
|
+
begin
|
|
1117
|
+
select * into c from @SCHEMA@.ctx();
|
|
1118
|
+
select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = p_new_id order by l.rev desc limit 1 for update;
|
|
1119
|
+
ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
|
|
1120
|
+
insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
|
|
1121
|
+
values (p_new_id, coalesce(last.rev, 0) + 1, p_class, coalesce((@SCHEMA@.class_row(p_class, o.tenant)).rev, 0), o.tenant, o.owner,
|
|
1122
|
+
p_links, p_data, o.tags, ts, ts, coalesce(c.author, '@SYSTEM@'::uuid), c.agent, p_op,
|
|
1123
|
+
nullif(current_setting('letopis.reason', true), ''), o.id, last.hash, null)
|
|
1124
|
+
returning * into n;
|
|
1125
|
+
perform @SCHEMA@.log_version(n);
|
|
1126
|
+
for r in select x.* from @SCHEMA@.entity x where x.ends @> array[o.id] and x.id <> p_new_id order by x.id for update loop
|
|
1127
|
+
nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
|
|
1128
|
+
rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
|
|
1129
|
+
if rid <> r.id then
|
|
1130
|
+
perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
|
|
1131
|
+
else
|
|
1132
|
+
perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
|
|
1133
|
+
end if;
|
|
1134
|
+
end loop;
|
|
1135
|
+
delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
|
|
1136
|
+
-- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
|
|
1137
|
+
perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
|
|
1138
|
+
return n;
|
|
1139
|
+
end $$;
|
|
1140
|
+
|
|
1141
|
+
-- Смена класса (этап 1, п. 9): у класса с ключом id вычисляется заново для нового класса, ссылки
|
|
1142
|
+
-- переводятся в той же транзакции, журнал связывает старый и новый id. У класса без ключа id
|
|
1143
|
+
-- сохраняется. Только read committed; на строку сначала берётся for update.
|
|
1144
|
+
create or replace function @SCHEMA@.reclass(p_id uuid, p_class text, p_data jsonb default null) returns uuid
|
|
1145
|
+
language plpgsql volatile security definer
|
|
1146
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1147
|
+
as $$
|
|
1148
|
+
declare
|
|
1149
|
+
c record;
|
|
1150
|
+
o @SCHEMA@.entity;
|
|
1151
|
+
cls @SCHEMA@.entity;
|
|
1152
|
+
res jsonb;
|
|
1153
|
+
d jsonb;
|
|
1154
|
+
bad jsonb;
|
|
1155
|
+
lk record;
|
|
1156
|
+
nid uuid;
|
|
1157
|
+
inc jsonb;
|
|
1158
|
+
rd jsonb;
|
|
1159
|
+
wplan jsonb;
|
|
1160
|
+
denied text;
|
|
1161
|
+
begin
|
|
1162
|
+
select * into c from @SCHEMA@.ctx();
|
|
1163
|
+
if c.account is null then
|
|
1164
|
+
perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
|
|
1165
|
+
end if;
|
|
1166
|
+
if current_setting('transaction_isolation') <> 'read committed' then
|
|
1167
|
+
perform @SCHEMA@.lt_fail('isolation_level', 'смена класса разрешена только в read committed');
|
|
1168
|
+
end if;
|
|
1169
|
+
rd := @SCHEMA@.acl_actor('READ');
|
|
1170
|
+
wplan := @SCHEMA@.acl_plan('WRITE');
|
|
1171
|
+
select x.* into o from @SCHEMA@.entity x where x.id = p_id and x.tenant = c.tenant for update;
|
|
1172
|
+
if o.id is null or not @SCHEMA@.row_visible(o.tenant, o.class, o.id, o.owner, o.tags, o.data, o.links, c.tenant, c.account, rd->'plan') then
|
|
1173
|
+
perform @SCHEMA@.lt_fail('target_not_found', 'цель не найдена');
|
|
1174
|
+
end if;
|
|
1175
|
+
if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
|
|
1176
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
|
|
1177
|
+
end if;
|
|
1178
|
+
-- строки прав и классов функция не трогает
|
|
1179
|
+
if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
1180
|
+
perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
|
|
1181
|
+
end if;
|
|
1182
|
+
cls := @SCHEMA@.class_row(p_class, o.tenant);
|
|
1183
|
+
if cls.id is null then
|
|
1184
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', p_class));
|
|
1185
|
+
end if;
|
|
1186
|
+
perform @SCHEMA@.family_lock(cls, false);
|
|
1187
|
+
cls := @SCHEMA@.class_row(p_class, o.tenant);
|
|
1188
|
+
res := cls.data->'resolved';
|
|
1189
|
+
if coalesce((res->>'abstract')::boolean, false) then
|
|
1190
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
|
|
1191
|
+
end if;
|
|
1192
|
+
d := @SCHEMA@.apply_defaults(res->'schema', coalesce(p_data, o.data));
|
|
1193
|
+
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
1194
|
+
if bad <> '[]'::jsonb then
|
|
1195
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
|
|
1196
|
+
end if;
|
|
1197
|
+
select * into lk from @SCHEMA@.check_links(res, o.links, o.tenant, true, rd);
|
|
1198
|
+
if lk.code is not null then
|
|
1199
|
+
perform @SCHEMA@.lt_fail(lk.code, lk.msg);
|
|
1200
|
+
end if;
|
|
1201
|
+
inc := @SCHEMA@.incoming_conflicts(o.id, p_class, o.tenant);
|
|
1202
|
+
if inc <> '[]'::jsonb then
|
|
1203
|
+
perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', p_class),
|
|
1204
|
+
@SCHEMA@.visible_detail(inc, c.tenant));
|
|
1205
|
+
end if;
|
|
1206
|
+
nid := coalesce(@SCHEMA@.key_id(res, p_class, o.tenant, d, lk.links), o.id);
|
|
1207
|
+
if not @SCHEMA@.acl_row_ok(wplan, p_class, nid, o.owner, o.tenant, o.tags, d, lk.links) then
|
|
1208
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
|
|
1209
|
+
end if;
|
|
1210
|
+
if nid <> o.id then
|
|
1211
|
+
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
|
|
1212
|
+
select min(x.class) into denied from @SCHEMA@.entity x
|
|
1213
|
+
where x.ends @> array[o.id] and not @SCHEMA@.acl_row_ok(wplan, x.class, x.id, x.owner, x.tenant, x.tags, x.data, x.links);
|
|
1214
|
+
if denied is not null then
|
|
1215
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied));
|
|
1216
|
+
end if;
|
|
1217
|
+
end if;
|
|
1218
|
+
if nid = o.id then
|
|
1219
|
+
perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
|
|
1220
|
+
else
|
|
1221
|
+
perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
|
|
1222
|
+
end if;
|
|
1223
|
+
return nid;
|
|
1224
|
+
end $$;
|
|
1225
|
+
|
|
1226
|
+
-- ========================= merge, inc, links_patch =========================
|
|
1227
|
+
|
|
1228
|
+
-- Правка концов слотами (этап 4, п. 4): .Роль.set(), .unset(), а для множественных концов .add()
|
|
1229
|
+
-- и .remove() — атомарно над свежей строкой, без предварительного чтения списка. p_ops — список
|
|
1230
|
+
-- [{role, op: set | unset | add | remove, value}]: у set — id, список id или null; у add и remove —
|
|
1231
|
+
-- список id. Повтор в add не добавляется, отсутствующий в remove — пропускается. Проверку концов
|
|
1232
|
+
-- делают триггеры, как при любой правке.
|
|
1233
|
+
create or replace function @SCHEMA@.links_patch(p_links jsonb, p_ops jsonb) returns jsonb
|
|
1234
|
+
language plpgsql immutable parallel safe
|
|
1235
|
+
as $$
|
|
1236
|
+
declare
|
|
1237
|
+
o jsonb;
|
|
1238
|
+
role text;
|
|
1239
|
+
res jsonb := coalesce(p_links, '{}'::jsonb);
|
|
1240
|
+
cur jsonb;
|
|
1241
|
+
v jsonb;
|
|
1242
|
+
begin
|
|
1243
|
+
for o in select x from jsonb_array_elements(coalesce(p_ops, '[]'::jsonb)) x loop
|
|
1244
|
+
role := o->>'role';
|
|
1245
|
+
case o->>'op'
|
|
1246
|
+
when 'set' then
|
|
1247
|
+
if o->'value' is null or o->'value' = 'null'::jsonb then
|
|
1248
|
+
res := res - role;
|
|
1249
|
+
else
|
|
1250
|
+
res := jsonb_set(res, array[role], o->'value');
|
|
1251
|
+
end if;
|
|
1252
|
+
when 'unset' then
|
|
1253
|
+
res := res - role;
|
|
1254
|
+
when 'add' then
|
|
1255
|
+
cur := coalesce(res->role, '[]'::jsonb);
|
|
1256
|
+
if jsonb_typeof(cur) = 'array' then
|
|
1257
|
+
for v in select x from jsonb_array_elements(o->'value') x loop
|
|
1258
|
+
v := to_jsonb(lower(v #>> '{}'));
|
|
1259
|
+
if not cur @> jsonb_build_array(v) then
|
|
1260
|
+
cur := cur || jsonb_build_array(v);
|
|
1261
|
+
end if;
|
|
1262
|
+
end loop;
|
|
1263
|
+
end if;
|
|
1264
|
+
res := jsonb_set(res, array[role], cur);
|
|
1265
|
+
when 'remove' then
|
|
1266
|
+
cur := coalesce(res->role, '[]'::jsonb);
|
|
1267
|
+
if jsonb_typeof(cur) = 'array' then
|
|
1268
|
+
cur := coalesce((select jsonb_agg(x) from jsonb_array_elements(cur) x
|
|
1269
|
+
where not exists (select 1 from jsonb_array_elements_text(o->'value') r where lower(r) = x #>> '{}')), '[]'::jsonb);
|
|
1270
|
+
end if;
|
|
1271
|
+
res := jsonb_set(res, array[role], cur);
|
|
1272
|
+
else
|
|
1273
|
+
raise exception using errcode = 'LT003', message = 'letopis: invalid_data: links_patch: неизвестная операция ' || coalesce(o->>'op', 'null');
|
|
1274
|
+
end case;
|
|
1275
|
+
end loop;
|
|
1276
|
+
return res;
|
|
1277
|
+
end $$;
|
|
1278
|
+
|
|
1279
|
+
-- Слияние патча (§2.5): повторяет deepMerge 0.21 без исключений — объекты сливаются рекурсивно,
|
|
1280
|
+
-- массивы, скаляры и null заменяются. Ключи на $ — обычные данные.
|
|
1281
|
+
create or replace function @SCHEMA@.merge(base jsonb, patch jsonb) returns jsonb
|
|
1282
|
+
language plpgsql immutable parallel safe
|
|
1283
|
+
as $$
|
|
1284
|
+
declare
|
|
1285
|
+
k text;
|
|
1286
|
+
v jsonb;
|
|
1287
|
+
res jsonb := coalesce(base, '{}'::jsonb);
|
|
1288
|
+
begin
|
|
1289
|
+
if patch is null then
|
|
1290
|
+
return res;
|
|
1291
|
+
end if;
|
|
1292
|
+
if jsonb_typeof(patch) <> 'object' or jsonb_typeof(res) <> 'object' then
|
|
1293
|
+
return patch;
|
|
1294
|
+
end if;
|
|
1295
|
+
for k, v in select key, value from jsonb_each(patch) loop
|
|
1296
|
+
if jsonb_typeof(v) = 'object' and jsonb_typeof(res->k) = 'object' then
|
|
1297
|
+
res := jsonb_set(res, array[k], @SCHEMA@.merge(res->k, v));
|
|
1298
|
+
else
|
|
1299
|
+
res := res || jsonb_build_object(k, v);
|
|
1300
|
+
end if;
|
|
1301
|
+
end loop;
|
|
1302
|
+
return res;
|
|
1303
|
+
end $$;
|
|
1304
|
+
|
|
1305
|
+
-- Приращение числа по пути (§2.5): путь только через существующие объекты; отсутствующее поле —
|
|
1306
|
+
-- ошибка без start; null и не число — ошибка и со start. Сумма считается в numeric точно.
|
|
1307
|
+
create or replace function @SCHEMA@.inc(data jsonb, path text[], delta numeric, start numeric default null) returns jsonb
|
|
1308
|
+
language plpgsql immutable parallel safe
|
|
1309
|
+
as $$
|
|
1310
|
+
declare
|
|
1311
|
+
cur jsonb := data;
|
|
1312
|
+
v jsonb;
|
|
1313
|
+
i int;
|
|
1314
|
+
fail text;
|
|
1315
|
+
begin
|
|
1316
|
+
if path is null or cardinality(path) = 0 or delta is null then
|
|
1317
|
+
fail := 'inc: нужны путь и величина';
|
|
1318
|
+
else
|
|
1319
|
+
for i in 1 .. cardinality(path) - 1 loop
|
|
1320
|
+
if jsonb_typeof(cur) is distinct from 'object' or not cur ? path[i] then
|
|
1321
|
+
fail := format('inc: путь %s идёт через отсутствующий объект', array_to_string(path, '.'));
|
|
1322
|
+
exit;
|
|
1323
|
+
end if;
|
|
1324
|
+
cur := cur->path[i];
|
|
1325
|
+
end loop;
|
|
1326
|
+
if fail is null and jsonb_typeof(cur) is distinct from 'object' then
|
|
1327
|
+
fail := format('inc: путь %s идёт не через объект', array_to_string(path, '.'));
|
|
1328
|
+
end if;
|
|
1329
|
+
if fail is null then
|
|
1330
|
+
v := cur->path[cardinality(path)];
|
|
1331
|
+
if v is null then
|
|
1332
|
+
if start is null then
|
|
1333
|
+
fail := format('inc: поля %s нет — задайте start', array_to_string(path, '.'));
|
|
1334
|
+
else
|
|
1335
|
+
return jsonb_set(data, path, to_jsonb(start + delta), true);
|
|
1336
|
+
end if;
|
|
1337
|
+
elsif jsonb_typeof(v) <> 'number' then
|
|
1338
|
+
fail := format('inc: в поле %s не число', array_to_string(path, '.'));
|
|
1339
|
+
else
|
|
1340
|
+
return jsonb_set(data, path, to_jsonb((v #>> '{}')::numeric + delta));
|
|
1341
|
+
end if;
|
|
1342
|
+
end if;
|
|
1343
|
+
end if;
|
|
1344
|
+
raise exception using errcode = 'LT003', message = 'letopis: invalid_data: ' || fail,
|
|
1345
|
+
detail = jsonb_build_object('issues', jsonb_build_array(jsonb_build_object(
|
|
1346
|
+
'path', to_jsonb(coalesce(path, '{}')), 'keyword', 'inc', 'message', fail)))::text;
|
|
1347
|
+
end $$;
|
|
1348
|
+
|
|
1349
|
+
do $$
|
|
1350
|
+
declare f text;
|
|
1351
|
+
begin
|
|
1352
|
+
foreach f in array array[
|
|
1353
|
+
'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
|
|
1354
|
+
'apply_defaults(jsonb, jsonb)', 'links_shape(jsonb, jsonb)', 'targets_bad(jsonb, boolean, jsonb)', 'check_links(jsonb, jsonb, uuid, boolean, jsonb)', 'key_id(jsonb, text, uuid, jsonb, jsonb)',
|
|
1355
|
+
'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
|
|
1356
|
+
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
|
|
1357
|
+
'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
|
|
1358
|
+
'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
|
|
1359
|
+
'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
|
|
1360
|
+
'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
|
|
1361
|
+
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
1362
|
+
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
1363
|
+
end loop;
|
|
1364
|
+
end $$;
|
|
1365
|
+
-- сырой SQL пишет теми же merge, inc и links_patch, что и библиотека; verify() — в 90-time.sql
|
|
1366
|
+
-- до этапа 6 verify() был без параметров
|
|
1367
|
+
drop function if exists @SCHEMA@.verify();
|
|
1368
|
+
grant execute on function @SCHEMA@.merge(jsonb, jsonb), @SCHEMA@.inc(jsonb, text[], numeric, numeric), @SCHEMA@.links_patch(jsonb, jsonb),
|
|
1369
|
+
@SCHEMA@.reserved_names(), @SCHEMA@.reclass(uuid, text, jsonb), @SCHEMA@.delete_preview(uuid[]) to @APP@;
|