letopis 0.20.3 → 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 +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- 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 +369 -551
- 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 -250
- 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 -351
- 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 -600
- package/sql/seed.auth.sql +0 -73
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
-- letopis 1.0 — идентификаторы v5 и проверка данных по JSON Schema (план, §2.3, §2.6, этап 1, п. 4–5).
|
|
2
|
+
|
|
3
|
+
-- ========================= v5 =========================
|
|
4
|
+
-- id = uuid_v5(арендатор, '["<класс>", "<значение 1>", …]'). Имя собирается одинаково в базе
|
|
5
|
+
-- (jsonb-массив в текстовом виде) и в TypeScript (JSON.stringify элементов через «, »);
|
|
6
|
+
-- совпадение закрепляет общий файл векторов lib/test/vectors/v5.json.
|
|
7
|
+
|
|
8
|
+
create or replace function @SCHEMA@.uuid_v5(ns uuid, name text) returns uuid
|
|
9
|
+
language sql immutable strict parallel safe
|
|
10
|
+
return (
|
|
11
|
+
select encode(
|
|
12
|
+
set_byte(set_byte(b, 6, (get_byte(b, 6) & 15) | 80), 8, (get_byte(b, 8) & 63) | 128),
|
|
13
|
+
'hex')::uuid
|
|
14
|
+
from (select substring(@PGCRYPTO@.digest(uuid_send(ns) || convert_to(name, 'UTF8'), 'sha1') from 1 for 16) as b) s
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
create or replace function @SCHEMA@.v5_name(class text, vals text[]) returns text
|
|
18
|
+
language sql immutable strict parallel safe
|
|
19
|
+
return (
|
|
20
|
+
select jsonb_agg(x order by i)::text
|
|
21
|
+
from unnest(array[class] || vals) with ordinality as u(x, i)
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
create or replace function @SCHEMA@.v5_id(tenant uuid, class text, vals text[]) returns uuid
|
|
25
|
+
language sql immutable strict parallel safe
|
|
26
|
+
return @SCHEMA@.uuid_v5(tenant, @SCHEMA@.v5_name(class, vals));
|
|
27
|
+
|
|
28
|
+
-- ========================= JSON Schema → jsonpath =========================
|
|
29
|
+
-- Компилятор переводит JSON Schema класса в список правил. Каждое правило — предикат jsonpath
|
|
30
|
+
-- вида lax !exists(<путь> ? (!(<условие>))) над документом: «нет узла, нарушающего условие».
|
|
31
|
+
-- Условие всегда начинается с проверки типа узла, поэтому развёртывание массивов в режиме lax
|
|
32
|
+
-- не даёт строке ["x"] пройти проверку «равно "x"». Значения правил идут переменными ($v1, $v2…),
|
|
33
|
+
-- шаблоны и имена полей экранируются как строковые литералы jsonpath (§2.6).
|
|
34
|
+
-- То, чего jsonpath не выражает (длина строки, uniqueItems, смысл даты), идёт «дополнительными
|
|
35
|
+
-- проверками» — отдельными записями с полем extra; их выполняет validate_data().
|
|
36
|
+
|
|
37
|
+
create or replace function @SCHEMA@.jp_str(s text) returns text
|
|
38
|
+
language sql immutable strict parallel safe
|
|
39
|
+
return to_jsonb(s)::text;
|
|
40
|
+
|
|
41
|
+
-- Перевод шаблона JavaScript в регулярное выражение PostgreSQL (этап 1, п. 5): разбор по символам,
|
|
42
|
+
-- как translatePattern в lib/src/jsonschema.ts. \d \w \s и их отрицания — явными наборами (внутри
|
|
43
|
+
-- [ ] — без скобок), экранированная обратная косая черта остаётся собой; \b, \p{…}, именованные
|
|
44
|
+
-- группы и обратные ссылки отклоняются.
|
|
45
|
+
create or replace function @SCHEMA@.jp_pattern(p text) returns text
|
|
46
|
+
language plpgsql immutable strict
|
|
47
|
+
as $$
|
|
48
|
+
declare
|
|
49
|
+
res text := '';
|
|
50
|
+
i int := 1;
|
|
51
|
+
c text;
|
|
52
|
+
n text;
|
|
53
|
+
in_class boolean := false;
|
|
54
|
+
sp constant text := ' \t\n\r\f\v';
|
|
55
|
+
why text;
|
|
56
|
+
begin
|
|
57
|
+
while i <= length(p) loop
|
|
58
|
+
c := substr(p, i, 1);
|
|
59
|
+
if c = '\' then
|
|
60
|
+
n := substr(p, i + 1, 1);
|
|
61
|
+
i := i + 2;
|
|
62
|
+
if n = '' then
|
|
63
|
+
why := 'обратная косая черта в конце шаблона';
|
|
64
|
+
elsif n = 'd' then
|
|
65
|
+
res := res || case when in_class then '0-9' else '[0-9]' end;
|
|
66
|
+
elsif n = 'w' then
|
|
67
|
+
res := res || case when in_class then 'A-Za-z0-9_' else '[A-Za-z0-9_]' end;
|
|
68
|
+
elsif n = 's' then
|
|
69
|
+
res := res || case when in_class then sp else '[' || sp || ']' end;
|
|
70
|
+
elsif n in ('D', 'W', 'S') then
|
|
71
|
+
if in_class then
|
|
72
|
+
why := '\' || n || ' внутри набора [ ] не переводится';
|
|
73
|
+
else
|
|
74
|
+
res := res || case n when 'D' then '[^0-9]' when 'W' then '[^A-Za-z0-9_]' else '[^' || sp || ']' end;
|
|
75
|
+
end if;
|
|
76
|
+
elsif n in ('b', 'B') then
|
|
77
|
+
why := '\' || n || ' в PostgreSQL значит другое';
|
|
78
|
+
elsif n in ('p', 'P') then
|
|
79
|
+
why := '\p{…} не поддерживается';
|
|
80
|
+
elsif n = 'k' or n ~ '[1-9]' then
|
|
81
|
+
why := 'обратные ссылки не поддерживаются';
|
|
82
|
+
else
|
|
83
|
+
res := res || c || n;
|
|
84
|
+
end if;
|
|
85
|
+
if why is not null then
|
|
86
|
+
raise exception using errcode = 'LT010', message = 'letopis: invalid_class: шаблон ' || p || ' несовместим с регулярными выражениями PostgreSQL (' || why || ')';
|
|
87
|
+
end if;
|
|
88
|
+
continue;
|
|
89
|
+
end if;
|
|
90
|
+
if not in_class and substr(p, i, 3) = '(?<' and substr(p, i + 3, 1) not in ('=', '!') then
|
|
91
|
+
raise exception using errcode = 'LT010', message = 'letopis: invalid_class: шаблон ' || p || ' несовместим с регулярными выражениями PostgreSQL (именованные группы не поддерживаются)';
|
|
92
|
+
end if;
|
|
93
|
+
if c = '[' and not in_class then
|
|
94
|
+
in_class := true;
|
|
95
|
+
elsif c = ']' and in_class then
|
|
96
|
+
in_class := false;
|
|
97
|
+
end if;
|
|
98
|
+
res := res || c;
|
|
99
|
+
i := i + 1;
|
|
100
|
+
end loop;
|
|
101
|
+
return res;
|
|
102
|
+
end $$;
|
|
103
|
+
|
|
104
|
+
-- Навигация в строгом режиме без ошибок на отсутствующих ключах и чужих типах:
|
|
105
|
+
-- в объект — через keyvalue() с фильтром по имени, в массив — только если узел массив.
|
|
106
|
+
create or replace function @SCHEMA@.jp_prop(p text, k text) returns text
|
|
107
|
+
language sql immutable strict parallel safe
|
|
108
|
+
return p || ' ? (@.type() == "object").keyvalue() ? (@.key == ' || @SCHEMA@.jp_str(k) || ').value';
|
|
109
|
+
|
|
110
|
+
create or replace function @SCHEMA@.jp_items(p text) returns text
|
|
111
|
+
language sql immutable strict parallel safe
|
|
112
|
+
return p || ' ? (@.type() == "array")[*]';
|
|
113
|
+
|
|
114
|
+
create or replace function @SCHEMA@.jp_type_cond(t text) returns text
|
|
115
|
+
language sql immutable strict parallel safe
|
|
116
|
+
return case t
|
|
117
|
+
when 'integer' then '(@.type() == "number" && @ == @.floor())'
|
|
118
|
+
when 'number' then '@.type() == "number"'
|
|
119
|
+
when 'string' then '@.type() == "string"'
|
|
120
|
+
when 'boolean' then '@.type() == "boolean"'
|
|
121
|
+
when 'object' then '@.type() == "object"'
|
|
122
|
+
when 'array' then '@.type() == "array"'
|
|
123
|
+
when 'null' then '@.type() == "null"'
|
|
124
|
+
end;
|
|
125
|
+
|
|
126
|
+
-- Рекурсивная компиляция узла. p_path — jsonpath-путь до узла от базы (например $."a"[*]);
|
|
127
|
+
-- p_disp — путь для сообщений; p_in_any — внутри anyOf/oneOf (там нельзя дополнительных проверок).
|
|
128
|
+
-- Возвращает массив правил {path, keyword, cond, path_expr, vars, extra}.
|
|
129
|
+
create or replace function @SCHEMA@.jp_compile(p_schema jsonb, p_path text, p_disp jsonb, p_in_any boolean, inout p_n int, out rules jsonb)
|
|
130
|
+
language plpgsql immutable
|
|
131
|
+
as $$
|
|
132
|
+
declare
|
|
133
|
+
k text;
|
|
134
|
+
v jsonb;
|
|
135
|
+
t text;
|
|
136
|
+
conds text[];
|
|
137
|
+
sub record;
|
|
138
|
+
r jsonb := '[]'::jsonb;
|
|
139
|
+
vname text;
|
|
140
|
+
vars jsonb;
|
|
141
|
+
allowed text[] := array['type','enum','const','minLength','maxLength','pattern','format','minimum','maximum',
|
|
142
|
+
'exclusiveMinimum','exclusiveMaximum','multipleOf','items','minItems','maxItems','uniqueItems','properties',
|
|
143
|
+
'required','additionalProperties','propertyNames','anyOf','oneOf','default','minProperties','maxProperties',
|
|
144
|
+
'title','description','examples','deprecated'];
|
|
145
|
+
tcond text;
|
|
146
|
+
br jsonb;
|
|
147
|
+
bv jsonb;
|
|
148
|
+
alt text[];
|
|
149
|
+
i int;
|
|
150
|
+
j int;
|
|
151
|
+
begin
|
|
152
|
+
if p_schema is null or p_schema = 'true'::jsonb or p_schema = '{}'::jsonb then
|
|
153
|
+
rules := r; return;
|
|
154
|
+
end if;
|
|
155
|
+
if jsonb_typeof(p_schema) <> 'object' then
|
|
156
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: описание узла %s — не объект', p_disp);
|
|
157
|
+
end if;
|
|
158
|
+
for k in select jsonb_object_keys(p_schema) loop
|
|
159
|
+
if not (k = any (allowed) or k like 'x-%') then
|
|
160
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: ключевое слово %s не поддерживается (узел %s)', k, p_disp);
|
|
161
|
+
end if;
|
|
162
|
+
end loop;
|
|
163
|
+
|
|
164
|
+
-- тип: строка или список строк
|
|
165
|
+
if p_schema ? 'type' then
|
|
166
|
+
if jsonb_typeof(p_schema->'type') = 'array' then
|
|
167
|
+
select string_agg(@SCHEMA@.jp_type_cond(x), ' || ') into tcond from jsonb_array_elements_text(p_schema->'type') x;
|
|
168
|
+
tcond := '(' || tcond || ')';
|
|
169
|
+
else
|
|
170
|
+
tcond := @SCHEMA@.jp_type_cond(p_schema->>'type');
|
|
171
|
+
end if;
|
|
172
|
+
if tcond is null then
|
|
173
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: неизвестный тип в узле %s', p_disp);
|
|
174
|
+
end if;
|
|
175
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'type', 'path_expr', p_path, 'cond', tcond, 'vars', '{}'::jsonb);
|
|
176
|
+
end if;
|
|
177
|
+
|
|
178
|
+
-- перечисление и константа: только скаляры внутри jsonpath; объекты и списки — сравнение jsonb
|
|
179
|
+
if p_schema ? 'enum' or p_schema ? 'const' then
|
|
180
|
+
vars := '{}'; conds := '{}';
|
|
181
|
+
for v in select x from jsonb_array_elements(coalesce(p_schema->'enum', jsonb_build_array(p_schema->'const'))) x loop
|
|
182
|
+
if jsonb_typeof(v) in ('object', 'array') then
|
|
183
|
+
if p_in_any then
|
|
184
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: перечисление объектов внутри anyOf не поддерживается (узел %s)', p_disp);
|
|
185
|
+
end if;
|
|
186
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'enum' then 'enum' else 'const' end,
|
|
187
|
+
'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb, 'extra', jsonb_build_object('kind', 'enum', 'values', coalesce(p_schema->'enum', jsonb_build_array(p_schema->'const'))));
|
|
188
|
+
conds := null; exit;
|
|
189
|
+
end if;
|
|
190
|
+
p_n := p_n + 1; vname := 'v' || p_n;
|
|
191
|
+
vars := vars || jsonb_build_object(vname, v);
|
|
192
|
+
conds := conds || format('(@.type() == %s && @ == $%s)', @SCHEMA@.jp_str(case jsonb_typeof(v) when 'number' then 'number' when 'string' then 'string' when 'boolean' then 'boolean' else 'null' end), vname);
|
|
193
|
+
end loop;
|
|
194
|
+
-- пустое перечисление не пропускает ничего: условие «узла нет» ложно для любого узла
|
|
195
|
+
if conds is not null then
|
|
196
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'enum' then 'enum' else 'const' end,
|
|
197
|
+
'path_expr', p_path, 'cond', case when cardinality(conds) = 0 then '!exists(@)' else '(' || array_to_string(conds, ' || ') || ')' end, 'vars', vars);
|
|
198
|
+
end if;
|
|
199
|
+
end if;
|
|
200
|
+
|
|
201
|
+
-- числовые границы
|
|
202
|
+
foreach k in array array['minimum','maximum','exclusiveMinimum','exclusiveMaximum','multipleOf'] loop
|
|
203
|
+
if p_schema ? k then
|
|
204
|
+
p_n := p_n + 1; vname := 'v' || p_n;
|
|
205
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', jsonb_build_object(vname, p_schema->k),
|
|
206
|
+
'cond', '(@.type() != "number" || ' || case k
|
|
207
|
+
when 'minimum' then '@ >= $' || vname
|
|
208
|
+
when 'maximum' then '@ <= $' || vname
|
|
209
|
+
when 'exclusiveMinimum' then '@ > $' || vname
|
|
210
|
+
when 'exclusiveMaximum' then '@ < $' || vname
|
|
211
|
+
else '(@ / $' || vname || ') == (@ / $' || vname || ').floor()' end || ')');
|
|
212
|
+
end if;
|
|
213
|
+
end loop;
|
|
214
|
+
|
|
215
|
+
-- строки
|
|
216
|
+
if p_schema ? 'pattern' then
|
|
217
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'pattern', 'path_expr', p_path, 'vars', '{}'::jsonb,
|
|
218
|
+
'cond', '(@.type() != "string" || @ like_regex ' || @SCHEMA@.jp_str(@SCHEMA@.jp_pattern(p_schema->>'pattern')) || ')');
|
|
219
|
+
end if;
|
|
220
|
+
if p_schema ? 'format' then
|
|
221
|
+
t := p_schema->>'format';
|
|
222
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'format', 'path_expr', p_path, 'vars', '{}'::jsonb,
|
|
223
|
+
'cond', '(@.type() != "string" || @ like_regex ' || @SCHEMA@.jp_str(case t
|
|
224
|
+
when 'uuid' then '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
|
|
225
|
+
when 'date' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
|
|
226
|
+
when 'date-time' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$'
|
|
227
|
+
when 'email' then '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$'
|
|
228
|
+
else null end) || ')');
|
|
229
|
+
if t not in ('uuid', 'date', 'date-time', 'email') then
|
|
230
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: format %s не поддерживается (узел %s)', t, p_disp);
|
|
231
|
+
end if;
|
|
232
|
+
if t in ('date', 'date-time') then
|
|
233
|
+
if p_in_any then
|
|
234
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: format %s внутри anyOf не поддерживается (узел %s)', t, p_disp);
|
|
235
|
+
end if;
|
|
236
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'format', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
|
|
237
|
+
'extra', jsonb_build_object('kind', 'date', 'type', case t when 'date' then 'date' else 'timestamptz' end,
|
|
238
|
+
're', case t when 'date' then '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
|
|
239
|
+
else '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})$' end));
|
|
240
|
+
end if;
|
|
241
|
+
end if;
|
|
242
|
+
if p_schema ? 'minLength' or p_schema ? 'maxLength' then
|
|
243
|
+
if p_in_any then
|
|
244
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: длина строки внутри anyOf не поддерживается (узел %s)', p_disp);
|
|
245
|
+
end if;
|
|
246
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minLength' then 'minLength' else 'maxLength' end,
|
|
247
|
+
'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
|
|
248
|
+
'extra', jsonb_build_object('kind', 'length', 'min', p_schema->'minLength', 'max', p_schema->'maxLength'));
|
|
249
|
+
end if;
|
|
250
|
+
|
|
251
|
+
-- массивы
|
|
252
|
+
foreach k in array array['minItems','maxItems'] loop
|
|
253
|
+
if p_schema ? k then
|
|
254
|
+
p_n := p_n + 1; vname := 'v' || p_n;
|
|
255
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', jsonb_build_object(vname, p_schema->k),
|
|
256
|
+
'cond', '(@.type() != "array" || @.size() ' || case k when 'minItems' then '>=' else '<=' end || ' $' || vname || ')');
|
|
257
|
+
end if;
|
|
258
|
+
end loop;
|
|
259
|
+
if coalesce((p_schema->>'uniqueItems')::boolean, false) then
|
|
260
|
+
if p_in_any then
|
|
261
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: uniqueItems внутри anyOf не поддерживается (узел %s)', p_disp);
|
|
262
|
+
end if;
|
|
263
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'uniqueItems', 'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
|
|
264
|
+
'extra', jsonb_build_object('kind', 'unique'));
|
|
265
|
+
end if;
|
|
266
|
+
if p_schema ? 'items' then
|
|
267
|
+
select c.p_n, c.rules into p_n, v from @SCHEMA@.jp_compile(p_schema->'items', @SCHEMA@.jp_items(p_path), p_disp || '"*"'::jsonb, p_in_any, p_n) c;
|
|
268
|
+
r := r || v;
|
|
269
|
+
end if;
|
|
270
|
+
|
|
271
|
+
-- объекты
|
|
272
|
+
if p_schema ? 'required' then
|
|
273
|
+
for k in select jsonb_array_elements_text(p_schema->'required') loop
|
|
274
|
+
r := r || jsonb_build_object('path', p_disp || to_jsonb(k), 'keyword', 'required', 'path_expr', p_path, 'vars', '{}'::jsonb,
|
|
275
|
+
'cond', '(@.type() != "object" || exists(@.keyvalue() ? (@.key == ' || @SCHEMA@.jp_str(k) || ')))');
|
|
276
|
+
end loop;
|
|
277
|
+
end if;
|
|
278
|
+
if p_schema ? 'minProperties' or p_schema ? 'maxProperties' then
|
|
279
|
+
if p_in_any then
|
|
280
|
+
raise exception using errcode = 'LT010', message = format('letopis: invalid_class: minProperties внутри anyOf не поддерживается (узел %s)', p_disp);
|
|
281
|
+
end if;
|
|
282
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', case when p_schema ? 'minProperties' then 'minProperties' else 'maxProperties' end,
|
|
283
|
+
'path_expr', p_path, 'cond', 'true', 'vars', '{}'::jsonb,
|
|
284
|
+
'extra', jsonb_build_object('kind', 'props', 'min', p_schema->'minProperties', 'max', p_schema->'maxProperties'));
|
|
285
|
+
end if;
|
|
286
|
+
if p_schema ? 'properties' then
|
|
287
|
+
for sub in select key, value from jsonb_each(p_schema->'properties') loop
|
|
288
|
+
select c.p_n, c.rules into p_n, v
|
|
289
|
+
from @SCHEMA@.jp_compile(sub.value, @SCHEMA@.jp_prop(p_path, sub.key), p_disp || to_jsonb(sub.key), p_in_any, p_n) c;
|
|
290
|
+
r := r || v;
|
|
291
|
+
end loop;
|
|
292
|
+
end if;
|
|
293
|
+
if p_schema ? 'additionalProperties' then
|
|
294
|
+
v := p_schema->'additionalProperties';
|
|
295
|
+
if v = 'false'::jsonb then
|
|
296
|
+
select string_agg('@.key == ' || @SCHEMA@.jp_str(x), ' || ') into t from jsonb_object_keys(coalesce(p_schema->'properties', '{}')) x;
|
|
297
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', 'additionalProperties', 'path_expr', p_path, 'vars', '{}'::jsonb,
|
|
298
|
+
'cond', case when t is null then '(@.type() != "object" || !exists(@.keyvalue()))'
|
|
299
|
+
else '(@.type() != "object" || !exists(@.keyvalue() ? (!(' || t || '))))' end);
|
|
300
|
+
elsif jsonb_typeof(v) = 'object' then
|
|
301
|
+
-- значения прочих ключей проверяются схемой additionalProperties
|
|
302
|
+
select string_agg('@.key != ' || @SCHEMA@.jp_str(x), ' && ') into t from jsonb_object_keys(coalesce(p_schema->'properties', '{}')) x;
|
|
303
|
+
select c.p_n, c.rules into p_n, v
|
|
304
|
+
from @SCHEMA@.jp_compile(v, p_path || ' ? (@.type() == "object").keyvalue() ? (' || coalesce(t, 'exists(@)') || ').value', p_disp || '"*"'::jsonb, p_in_any, p_n) c;
|
|
305
|
+
r := r || v;
|
|
306
|
+
end if;
|
|
307
|
+
end if;
|
|
308
|
+
if p_schema ? 'propertyNames' then
|
|
309
|
+
select c.p_n, c.rules into p_n, v
|
|
310
|
+
from @SCHEMA@.jp_compile(p_schema->'propertyNames' || '{"type": "string"}', p_path || ' ? (@.type() == "object").keyvalue().key', p_disp || '"<ключ>"'::jsonb, p_in_any, p_n) c;
|
|
311
|
+
r := r || v;
|
|
312
|
+
end if;
|
|
313
|
+
|
|
314
|
+
-- anyOf и oneOf: каждая ветка — конъюнкция её правил относительно узла @
|
|
315
|
+
foreach k in array array['anyOf', 'oneOf'] loop
|
|
316
|
+
if p_schema ? k then
|
|
317
|
+
alt := '{}';
|
|
318
|
+
vars := '{}';
|
|
319
|
+
for bv in select x from jsonb_array_elements(p_schema->k) x loop
|
|
320
|
+
select c.p_n, c.rules into p_n, br from @SCHEMA@.jp_compile(bv, '@', p_disp, true, p_n) c;
|
|
321
|
+
-- ветка без правил принимает любой узел; в фильтре jsonpath нужно условие, а не значение true
|
|
322
|
+
select coalesce(string_agg(format('!exists(%s ? (!(%s)))', e->>'path_expr', e->>'cond'), ' && '), 'exists(@)')
|
|
323
|
+
into t from jsonb_array_elements(br) e;
|
|
324
|
+
alt := alt || ('(' || t || ')');
|
|
325
|
+
vars := vars || coalesce((select jsonb_object_agg(kv.key, kv.value) from jsonb_array_elements(br) e2, jsonb_each(e2->'vars') kv), '{}');
|
|
326
|
+
end loop;
|
|
327
|
+
t := array_to_string(alt, ' || ');
|
|
328
|
+
if k = 'oneOf' then
|
|
329
|
+
for i in 1 .. cardinality(alt) loop
|
|
330
|
+
for j in i + 1 .. cardinality(alt) loop
|
|
331
|
+
t := t || ') && !(' || alt[i] || ' && ' || alt[j];
|
|
332
|
+
end loop;
|
|
333
|
+
end loop;
|
|
334
|
+
end if;
|
|
335
|
+
r := r || jsonb_build_object('path', p_disp, 'keyword', k, 'path_expr', p_path, 'vars', vars, 'cond', '((' || t || '))');
|
|
336
|
+
end if;
|
|
337
|
+
end loop;
|
|
338
|
+
rules := r;
|
|
339
|
+
end $$;
|
|
340
|
+
|
|
341
|
+
-- Компиляция описания класса: правила и общее выражение validator (одно на класс, §2.6).
|
|
342
|
+
create or replace function @SCHEMA@.compile_validator(p_schema jsonb, out rules jsonb, out validator jsonpath, out vars jsonb)
|
|
343
|
+
language plpgsql immutable
|
|
344
|
+
as $$
|
|
345
|
+
declare
|
|
346
|
+
n int := 0;
|
|
347
|
+
expr text;
|
|
348
|
+
begin
|
|
349
|
+
select c.rules into rules from @SCHEMA@.jp_compile(p_schema, '$', '[]'::jsonb, false, n) c;
|
|
350
|
+
select coalesce(string_agg(format('!exists(%s ? (!(%s)))', e->>'path_expr', e->>'cond'), ' && ') filter (where e->>'cond' <> 'true'), 'true'),
|
|
351
|
+
coalesce((select jsonb_object_agg(kv.key, kv.value) from jsonb_array_elements(rules) e2, jsonb_each(e2->'vars') kv), '{}')
|
|
352
|
+
into expr, vars
|
|
353
|
+
from jsonb_array_elements(rules) e;
|
|
354
|
+
validator := ('strict ' || expr)::jsonpath;
|
|
355
|
+
end $$;
|
|
356
|
+
|
|
357
|
+
-- Проверка данных: общий вызов jsonb_path_match; при отказе — список нарушений по правилам.
|
|
358
|
+
-- Результат сравнивается через is true: ошибка внутри выражения даёт NULL, а не «да» (этап 1, п. 5).
|
|
359
|
+
create or replace function @SCHEMA@.validate_data(p_rules jsonb, p_validator jsonpath, p_vars jsonb, p_data jsonb) returns jsonb
|
|
360
|
+
language plpgsql stable
|
|
361
|
+
as $$
|
|
362
|
+
declare
|
|
363
|
+
e jsonb;
|
|
364
|
+
bad jsonb := '[]'::jsonb;
|
|
365
|
+
ok boolean;
|
|
366
|
+
x jsonb;
|
|
367
|
+
begin
|
|
368
|
+
ok := jsonb_path_match(p_data, p_validator, p_vars, true) is true;
|
|
369
|
+
if ok and not exists (select 1 from jsonb_array_elements(p_rules) r where r ? 'extra') then
|
|
370
|
+
return bad;
|
|
371
|
+
end if;
|
|
372
|
+
for e in select value from jsonb_array_elements(p_rules) loop
|
|
373
|
+
if e ? 'extra' then
|
|
374
|
+
for x in select * from jsonb_path_query(p_data, ('strict ' || (e->>'path_expr'))::jsonpath, '{}'::jsonb, true) loop
|
|
375
|
+
if (e->'extra'->>'kind') = 'length' and jsonb_typeof(x) = 'string' then
|
|
376
|
+
if char_length(x #>> '{}') < coalesce((e->'extra'->>'min')::int, 0)
|
|
377
|
+
or char_length(x #>> '{}') > coalesce((e->'extra'->>'max')::int, 2147483647) then
|
|
378
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'длина строки вне границ');
|
|
379
|
+
end if;
|
|
380
|
+
elsif (e->'extra'->>'kind') = 'unique' and jsonb_typeof(x) = 'array' then
|
|
381
|
+
if (select count(*) from jsonb_array_elements(x)) <> (select count(distinct y) from jsonb_array_elements(x) y) then
|
|
382
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'uniqueItems', 'message', 'элементы повторяются');
|
|
383
|
+
end if;
|
|
384
|
+
elsif (e->'extra'->>'kind') = 'date' and jsonb_typeof(x) = 'string' then
|
|
385
|
+
-- смысл проверяется только у строки правильной формы: форму проверяет правило format
|
|
386
|
+
if (x #>> '{}') ~ (e->'extra'->>'re') and not pg_input_is_valid(x #>> '{}', e->'extra'->>'type') then
|
|
387
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', 'format', 'message', 'такой даты не бывает');
|
|
388
|
+
end if;
|
|
389
|
+
elsif (e->'extra'->>'kind') = 'props' and jsonb_typeof(x) = 'object' then
|
|
390
|
+
if (select count(*) from jsonb_object_keys(x)) < coalesce((e->'extra'->>'min')::int, 0)
|
|
391
|
+
or (select count(*) from jsonb_object_keys(x)) > coalesce((e->'extra'->>'max')::int, 2147483647) then
|
|
392
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'число ключей вне границ');
|
|
393
|
+
end if;
|
|
394
|
+
elsif (e->'extra'->>'kind') = 'enum' then
|
|
395
|
+
if not exists (select 1 from jsonb_array_elements(e->'extra'->'values') y where y = x) then
|
|
396
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'значение не из перечисления');
|
|
397
|
+
end if;
|
|
398
|
+
end if;
|
|
399
|
+
end loop;
|
|
400
|
+
elsif not ok then
|
|
401
|
+
if jsonb_path_match(p_data, ('strict !exists(' || (e->>'path_expr') || ' ? (!(' || (e->>'cond') || ')))')::jsonpath, e->'vars', true) is not true then
|
|
402
|
+
bad := bad || jsonb_build_object('path', e->'path', 'keyword', e->>'keyword', 'message', 'нарушено правило ' || (e->>'keyword'));
|
|
403
|
+
end if;
|
|
404
|
+
end if;
|
|
405
|
+
end loop;
|
|
406
|
+
if not ok and bad = '[]'::jsonb then
|
|
407
|
+
bad := jsonb_build_array(jsonb_build_object('path', '[]'::jsonb, 'keyword', 'schema', 'message', 'данные не проходят описание'));
|
|
408
|
+
end if;
|
|
409
|
+
return bad;
|
|
410
|
+
end $$;
|
|
411
|
+
|
|
412
|
+
do $$
|
|
413
|
+
declare f text;
|
|
414
|
+
begin
|
|
415
|
+
foreach f in array array[
|
|
416
|
+
'uuid_v5(uuid, text)', 'v5_name(text, text[])', 'v5_id(uuid, text, text[])', 'jp_str(text)', 'jp_pattern(text)',
|
|
417
|
+
'jp_type_cond(text)', 'jp_prop(text, text)', 'jp_items(text)', 'jp_compile(jsonb, text, jsonb, boolean, int)', 'compile_validator(jsonb)',
|
|
418
|
+
'validate_data(jsonb, jsonpath, jsonb, jsonb)'] loop
|
|
419
|
+
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
420
|
+
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
421
|
+
end loop;
|
|
422
|
+
end $$;
|
|
423
|
+
grant execute on function @SCHEMA@.uuid_v5(uuid, text), @SCHEMA@.v5_name(text, text[]), @SCHEMA@.v5_id(uuid, text, text[]) to @APP@;
|