letopis 1.1.0 → 1.3.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 +113 -16
- package/AGENT-CHEATSHEET.md +106 -14
- package/CHANGELOG.md +702 -0
- package/README.en.md +798 -74
- package/README.md +756 -68
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +94 -23
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +318 -0
- package/dist/project.js +1142 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/sync.js +11 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +48 -4
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +13 -2
- package/sql/10-core.sql +8 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +629 -56
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +154 -13
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
-- letopis 1.2 — производные представления (план 1.1, §2.4): проекторы из журнала (этап 4а) и агрегаты в базе
|
|
2
|
+
-- (этап 4б, раздел «Агрегаты» ниже).
|
|
3
|
+
--
|
|
4
|
+
-- Проектор — процесс администратора, как загрузчик: работает от роли владельца (set local role),
|
|
5
|
+
-- читает журнал всех арендаторов функцией watch_read и пишет строки производных классов (derivedBy)
|
|
6
|
+
-- только функцией derived_apply. Чекпоинт — строка secret вида PROJECTOR (account = System, ident —
|
|
7
|
+
-- имя проектора): курсор журнала со снимком, версия проектора, состояние, выбывшие арендаторы. Курсор
|
|
8
|
+
-- сдвигается в той же транзакции, что и запись результатов; писатель один — строка-замок secret вида LOCK
|
|
9
|
+
-- («схема:projector:имя», lock_try в 60-write.sql) на каждую пачку. Функции кредов вид PROJECTOR не видят
|
|
10
|
+
-- (80-auth.sql). Приложению функции файла не выданы: их вызывает только роль владельца.
|
|
11
|
+
|
|
12
|
+
-- Чекпоинт проектора: строка создаётся при первом обращении и блокируется до конца транзакции. Чекпоинт — только
|
|
13
|
+
-- строка System (ревью безопасности №2): строка вида PROJECTOR другого аккаунта им не бывает. Такая строка с тем же
|
|
14
|
+
-- именем заняла бы уникальный индекс secret_kind_ident, и своя не записалась бы, — она помечается удалённой, как при
|
|
15
|
+
-- накате (ниже).
|
|
16
|
+
create or replace function @SCHEMA@.projector_state(p_name text) returns jsonb
|
|
17
|
+
language plpgsql volatile
|
|
18
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
19
|
+
as $$
|
|
20
|
+
declare
|
|
21
|
+
m jsonb;
|
|
22
|
+
begin
|
|
23
|
+
select s.meta into m from @SCHEMA@.secret s
|
|
24
|
+
where s.kind = 'PROJECTOR' and s.ident = p_name and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
|
|
25
|
+
for update;
|
|
26
|
+
if not found then
|
|
27
|
+
update @SCHEMA@.secret s set deleted_at = clock_timestamp()
|
|
28
|
+
where s.kind = 'PROJECTOR' and casefold(s.ident) = casefold(p_name) and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
|
|
29
|
+
insert into @SCHEMA@.secret (account, kind, ident, meta) values ('@SYSTEM@'::uuid, 'PROJECTOR', p_name, '{}'::jsonb);
|
|
30
|
+
return '{}'::jsonb;
|
|
31
|
+
end if;
|
|
32
|
+
return coalesce(m, '{}'::jsonb);
|
|
33
|
+
end $$;
|
|
34
|
+
|
|
35
|
+
-- Сдвиг чекпоинта: только если курсор тот, что прочитан в начале пачки (иначе false — пачку откатывают).
|
|
36
|
+
create or replace function @SCHEMA@.projector_advance(p_name text, p_expected text, p_meta jsonb) returns boolean
|
|
37
|
+
language plpgsql volatile
|
|
38
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
39
|
+
as $$
|
|
40
|
+
begin
|
|
41
|
+
update @SCHEMA@.secret s set meta = p_meta
|
|
42
|
+
where s.kind = 'PROJECTOR' and s.ident = p_name and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
|
|
43
|
+
and s.meta->>'cursor' is not distinct from p_expected;
|
|
44
|
+
return found;
|
|
45
|
+
end $$;
|
|
46
|
+
|
|
47
|
+
-- Строки вида PROJECTOR других аккаунтов — не чекпоинты (ревью безопасности №2): при накате помечаются удалёнными;
|
|
48
|
+
-- повторный накат их уже не находит.
|
|
49
|
+
update @SCHEMA@.secret s set deleted_at = clock_timestamp()
|
|
50
|
+
where s.kind = 'PROJECTOR' and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
|
|
51
|
+
|
|
52
|
+
-- Строка проектора в формате загрузчика: поля и роли концов на верхнем уровне, служебные $tags, $owner,
|
|
53
|
+
-- $id; ключи на $ внутри данных — обычные данные. Делится на данные и концы по описанию класса.
|
|
54
|
+
create or replace function @SCHEMA@.derived_split(p_res jsonb, p_row jsonb, out data jsonb, out links jsonb)
|
|
55
|
+
language sql immutable
|
|
56
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
57
|
+
as $$
|
|
58
|
+
select coalesce(jsonb_object_agg(x.key, x.value) filter (where not coalesce(p_res->'ends' ? x.key, false) and left(x.key, 1) <> '$'), '{}'::jsonb),
|
|
59
|
+
coalesce(jsonb_object_agg(x.key, x.value) filter (where coalesce(p_res->'ends' ? x.key, false)), '{}'::jsonb)
|
|
60
|
+
from jsonb_each(coalesce(p_row, '{}'::jsonb)) x
|
|
61
|
+
$$;
|
|
62
|
+
|
|
63
|
+
-- id строки проектора: v5 от ключа класса; у класса без ключа — $id строки (null — не задан).
|
|
64
|
+
create or replace function @SCHEMA@.derived_id(p_class text, p_tenant uuid, p_row jsonb) returns uuid
|
|
65
|
+
language plpgsql stable
|
|
66
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
67
|
+
as $$
|
|
68
|
+
declare
|
|
69
|
+
res jsonb := (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved';
|
|
70
|
+
sp record;
|
|
71
|
+
kid uuid;
|
|
72
|
+
begin
|
|
73
|
+
select * into sp from @SCHEMA@.derived_split(res, p_row);
|
|
74
|
+
kid := @SCHEMA@.key_id(res, p_class, p_tenant, sp.data, sp.links);
|
|
75
|
+
if kid is null and pg_input_is_valid(p_row->>'$id', 'uuid') then
|
|
76
|
+
kid := (p_row->>'$id')::uuid;
|
|
77
|
+
end if;
|
|
78
|
+
return kid;
|
|
79
|
+
end $$;
|
|
80
|
+
|
|
81
|
+
-- Запись результатов проектора p_name. Операции — [{op, class, tenant, row, deltas}]:
|
|
82
|
+
-- upsert — создать или заменить целиком по ключу;
|
|
83
|
+
-- inc — создать или дополнить данными строки и прибавить deltas {поле: число} (нет поля — от 0);
|
|
84
|
+
-- delete — удалить по ключу или $id (строки нет — ничего).
|
|
85
|
+
-- row — строка в формате загрузчика (derived_split); $id нужен только классу без ключа, $owner — при
|
|
86
|
+
-- создании. Класс — производный именно этого проектора в арендаторе строки; данные — по описанию класса
|
|
87
|
+
-- (умолчания, проверка); концы — живые цели своего арендатора или System подходящего класса, не производные;
|
|
88
|
+
-- id — v5 от ключа. Пустая правка версии не даёт. Журнал — по history класса, сигнал — всегда (log_version,
|
|
89
|
+
-- tombstone). Текст ошибки называет проектор, арендатора и id строки (ревью безопасности №2): по нему видно, чьи
|
|
90
|
+
-- данные проектор не записал. Возвращает {created, updated, deleted, unchanged}.
|
|
91
|
+
create or replace function @SCHEMA@.derived_apply(p_name text, p_ops jsonb) returns jsonb
|
|
92
|
+
language plpgsql volatile
|
|
93
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
94
|
+
as $$
|
|
95
|
+
declare
|
|
96
|
+
o jsonb;
|
|
97
|
+
op text;
|
|
98
|
+
cname text;
|
|
99
|
+
tid uuid;
|
|
100
|
+
cls @SCHEMA@.entity;
|
|
101
|
+
res jsonb;
|
|
102
|
+
rw jsonb;
|
|
103
|
+
sp record;
|
|
104
|
+
d jsonb;
|
|
105
|
+
bad jsonb;
|
|
106
|
+
lk record;
|
|
107
|
+
kid uuid;
|
|
108
|
+
cur @SCHEMA@.entity;
|
|
109
|
+
e @SCHEMA@.entity;
|
|
110
|
+
last record;
|
|
111
|
+
ts timestamptz;
|
|
112
|
+
k record;
|
|
113
|
+
tg text[];
|
|
114
|
+
own uuid;
|
|
115
|
+
-- где ошибка: проектор, арендатор и (когда известен) id строки
|
|
116
|
+
w text;
|
|
117
|
+
v_agent text := 'letopis projector ' || p_name;
|
|
118
|
+
v_reason text := nullif(current_setting('letopis.reason', true), '');
|
|
119
|
+
n_created int := 0;
|
|
120
|
+
n_updated int := 0;
|
|
121
|
+
n_deleted int := 0;
|
|
122
|
+
n_unchanged int := 0;
|
|
123
|
+
begin
|
|
124
|
+
for o in select x from jsonb_array_elements(coalesce(p_ops, '[]'::jsonb)) x loop
|
|
125
|
+
op := o->>'op';
|
|
126
|
+
cname := o->>'class';
|
|
127
|
+
rw := coalesce(o->'row', '{}'::jsonb);
|
|
128
|
+
if op is null or op not in ('upsert', 'inc', 'delete') then
|
|
129
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: неизвестная операция %s', p_name, coalesce(op, 'null')));
|
|
130
|
+
end if;
|
|
131
|
+
if jsonb_typeof(rw) <> 'object' then
|
|
132
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: строка — не объект', p_name));
|
|
133
|
+
end if;
|
|
134
|
+
if not pg_input_is_valid(o->>'tenant', 'uuid') then
|
|
135
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('проектор %s: арендатор строки — не uuid', p_name));
|
|
136
|
+
end if;
|
|
137
|
+
tid := (o->>'tenant')::uuid;
|
|
138
|
+
w := format(' (проектор %s, арендатор %s)', p_name, tid);
|
|
139
|
+
cls := @SCHEMA@.class_row(cname, tid);
|
|
140
|
+
if cls.id is null then
|
|
141
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', cname) || w);
|
|
142
|
+
end if;
|
|
143
|
+
-- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
|
|
144
|
+
perform @SCHEMA@.family_lock(cls, false);
|
|
145
|
+
cls := @SCHEMA@.class_row(cname, tid);
|
|
146
|
+
res := cls.data->'resolved';
|
|
147
|
+
if res->>'derivedBy' is distinct from p_name then
|
|
148
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
|
|
149
|
+
end if;
|
|
150
|
+
if coalesce((res->>'abstract')::boolean, false) then
|
|
151
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', cname) || w);
|
|
152
|
+
end if;
|
|
153
|
+
select * into sp from @SCHEMA@.derived_split(res, rw);
|
|
154
|
+
-- id: v5 от ключа; у класса без ключа — $id строки
|
|
155
|
+
kid := @SCHEMA@.key_id(res, cname, tid, sp.data, sp.links);
|
|
156
|
+
if kid is null then
|
|
157
|
+
if not pg_input_is_valid(rw->>'$id', 'uuid') then
|
|
158
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('класс %s без ключа: проектору нужен $id строки', cname) || w);
|
|
159
|
+
end if;
|
|
160
|
+
kid := (rw->>'$id')::uuid;
|
|
161
|
+
elsif rw ? '$id' and (not pg_input_is_valid(rw->>'$id', 'uuid') or (rw->>'$id')::uuid <> kid) then
|
|
162
|
+
perform @SCHEMA@.lt_fail('id_mismatch', format('$id не равен v5 от ключа класса %s', cname) || w, jsonb_build_object('expected', kid));
|
|
163
|
+
end if;
|
|
164
|
+
w := format(' (проектор %s, арендатор %s, строка %s)', p_name, tid, kid);
|
|
165
|
+
select x.* into cur from @SCHEMA@.entity x where x.id = kid for update;
|
|
166
|
+
if cur.id is not null and (cur.class <> cname or cur.tenant <> tid) then
|
|
167
|
+
perform @SCHEMA@.lt_fail('id_mismatch', 'id занят строкой другого класса или арендатора' || w);
|
|
168
|
+
end if;
|
|
169
|
+
if op = 'delete' then
|
|
170
|
+
if cur.id is not null then
|
|
171
|
+
delete from @SCHEMA@.entity x where x.id = kid returning x.* into e;
|
|
172
|
+
perform @SCHEMA@.tombstone(e);
|
|
173
|
+
n_deleted := n_deleted + 1;
|
|
174
|
+
end if;
|
|
175
|
+
continue;
|
|
176
|
+
end if;
|
|
177
|
+
if op = 'inc' then
|
|
178
|
+
d := @SCHEMA@.merge(coalesce(cur.data, '{}'::jsonb), sp.data);
|
|
179
|
+
for k in select key, value from jsonb_each(coalesce(o->'deltas', '{}'::jsonb)) loop
|
|
180
|
+
if jsonb_typeof(k.value) <> 'number' then
|
|
181
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('приращение поля %s — не число', k.key) || w);
|
|
182
|
+
end if;
|
|
183
|
+
d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
|
|
184
|
+
end loop;
|
|
185
|
+
else
|
|
186
|
+
d := sp.data;
|
|
187
|
+
end if;
|
|
188
|
+
d := @SCHEMA@.apply_defaults(res->'schema', d);
|
|
189
|
+
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
190
|
+
if bad <> '[]'::jsonb then
|
|
191
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', cname) || w,
|
|
192
|
+
jsonb_build_object('issues', bad, 'tenant', tid, 'id', kid));
|
|
193
|
+
end if;
|
|
194
|
+
-- концы: у inc без концов в строке — прежние
|
|
195
|
+
select * into lk from @SCHEMA@.check_links(res,
|
|
196
|
+
case when op = 'inc' and sp.links = '{}'::jsonb then coalesce(cur.links, '{}'::jsonb) else sp.links end,
|
|
197
|
+
tid, true, null);
|
|
198
|
+
if lk.code is not null then
|
|
199
|
+
perform @SCHEMA@.lt_fail(lk.code, lk.msg || w);
|
|
200
|
+
end if;
|
|
201
|
+
if rw ? '$tags' and jsonb_typeof(rw->'$tags') <> 'array' then
|
|
202
|
+
perform @SCHEMA@.lt_fail('invalid_data', '$tags — список строк' || w);
|
|
203
|
+
end if;
|
|
204
|
+
tg := case when rw ? '$tags' then array(select jsonb_array_elements_text(rw->'$tags')) else coalesce(cur.tags, '{}') end;
|
|
205
|
+
if cur.id is null then
|
|
206
|
+
own := tid;
|
|
207
|
+
if rw->>'$owner' is not null then
|
|
208
|
+
if not pg_input_is_valid(rw->>'$owner', 'uuid') then
|
|
209
|
+
perform @SCHEMA@.lt_fail('invalid_data', '$owner — не uuid' || w);
|
|
210
|
+
end if;
|
|
211
|
+
own := (rw->>'$owner')::uuid;
|
|
212
|
+
if own <> tid and not exists (select 1 from @SCHEMA@.entity a where a.id = own and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid) then
|
|
213
|
+
perform @SCHEMA@.lt_fail('target_not_found', 'владелец строки не найден' || w);
|
|
214
|
+
end if;
|
|
215
|
+
end if;
|
|
216
|
+
-- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала (как trg_entity_bi)
|
|
217
|
+
select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = kid order by l.rev desc limit 1 for update;
|
|
218
|
+
ts := greatest(@SCHEMA@.now_ts(), last.at + interval '1 microsecond');
|
|
219
|
+
insert into @SCHEMA@.entity (id, rev, class, cv, tenant, owner, links, data, tags, at, created_at, author, agent, op, reason, moved, prev_hash, validator)
|
|
220
|
+
values (kid, coalesce(last.rev, 0) + 1, cname, cls.rev, tid, own, lk.links, d, tg, ts, ts, '@SYSTEM@'::uuid, v_agent, 'create',
|
|
221
|
+
v_reason, null, last.hash, null)
|
|
222
|
+
returning * into e;
|
|
223
|
+
perform @SCHEMA@.log_version(e);
|
|
224
|
+
n_created := n_created + 1;
|
|
225
|
+
elsif cur.data = d and cur.links = lk.links and cur.tags = tg then
|
|
226
|
+
n_unchanged := n_unchanged + 1;
|
|
227
|
+
else
|
|
228
|
+
update @SCHEMA@.entity x
|
|
229
|
+
set data = d, links = lk.links, tags = tg, rev = x.rev + 1, prev_hash = x.hash,
|
|
230
|
+
at = greatest(@SCHEMA@.now_ts(), x.at + interval '1 microsecond'),
|
|
231
|
+
author = '@SYSTEM@'::uuid, agent = v_agent, op = 'update', reason = v_reason, moved = null, cv = cls.rev
|
|
232
|
+
where x.id = kid
|
|
233
|
+
returning x.* into e;
|
|
234
|
+
perform @SCHEMA@.log_version(e);
|
|
235
|
+
n_updated := n_updated + 1;
|
|
236
|
+
end if;
|
|
237
|
+
end loop;
|
|
238
|
+
return jsonb_build_object('created', n_created, 'updated', n_updated, 'deleted', n_deleted, 'unchanged', n_unchanged);
|
|
239
|
+
end $$;
|
|
240
|
+
|
|
241
|
+
-- Перестройка проектора: строки его производных классов удаляются одним оператором — только пары «арендатор, класс»,
|
|
242
|
+
-- которые передал проектор (p_tenants[i], p_classes[i]: участники и их семейства into), и только классы с пометкой
|
|
243
|
+
-- derivedBy этого проектора. Прежде удалялись строки всех классов с пометкой во всех арендаторах — и тех, кто
|
|
244
|
+
-- участником не был (ревью безопасности №2). Надгробия — по history класса одной вставкой, сигнал — по парам «класс,
|
|
245
|
+
-- арендатор». Возвращает число удалённых строк.
|
|
246
|
+
create or replace function @SCHEMA@.derived_clear(p_name text, p_tenants uuid[], p_classes text[]) returns int
|
|
247
|
+
language plpgsql volatile
|
|
248
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
249
|
+
as $$
|
|
250
|
+
declare
|
|
251
|
+
n int;
|
|
252
|
+
pairs text[];
|
|
253
|
+
v_author uuid := '@SYSTEM@'::uuid;
|
|
254
|
+
v_agent text := 'letopis projector ' || p_name;
|
|
255
|
+
v_reason text := nullif(current_setting('letopis.reason', true), '');
|
|
256
|
+
begin
|
|
257
|
+
with c as (
|
|
258
|
+
select distinct x.data->>'name' as name, x.tenant
|
|
259
|
+
from unnest(coalesce(p_tenants, '{}'), coalesce(p_classes, '{}')) p(tenant, name)
|
|
260
|
+
join @SCHEMA@.entity x on x.class = 'Class' and x.tenant = p.tenant and x.data->>'name' = p.name
|
|
261
|
+
where x.data->'resolved'->>'derivedBy' = p_name
|
|
262
|
+
), g as (
|
|
263
|
+
delete from @SCHEMA@.entity e using c where e.class = c.name and e.tenant = c.tenant
|
|
264
|
+
returning e.*
|
|
265
|
+
), t as (
|
|
266
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
267
|
+
select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
|
|
268
|
+
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, v_agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
|
|
269
|
+
from g
|
|
270
|
+
join (select d.class, d.tenant from (select distinct y.class, y.tenant from g y) d
|
|
271
|
+
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
|
|
272
|
+
)
|
|
273
|
+
select count(*)::int, coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}') into n, pairs from g;
|
|
274
|
+
if n > 0 then
|
|
275
|
+
perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
|
|
276
|
+
from unnest(pairs) x(p);
|
|
277
|
+
end if;
|
|
278
|
+
return n;
|
|
279
|
+
end $$;
|
|
280
|
+
|
|
281
|
+
-- до ревью безопасности №2 перестройка удаляла строки всех классов с пометкой во всех арендаторах
|
|
282
|
+
drop function if exists @SCHEMA@.derived_clear(text);
|
|
283
|
+
|
|
284
|
+
do $$
|
|
285
|
+
declare f text;
|
|
286
|
+
begin
|
|
287
|
+
foreach f in array array['projector_state(text)', 'projector_advance(text, text, jsonb)', 'derived_split(jsonb, jsonb)',
|
|
288
|
+
'derived_id(text, uuid, jsonb)', 'derived_apply(text, jsonb)', 'derived_clear(text, uuid[], text[])'] loop
|
|
289
|
+
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
290
|
+
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
291
|
+
end loop;
|
|
292
|
+
end $$;
|
|
293
|
+
|
|
294
|
+
-- ========================= агрегаты в базе (этап 4б) =========================
|
|
295
|
+
--
|
|
296
|
+
-- Опция класса aggregate (40-class.sql): { <поле>: { sum: <поле источника> | count: true, of: <класс-источник>,
|
|
297
|
+
-- via: <роль конца источника>, where?: JSON Schema } }. Поле цели — сумма поля sum или число живых строк класса of
|
|
298
|
+
-- (с потомками) того же арендатора, чей одиночный конец via ведёт на цель и чьи данные проходят where. Строка System
|
|
299
|
+
-- и строки других арендаторов в агрегат не входят: общий справочник не считает данные всех арендаторов. Агрегат класса
|
|
300
|
+
-- арендатора считает только классы своего арендатора: класс of и потомков ищут среди них, класс System с тем же именем
|
|
301
|
+
-- его источником не бывает — везде одинаково (agg_prepare, agg_source, agg_classes, agg_apply, agg_fill); агрегат
|
|
302
|
+
-- класса System считает классы System и классы арендатора строки-цели.
|
|
303
|
+
--
|
|
304
|
+
-- Держит поле триггер после оператора entity_total_* (без условия WHEN — и для владельца, enable always): по
|
|
305
|
+
-- переходным таблицам вклады строк оператора (прежние версии со знаком минус, новые — плюс) группируются по цели,
|
|
306
|
+
-- цели блокируются в порядке id, приращение — inc, итог проверяет описание класса цели (minimum: 0 → invalid_data),
|
|
307
|
+
-- версия — sys_update (автор, агент и причина — из контекста оператора, журнал по history, сигнал). Новые цели
|
|
308
|
+
-- оператора (вставка, restore, смена класса) считаются целиком из живых источников и пишутся в ту же версию — в entity
|
|
309
|
+
-- и в уже записанную версию журнала (хэши — генерируемые колонки); поэтому функции владельца, пишущие журнал после
|
|
310
|
+
-- вставки, перечитывают строку (restore, sys_update со сменой класса), а sys_create и загрузчик пишут журнал раньше.
|
|
311
|
+
-- Перенос строки (move_row: reclass, rekey) — несколько операторов: на это время триггер копит вклады
|
|
312
|
+
-- (agg_hold_begin), а перенос в конце применяет их одним итогом. Признак — letopis.agg_hold: HMAC ключом строки KEY
|
|
313
|
+
-- от «режим:транзакция», поэтому выставить его приложение не может.
|
|
314
|
+
--
|
|
315
|
+
-- Источник узнаётся по метке aggregatedBy в resolved его класса: [{ class, field, via }] — какие цели считают его
|
|
316
|
+
-- строки (агрегаты своего арендатора и System). Метку считает class_prepare (agg_source), а при появлении, правке и
|
|
317
|
+
-- удалении цели — agg_mark: класс-источник часто описывают после цели. Описание агрегата — ужесточение
|
|
318
|
+
-- (class_change_safe): исключительные блокировки семейства цели и семейств источников, пересчёт поля у всех целей
|
|
319
|
+
-- (class_evolve). Ремонт — rebuild_aggregates, расхождения показывает verify(true) (agg_issues), первая заливка —
|
|
320
|
+
-- agg_fill.
|
|
321
|
+
--
|
|
322
|
+
-- Права (ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника, без прав чтения, поэтому
|
|
323
|
+
-- строки попадают в агрегат только с ведома того, кто читает их все. Описать или изменить агрегат приложение может с
|
|
324
|
+
-- правом READ без условий на каждый класс семейства источника (class_admit), а сделать класс источником (новый класс,
|
|
325
|
+
-- смена parent, источник после цели) — с таким правом на сам класс и его потомков, до правки и после
|
|
326
|
+
-- (agg_source_admit).
|
|
327
|
+
|
|
328
|
+
-- Агрегатные поля класса по развёрнутому описанию.
|
|
329
|
+
create or replace function @SCHEMA@.agg_fields(p_res jsonb) returns text[]
|
|
330
|
+
language sql immutable parallel safe
|
|
331
|
+
return array(select jsonb_object_keys(coalesce(p_res->'aggregate', '{}'::jsonb)));
|
|
332
|
+
|
|
333
|
+
-- Классы-источники агрегатов класса (of).
|
|
334
|
+
create or replace function @SCHEMA@.agg_of(p_res jsonb) returns text[]
|
|
335
|
+
language sql immutable parallel safe
|
|
336
|
+
return array(select distinct f.value->>'of' from jsonb_each(coalesce(p_res->'aggregate', '{}'::jsonb)) f);
|
|
337
|
+
|
|
338
|
+
-- Данные с агрегатными полями: число остаётся (его пересчитают), иначе 0.
|
|
339
|
+
create or replace function @SCHEMA@.agg_zero(p_res jsonb, p_data jsonb) returns jsonb
|
|
340
|
+
language sql immutable parallel safe
|
|
341
|
+
return coalesce(p_data, '{}'::jsonb) || coalesce((select jsonb_object_agg(f, case when jsonb_typeof(p_data->f) = 'number' then p_data->f else '0'::jsonb end)
|
|
342
|
+
from unnest(@SCHEMA@.agg_fields(p_res)) f), '{}'::jsonb);
|
|
343
|
+
|
|
344
|
+
-- Число из значения источника: не число — 0.
|
|
345
|
+
create or replace function @SCHEMA@.agg_num(p_v jsonb) returns numeric
|
|
346
|
+
language sql immutable parallel safe
|
|
347
|
+
return case when jsonb_typeof(p_v) = 'number' then (p_v #>> '{}')::numeric else 0 end;
|
|
348
|
+
|
|
349
|
+
-- Проходят ли данные источника условие where агрегата (скомпилированное: rules, path, vars); условия нет — да.
|
|
350
|
+
create or replace function @SCHEMA@.agg_where_ok(p_m jsonb, p_data jsonb) returns boolean
|
|
351
|
+
language sql stable
|
|
352
|
+
return p_m is null or @SCHEMA@.validate_data(p_m->'rules', (p_m->>'path')::jsonpath, p_m->'vars', p_data) = '[]'::jsonb;
|
|
353
|
+
|
|
354
|
+
-- Правка строки приложением (trg_entity_bu): агрегатное поле не меняется — изменённое значение отклоняется, без поля
|
|
355
|
+
-- (замена данных целиком) возвращается прежнее; при смене класса — прежнее число или 0 (итог посчитает триггер).
|
|
356
|
+
create or replace function @SCHEMA@.agg_guard(p_res jsonb, p_old jsonb, p_new jsonb, p_reclass boolean) returns jsonb
|
|
357
|
+
language plpgsql stable
|
|
358
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
359
|
+
as $$
|
|
360
|
+
declare
|
|
361
|
+
f text;
|
|
362
|
+
d jsonb := coalesce(p_new, '{}'::jsonb);
|
|
363
|
+
begin
|
|
364
|
+
if p_reclass then
|
|
365
|
+
return @SCHEMA@.agg_zero(p_res, d);
|
|
366
|
+
end if;
|
|
367
|
+
foreach f in array @SCHEMA@.agg_fields(p_res) loop
|
|
368
|
+
if not (d ? f) then
|
|
369
|
+
d := d || jsonb_build_object(f, coalesce(p_old->f, '0'::jsonb));
|
|
370
|
+
elsif d->f is distinct from p_old->f then
|
|
371
|
+
perform @SCHEMA@.lt_fail('aggregate_field', format('поле %s класса %s считает база (aggregate) — его не меняют', f, p_res->>'name'));
|
|
372
|
+
end if;
|
|
373
|
+
end loop;
|
|
374
|
+
return d;
|
|
375
|
+
end $$;
|
|
376
|
+
|
|
377
|
+
-- Правка существующей строки загрузчиком (existing: 'update'): агрегатные поля — прежние.
|
|
378
|
+
create or replace function @SCHEMA@.agg_keep(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns jsonb
|
|
379
|
+
language plpgsql stable
|
|
380
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
381
|
+
as $$
|
|
382
|
+
declare
|
|
383
|
+
res jsonb := (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved';
|
|
384
|
+
begin
|
|
385
|
+
if res is null or not (res ? 'aggregate') then
|
|
386
|
+
return p_new;
|
|
387
|
+
end if;
|
|
388
|
+
return coalesce(p_new, '{}'::jsonb) || coalesce((select jsonb_object_agg(f, coalesce(p_old->f, '0'::jsonb))
|
|
389
|
+
from unnest(@SCHEMA@.agg_fields(res)) f), '{}'::jsonb);
|
|
390
|
+
end $$;
|
|
391
|
+
|
|
392
|
+
-- Сверка источника с агрегатом цели: класс не системный и не производный, роль via — одиночный конец, принимающий
|
|
393
|
+
-- класс цели (или предка), поле суммы — число (сумма number в поле integer не помещается). p_src, p_tgt — resolved.
|
|
394
|
+
create or replace function @SCHEMA@.agg_check_source(p_src jsonb, p_tgt jsonb, p_field text, p_def jsonb) returns void
|
|
395
|
+
language plpgsql stable
|
|
396
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
397
|
+
as $$
|
|
398
|
+
declare
|
|
399
|
+
w text := format('агрегат %s.%s', p_tgt->>'name', p_field);
|
|
400
|
+
e jsonb := p_src->'ends'->(p_def->>'via');
|
|
401
|
+
fld jsonb;
|
|
402
|
+
begin
|
|
403
|
+
if (p_src->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
|
|
404
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: системный класс %s не бывает источником агрегата', w, p_src->>'name'));
|
|
405
|
+
end if;
|
|
406
|
+
if p_src ? 'derivedBy' then
|
|
407
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: производный класс %s не бывает источником агрегата', w, p_src->>'name'));
|
|
408
|
+
end if;
|
|
409
|
+
if e is null then
|
|
410
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: у источника %s нет конца %s', w, p_src->>'name', p_def->>'via'));
|
|
411
|
+
end if;
|
|
412
|
+
if coalesce((e->>'many')::boolean, false) then
|
|
413
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
|
|
414
|
+
format('%s: конец %s источника %s множественный — агрегат считается через одиночный конец', w, p_def->>'via', p_src->>'name'));
|
|
415
|
+
end if;
|
|
416
|
+
if not coalesce(@SCHEMA@.class_set(e->'class') ?| array(select jsonb_array_elements_text(p_tgt->'ancestors')), false) then
|
|
417
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class',
|
|
418
|
+
format('%s: конец %s источника %s не принимает класс %s', w, p_def->>'via', p_src->>'name', p_tgt->>'name'));
|
|
419
|
+
end if;
|
|
420
|
+
if p_def ? 'sum' then
|
|
421
|
+
fld := p_src->'schema'->'properties'->(p_def->>'sum');
|
|
422
|
+
if fld is null or coalesce(fld->>'type', '') not in ('integer', 'number') then
|
|
423
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: поле суммы %s источника %s — число (integer или number)', w, p_def->>'sum', p_src->>'name'));
|
|
424
|
+
end if;
|
|
425
|
+
if fld->>'type' = 'number' and p_tgt->'schema'->'properties'->p_field->>'type' = 'integer' then
|
|
426
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('%s: сумма поля number %s.%s в поле integer не помещается', w, p_src->>'name', p_def->>'sum'));
|
|
427
|
+
end if;
|
|
428
|
+
end if;
|
|
429
|
+
end $$;
|
|
430
|
+
|
|
431
|
+
-- Цикл агрегатов через класс p_res (арендатор и System; описание p_res — вместо его строки): цель a зависит от цели
|
|
432
|
+
-- b, если считает строки семейства, в которое входит b (агрегат класса арендатора — только классы арендатора). Нет
|
|
433
|
+
-- цикла — null, иначе путь «A → B → A».
|
|
434
|
+
create or replace function @SCHEMA@.agg_cycle(p_tenant uuid, p_res jsonb) returns text
|
|
435
|
+
language sql stable
|
|
436
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
437
|
+
as $$
|
|
438
|
+
with recursive tg as (
|
|
439
|
+
select x.tenant, x.data->>'name' as name, x.data->'resolved' as res from @SCHEMA@.entity x
|
|
440
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> p_res->>'name'
|
|
441
|
+
and x.data->'resolved' ? 'aggregate'
|
|
442
|
+
union all
|
|
443
|
+
select p_tenant, p_res->>'name', p_res
|
|
444
|
+
), edge as (
|
|
445
|
+
select distinct a.name as a, b.name as b
|
|
446
|
+
from tg a
|
|
447
|
+
cross join lateral jsonb_each(a.res->'aggregate') g
|
|
448
|
+
join tg b on b.res->'ancestors' ? (g.value->>'of') and (a.tenant = '@SYSTEM@'::uuid or b.tenant = a.tenant)
|
|
449
|
+
), walk(node, path) as (
|
|
450
|
+
select e.b, array[e.a, e.b] from edge e where e.a = p_res->>'name'
|
|
451
|
+
union all
|
|
452
|
+
select e.b, w.path || e.b from walk w join edge e on e.a = w.node
|
|
453
|
+
where w.node <> p_res->>'name' and not (e.b = any (w.path[2:]))
|
|
454
|
+
)
|
|
455
|
+
select array_to_string(w.path, ' → ') from walk w where w.node = p_res->>'name' limit 1
|
|
456
|
+
$$;
|
|
457
|
+
|
|
458
|
+
-- Описание агрегатов цели (из class_prepare): ровно одно из sum и count; поле — число из схемы класса, не в ключе;
|
|
459
|
+
-- уже описанные источники (класс of и его потомки) сверены; цели нет в цикле; условие where скомпилировано (match).
|
|
460
|
+
-- Цель — не системный и не производный класс. Источники агрегата класса арендатора ищут только среди классов
|
|
461
|
+
-- арендатора: класс System с именем of источником не бывает, а нет такого класса у арендатора — источник ещё не
|
|
462
|
+
-- описан (ревью коммита этапа 4б); у класса System — во всех арендаторах.
|
|
463
|
+
create or replace function @SCHEMA@.agg_prepare(p_res jsonb, p_data jsonb, p_tenant uuid) returns jsonb
|
|
464
|
+
language plpgsql stable
|
|
465
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
466
|
+
as $$
|
|
467
|
+
declare
|
|
468
|
+
nm text := p_res->>'name';
|
|
469
|
+
f record;
|
|
470
|
+
d jsonb;
|
|
471
|
+
fld jsonb;
|
|
472
|
+
s record;
|
|
473
|
+
c record;
|
|
474
|
+
outv jsonb := '{}'::jsonb;
|
|
475
|
+
cyc text;
|
|
476
|
+
begin
|
|
477
|
+
if coalesce((p_data->>'system')::boolean, false) then
|
|
478
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', 'системный класс не бывает целью агрегата');
|
|
479
|
+
end if;
|
|
480
|
+
if p_res ? 'derivedBy' then
|
|
481
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('производный класс %s не бывает целью агрегата: его строки строит проектор', nm));
|
|
482
|
+
end if;
|
|
483
|
+
for f in select key, value from jsonb_each(p_res->'aggregate') order by key loop
|
|
484
|
+
d := f.value - 'match';
|
|
485
|
+
if (d ? 'sum') = (d ? 'count') then
|
|
486
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегат %s.%s: нужно ровно одно из sum (поле источника) и count: true', nm, f.key));
|
|
487
|
+
end if;
|
|
488
|
+
fld := p_res->'schema'->'properties'->f.key;
|
|
489
|
+
if fld is null then
|
|
490
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатного поля %s нет среди полей класса %s', f.key, nm));
|
|
491
|
+
end if;
|
|
492
|
+
if coalesce(fld->>'type', '') not in ('integer', 'number') then
|
|
493
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатное поле %s.%s — число (integer или number)', nm, f.key));
|
|
494
|
+
end if;
|
|
495
|
+
if coalesce(p_res->'key' ? f.key, false) then
|
|
496
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('агрегатное поле %s.%s в ключ не входит', nm, f.key));
|
|
497
|
+
end if;
|
|
498
|
+
for s in select x.data->'resolved' as res from @SCHEMA@.entity x
|
|
499
|
+
where x.class = 'Class' and x.data->>'name' <> nm
|
|
500
|
+
and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
501
|
+
and x.data->'resolved'->'ancestors' ? (d->>'of')
|
|
502
|
+
order by x.id loop
|
|
503
|
+
perform @SCHEMA@.agg_check_source(s.res, p_res, f.key, d);
|
|
504
|
+
end loop;
|
|
505
|
+
if d ? 'where' then
|
|
506
|
+
select * into c from @SCHEMA@.compile_validator(d->'where');
|
|
507
|
+
d := d || jsonb_build_object('match', jsonb_build_object('rules', c.rules, 'path', c.validator::text, 'vars', c.vars));
|
|
508
|
+
end if;
|
|
509
|
+
outv := outv || jsonb_build_object(f.key, d);
|
|
510
|
+
end loop;
|
|
511
|
+
p_res := p_res || jsonb_build_object('aggregate', outv);
|
|
512
|
+
cyc := @SCHEMA@.agg_cycle(p_tenant, p_res);
|
|
513
|
+
if cyc is not null then
|
|
514
|
+
perform @SCHEMA@.lt_raise('LT010', 'invalid_class', format('цикл агрегатов: %s', cyc));
|
|
515
|
+
end if;
|
|
516
|
+
return p_res;
|
|
517
|
+
end $$;
|
|
518
|
+
|
|
519
|
+
-- Метка источника (из class_prepare): агрегаты своего арендатора и System, которые считают строки класса (of — сам
|
|
520
|
+
-- класс или предок), и та же сверка, что при описании цели. Классу System метка — только от агрегатов System:
|
|
521
|
+
-- агрегаты арендаторов пропускаются, а не отклоняют правку класса System (ревью коммита этапа 4б).
|
|
522
|
+
create or replace function @SCHEMA@.agg_source(p_res jsonb, p_tenant uuid) returns jsonb
|
|
523
|
+
language plpgsql stable
|
|
524
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
525
|
+
as $$
|
|
526
|
+
declare
|
|
527
|
+
k record;
|
|
528
|
+
f record;
|
|
529
|
+
marks jsonb := '[]'::jsonb;
|
|
530
|
+
begin
|
|
531
|
+
for k in select x.tenant, x.data->'resolved' as res from @SCHEMA@.entity x
|
|
532
|
+
where x.class = 'Class' and x.data->>'name' <> p_res->>'name' and x.data->'resolved' ? 'aggregate'
|
|
533
|
+
and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
534
|
+
order by x.data->>'name', x.tenant loop
|
|
535
|
+
for f in select key, value from jsonb_each(k.res->'aggregate') where p_res->'ancestors' ? (value->>'of') order by key loop
|
|
536
|
+
if p_tenant = '@SYSTEM@'::uuid and k.tenant <> '@SYSTEM@'::uuid then
|
|
537
|
+
-- агрегат арендатора считает только классы своего арендатора: класс System его источником не бывает и
|
|
538
|
+
-- метки не получает. Отказывать здесь нельзя — иначе описание арендатора останавливало бы правку System
|
|
539
|
+
continue;
|
|
540
|
+
end if;
|
|
541
|
+
perform @SCHEMA@.agg_check_source(p_res, k.res, f.key, f.value);
|
|
542
|
+
marks := marks || jsonb_build_array(jsonb_build_object('class', k.res->>'name', 'field', f.key, 'via', f.value->>'via'));
|
|
543
|
+
end loop;
|
|
544
|
+
end loop;
|
|
545
|
+
return case when marks = '[]'::jsonb then p_res - 'aggregatedBy' else p_res || jsonb_build_object('aggregatedBy', marks) end;
|
|
546
|
+
end $$;
|
|
547
|
+
|
|
548
|
+
-- Цели, чей набор источников изменился вместе с меткой класса (смена предков): их поле считается заново.
|
|
549
|
+
create or replace function @SCHEMA@.agg_marked(p_old jsonb, p_new jsonb) returns text[]
|
|
550
|
+
language sql immutable parallel safe
|
|
551
|
+
return case when p_old->'aggregatedBy' is not distinct from p_new->'aggregatedBy' then '{}'::text[]
|
|
552
|
+
else array(select distinct m->>'class' from jsonb_array_elements(coalesce(p_old->'aggregatedBy', '[]'::jsonb)
|
|
553
|
+
|| coalesce(p_new->'aggregatedBy', '[]'::jsonb)) m) end;
|
|
554
|
+
|
|
555
|
+
-- Первая метка aggregatedBy описания p_new, которой нет в p_old: класс становится источником этого агрегата (новый
|
|
556
|
+
-- класс, смена parent, источник описан после цели). Новых меток нет — null.
|
|
557
|
+
create or replace function @SCHEMA@.agg_new_mark(p_old jsonb, p_new jsonb) returns jsonb
|
|
558
|
+
language sql immutable parallel safe
|
|
559
|
+
return (select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
|
|
560
|
+
where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
|
|
561
|
+
order by x->>'class', x->>'field' limit 1);
|
|
562
|
+
|
|
563
|
+
-- Класс p_class становится источником агрегата p_mark (ревью коммита этапа 4б): его строки войдут в агрегат, поэтому
|
|
564
|
+
-- актору нужно право READ без условий на все строки класса, иначе acl_denied; p_mark null — проверять нечего. Путь
|
|
565
|
+
-- приложения: class_admit — до правки, по прежним предкам (строки уже есть, в том числе у потомков), триггеры после
|
|
566
|
+
-- оператора и class_evolve — после: строка Class записана, и план прав видит новый класс и его новых предков.
|
|
567
|
+
create or replace function @SCHEMA@.agg_source_admit(p_class text, p_mark jsonb) returns void
|
|
568
|
+
language plpgsql stable
|
|
569
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
570
|
+
as $$
|
|
571
|
+
declare
|
|
572
|
+
chk jsonb;
|
|
573
|
+
begin
|
|
574
|
+
if p_mark is null then
|
|
575
|
+
return;
|
|
576
|
+
end if;
|
|
577
|
+
chk := @SCHEMA@.acl_check_data(p_class, 'READ');
|
|
578
|
+
if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
|
|
579
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s: нужно право READ на все его строки',
|
|
580
|
+
p_class, p_mark->>'class', p_mark->>'field'));
|
|
581
|
+
end if;
|
|
582
|
+
end $$;
|
|
583
|
+
|
|
584
|
+
-- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
|
|
585
|
+
-- изменившееся описание — новой версией строки Class (журнал, сигнал).
|
|
586
|
+
create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
|
|
587
|
+
language plpgsql volatile
|
|
588
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
589
|
+
as $$
|
|
590
|
+
declare
|
|
591
|
+
d @SCHEMA@.entity;
|
|
592
|
+
prep record;
|
|
593
|
+
begin
|
|
594
|
+
if p_names is null or cardinality(p_names) = 0 then
|
|
595
|
+
return;
|
|
596
|
+
end if;
|
|
597
|
+
for d in select x.* from @SCHEMA@.entity x
|
|
598
|
+
where x.class = 'Class' and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
599
|
+
and x.data->'resolved'->'ancestors' ?| p_names
|
|
600
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.id loop
|
|
601
|
+
select p.data, p.validator into prep from @SCHEMA@.class_prepare(d.data, d.tenant) p;
|
|
602
|
+
if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
|
|
603
|
+
perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
|
|
604
|
+
end if;
|
|
605
|
+
end loop;
|
|
606
|
+
end $$;
|
|
607
|
+
|
|
608
|
+
-- Исключительные блокировки семейств источников прежнего и нового описания агрегатов (смена описания, ремонт):
|
|
609
|
+
-- писатели источников держат разделяемую, поэтому зафиксированное до пересчёта в него войдёт, а ожидающие допишут
|
|
610
|
+
-- вклад по новому описанию. Цель System — источники во всех арендаторах.
|
|
611
|
+
create or replace function @SCHEMA@.agg_lock_sources(p_old jsonb, p_new jsonb, p_tenant uuid) returns void
|
|
612
|
+
language plpgsql volatile
|
|
613
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
614
|
+
as $$
|
|
615
|
+
declare
|
|
616
|
+
tc @SCHEMA@.entity;
|
|
617
|
+
begin
|
|
618
|
+
for tc in select x.* from @SCHEMA@.entity x
|
|
619
|
+
where x.class = 'Class' and x.data->>'name' = any (@SCHEMA@.agg_of(p_old) || @SCHEMA@.agg_of(p_new))
|
|
620
|
+
and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
621
|
+
order by x.id loop
|
|
622
|
+
perform @SCHEMA@.family_lock(tc, true);
|
|
623
|
+
end loop;
|
|
624
|
+
end $$;
|
|
625
|
+
|
|
626
|
+
-- Классы-источники of для строк цели арендатора p_tenant: сам класс и потомки среди классов арендатора, а у агрегата
|
|
627
|
+
-- класса System (p_system) — ещё классы System. Агрегат класса арендатора классы System не считает (ревью коммита
|
|
628
|
+
-- этапа 4б), как и метки (agg_source) и приращения (agg_apply).
|
|
629
|
+
create or replace function @SCHEMA@.agg_classes(p_of text, p_tenant uuid, p_system boolean) returns text[]
|
|
630
|
+
language sql stable
|
|
631
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
632
|
+
as $$
|
|
633
|
+
select coalesce(array_agg(x.data->>'name'), '{}') from @SCHEMA@.entity x
|
|
634
|
+
where x.class = 'Class' and (x.tenant = p_tenant or (p_system and x.tenant = '@SYSTEM@'::uuid))
|
|
635
|
+
and x.data->'resolved'->'ancestors' ? p_of
|
|
636
|
+
$$;
|
|
637
|
+
|
|
638
|
+
-- до ревью коммита этапа 4б семейство брало классы System и для агрегата класса арендатора
|
|
639
|
+
drop function if exists @SCHEMA@.agg_classes(text, uuid);
|
|
640
|
+
|
|
641
|
+
-- Агрегатные поля цели p_id целиком из живых источников её арендатора: { поле: итог }. Класс цели — по строке
|
|
642
|
+
-- арендатора p_tenant (свой или System): от него зависит, входят ли в семейство классы System.
|
|
643
|
+
create or replace function @SCHEMA@.agg_compute(p_res jsonb, p_id uuid, p_tenant uuid) returns jsonb
|
|
644
|
+
language sql stable
|
|
645
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
646
|
+
as $$
|
|
647
|
+
with d as (
|
|
648
|
+
select f.key as field, f.value as def, @SCHEMA@.agg_classes(f.value->>'of', p_tenant, k.sys) as cls
|
|
649
|
+
from jsonb_each(coalesce(p_res->'aggregate', '{}'::jsonb)) f
|
|
650
|
+
cross join (select (@SCHEMA@.class_row(p_res->>'name', p_tenant)).tenant = '@SYSTEM@'::uuid as sys) k
|
|
651
|
+
)
|
|
652
|
+
select coalesce(jsonb_object_agg(d.field, to_jsonb(coalesce(x.v, 0))), '{}'::jsonb)
|
|
653
|
+
from d
|
|
654
|
+
left join lateral (
|
|
655
|
+
select sum(case when d.def ? 'count' then 1 else @SCHEMA@.agg_num(s.data->(d.def->>'sum')) end) as v
|
|
656
|
+
from @SCHEMA@.entity s
|
|
657
|
+
where s.ends @> array[p_id] and s.tenant = p_tenant and s.class = any (d.cls)
|
|
658
|
+
and s.links->>(d.def->>'via') = p_id::text and @SCHEMA@.agg_where_ok(d.def->'match', s.data)
|
|
659
|
+
) x on true
|
|
660
|
+
$$;
|
|
661
|
+
|
|
662
|
+
-- Итог цели проходит описание её класса (minimum: 0 и прочие правила), иначе invalid_data с нарушениями.
|
|
663
|
+
create or replace function @SCHEMA@.agg_check(p_class text, p_tenant uuid, p_data jsonb) returns void
|
|
664
|
+
language plpgsql stable
|
|
665
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
666
|
+
as $$
|
|
667
|
+
declare
|
|
668
|
+
cls @SCHEMA@.entity := @SCHEMA@.class_row(p_class, p_tenant);
|
|
669
|
+
bad jsonb;
|
|
670
|
+
begin
|
|
671
|
+
bad := @SCHEMA@.validate_data(cls.data->'resolved'->'rules', cls.validator, cls.data->'resolved'->'vars', p_data);
|
|
672
|
+
if bad <> '[]'::jsonb then
|
|
673
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s (агрегатное поле)', p_class), jsonb_build_object('issues', bad));
|
|
674
|
+
end if;
|
|
675
|
+
end $$;
|
|
676
|
+
|
|
677
|
+
-- Новое значение цели — версией от имени владельца (sys_update); то же значение — без версии. Возвращает «записано».
|
|
678
|
+
create or replace function @SCHEMA@.agg_write(r @SCHEMA@.entity, p_data jsonb, p_check boolean) returns boolean
|
|
679
|
+
language plpgsql volatile
|
|
680
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
681
|
+
as $$
|
|
682
|
+
begin
|
|
683
|
+
if p_data is not distinct from r.data then
|
|
684
|
+
return false;
|
|
685
|
+
end if;
|
|
686
|
+
if p_check then
|
|
687
|
+
perform @SCHEMA@.agg_check(r.class, r.tenant, p_data);
|
|
688
|
+
end if;
|
|
689
|
+
perform @SCHEMA@.sys_update(r.id, p_data, r.links, r.validator, 'update');
|
|
690
|
+
return true;
|
|
691
|
+
end $$;
|
|
692
|
+
|
|
693
|
+
-- Признак «триггер агрегатов копит» (acc) или «молчит» (off) в этой транзакции: HMAC ключом строки KEY.
|
|
694
|
+
create or replace function @SCHEMA@.agg_token(p_mode text) returns text
|
|
695
|
+
language sql volatile
|
|
696
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
697
|
+
as $$
|
|
698
|
+
select encode(@PGCRYPTO@.hmac(convert_to('letopis:aggregates:' || p_mode || ':' || pg_current_xact_id()::text, 'UTF8'), s.material, 'sha256'), 'hex')
|
|
699
|
+
from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY'
|
|
700
|
+
$$;
|
|
701
|
+
|
|
702
|
+
create or replace function @SCHEMA@.agg_held() returns text
|
|
703
|
+
language plpgsql volatile
|
|
704
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
705
|
+
as $$
|
|
706
|
+
declare
|
|
707
|
+
h text := current_setting('letopis.agg_hold', true);
|
|
708
|
+
begin
|
|
709
|
+
if h is null or h = '' then
|
|
710
|
+
return null;
|
|
711
|
+
end if;
|
|
712
|
+
if h = @SCHEMA@.agg_token('acc') then
|
|
713
|
+
return 'acc';
|
|
714
|
+
end if;
|
|
715
|
+
if h = @SCHEMA@.agg_token('off') then
|
|
716
|
+
return 'off';
|
|
717
|
+
end if;
|
|
718
|
+
return null;
|
|
719
|
+
end $$;
|
|
720
|
+
|
|
721
|
+
-- Начало несколькооператорной операции владельца: acc — вклады копятся до agg_hold_end, off — не считаются (сброс
|
|
722
|
+
-- арендатора). Уже начато (вложенный перенос) — false: итог применит внешний вызов.
|
|
723
|
+
create or replace function @SCHEMA@.agg_hold_begin(p_mode text) returns boolean
|
|
724
|
+
language plpgsql volatile
|
|
725
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
726
|
+
as $$
|
|
727
|
+
declare
|
|
728
|
+
tok text;
|
|
729
|
+
begin
|
|
730
|
+
if @SCHEMA@.agg_held() is not null then
|
|
731
|
+
return false;
|
|
732
|
+
end if;
|
|
733
|
+
tok := @SCHEMA@.agg_token(p_mode);
|
|
734
|
+
if tok is null then
|
|
735
|
+
return false;
|
|
736
|
+
end if;
|
|
737
|
+
perform set_config('letopis.agg_acc', '{}', true);
|
|
738
|
+
perform set_config('letopis.agg_hold', tok, true);
|
|
739
|
+
return true;
|
|
740
|
+
end $$;
|
|
741
|
+
|
|
742
|
+
-- Конец: признак снимается до применения — записи итога идут обычным путём (и считаются агрегатами выше по цепочке).
|
|
743
|
+
create or replace function @SCHEMA@.agg_hold_end(p_apply boolean) returns void
|
|
744
|
+
language plpgsql volatile
|
|
745
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
746
|
+
as $$
|
|
747
|
+
declare
|
|
748
|
+
acc jsonb := case when p_apply then nullif(current_setting('letopis.agg_acc', true), '')::jsonb end;
|
|
749
|
+
begin
|
|
750
|
+
perform set_config('letopis.agg_hold', '', true);
|
|
751
|
+
perform set_config('letopis.agg_acc', '', true);
|
|
752
|
+
if acc is not null then
|
|
753
|
+
perform @SCHEMA@.agg_flush(acc);
|
|
754
|
+
end if;
|
|
755
|
+
end $$;
|
|
756
|
+
|
|
757
|
+
-- Сложение накопленных приращений { id: { поле: число } }.
|
|
758
|
+
create or replace function @SCHEMA@.agg_merge(a jsonb, b jsonb) returns jsonb
|
|
759
|
+
language sql immutable parallel safe
|
|
760
|
+
return (
|
|
761
|
+
select coalesce(jsonb_object_agg(z.id, z.f), '{}'::jsonb)
|
|
762
|
+
from (select y.id, jsonb_object_agg(y.field, y.v) as f
|
|
763
|
+
from (select i.key as id, f.key as field, sum((f.value #>> '{}')::numeric) as v
|
|
764
|
+
from (select * from jsonb_each(coalesce(a, '{}'::jsonb)) union all select * from jsonb_each(coalesce(b, '{}'::jsonb))) i
|
|
765
|
+
cross join lateral jsonb_each(i.value) f
|
|
766
|
+
group by 1, 2) y
|
|
767
|
+
group by y.id) z
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
-- Итог накопленного: цели по возрастанию id, новые (f) — целиком из живых источников, остальные — приращения (d).
|
|
771
|
+
create or replace function @SCHEMA@.agg_flush(p_acc jsonb) returns void
|
|
772
|
+
language plpgsql volatile
|
|
773
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
774
|
+
as $$
|
|
775
|
+
declare
|
|
776
|
+
ids uuid[] := array(select distinct y.x::uuid from (
|
|
777
|
+
select jsonb_object_keys(coalesce(p_acc->'d', '{}'::jsonb)) as x
|
|
778
|
+
union select jsonb_array_elements_text(coalesce(p_acc->'f', '[]'::jsonb))) y);
|
|
779
|
+
r @SCHEMA@.entity;
|
|
780
|
+
res jsonb;
|
|
781
|
+
d jsonb;
|
|
782
|
+
k record;
|
|
783
|
+
begin
|
|
784
|
+
for r in select x.* from @SCHEMA@.entity x where x.id = any (ids) order by x.id for no key update loop
|
|
785
|
+
res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
|
|
786
|
+
continue when not coalesce(res ? 'aggregate', false);
|
|
787
|
+
if coalesce(p_acc->'f', '[]'::jsonb) ? r.id::text then
|
|
788
|
+
d := r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant);
|
|
789
|
+
else
|
|
790
|
+
d := r.data;
|
|
791
|
+
for k in select key, value from jsonb_each(p_acc->'d'->(r.id::text)) where res->'aggregate' ? key loop
|
|
792
|
+
d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
|
|
793
|
+
end loop;
|
|
794
|
+
end if;
|
|
795
|
+
perform @SCHEMA@.agg_write(r, d, true);
|
|
796
|
+
end loop;
|
|
797
|
+
end $$;
|
|
798
|
+
|
|
799
|
+
-- Касается ли класс агрегатов в арендаторе: target — у него есть агрегаты, source — его строки считают, both — оба.
|
|
800
|
+
create or replace function @SCHEMA@.agg_kind(p_class text, p_tenant uuid) returns text
|
|
801
|
+
language sql stable
|
|
802
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
803
|
+
as $$
|
|
804
|
+
select case when x.r ? 'aggregate' then case when x.r ? 'aggregatedBy' then 'both' else 'target' end
|
|
805
|
+
when x.r ? 'aggregatedBy' then 'source' end
|
|
806
|
+
from (select (@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved' as r) x
|
|
807
|
+
$$;
|
|
808
|
+
|
|
809
|
+
-- Вклады строк одного оператора (из триггеров entity_total_*): p_old — прежние версии (вклад вычитается), p_new —
|
|
810
|
+
-- новые (прибавляется), [{id, class, tenant, links, data}] только классов, касающихся агрегатов; p_fresh — новые цели
|
|
811
|
+
-- оператора, их поле считается целиком.
|
|
812
|
+
create or replace function @SCHEMA@.agg_apply(p_old jsonb, p_new jsonb, p_fresh uuid[]) returns void
|
|
813
|
+
language plpgsql volatile
|
|
814
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
815
|
+
as $$
|
|
816
|
+
declare
|
|
817
|
+
hold text := @SCHEMA@.agg_held();
|
|
818
|
+
fresh uuid[] := coalesce(p_fresh, '{}');
|
|
819
|
+
tids uuid[];
|
|
820
|
+
deltas jsonb := '{}'::jsonb;
|
|
821
|
+
acc jsonb;
|
|
822
|
+
r @SCHEMA@.entity;
|
|
823
|
+
res jsonb;
|
|
824
|
+
d jsonb;
|
|
825
|
+
k record;
|
|
826
|
+
begin
|
|
827
|
+
if hold = 'off' then
|
|
828
|
+
return;
|
|
829
|
+
end if;
|
|
830
|
+
-- цели вкладов: живые строки того же арендатора по концам via из меток классов-источников; новые цели оператора
|
|
831
|
+
-- приращения обходят — их поле считается целиком ниже
|
|
832
|
+
with src as (
|
|
833
|
+
select x.* from jsonb_to_recordset(coalesce(p_old, '[]'::jsonb) || coalesce(p_new, '[]'::jsonb))
|
|
834
|
+
as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
|
|
835
|
+
), mk as (
|
|
836
|
+
select distinct p.class, p.tenant, m.value->>'via' as via
|
|
837
|
+
from (select distinct s.class, s.tenant from src s) p
|
|
838
|
+
cross join lateral jsonb_array_elements(coalesce((@SCHEMA@.class_row(p.class, p.tenant)).data->'resolved'->'aggregatedBy', '[]'::jsonb)) m
|
|
839
|
+
)
|
|
840
|
+
select coalesce(array_agg(distinct t.id order by t.id), '{}') into tids
|
|
841
|
+
from src s
|
|
842
|
+
join mk on mk.class = s.class and mk.tenant = s.tenant
|
|
843
|
+
join @SCHEMA@.entity t on t.id = case when jsonb_typeof(s.links->mk.via) = 'string' then (s.links->>mk.via)::uuid end
|
|
844
|
+
and t.tenant = s.tenant
|
|
845
|
+
where not (t.id = any (fresh));
|
|
846
|
+
if cardinality(tids) > 0 then
|
|
847
|
+
-- блокировка до подсчёта: описание класса цели и её строка — свежие
|
|
848
|
+
perform 1 from @SCHEMA@.entity x where x.id = any (tids) order by x.id for no key update;
|
|
849
|
+
with src as (
|
|
850
|
+
select -1 as sign, x.* from jsonb_to_recordset(coalesce(p_old, '[]'::jsonb)) as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
|
|
851
|
+
union all
|
|
852
|
+
select 1, x.* from jsonb_to_recordset(coalesce(p_new, '[]'::jsonb)) as x(id uuid, class text, tenant uuid, links jsonb, data jsonb)
|
|
853
|
+
), mk as (
|
|
854
|
+
select distinct p.class, p.tenant, m.value->>'via' as via
|
|
855
|
+
from (select distinct s.class, s.tenant from src s) p
|
|
856
|
+
cross join lateral jsonb_array_elements(coalesce((@SCHEMA@.class_row(p.class, p.tenant)).data->'resolved'->'aggregatedBy', '[]'::jsonb)) m
|
|
857
|
+
), lk as (
|
|
858
|
+
select s.sign, s.class, s.tenant, s.data, mk.via,
|
|
859
|
+
case when jsonb_typeof(s.links->mk.via) = 'string' then (s.links->>mk.via)::uuid end as tid
|
|
860
|
+
from src s join mk on mk.class = s.class and mk.tenant = s.tenant
|
|
861
|
+
), tg as (
|
|
862
|
+
select lk.*, t.class as tclass from lk join @SCHEMA@.entity t on t.id = lk.tid and t.tenant = lk.tenant
|
|
863
|
+
where lk.tid = any (tids)
|
|
864
|
+
), td as (
|
|
865
|
+
select p.tclass, p.tenant, c.tenant as ct, c.data->'resolved'->'aggregate' as agg
|
|
866
|
+
from (select distinct tg.tclass, tg.tenant from tg) p
|
|
867
|
+
cross join lateral @SCHEMA@.class_row(p.tclass, p.tenant) c
|
|
868
|
+
), sa as (
|
|
869
|
+
select p.class, p.tenant, c.tenant as ct, c.data->'resolved'->'ancestors' as anc
|
|
870
|
+
from (select distinct tg.class, tg.tenant from tg) p
|
|
871
|
+
cross join lateral @SCHEMA@.class_row(p.class, p.tenant) c
|
|
872
|
+
), ct as (
|
|
873
|
+
-- класс System — источник только агрегатов класса System (ревью коммита этапа 4б): метки он получает только от
|
|
874
|
+
-- них, но конец via у них бывает общий с агрегатом класса арендатора
|
|
875
|
+
select tg.tid, f.key as field,
|
|
876
|
+
sum(tg.sign * case when f.value ? 'count' then 1 else @SCHEMA@.agg_num(tg.data->(f.value->>'sum')) end) as delta
|
|
877
|
+
from tg
|
|
878
|
+
join td on td.tclass = tg.tclass and td.tenant = tg.tenant
|
|
879
|
+
join sa on sa.class = tg.class and sa.tenant = tg.tenant
|
|
880
|
+
cross join lateral jsonb_each(coalesce(td.agg, '{}'::jsonb)) f
|
|
881
|
+
where f.value->>'via' = tg.via and sa.anc ? (f.value->>'of') and (sa.ct <> '@SYSTEM@'::uuid or td.ct = '@SYSTEM@'::uuid)
|
|
882
|
+
and @SCHEMA@.agg_where_ok(f.value->'match', tg.data)
|
|
883
|
+
group by tg.tid, f.key
|
|
884
|
+
)
|
|
885
|
+
select coalesce(jsonb_object_agg(z.tid, z.f), '{}'::jsonb) into deltas
|
|
886
|
+
from (select ct.tid, jsonb_object_agg(ct.field, ct.delta) as f from ct where ct.delta <> 0 group by ct.tid) z;
|
|
887
|
+
end if;
|
|
888
|
+
-- перенос строки (move_row): вклады и новые цели копятся до его конца
|
|
889
|
+
if hold = 'acc' then
|
|
890
|
+
acc := coalesce(nullif(current_setting('letopis.agg_acc', true), '')::jsonb, '{}'::jsonb);
|
|
891
|
+
acc := jsonb_build_object('d', @SCHEMA@.agg_merge(acc->'d', deltas),
|
|
892
|
+
'f', to_jsonb(array(select distinct x from unnest(array(select jsonb_array_elements_text(coalesce(acc->'f', '[]'::jsonb))) || fresh::text[]) x
|
|
893
|
+
order by x)));
|
|
894
|
+
perform set_config('letopis.agg_acc', acc::text, true);
|
|
895
|
+
return;
|
|
896
|
+
end if;
|
|
897
|
+
-- приращения: inc по пути, итог — по описанию класса, версия — sys_update
|
|
898
|
+
for r in select x.* from @SCHEMA@.entity x where x.id = any (tids) and deltas ? x.id::text order by x.id loop
|
|
899
|
+
d := r.data;
|
|
900
|
+
for k in select key, value from jsonb_each(deltas->(r.id::text)) loop
|
|
901
|
+
d := @SCHEMA@.inc(d, array[k.key], (k.value #>> '{}')::numeric, 0);
|
|
902
|
+
end loop;
|
|
903
|
+
perform @SCHEMA@.agg_write(r, d, true);
|
|
904
|
+
end loop;
|
|
905
|
+
-- новые цели оператора: поле целиком из живых источников — в ту же версию, в entity и в журнал
|
|
906
|
+
for r in select x.* from @SCHEMA@.entity x where x.id = any (fresh) order by x.id loop
|
|
907
|
+
res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
|
|
908
|
+
continue when not coalesce(res ? 'aggregate', false);
|
|
909
|
+
d := r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant);
|
|
910
|
+
continue when d = r.data;
|
|
911
|
+
perform @SCHEMA@.agg_check(r.class, r.tenant, d);
|
|
912
|
+
update @SCHEMA@.entity x set data = d where x.id = r.id;
|
|
913
|
+
update @SCHEMA@.log l set data = d, changed = null where l.id = r.id and l.rev = r.rev;
|
|
914
|
+
end loop;
|
|
915
|
+
end $$;
|
|
916
|
+
|
|
917
|
+
-- Поле целей классов p_classes арендатора p_tenant (System — всех арендаторов) целиком из живых источников: строки по
|
|
918
|
+
-- возрастанию id под блокировкой, версия — только там, где значение изменилось. p_check — проверять итог описанием
|
|
919
|
+
-- класса (пересчёт при ужесточении проверяет ужесточение). Возвращает число исправленных строк.
|
|
920
|
+
create or replace function @SCHEMA@.agg_refresh(p_classes text[], p_tenant uuid, p_check boolean) returns int
|
|
921
|
+
language plpgsql volatile
|
|
922
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
923
|
+
as $$
|
|
924
|
+
declare
|
|
925
|
+
r @SCHEMA@.entity;
|
|
926
|
+
res jsonb;
|
|
927
|
+
n int := 0;
|
|
928
|
+
begin
|
|
929
|
+
for r in select x.* from @SCHEMA@.entity x
|
|
930
|
+
where x.class = any (p_classes) and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
931
|
+
order by x.id for no key update loop
|
|
932
|
+
res := (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved';
|
|
933
|
+
continue when not coalesce(res ? 'aggregate', false);
|
|
934
|
+
if @SCHEMA@.agg_write(r, r.data || @SCHEMA@.agg_compute(res, r.id, r.tenant), p_check) then
|
|
935
|
+
n := n + 1;
|
|
936
|
+
end if;
|
|
937
|
+
end loop;
|
|
938
|
+
return n;
|
|
939
|
+
end $$;
|
|
940
|
+
|
|
941
|
+
-- Ремонт (db.rebuildAggregates): агрегаты класса и его потомков во всех арендаторах — из текущих строк, под
|
|
942
|
+
-- исключительными блокировками семейств целей и источников. Только роль владельца (администраторское подключение).
|
|
943
|
+
create or replace function @SCHEMA@.rebuild_aggregates(p_class text) returns int
|
|
944
|
+
language plpgsql volatile
|
|
945
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
946
|
+
as $$
|
|
947
|
+
declare
|
|
948
|
+
cr @SCHEMA@.entity;
|
|
949
|
+
names text[] := '{}';
|
|
950
|
+
begin
|
|
951
|
+
for cr in select x.* from @SCHEMA@.entity x
|
|
952
|
+
where x.class = 'Class' and x.data->'resolved' ? 'aggregate' and x.data->'resolved'->'ancestors' ? p_class
|
|
953
|
+
order by x.id loop
|
|
954
|
+
perform @SCHEMA@.family_lock(cr, true);
|
|
955
|
+
perform @SCHEMA@.agg_lock_sources(cr.data->'resolved', null, cr.tenant);
|
|
956
|
+
names := names || (cr.data->>'name');
|
|
957
|
+
end loop;
|
|
958
|
+
if cardinality(names) = 0 then
|
|
959
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s и его потомков нет агрегатов (aggregate) — пересчитывать нечего', p_class));
|
|
960
|
+
end if;
|
|
961
|
+
return @SCHEMA@.agg_refresh(array(select distinct x from unnest(names) x), '@SYSTEM@'::uuid, true);
|
|
962
|
+
end $$;
|
|
963
|
+
|
|
964
|
+
-- Расхождения агрегатов (verify с p_data): строка, поле, значение в строке и итог живых источников.
|
|
965
|
+
create or replace function @SCHEMA@.agg_issues() returns table (id uuid, class text, field text, stored jsonb, expected jsonb)
|
|
966
|
+
language sql stable
|
|
967
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
968
|
+
as $$
|
|
969
|
+
select x.id, x.class, f.key, x.data->f.key, f.value
|
|
970
|
+
from @SCHEMA@.entity x
|
|
971
|
+
cross join lateral (select (@SCHEMA@.class_row(x.class, x.tenant)).data->'resolved' as res) c
|
|
972
|
+
cross join lateral jsonb_each(@SCHEMA@.agg_compute(c.res, x.id, x.tenant)) f
|
|
973
|
+
where x.class in (select k.data->>'name' from @SCHEMA@.entity k where k.class = 'Class' and k.data->'resolved' ? 'aggregate')
|
|
974
|
+
and coalesce(c.res ? 'aggregate', false)
|
|
975
|
+
and x.data->f.key is distinct from f.value
|
|
976
|
+
order by x.id, f.key
|
|
977
|
+
$$;
|
|
978
|
+
|
|
979
|
+
-- Первая заливка (load.ts:initialSession): триггеров и индексов нет, поэтому поле целей считается одним оператором на
|
|
980
|
+
-- агрегат и пишется в ту же (последнюю) версию в entity и log — строки ещё никто не видел, хэши — генерируемые колонки.
|
|
981
|
+
-- Цепочки агрегатов (сумма агрегатного поля) сходятся повторными проходами. Итог проверяется описанием класса.
|
|
982
|
+
-- Семейство источника — как agg_classes: классы арендатора строки, у агрегата класса System — ещё классы System.
|
|
983
|
+
create or replace function @SCHEMA@.agg_fill() returns int
|
|
984
|
+
language plpgsql volatile
|
|
985
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
986
|
+
as $$
|
|
987
|
+
declare
|
|
988
|
+
k record;
|
|
989
|
+
f record;
|
|
990
|
+
m int;
|
|
991
|
+
n int := 0;
|
|
992
|
+
pass int := 0;
|
|
993
|
+
more boolean;
|
|
994
|
+
bad record;
|
|
995
|
+
begin
|
|
996
|
+
loop
|
|
997
|
+
more := false;
|
|
998
|
+
for k in select x.tenant as ct, x.data->>'name' as name, x.data->'resolved' as res from @SCHEMA@.entity x
|
|
999
|
+
where x.class = 'Class' and x.data->'resolved' ? 'aggregate' order by x.id loop
|
|
1000
|
+
for f in select key, value from jsonb_each(k.res->'aggregate') order by key loop
|
|
1001
|
+
with sc as (
|
|
1002
|
+
select y.tenant as ct, y.data->>'name' as name from @SCHEMA@.entity y
|
|
1003
|
+
where y.class = 'Class' and y.data->'resolved'->'ancestors' ? (f.value->>'of')
|
|
1004
|
+
), tot as (
|
|
1005
|
+
select (s.links->>(f.value->>'via'))::uuid as tid, s.tenant,
|
|
1006
|
+
sum(case when f.value ? 'count' then 1 else @SCHEMA@.agg_num(s.data->(f.value->>'sum')) end) as v
|
|
1007
|
+
from @SCHEMA@.entity s
|
|
1008
|
+
join sc on sc.name = s.class and (sc.ct = s.tenant or (k.ct = '@SYSTEM@'::uuid and sc.ct = '@SYSTEM@'::uuid))
|
|
1009
|
+
where jsonb_typeof(s.links->(f.value->>'via')) = 'string' and @SCHEMA@.agg_where_ok(f.value->'match', s.data)
|
|
1010
|
+
group by 1, 2
|
|
1011
|
+
), nv as (
|
|
1012
|
+
select t.id, t.rev, jsonb_set(t.data, array[f.key], to_jsonb(coalesce(tot.v, 0))) as data
|
|
1013
|
+
from @SCHEMA@.entity t
|
|
1014
|
+
left join tot on tot.tid = t.id and tot.tenant = t.tenant
|
|
1015
|
+
where t.class = k.name and (t.tenant = k.ct or k.ct = '@SYSTEM@'::uuid)
|
|
1016
|
+
and t.data->f.key is distinct from to_jsonb(coalesce(tot.v, 0))
|
|
1017
|
+
), ue as (
|
|
1018
|
+
update @SCHEMA@.entity x set data = nv.data from nv where x.id = nv.id returning 1
|
|
1019
|
+
), ul as (
|
|
1020
|
+
update @SCHEMA@.log l set data = nv.data from nv where l.id = nv.id and l.rev = nv.rev returning 1
|
|
1021
|
+
)
|
|
1022
|
+
select count(*)::int into m from ue;
|
|
1023
|
+
if m > 0 then
|
|
1024
|
+
more := true;
|
|
1025
|
+
n := n + m;
|
|
1026
|
+
end if;
|
|
1027
|
+
end loop;
|
|
1028
|
+
end loop;
|
|
1029
|
+
exit when not more;
|
|
1030
|
+
pass := pass + 1;
|
|
1031
|
+
if pass > 32 then
|
|
1032
|
+
perform @SCHEMA@.lt_fail('invalid_class', 'первая заливка: агрегаты не сошлись за 32 прохода');
|
|
1033
|
+
end if;
|
|
1034
|
+
end loop;
|
|
1035
|
+
select x.id, x.class, v.bad into bad
|
|
1036
|
+
from @SCHEMA@.entity x
|
|
1037
|
+
cross join lateral (select c.data->'resolved' as res, c.validator from @SCHEMA@.class_row(x.class, x.tenant) c) c
|
|
1038
|
+
cross join lateral (select @SCHEMA@.validate_data(c.res->'rules', c.validator, c.res->'vars', x.data) as bad) v
|
|
1039
|
+
where x.class in (select k2.data->>'name' from @SCHEMA@.entity k2 where k2.class = 'Class' and k2.data->'resolved' ? 'aggregate')
|
|
1040
|
+
and v.bad <> '[]'::jsonb
|
|
1041
|
+
limit 1;
|
|
1042
|
+
if bad.id is not null then
|
|
1043
|
+
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s (агрегатное поле строки %s после первой заливки)', bad.class, bad.id),
|
|
1044
|
+
jsonb_build_object('issues', bad.bad, 'id', bad.id));
|
|
1045
|
+
end if;
|
|
1046
|
+
return n;
|
|
1047
|
+
end $$;
|
|
1048
|
+
|
|
1049
|
+
-- Триггеры агрегатов: после оператора, по одному на событие, с переходными таблицами, без условия WHEN — и для
|
|
1050
|
+
-- владельца (функции владельца, каскад, загрузчик, сырой SQL); имена — после entity_a*: AFTER-триггеры одного события
|
|
1051
|
+
-- срабатывают по алфавиту, журнал и проверки целей оператора уже записаны. Быстрый выход — среди пар «класс,
|
|
1052
|
+
-- арендатор» оператора нет ни цели, ни источника агрегата.
|
|
1053
|
+
create or replace function @SCHEMA@.trg_entity_total_ai() returns trigger
|
|
1054
|
+
language plpgsql security definer
|
|
1055
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1056
|
+
as $$
|
|
1057
|
+
declare
|
|
1058
|
+
rel text[];
|
|
1059
|
+
tgt text[];
|
|
1060
|
+
begin
|
|
1061
|
+
select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}'),
|
|
1062
|
+
coalesce(array_agg(p.k) filter (where p.kind in ('target', 'both')), '{}')
|
|
1063
|
+
into rel, tgt
|
|
1064
|
+
from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
|
|
1065
|
+
from (select distinct a.class as c, a.tenant as t from added a) k) p;
|
|
1066
|
+
if cardinality(rel) = 0 then
|
|
1067
|
+
return null;
|
|
1068
|
+
end if;
|
|
1069
|
+
perform @SCHEMA@.agg_apply('[]'::jsonb,
|
|
1070
|
+
(select jsonb_agg(jsonb_build_object('id', a.id, 'class', a.class, 'tenant', a.tenant, 'links', a.links, 'data', a.data))
|
|
1071
|
+
from added a where a.class || ' ' || a.tenant = any (rel)),
|
|
1072
|
+
array(select a.id from added a where a.class || ' ' || a.tenant = any (tgt)));
|
|
1073
|
+
return null;
|
|
1074
|
+
end $$;
|
|
1075
|
+
|
|
1076
|
+
create or replace function @SCHEMA@.trg_entity_total_au() returns trigger
|
|
1077
|
+
language plpgsql security definer
|
|
1078
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1079
|
+
as $$
|
|
1080
|
+
declare
|
|
1081
|
+
rel text[];
|
|
1082
|
+
tgt text[];
|
|
1083
|
+
begin
|
|
1084
|
+
-- строки, у которых изменились класс, концы или данные (владелец и теги вклада не меняют)
|
|
1085
|
+
select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}'),
|
|
1086
|
+
coalesce(array_agg(p.k) filter (where p.kind in ('target', 'both')), '{}')
|
|
1087
|
+
into rel, tgt
|
|
1088
|
+
from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
|
|
1089
|
+
from (select distinct v.c, v.t from new_rows n join old_rows o on o.id = n.id
|
|
1090
|
+
cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(c, t)
|
|
1091
|
+
where n.class <> o.class or n.links <> o.links or n.data <> o.data) k) p;
|
|
1092
|
+
if cardinality(rel) = 0 then
|
|
1093
|
+
return null;
|
|
1094
|
+
end if;
|
|
1095
|
+
perform @SCHEMA@.agg_apply(
|
|
1096
|
+
(select jsonb_agg(jsonb_build_object('id', o.id, 'class', o.class, 'tenant', o.tenant, 'links', o.links, 'data', o.data))
|
|
1097
|
+
from old_rows o join new_rows n on n.id = o.id
|
|
1098
|
+
where (n.class <> o.class or n.links <> o.links or n.data <> o.data) and o.class || ' ' || o.tenant = any (rel)),
|
|
1099
|
+
(select jsonb_agg(jsonb_build_object('id', n.id, 'class', n.class, 'tenant', n.tenant, 'links', n.links, 'data', n.data))
|
|
1100
|
+
from new_rows n join old_rows o on o.id = n.id
|
|
1101
|
+
where (n.class <> o.class or n.links <> o.links or n.data <> o.data) and n.class || ' ' || n.tenant = any (rel)),
|
|
1102
|
+
-- смена класса в цель: поле считается целиком
|
|
1103
|
+
array(select n.id from new_rows n join old_rows o on o.id = n.id
|
|
1104
|
+
where n.class <> o.class and n.class || ' ' || n.tenant = any (tgt)));
|
|
1105
|
+
return null;
|
|
1106
|
+
end $$;
|
|
1107
|
+
|
|
1108
|
+
create or replace function @SCHEMA@.trg_entity_total_ad() returns trigger
|
|
1109
|
+
language plpgsql security definer
|
|
1110
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1111
|
+
as $$
|
|
1112
|
+
declare
|
|
1113
|
+
rel text[];
|
|
1114
|
+
begin
|
|
1115
|
+
select coalesce(array_agg(p.k) filter (where p.kind is not null), '{}') into rel
|
|
1116
|
+
from (select k.c || ' ' || k.t as k, @SCHEMA@.agg_kind(k.c, k.t) as kind
|
|
1117
|
+
from (select distinct g.class as c, g.tenant as t from gone g) k) p;
|
|
1118
|
+
if cardinality(rel) = 0 then
|
|
1119
|
+
return null;
|
|
1120
|
+
end if;
|
|
1121
|
+
perform @SCHEMA@.agg_apply(
|
|
1122
|
+
(select jsonb_agg(jsonb_build_object('id', g.id, 'class', g.class, 'tenant', g.tenant, 'links', g.links, 'data', g.data))
|
|
1123
|
+
from gone g where g.class || ' ' || g.tenant = any (rel)),
|
|
1124
|
+
'[]'::jsonb, '{}'::uuid[]);
|
|
1125
|
+
return null;
|
|
1126
|
+
end $$;
|
|
1127
|
+
|
|
1128
|
+
create or replace trigger entity_total_ai after insert on @SCHEMA@.entity referencing new table as added
|
|
1129
|
+
for each statement execute function @SCHEMA@.trg_entity_total_ai();
|
|
1130
|
+
create or replace trigger entity_total_au after update on @SCHEMA@.entity referencing old table as old_rows new table as new_rows
|
|
1131
|
+
for each statement execute function @SCHEMA@.trg_entity_total_au();
|
|
1132
|
+
create or replace trigger entity_total_ad after delete on @SCHEMA@.entity referencing old table as gone
|
|
1133
|
+
for each statement execute function @SCHEMA@.trg_entity_total_ad();
|
|
1134
|
+
-- и в режиме репликации (session_replication_role = replica)
|
|
1135
|
+
alter table @SCHEMA@.entity enable always trigger entity_total_ai;
|
|
1136
|
+
alter table @SCHEMA@.entity enable always trigger entity_total_au;
|
|
1137
|
+
alter table @SCHEMA@.entity enable always trigger entity_total_ad;
|
|
1138
|
+
|
|
1139
|
+
do $$
|
|
1140
|
+
declare f text;
|
|
1141
|
+
begin
|
|
1142
|
+
foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
|
|
1143
|
+
'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
|
|
1144
|
+
'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
|
|
1145
|
+
'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
|
|
1146
|
+
'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
|
|
1147
|
+
'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
|
|
1148
|
+
'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',
|
|
1149
|
+
'agg_apply(jsonb, jsonb, uuid[])', 'agg_refresh(text[], uuid, boolean)', 'rebuild_aggregates(text)', 'agg_issues()', 'agg_fill()',
|
|
1150
|
+
'trg_entity_total_ai()', 'trg_entity_total_au()', 'trg_entity_total_ad()'] loop
|
|
1151
|
+
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
1152
|
+
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
1153
|
+
end loop;
|
|
1154
|
+
end $$;
|