dzql 0.7.0 → 0.8.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/README.md +16 -0
- package/package.json +11 -4
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
-- =========================================================================
|
|
2
|
+
-- Delta-doc: cascade removal, delta_apply
|
|
3
|
+
-- =========================================================================
|
|
4
|
+
|
|
5
|
+
-- ---------------------------------------------------------------------------
|
|
6
|
+
-- _delta_serial_past: moves a collection's serial (seq_<table>) past an id an
|
|
7
|
+
-- add named, never back, so a later add at /- does not meet it (todo #63).
|
|
8
|
+
-- setval needs UPDATE on the sequence, and an app role is granted USAGE,
|
|
9
|
+
-- SELECT: so it runs as the framework's owner (SECURITY DEFINER), for a table
|
|
10
|
+
-- delta knows, an id a path may name, and a caller that may insert into the
|
|
11
|
+
-- table, and nothing else. Running as the owner, current_user is the owner:
|
|
12
|
+
-- the caller is the role SET ROLE named, else the session's. Its search_path
|
|
13
|
+
-- is pinned below, pg_temp last.
|
|
14
|
+
-- ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
CREATE OR REPLACE FUNCTION _delta_serial_past(p_table TEXT, p_id BIGINT)
|
|
17
|
+
RETURNS void LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
18
|
+
BEGIN
|
|
19
|
+
IF p_id > 9007199254740991 OR NOT EXISTS (SELECT 1 FROM _delta_collections WHERE table_name = p_table)
|
|
20
|
+
OR NOT has_table_privilege(COALESCE(NULLIF(current_setting('role'), 'none'), session_user), p_table, 'INSERT') THEN
|
|
21
|
+
RETURN;
|
|
22
|
+
END IF;
|
|
23
|
+
EXECUTE format('SELECT setval(%L, $1) FROM %I WHERE $1 >= last_value + is_called::int',
|
|
24
|
+
'seq_' || p_table, 'seq_' || p_table) USING p_id;
|
|
25
|
+
END;
|
|
26
|
+
$$;
|
|
27
|
+
-- the search path it was applied with, and pg_temp last (as _delta_holders, 001b)
|
|
28
|
+
DO $$ BEGIN
|
|
29
|
+
EXECUTE format('ALTER FUNCTION _delta_serial_past(TEXT, BIGINT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
|
|
30
|
+
END $$;
|
|
31
|
+
|
|
32
|
+
-- ---------------------------------------------------------------------------
|
|
33
|
+
-- _delta_serial_new: whether an id is past every one a collection's serial has
|
|
34
|
+
-- handed out (minted, or passed for an add that named it): what an add may
|
|
35
|
+
-- name (todo #71). The serial is read as the framework's owner, as
|
|
36
|
+
-- _delta_serial_past sets it, for a caller that may read the table: to any
|
|
37
|
+
-- other every id is taken, so it learns nothing of the table's rows.
|
|
38
|
+
-- ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
CREATE OR REPLACE FUNCTION _delta_serial_new(p_table TEXT, p_id BIGINT)
|
|
41
|
+
RETURNS BOOLEAN LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
42
|
+
DECLARE
|
|
43
|
+
v_new BOOLEAN;
|
|
44
|
+
BEGIN
|
|
45
|
+
IF NOT EXISTS (SELECT 1 FROM _delta_collections WHERE table_name = p_table)
|
|
46
|
+
OR NOT has_table_privilege(COALESCE(NULLIF(current_setting('role'), 'none'), session_user), p_table, 'SELECT') THEN
|
|
47
|
+
RETURN FALSE;
|
|
48
|
+
END IF;
|
|
49
|
+
EXECUTE format('SELECT $1 >= last_value + is_called::int FROM %I', 'seq_' || p_table) INTO v_new USING p_id;
|
|
50
|
+
RETURN v_new;
|
|
51
|
+
END;
|
|
52
|
+
$$;
|
|
53
|
+
|
|
54
|
+
-- Both run as the framework's owner, so each looks its names up only in the
|
|
55
|
+
-- schemas the framework is applied in, and pg_temp last: a caller's temporary
|
|
56
|
+
-- _delta_collections, or a temporary table named as one of delta's, never
|
|
57
|
+
-- stands in for the real one. (search_path FROM CURRENT took "$user", public,
|
|
58
|
+
-- and pg_temp is searched first when it is not named.)
|
|
59
|
+
DO $$
|
|
60
|
+
DECLARE
|
|
61
|
+
v_path TEXT := COALESCE((SELECT string_agg(quote_ident(s), ', ') FROM unnest(current_schemas(false)) AS s), 'public') || ', pg_temp';
|
|
62
|
+
BEGIN
|
|
63
|
+
EXECUTE format('ALTER FUNCTION _delta_serial_past(text, bigint) SET search_path = %s', v_path);
|
|
64
|
+
EXECUTE format('ALTER FUNCTION _delta_serial_new(text, bigint) SET search_path = %s', v_path);
|
|
65
|
+
END $$;
|
|
66
|
+
|
|
67
|
+
-- ---------------------------------------------------------------------------
|
|
68
|
+
-- _delta_cascade_remove: remove a row + cascade to children & referencedBy,
|
|
69
|
+
-- as the schema says, whichever document the removal is written through: one
|
|
70
|
+
-- that does not include the drinks still takes a course's drinks (no orphans).
|
|
71
|
+
-- Returns the array of broadcast ops generated. (Until 0.12.0 it took the
|
|
72
|
+
-- writer's include, and left the rest: that form is dropped.) In one order on
|
|
73
|
+
-- every backend: each child collection by key, each row by id, then each
|
|
74
|
+
-- collection that cascades on it (cascadeRows in sqlite.ts).
|
|
75
|
+
-- ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
DROP FUNCTION IF EXISTS _delta_cascade_remove(TEXT, BIGINT, TEXT[]);
|
|
78
|
+
CREATE OR REPLACE FUNCTION _delta_cascade_remove(
|
|
79
|
+
p_collection_key TEXT,
|
|
80
|
+
p_id BIGINT
|
|
81
|
+
) RETURNS JSONB AS $$
|
|
82
|
+
DECLARE
|
|
83
|
+
v_coll RECORD;
|
|
84
|
+
v_view TEXT;
|
|
85
|
+
v_child RECORD;
|
|
86
|
+
v_child_row RECORD;
|
|
87
|
+
v_ref RECORD;
|
|
88
|
+
v_ref_row RECORD;
|
|
89
|
+
v_affected BIGINT;
|
|
90
|
+
v_ops JSONB := '[]'::jsonb;
|
|
91
|
+
BEGIN
|
|
92
|
+
SELECT * INTO v_coll FROM _delta_collections
|
|
93
|
+
WHERE collection_key = p_collection_key;
|
|
94
|
+
IF NOT FOUND THEN RETURN v_ops; END IF;
|
|
95
|
+
|
|
96
|
+
-- Close or delete the row itself
|
|
97
|
+
IF v_coll.temporal THEN
|
|
98
|
+
EXECUTE format(
|
|
99
|
+
'UPDATE %I SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL',
|
|
100
|
+
v_coll.table_name
|
|
101
|
+
) USING p_id;
|
|
102
|
+
ELSE
|
|
103
|
+
EXECUTE format('DELETE FROM %I WHERE id = $1', v_coll.table_name)
|
|
104
|
+
USING p_id;
|
|
105
|
+
END IF;
|
|
106
|
+
|
|
107
|
+
-- Only claim a removal that actually happened. The row may already be closed,
|
|
108
|
+
-- or an RLS policy may have silently filtered the UPDATE/DELETE to zero rows —
|
|
109
|
+
-- in which case emitting the op would tell every subscriber to drop a row that
|
|
110
|
+
-- is still in the table, and cascading from it would compound the lie.
|
|
111
|
+
GET DIAGNOSTICS v_affected = ROW_COUNT;
|
|
112
|
+
IF v_affected = 0 THEN RETURN v_ops; END IF;
|
|
113
|
+
|
|
114
|
+
v_ops := v_ops || jsonb_build_array(
|
|
115
|
+
jsonb_build_object('op', 'remove', 'path', _delta_build_path(p_collection_key, p_id::text))
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- Cascade via parent relationship (children of this collection)
|
|
119
|
+
FOR v_child IN
|
|
120
|
+
SELECT * FROM _delta_collections
|
|
121
|
+
WHERE parent_collection = p_collection_key
|
|
122
|
+
ORDER BY collection_key COLLATE "C"
|
|
123
|
+
LOOP
|
|
124
|
+
v_view := _delta_source_view(v_child.table_name, v_child.temporal);
|
|
125
|
+
|
|
126
|
+
FOR v_child_row IN
|
|
127
|
+
EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', v_view, v_child.parent_fk)
|
|
128
|
+
USING p_id
|
|
129
|
+
LOOP
|
|
130
|
+
v_ops := v_ops || _delta_cascade_remove(v_child.collection_key, v_child_row.id);
|
|
131
|
+
END LOOP;
|
|
132
|
+
END LOOP;
|
|
133
|
+
|
|
134
|
+
-- Cascade via cascadeOn (referencedBy)
|
|
135
|
+
-- Find collections whose cascade_on references this collection
|
|
136
|
+
FOR v_ref IN
|
|
137
|
+
SELECT c.collection_key, c.table_name, c.temporal,
|
|
138
|
+
elem->>'fk' AS fk_column
|
|
139
|
+
FROM _delta_collections c,
|
|
140
|
+
jsonb_array_elements(c.cascade_on) AS elem
|
|
141
|
+
WHERE elem->>'collection' = p_collection_key
|
|
142
|
+
ORDER BY c.collection_key COLLATE "C", elem->>'fk' COLLATE "C"
|
|
143
|
+
LOOP
|
|
144
|
+
v_view := _delta_source_view(v_ref.table_name, v_ref.temporal);
|
|
145
|
+
|
|
146
|
+
FOR v_ref_row IN
|
|
147
|
+
EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', v_view, v_ref.fk_column)
|
|
148
|
+
USING p_id
|
|
149
|
+
LOOP
|
|
150
|
+
v_ops := v_ops || _delta_cascade_remove(v_ref.collection_key, v_ref_row.id);
|
|
151
|
+
END LOOP;
|
|
152
|
+
END LOOP;
|
|
153
|
+
|
|
154
|
+
RETURN v_ops;
|
|
155
|
+
END;
|
|
156
|
+
$$ LANGUAGE plpgsql;
|
|
157
|
+
|
|
158
|
+
-- ---------------------------------------------------------------------------
|
|
159
|
+
-- _delta_cascade_rows: the rows _delta_cascade_remove would take, read before
|
|
160
|
+
-- it takes them, in the order it emits their removes -- so who holds each can
|
|
161
|
+
-- be asked while every row of every chain is still there.
|
|
162
|
+
-- ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
DROP FUNCTION IF EXISTS _delta_cascade_rows(TEXT, BIGINT, TEXT[]);
|
|
165
|
+
CREATE OR REPLACE FUNCTION _delta_cascade_rows(
|
|
166
|
+
p_collection_key TEXT,
|
|
167
|
+
p_id BIGINT
|
|
168
|
+
) RETURNS JSONB AS $$
|
|
169
|
+
DECLARE
|
|
170
|
+
v_coll RECORD;
|
|
171
|
+
v_view TEXT;
|
|
172
|
+
v_row JSONB;
|
|
173
|
+
v_child RECORD;
|
|
174
|
+
v_ref RECORD;
|
|
175
|
+
v_id BIGINT;
|
|
176
|
+
v_rows JSONB := '[]'::jsonb;
|
|
177
|
+
BEGIN
|
|
178
|
+
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_collection_key;
|
|
179
|
+
IF NOT FOUND THEN RETURN v_rows; END IF;
|
|
180
|
+
v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
|
|
181
|
+
EXECUTE format('SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1', v_view) INTO v_row USING p_id;
|
|
182
|
+
IF v_row IS NULL THEN RETURN v_rows; END IF;
|
|
183
|
+
v_rows := jsonb_build_array(jsonb_build_object('coll', p_collection_key, 'id', p_id, 'row', _delta_strip_temporal(v_row)));
|
|
184
|
+
|
|
185
|
+
FOR v_child IN
|
|
186
|
+
SELECT * FROM _delta_collections
|
|
187
|
+
WHERE parent_collection = p_collection_key
|
|
188
|
+
ORDER BY collection_key COLLATE "C"
|
|
189
|
+
LOOP
|
|
190
|
+
FOR v_id IN EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', _delta_source_view(v_child.table_name, v_child.temporal), v_child.parent_fk) USING p_id
|
|
191
|
+
LOOP
|
|
192
|
+
v_rows := v_rows || _delta_cascade_rows(v_child.collection_key, v_id);
|
|
193
|
+
END LOOP;
|
|
194
|
+
END LOOP;
|
|
195
|
+
|
|
196
|
+
FOR v_ref IN
|
|
197
|
+
SELECT c.collection_key, c.table_name, c.temporal, elem->>'fk' AS fk_column
|
|
198
|
+
FROM _delta_collections c, jsonb_array_elements(c.cascade_on) AS elem
|
|
199
|
+
WHERE elem->>'collection' = p_collection_key
|
|
200
|
+
ORDER BY c.collection_key COLLATE "C", elem->>'fk' COLLATE "C"
|
|
201
|
+
LOOP
|
|
202
|
+
FOR v_id IN EXECUTE format('SELECT id FROM %I WHERE %I = $1 ORDER BY id', _delta_source_view(v_ref.table_name, v_ref.temporal), v_ref.fk_column) USING p_id
|
|
203
|
+
LOOP
|
|
204
|
+
v_rows := v_rows || _delta_cascade_rows(v_ref.collection_key, v_id);
|
|
205
|
+
END LOOP;
|
|
206
|
+
END LOOP;
|
|
207
|
+
|
|
208
|
+
RETURN v_rows;
|
|
209
|
+
END;
|
|
210
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
211
|
+
|
|
212
|
+
-- ---------------------------------------------------------------------------
|
|
213
|
+
-- _delta_hanging_rows: the rows that hang from a row -- its children, theirs,
|
|
214
|
+
-- and on down, whichever documents include them -- parent first, each
|
|
215
|
+
-- {coll, id, row}. A row moved to another parent takes them along (todo #62;
|
|
216
|
+
-- hanging in sqlite.ts).
|
|
217
|
+
-- ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
CREATE OR REPLACE FUNCTION _delta_hanging_rows(p_collection_key TEXT, p_id BIGINT)
|
|
220
|
+
RETURNS JSONB AS $$
|
|
221
|
+
DECLARE
|
|
222
|
+
v_child RECORD;
|
|
223
|
+
v_part JSONB;
|
|
224
|
+
v_rows JSONB := '[]'::jsonb;
|
|
225
|
+
BEGIN
|
|
226
|
+
-- a child collection at a time, in one aggregate: each row by id, the rows under it after it
|
|
227
|
+
FOR v_child IN SELECT * FROM _delta_collections WHERE parent_collection = p_collection_key ORDER BY collection_key COLLATE "C" LOOP
|
|
228
|
+
EXECUTE format(
|
|
229
|
+
'SELECT COALESCE(jsonb_agg(e ORDER BY t.id, n), ''[]''::jsonb) FROM %I t,
|
|
230
|
+
jsonb_array_elements(jsonb_build_array(jsonb_build_object(''coll'', %L, ''id'', t.id, ''row'', _delta_strip_temporal(to_jsonb(t.*))))
|
|
231
|
+
|| _delta_hanging_rows(%L, t.id)) WITH ORDINALITY AS x(e, n)
|
|
232
|
+
WHERE t.%I = $1',
|
|
233
|
+
_delta_source_view(v_child.table_name, v_child.temporal), v_child.collection_key, v_child.collection_key, v_child.parent_fk
|
|
234
|
+
) INTO v_part USING p_id;
|
|
235
|
+
v_rows := v_rows || v_part;
|
|
236
|
+
END LOOP;
|
|
237
|
+
RETURN v_rows;
|
|
238
|
+
END;
|
|
239
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
240
|
+
|
|
241
|
+
-- ---------------------------------------------------------------------------
|
|
242
|
+
-- _delta_tell: tell every document that holds a row a write changed what
|
|
243
|
+
-- changed for it (todo #28), and answer the writer's new version.
|
|
244
|
+
--
|
|
245
|
+
-- `p_touched` is the write's rows in order, each {coll, id, before, after}:
|
|
246
|
+
-- `before` the documents that held it before that touch (_delta_holders, asked
|
|
247
|
+
-- then), `after` the row as the touch left it (null when it is gone), and a
|
|
248
|
+
-- replace's `was`, the row as it was before (a document that holds the row
|
|
249
|
+
-- either way is told only the fields that changed: set by the write, a
|
|
250
|
+
-- default or a trigger -- each a replace of its field, in field order); a
|
|
251
|
+
-- `carried` row hangs under one the write moved, and is told only where it
|
|
252
|
+
-- arrives or leaves (todo #62). Each
|
|
253
|
+
-- touch is told in turn: the documents that hold the row after it are those
|
|
254
|
+
-- that held it at the row's next touch, or after its last, those that hold it
|
|
255
|
+
-- once the write is done -- so a row made and then taken out (a drink, with its
|
|
256
|
+
-- course) is told its add wherever its remove is told (todo #75; tell in
|
|
257
|
+
-- sqlite.ts). For each document that held it before or holds it after: a row
|
|
258
|
+
-- that arrives is an add, one that stays a replace of each field that changed,
|
|
259
|
+
-- one that leaves a remove -- and where the row is the document's root, a
|
|
260
|
+
-- replace of the root when it arrives (null when it leaves). The
|
|
261
|
+
-- writer's document is always told, and told first; each other document is
|
|
262
|
+
-- told only what concerns it, with a version of its own, one per write.
|
|
263
|
+
-- `p_applied`, the write as applied, is logged once, on the writer's entry,
|
|
264
|
+
-- for the custom documents that watch the rows (001a's `applied`) -- only where
|
|
265
|
+
-- it differs from what the writer is told (a row that left the writer's
|
|
266
|
+
-- document, or was never in it); where they agree the entry carries none, and
|
|
267
|
+
-- is heard as told. Every other document's entry carries '[]'. The
|
|
268
|
+
-- two-argument form logs none.
|
|
269
|
+
-- ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
CREATE OR REPLACE FUNCTION _delta_tell(p_writer TEXT, p_touched JSONB, p_applied JSONB)
|
|
272
|
+
RETURNS BIGINT AS $$
|
|
273
|
+
DECLARE
|
|
274
|
+
v_rows JSONB := '[]'::jsonb;
|
|
275
|
+
v_t JSONB;
|
|
276
|
+
v_after JSONB;
|
|
277
|
+
v_targets TEXT[] := ARRAY[]::text[];
|
|
278
|
+
v_target TEXT;
|
|
279
|
+
v_def _delta_docs;
|
|
280
|
+
v_single BOOLEAN;
|
|
281
|
+
v_told JSONB;
|
|
282
|
+
v_was BOOLEAN;
|
|
283
|
+
v_is BOOLEAN;
|
|
284
|
+
v_root BOOLEAN;
|
|
285
|
+
v_path TEXT;
|
|
286
|
+
v_version BIGINT;
|
|
287
|
+
v_next JSONB := '{}'::jsonb; -- a row's holders, as its next touch found them
|
|
288
|
+
v_key TEXT;
|
|
289
|
+
v_i INT;
|
|
290
|
+
BEGIN
|
|
291
|
+
-- who holds each row after each touch, last touch first, and every document concerned
|
|
292
|
+
FOR v_i IN REVERSE COALESCE(jsonb_array_length(p_touched), 0) - 1 .. 0 LOOP
|
|
293
|
+
v_t := p_touched->v_i;
|
|
294
|
+
v_key := (v_t->>'coll') || '/' || (v_t->>'id');
|
|
295
|
+
v_after := CASE WHEN jsonb_typeof(v_t->'after') = 'object' THEN v_t->'after' END;
|
|
296
|
+
v_t := v_t || jsonb_build_object('holders',
|
|
297
|
+
COALESCE(v_next->v_key, to_jsonb(_delta_holders(v_t->>'coll', v_after, p_writer))));
|
|
298
|
+
v_next := v_next || jsonb_build_object(v_key, v_t->'before');
|
|
299
|
+
v_rows := jsonb_build_array(v_t) || v_rows;
|
|
300
|
+
v_targets := v_targets
|
|
301
|
+
|| ARRAY(SELECT jsonb_array_elements_text(v_t->'before'))
|
|
302
|
+
|| ARRAY(SELECT jsonb_array_elements_text(v_t->'holders'));
|
|
303
|
+
END LOOP;
|
|
304
|
+
|
|
305
|
+
FOR v_target IN
|
|
306
|
+
SELECT p_writer
|
|
307
|
+
UNION ALL
|
|
308
|
+
SELECT DISTINCT t FROM unnest(v_targets) AS t WHERE t IS DISTINCT FROM p_writer
|
|
309
|
+
LOOP
|
|
310
|
+
v_def := _delta_find_doc(v_target);
|
|
311
|
+
CONTINUE WHEN v_def.prefix IS NULL;
|
|
312
|
+
v_single := (_delta_resolve_scope(v_def, v_target)->>'mode') = 'single';
|
|
313
|
+
v_told := '[]'::jsonb;
|
|
314
|
+
FOR v_t IN SELECT jsonb_array_elements(v_rows) LOOP
|
|
315
|
+
v_was := (v_t->'before') ? v_target;
|
|
316
|
+
v_is := (v_t->'holders') ? v_target;
|
|
317
|
+
CONTINUE WHEN NOT v_was AND NOT v_is;
|
|
318
|
+
CONTINUE WHEN v_was AND v_is AND (v_t ? 'carried'); -- carried along, and held either way: unchanged for it
|
|
319
|
+
v_root := v_single AND (v_t->>'coll') = v_def.root_collection;
|
|
320
|
+
v_path := CASE WHEN v_root THEN _delta_build_path(v_t->>'coll') ELSE _delta_build_path(v_t->>'coll', v_t->>'id') END;
|
|
321
|
+
-- each row as this document shows it: the columns it holds (_delta_show, todo #70)
|
|
322
|
+
IF v_was AND v_is THEN -- stayed: what changed
|
|
323
|
+
v_told := v_told || COALESCE((SELECT jsonb_agg(jsonb_build_object('op', 'replace', 'path', v_path || _delta_build_path(f), 'value', v) ORDER BY f COLLATE "C")
|
|
324
|
+
FROM jsonb_each(_delta_show(v_def, v_t->>'coll', v_t->'after')) AS e(f, v) WHERE v IS DISTINCT FROM v_t->'was'->f), '[]'::jsonb);
|
|
325
|
+
ELSIF v_is THEN
|
|
326
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object(
|
|
327
|
+
'op', CASE WHEN v_root THEN 'replace' ELSE 'add' END, 'path', v_path, 'value', _delta_show(v_def, v_t->>'coll', v_t->'after')));
|
|
328
|
+
ELSIF v_root THEN -- gone: null, or the empty root an implied document then opens with
|
|
329
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_path, 'value',
|
|
330
|
+
CASE WHEN COALESCE(v_def.implied, FALSE) THEN _delta_show(v_def, v_t->>'coll', _delta_implied_root(v_def, (v_t->>'id')::BIGINT)) ELSE 'null'::jsonb END));
|
|
331
|
+
ELSE
|
|
332
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path));
|
|
333
|
+
END IF;
|
|
334
|
+
END LOOP;
|
|
335
|
+
IF v_target = p_writer THEN
|
|
336
|
+
v_version := _delta_bump_and_notify(p_writer, v_told, CASE WHEN p_applied IS DISTINCT FROM v_told THEN p_applied END);
|
|
337
|
+
ELSIF jsonb_array_length(v_told) > 0 THEN
|
|
338
|
+
PERFORM _delta_bump_and_notify(v_target, v_told, CASE WHEN p_applied IS NOT NULL THEN '[]'::jsonb END);
|
|
339
|
+
END IF;
|
|
340
|
+
END LOOP;
|
|
341
|
+
RETURN v_version;
|
|
342
|
+
END;
|
|
343
|
+
$$ LANGUAGE plpgsql;
|
|
344
|
+
|
|
345
|
+
CREATE OR REPLACE FUNCTION _delta_tell(p_writer TEXT, p_touched JSONB)
|
|
346
|
+
RETURNS BIGINT LANGUAGE sql AS $$ SELECT _delta_tell(p_writer, p_touched, NULL); $$;
|
|
347
|
+
|
|
348
|
+
-- ---------------------------------------------------------------------------
|
|
349
|
+
-- _delta_row_there: is row p_id of a collection there now? A parent a row is
|
|
350
|
+
-- added or moved under must be, whatever document the write comes through: a
|
|
351
|
+
-- list holds every row of its root, but names their parents only by key, and
|
|
352
|
+
-- a row under a parent that is not there is no document's (an implied one
|
|
353
|
+
-- not yet written opens empty). `there` in sqlite.ts.
|
|
354
|
+
-- ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
CREATE OR REPLACE FUNCTION _delta_row_there(p_collection_key TEXT, p_id BIGINT)
|
|
357
|
+
RETURNS BOOLEAN AS $$
|
|
358
|
+
DECLARE
|
|
359
|
+
v_coll RECORD;
|
|
360
|
+
v_exists BOOLEAN;
|
|
361
|
+
BEGIN
|
|
362
|
+
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_collection_key;
|
|
363
|
+
IF NOT FOUND OR p_id IS NULL THEN RETURN FALSE; END IF;
|
|
364
|
+
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', _delta_source_view(v_coll.table_name, v_coll.temporal))
|
|
365
|
+
INTO v_exists USING p_id;
|
|
366
|
+
RETURN v_exists;
|
|
367
|
+
END;
|
|
368
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
369
|
+
|
|
370
|
+
-- ---------------------------------------------------------------------------
|
|
371
|
+
-- _delta_assert_parent_held: a parent key written (in `p_value`, over the row
|
|
372
|
+
-- as it is, `p_row`) moves the row, and only under a parent the document holds,
|
|
373
|
+
-- as an add names one: through it, a row is never moved into another
|
|
374
|
+
-- document (you may write what you may read), RLS or none. A single
|
|
375
|
+
-- document's own root too: it holds no parent of it, so the root moves
|
|
376
|
+
-- through a list that holds both (0.10.0 review). The parent must be there
|
|
377
|
+
-- (_delta_row_there). A key written as it is, is no move. P0002 (404) when refused.
|
|
378
|
+
-- ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
CREATE OR REPLACE FUNCTION _delta_assert_parent_held(p_def _delta_docs, p_doc_name TEXT, p_parent_fk TEXT, p_parent TEXT, p_row JSONB, p_value JSONB)
|
|
381
|
+
RETURNS void AS $$
|
|
382
|
+
BEGIN
|
|
383
|
+
IF p_parent_fk IS NOT NULL
|
|
384
|
+
AND jsonb_typeof(p_value->p_parent_fk) IN ('number', 'string')
|
|
385
|
+
AND (p_value->>p_parent_fk)::BIGINT IS DISTINCT FROM (p_row->>p_parent_fk)::BIGINT
|
|
386
|
+
AND (NOT _delta_row_in_scope(p_def, p_doc_name, p_parent, (p_value->>p_parent_fk)::BIGINT)
|
|
387
|
+
OR NOT _delta_row_there(p_parent, (p_value->>p_parent_fk)::BIGINT)) THEN
|
|
388
|
+
RAISE EXCEPTION 'row not found: %/%', p_parent, p_value->>p_parent_fk
|
|
389
|
+
USING ERRCODE = 'P0002';
|
|
390
|
+
END IF;
|
|
391
|
+
END;
|
|
392
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
393
|
+
|
|
394
|
+
-- ---------------------------------------------------------------------------
|
|
395
|
+
-- _delta_whole: a replace of a row itself (`/<coll>/<id>`, a single
|
|
396
|
+
-- document's `/<root>`) is the row, as RFC 6902 says: each column
|
|
397
|
+
-- (`p_columns`) holds what an add gives one its value leaves out -- its
|
|
398
|
+
-- default, or null -- the row as blank as that, for the row as it is to be
|
|
399
|
+
-- written over and the value merged over that. Its id and parent key, where
|
|
400
|
+
-- it is, stay. A required column the value leaves out (not nullable, no
|
|
401
|
+
-- default) is the writer's mistake: 23502, 400 on the wire, as for an add,
|
|
402
|
+
-- and asked with the value's fields, before whether the row is there (todo
|
|
403
|
+
-- #3; replaceRow in sqlite.ts).
|
|
404
|
+
-- ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
-- _delta_held_columns: the columns of `p_coll` (`p_columns`, its columns_def)
|
|
407
|
+
-- a document holds -- those it names (`held`, todo #70), else all. A write
|
|
408
|
+
-- through it names those alone (_delta_assert_fields: 400 for another, as for
|
|
409
|
+
-- a column there is not), and replacing a row whole sets those alone.
|
|
410
|
+
|
|
411
|
+
CREATE OR REPLACE FUNCTION _delta_held_columns(p_def _delta_docs, p_coll TEXT, p_columns JSONB)
|
|
412
|
+
RETURNS JSONB AS $$
|
|
413
|
+
SELECT CASE WHEN NOT COALESCE(p_def.held, '{}'::jsonb) ? p_coll THEN p_columns
|
|
414
|
+
ELSE COALESCE((SELECT jsonb_object_agg(k, v) FROM jsonb_each(p_columns) AS e(k, v) WHERE (p_def.held->p_coll) ? k), '{}'::jsonb) END;
|
|
415
|
+
$$ LANGUAGE sql STABLE;
|
|
416
|
+
|
|
417
|
+
CREATE OR REPLACE FUNCTION _delta_whole(p_columns JSONB, p_value JSONB)
|
|
418
|
+
RETURNS JSONB AS $$
|
|
419
|
+
DECLARE
|
|
420
|
+
v_missing TEXT;
|
|
421
|
+
BEGIN
|
|
422
|
+
SELECT string_agg(col_key, ', ' ORDER BY col_key) INTO v_missing
|
|
423
|
+
FROM jsonb_each(p_columns) AS x(col_key, col_def)
|
|
424
|
+
WHERE NOT p_value ? col_key AND NOT COALESCE((col_def->>'nullable')::boolean, false) AND NOT col_def ? 'default';
|
|
425
|
+
IF v_missing IS NOT NULL THEN
|
|
426
|
+
RAISE EXCEPTION 'Required field missing: % (give it a value, or declare a default or make it nullable in the schema)', v_missing
|
|
427
|
+
USING ERRCODE = '23502';
|
|
428
|
+
END IF;
|
|
429
|
+
RETURN COALESCE((SELECT jsonb_object_agg(col_key, COALESCE(col_def->'default', 'null'::jsonb)) FROM jsonb_each(p_columns) AS x(col_key, col_def)), '{}'::jsonb);
|
|
430
|
+
END;
|
|
431
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|
|
432
|
+
|
|
433
|
+
-- ---------------------------------------------------------------------------
|
|
434
|
+
-- delta_apply: apply delta ops to relational tables, bump version, NOTIFY
|
|
435
|
+
--
|
|
436
|
+
-- Handles:
|
|
437
|
+
-- replace /<root> → the root row, whole (_delta_whole)
|
|
438
|
+
-- replace /<root>/field → temporal update on root row
|
|
439
|
+
-- add /<collection>/<id> → insert new row
|
|
440
|
+
-- remove /<collection>/<id> → temporal close + cascades
|
|
441
|
+
-- replace /<collection>/<id>/f → temporal update on collection row field
|
|
442
|
+
--
|
|
443
|
+
-- Returns {version, ops} where ops are the broadcast ops.
|
|
444
|
+
--
|
|
445
|
+
-- `p_walk`: the write is a walk of the ledger, an undo or redo (001g's
|
|
446
|
+
-- delta_apply_logged says so for delta_walk). A single document whose root is
|
|
447
|
+
-- gone takes no writes but a walk, which may put that root back (todo #43,
|
|
448
|
+
-- #59), or its root's add in a write that took it out (todo #61). The
|
|
449
|
+
-- two-argument form is no walk.
|
|
450
|
+
-- ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
CREATE OR REPLACE FUNCTION delta_apply(p_doc_name TEXT, p_ops JSONB, p_walk BOOLEAN)
|
|
453
|
+
RETURNS JSONB AS $$
|
|
454
|
+
DECLARE
|
|
455
|
+
v_def _delta_docs;
|
|
456
|
+
v_scope JSONB;
|
|
457
|
+
v_is_list BOOLEAN;
|
|
458
|
+
v_doc_id BIGINT;
|
|
459
|
+
v_root_coll RECORD;
|
|
460
|
+
v_op JSONB;
|
|
461
|
+
v_parts TEXT[];
|
|
462
|
+
v_coll_key TEXT;
|
|
463
|
+
v_coll RECORD;
|
|
464
|
+
v_view TEXT;
|
|
465
|
+
v_root_view TEXT;
|
|
466
|
+
v_opened BOOLEAN; -- a single document's root was there as the write began
|
|
467
|
+
v_back BOOLEAN; -- this op adds that root back, taken out earlier in the write
|
|
468
|
+
v_root_was JSONB; -- that root as this write took it out: added back, it goes under the same parent
|
|
469
|
+
v_id BIGINT;
|
|
470
|
+
v_id_text TEXT;
|
|
471
|
+
v_field TEXT;
|
|
472
|
+
v_row JSONB;
|
|
473
|
+
v_new_row JSONB;
|
|
474
|
+
v_blank JSONB; -- a replace of the row whole: each column as an add leaves it (_delta_whole); of a field, nothing
|
|
475
|
+
v_cols JSONB; -- the op's collection's columns the document holds (_delta_held_columns); a walk's, all
|
|
476
|
+
v_exists BOOLEAN;
|
|
477
|
+
v_missing TEXT;
|
|
478
|
+
v_ts TIMESTAMPTZ := NOW();
|
|
479
|
+
v_version BIGINT;
|
|
480
|
+
v_broadcast_ops JSONB := '[]'::jsonb;
|
|
481
|
+
-- the rows this write changes, in order, for _delta_tell: {coll, id, before, after}
|
|
482
|
+
v_touched JSONB := '[]'::jsonb;
|
|
483
|
+
v_before TEXT[];
|
|
484
|
+
v_befores JSONB;
|
|
485
|
+
v_removed JSONB;
|
|
486
|
+
v_r JSONB;
|
|
487
|
+
v_rp TEXT[];
|
|
488
|
+
v_told_path TEXT;
|
|
489
|
+
v_run_at INT; -- the touched entry of the run of replaces under way
|
|
490
|
+
v_run_carried BOOLEAN := FALSE; -- that run has carried the rows under its row along
|
|
491
|
+
v_hung JSONB;
|
|
492
|
+
v_outside BOOLEAN; -- the op's collection is neither the root nor included: a walk's, put back or taken again
|
|
493
|
+
v_passed JSONB := '{}'::jsonb; -- each table's highest id this write named: its serial passes it once the write is done
|
|
494
|
+
BEGIN
|
|
495
|
+
-- Guard: ops must be a JSON array
|
|
496
|
+
IF p_ops IS NULL OR jsonb_typeof(p_ops) != 'array' THEN
|
|
497
|
+
RAISE EXCEPTION 'ops must be a JSON array';
|
|
498
|
+
END IF;
|
|
499
|
+
-- every path is a JSON Pointer, asked of the whole write before any op (400): a grammar, not a row
|
|
500
|
+
PERFORM _delta_split_path(o->>'path') FROM jsonb_array_elements(p_ops) AS o;
|
|
501
|
+
|
|
502
|
+
v_def := _delta_find_doc(p_doc_name);
|
|
503
|
+
IF v_def.prefix IS NULL THEN
|
|
504
|
+
RAISE EXCEPTION 'no doc def for: %', p_doc_name;
|
|
505
|
+
END IF;
|
|
506
|
+
|
|
507
|
+
v_scope := _delta_resolve_scope(v_def, p_doc_name);
|
|
508
|
+
-- a document named with a time reads it as it stood: what is written now is not its to write (403)
|
|
509
|
+
IF v_scope->>'at' IS NOT NULL THEN
|
|
510
|
+
RAISE EXCEPTION '% reads % as it stood at %: it takes no writes. Write through a document with no time in its name.',
|
|
511
|
+
p_doc_name, v_def.root_collection, left(v_scope->>'at', 10) USING ERRCODE = '25006';
|
|
512
|
+
END IF;
|
|
513
|
+
v_is_list := (v_scope->>'mode') = 'list';
|
|
514
|
+
IF NOT v_is_list THEN
|
|
515
|
+
v_doc_id := (v_scope->'values'->>'id')::BIGINT;
|
|
516
|
+
END IF;
|
|
517
|
+
|
|
518
|
+
SELECT * INTO v_root_coll FROM _delta_collections
|
|
519
|
+
WHERE collection_key = v_def.root_collection;
|
|
520
|
+
v_root_view := _delta_source_view(v_root_coll.table_name, v_root_coll.temporal);
|
|
521
|
+
|
|
522
|
+
-- an implied document's first write makes its root row (todo #34), told as a
|
|
523
|
+
-- row carried in: an add to a list of those roots, nothing to the implied
|
|
524
|
+
-- documents, which showed it already (ensureImpliedRoot in sqlite.ts); an
|
|
525
|
+
-- empty write makes nothing
|
|
526
|
+
IF NOT v_is_list AND COALESCE(v_def.implied, FALSE) AND jsonb_array_length(p_ops) > 0 THEN
|
|
527
|
+
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', v_root_view) INTO v_exists USING v_doc_id;
|
|
528
|
+
PERFORM _delta_make_implied(v_def, v_doc_id, p_ops, v_ts);
|
|
529
|
+
IF NOT v_exists THEN
|
|
530
|
+
v_passed := jsonb_build_object(v_root_coll.table_name, v_doc_id);
|
|
531
|
+
EXECUTE format('SELECT _delta_strip_temporal(to_jsonb(t.*)) FROM %I t WHERE t.id = $1', v_root_view) INTO v_row USING v_doc_id;
|
|
532
|
+
IF v_row IS NOT NULL THEN
|
|
533
|
+
v_touched := jsonb_build_array(jsonb_build_object('coll', v_def.root_collection, 'id', v_doc_id, 'after', v_row, 'carried', TRUE,
|
|
534
|
+
'before', COALESCE((SELECT jsonb_agg(n) FROM unnest(_delta_holders(v_def.root_collection, v_row, p_doc_name)) AS n
|
|
535
|
+
WHERE COALESCE((_delta_find_doc(n)).implied, FALSE)
|
|
536
|
+
AND _delta_resolve_scope(_delta_find_doc(n), n)->>'mode' = 'single'), '[]'::jsonb)));
|
|
537
|
+
END IF;
|
|
538
|
+
END IF;
|
|
539
|
+
END IF;
|
|
540
|
+
|
|
541
|
+
-- An empty write is asked what each op is (below): a single document whose
|
|
542
|
+
-- root is not there is not found, as on SQLite -- unless implied, or a walk.
|
|
543
|
+
IF jsonb_array_length(p_ops) = 0 AND NOT v_is_list AND NOT COALESCE(p_walk, FALSE) AND NOT COALESCE(v_def.implied, FALSE) THEN
|
|
544
|
+
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I t WHERE t.id = $1 AND %s)', v_root_view, v_scope->>'where') INTO v_exists USING v_doc_id;
|
|
545
|
+
IF NOT v_exists THEN
|
|
546
|
+
RAISE EXCEPTION 'document not found: % (its root, %, is not there)',
|
|
547
|
+
p_doc_name, _delta_build_path(v_def.root_collection, v_doc_id::text)
|
|
548
|
+
USING ERRCODE = 'P0002';
|
|
549
|
+
END IF;
|
|
550
|
+
END IF;
|
|
551
|
+
|
|
552
|
+
FOR v_op IN SELECT jsonb_array_elements(p_ops)
|
|
553
|
+
LOOP
|
|
554
|
+
-- A single document is its root row and what hangs from it. One whose
|
|
555
|
+
-- root is not there -- taken out, through it or another, or never there --
|
|
556
|
+
-- opens as not found, and takes no writes (todo #59): asked before each
|
|
557
|
+
-- op, so an add under a root that is gone makes no orphan. But a write
|
|
558
|
+
-- through a document that opened (its root there as the write began, the
|
|
559
|
+
-- first op's answer; an implied one always opens, todo #34) may add that
|
|
560
|
+
-- root back after taking it out, and
|
|
561
|
+
-- write on through it: a remove and an add of the root in one write is
|
|
562
|
+
-- one row, walked as one (_delta_walk_key). A walk is not asked: an undo
|
|
563
|
+
-- of the root's removal puts it back (todo #43), and a walk of such a
|
|
564
|
+
-- document starts from the root absent, guarded by its plan
|
|
565
|
+
-- (_delta_walk_plan), as SQLite's and ./json's are.
|
|
566
|
+
IF NOT v_is_list AND NOT COALESCE(p_walk, FALSE) THEN -- a walk not said (null) is none
|
|
567
|
+
-- there, and meeting the whole of its name's scope (a column beside its id), as delta_open reads it
|
|
568
|
+
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I t WHERE t.id = $1 AND %s)', v_root_view, v_scope->>'where') INTO v_exists USING v_doc_id;
|
|
569
|
+
v_opened := COALESCE(v_opened, v_exists OR COALESCE(v_def.implied, FALSE));
|
|
570
|
+
v_back := FALSE;
|
|
571
|
+
IF NOT v_exists AND v_opened AND v_op->>'op' = 'add' THEN
|
|
572
|
+
v_parts := _delta_split_path(v_op->>'path');
|
|
573
|
+
v_back := array_length(v_parts, 1) = 2 AND v_parts[1] = v_def.root_collection
|
|
574
|
+
AND v_parts[2] ~ '^[0-9]+$' AND v_parts[2]::numeric = v_doc_id;
|
|
575
|
+
END IF;
|
|
576
|
+
IF NOT v_exists AND NOT v_back THEN
|
|
577
|
+
RAISE EXCEPTION 'document not found: % (its root, %, is not there)',
|
|
578
|
+
p_doc_name, _delta_build_path(v_def.root_collection, v_doc_id::text)
|
|
579
|
+
USING ERRCODE = 'P0002';
|
|
580
|
+
END IF;
|
|
581
|
+
END IF;
|
|
582
|
+
|
|
583
|
+
v_parts := _delta_split_path(v_op->>'path');
|
|
584
|
+
v_coll_key := v_parts[1];
|
|
585
|
+
|
|
586
|
+
SELECT * INTO v_coll FROM _delta_collections
|
|
587
|
+
WHERE collection_key = v_coll_key;
|
|
588
|
+
IF NOT FOUND THEN
|
|
589
|
+
RAISE EXCEPTION 'unknown collection: %', v_coll_key USING ERRCODE = '22023';
|
|
590
|
+
END IF;
|
|
591
|
+
-- a walk puts back what the ledger recorded, whole
|
|
592
|
+
-- (the columns this process declares, _delta_cols; a walk's, every column the row was stored with too)
|
|
593
|
+
v_cols := CASE WHEN COALESCE(p_walk, FALSE)
|
|
594
|
+
THEN COALESCE((SELECT jsonb_object_agg(c, '{"nullable": true}'::jsonb) FROM unnest(_delta_stored_columns(v_coll.table_name)) AS c
|
|
595
|
+
WHERE c NOT IN ('id', 'valid_from', 'valid_to') AND c IS DISTINCT FROM v_coll.parent_fk), '{}'::jsonb)
|
|
596
|
+
|| _delta_cols(v_coll_key, v_coll.columns_def)
|
|
597
|
+
ELSE _delta_held_columns(v_def, v_coll_key, _delta_cols(v_coll_key, v_coll.columns_def)) END;
|
|
598
|
+
|
|
599
|
+
-- Scope guard: an op may only target the doc's root or an included
|
|
600
|
+
-- collection. Defence-in-depth — a client that opened doc A must not write
|
|
601
|
+
-- to an unrelated collection through it. (The TS validateOps enforces the
|
|
602
|
+
-- same pre-flight, but this guards every path into delta_apply, including
|
|
603
|
+
-- SQL-side composition via delta_apply_as.)
|
|
604
|
+
-- A walk may put back, or take again, a row outside the document that its
|
|
605
|
+
-- entry's cascade reached (the schema's, not the include's): its plan
|
|
606
|
+
-- guards it against the tables (_delta_walk_plan).
|
|
607
|
+
v_outside := v_coll_key IS DISTINCT FROM v_def.root_collection
|
|
608
|
+
AND NOT (v_coll_key = ANY(COALESCE(v_def.include, ARRAY[]::text[])));
|
|
609
|
+
IF v_outside AND NOT (COALESCE(p_walk, FALSE) AND array_length(v_parts, 1) = 2 AND v_op->>'op' IN ('add', 'remove')) THEN
|
|
610
|
+
RAISE EXCEPTION
|
|
611
|
+
'op collection "%" is not part of doc "%" (root: %, include: %)',
|
|
612
|
+
v_coll_key, v_def.prefix, v_def.root_collection, v_def.include
|
|
613
|
+
USING ERRCODE = '22023';
|
|
614
|
+
END IF;
|
|
615
|
+
|
|
616
|
+
v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
|
|
617
|
+
|
|
618
|
+
-- ---------------------------------------------------------------
|
|
619
|
+
-- Root replace: replace /<root> (partial row merge)
|
|
620
|
+
-- replace /<root>/field (single field shorthand)
|
|
621
|
+
-- Single-row docs only.
|
|
622
|
+
-- ---------------------------------------------------------------
|
|
623
|
+
IF NOT v_is_list AND v_coll_key = v_def.root_collection
|
|
624
|
+
AND array_length(v_parts, 1) <= 2 AND v_op->>'op' = 'replace'
|
|
625
|
+
AND (array_length(v_parts, 1) = 1
|
|
626
|
+
OR (array_length(v_parts, 1) = 2 AND v_parts[2] ~ '^\d+$' IS FALSE)) THEN
|
|
627
|
+
|
|
628
|
+
-- 2-segment /root/field: wrap into partial row. A row's id is not a
|
|
629
|
+
-- field: its path (here, the document's name) says it.
|
|
630
|
+
IF array_length(v_parts, 1) = 2 THEN
|
|
631
|
+
IF v_parts[2] = 'id' THEN
|
|
632
|
+
RAISE EXCEPTION 'Unknown field: id (a row keeps the id its path names)' USING ERRCODE = '22023';
|
|
633
|
+
END IF;
|
|
634
|
+
v_op := jsonb_set(v_op, '{value}', jsonb_build_object(v_parts[2], v_op->'value'));
|
|
635
|
+
END IF;
|
|
636
|
+
PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
|
|
637
|
+
v_blank := CASE WHEN array_length(v_parts, 1) = 1 THEN _delta_whole(v_cols, v_op->'value') ELSE '{}'::jsonb END;
|
|
638
|
+
|
|
639
|
+
-- Read + lock
|
|
640
|
+
IF v_coll.temporal THEN
|
|
641
|
+
EXECUTE format(
|
|
642
|
+
'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 AND valid_to IS NULL FOR UPDATE',
|
|
643
|
+
v_coll.table_name
|
|
644
|
+
) INTO v_row USING v_doc_id;
|
|
645
|
+
ELSE
|
|
646
|
+
EXECUTE format(
|
|
647
|
+
'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 FOR UPDATE',
|
|
648
|
+
v_coll.table_name
|
|
649
|
+
) INTO v_row USING v_doc_id;
|
|
650
|
+
END IF;
|
|
651
|
+
|
|
652
|
+
IF v_row IS NULL THEN
|
|
653
|
+
RAISE EXCEPTION 'root row not found: %', v_def.root_collection USING ERRCODE = 'P0002';
|
|
654
|
+
END IF;
|
|
655
|
+
PERFORM _delta_assert_parent_held(v_def, p_doc_name, v_coll.parent_fk, v_coll.parent_collection, v_row, v_op->'value');
|
|
656
|
+
v_before := _delta_holders(v_coll_key, _delta_strip_temporal(v_row), p_doc_name);
|
|
657
|
+
|
|
658
|
+
-- `/<root>` is the row whole (_delta_whole); `/<root>/<field>` that field.
|
|
659
|
+
-- The row keeps its id: one in the value is the document's, as an add's
|
|
660
|
+
-- is the path's (it renumbered the row).
|
|
661
|
+
v_new_row := v_row || v_blank || (v_op->'value') || jsonb_build_object('id', v_doc_id);
|
|
662
|
+
|
|
663
|
+
IF v_coll.temporal THEN
|
|
664
|
+
-- One version per write: a version this write made (its valid_from is the
|
|
665
|
+
-- write's NOW()) gives way to the next, rather than closing at the same
|
|
666
|
+
-- instant and colliding with it on (id, valid_from).
|
|
667
|
+
EXECUTE format(
|
|
668
|
+
'DELETE FROM %I WHERE id = $1 AND valid_to IS NULL AND valid_from = $2',
|
|
669
|
+
v_coll.table_name
|
|
670
|
+
) USING v_doc_id, v_ts;
|
|
671
|
+
EXECUTE format(
|
|
672
|
+
'UPDATE %I SET valid_to = $2 WHERE id = $1 AND valid_to IS NULL',
|
|
673
|
+
v_coll.table_name
|
|
674
|
+
) USING v_doc_id, v_ts;
|
|
675
|
+
|
|
676
|
+
v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
|
|
677
|
+
|
|
678
|
+
EXECUTE format(
|
|
679
|
+
'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
|
|
680
|
+
v_coll.table_name, v_coll.table_name
|
|
681
|
+
) INTO v_new_row USING v_new_row;
|
|
682
|
+
|
|
683
|
+
v_new_row := _delta_strip_temporal(v_new_row);
|
|
684
|
+
ELSE
|
|
685
|
+
EXECUTE format('DELETE FROM %I WHERE id = $1', v_coll.table_name) USING v_doc_id;
|
|
686
|
+
EXECUTE format(
|
|
687
|
+
'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
|
|
688
|
+
v_coll.table_name, v_coll.table_name
|
|
689
|
+
) INTO v_new_row USING v_new_row;
|
|
690
|
+
END IF;
|
|
691
|
+
|
|
692
|
+
-- The root written through its document stays in its scope, as a list's
|
|
693
|
+
-- rows do (below): one written out of the rest of its name's scope -- a
|
|
694
|
+
-- seat of fo-seat-of:1:3 to table 7 -- is refused, and the write undone
|
|
695
|
+
-- (0.10.0 review).
|
|
696
|
+
IF NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_doc_id) THEN
|
|
697
|
+
RAISE EXCEPTION 'row not found: %/% -- the write would take it out of %',
|
|
698
|
+
v_coll_key, v_doc_id, p_doc_name
|
|
699
|
+
USING ERRCODE = 'P0002';
|
|
700
|
+
END IF;
|
|
701
|
+
|
|
702
|
+
-- A replace straight after a replace of the same row is one run: answered,
|
|
703
|
+
-- logged and told once, as the run leaves it (who held it before, the
|
|
704
|
+
-- run's first). SQLite's applyOps keeps the same rule.
|
|
705
|
+
v_told_path := _delta_build_path(v_def.root_collection);
|
|
706
|
+
IF v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path THEN
|
|
707
|
+
v_broadcast_ops := jsonb_set(v_broadcast_ops, '{-1,value}', v_new_row);
|
|
708
|
+
v_touched := jsonb_set(v_touched, ARRAY[v_run_at::text, 'after'], v_new_row);
|
|
709
|
+
ELSE
|
|
710
|
+
v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
|
|
711
|
+
jsonb_build_object('op', 'replace', 'path', v_told_path, 'value', v_new_row)
|
|
712
|
+
);
|
|
713
|
+
v_touched := v_touched || jsonb_build_array(jsonb_build_object(
|
|
714
|
+
'coll', v_coll_key, 'id', v_doc_id, 'before', to_jsonb(v_before), 'after', v_new_row, 'was', CASE WHEN v_coll.temporal THEN _delta_strip_temporal(v_row) ELSE v_row END));
|
|
715
|
+
v_run_at := jsonb_array_length(v_touched) - 1;
|
|
716
|
+
v_run_carried := FALSE;
|
|
717
|
+
END IF;
|
|
718
|
+
CONTINUE;
|
|
719
|
+
END IF;
|
|
720
|
+
|
|
721
|
+
-- ---------------------------------------------------------------
|
|
722
|
+
-- Add row: add /<collection>/<id>
|
|
723
|
+
-- ---------------------------------------------------------------
|
|
724
|
+
IF array_length(v_parts, 1) = 2 AND v_op->>'op' = 'add' THEN
|
|
725
|
+
-- ... and it holds one root row, the one its name names: it adds no
|
|
726
|
+
-- other (todo #52). A row it could not read would be told to no one.
|
|
727
|
+
-- Its own it adds only when it is gone (above: taken out earlier in this
|
|
728
|
+
-- write, or by a walk, an undo of its removal); there, the live check
|
|
729
|
+
-- below answers 409.
|
|
730
|
+
IF NOT v_is_list AND v_coll_key = v_def.root_collection
|
|
731
|
+
AND (v_parts[2] !~ '^[0-9]+$' OR v_parts[2]::numeric <> v_doc_id) THEN
|
|
732
|
+
RAISE EXCEPTION 'invalid op: add % -- document % holds one %, its root: add it through a document that lists them',
|
|
733
|
+
v_op->>'path', p_doc_name, v_def.root_collection
|
|
734
|
+
USING ERRCODE = '22023';
|
|
735
|
+
END IF;
|
|
736
|
+
PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
|
|
737
|
+
v_id_text := v_parts[2];
|
|
738
|
+
-- Auto-generate ID from sequence if path ends with '-'
|
|
739
|
+
IF v_id_text = '-' THEN
|
|
740
|
+
EXECUTE format('SELECT nextval(%L)', 'seq_' || v_coll.table_name) INTO v_id;
|
|
741
|
+
ELSE
|
|
742
|
+
v_id := _delta_row_id(v_coll_key, v_id_text);
|
|
743
|
+
END IF;
|
|
744
|
+
-- An add makes a new row, under an id the serial has not handed out
|
|
745
|
+
-- (todo #71): one it has (minted, or passed for an add that named it) is
|
|
746
|
+
-- refused, whether a row holds it now or none does -- or RLS hides it --
|
|
747
|
+
-- so the answer says nothing of rows the writer may not read. But for
|
|
748
|
+
-- ids the writer has read: a walk puts back the rows its entry took, and
|
|
749
|
+
-- a write may add back a row it has itself written (a single document's
|
|
750
|
+
-- root taken out and put back). A single document's own root is no
|
|
751
|
+
-- other: whether RLS hides it, its name does not say. SQLite's applyOps
|
|
752
|
+
-- keeps the same rule.
|
|
753
|
+
--
|
|
754
|
+
-- A temporal key is (id, valid_from), so nothing in the table stops a
|
|
755
|
+
-- second live version of an id (todo #16): a mint can meet a row put in
|
|
756
|
+
-- by hand, and two writers through two documents can name one id at
|
|
757
|
+
-- once. A lock on the id, held to commit, and the checks under it do: a
|
|
758
|
+
-- second add waits for the first, then sees the serial past it (and its
|
|
759
|
+
-- row: each statement reads what is committed) and is refused (SQLSTATE
|
|
760
|
+
-- 23505, 409 on the wire), as a plain table's key refuses it.
|
|
761
|
+
IF v_coll.temporal THEN
|
|
762
|
+
PERFORM pg_advisory_xact_lock(hashtext('delta-row:' || v_coll.table_name), hashtext(v_id::text));
|
|
763
|
+
END IF;
|
|
764
|
+
v_exists := FALSE;
|
|
765
|
+
IF v_id_text <> '-' AND NOT COALESCE(p_walk, FALSE)
|
|
766
|
+
AND NOT v_touched @> jsonb_build_array(jsonb_build_object('coll', v_coll_key, 'id', v_id)) THEN
|
|
767
|
+
v_exists := NOT _delta_serial_new(v_coll.table_name, v_id);
|
|
768
|
+
END IF;
|
|
769
|
+
IF NOT v_exists AND (v_id_text <> '-' OR v_coll.temporal) THEN
|
|
770
|
+
EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I WHERE id = $1)', v_view) INTO v_exists USING v_id;
|
|
771
|
+
END IF;
|
|
772
|
+
IF v_exists THEN
|
|
773
|
+
RAISE EXCEPTION 'id already taken: % -- add to % for a new id',
|
|
774
|
+
_delta_build_path(v_coll_key, v_id::text), _delta_build_path(v_coll_key, '-')
|
|
775
|
+
USING ERRCODE = '23505';
|
|
776
|
+
END IF;
|
|
777
|
+
-- The path names the row, whatever the value says.
|
|
778
|
+
v_new_row := (v_op->'value') || jsonb_build_object('id', v_id);
|
|
779
|
+
|
|
780
|
+
-- Set FK: for list-mode root adds, apply scope equality values, each as
|
|
781
|
+
-- the scope's condition reads it (_delta_scope_row);
|
|
782
|
+
-- for child collections in single-mode, set FK to root id. In list-mode
|
|
783
|
+
-- child adds (e.g. a product-catalog doc adding a `parts` row) there is
|
|
784
|
+
-- no doc-id to inject, so we trust the parent_fk supplied in the op value.
|
|
785
|
+
IF v_is_list AND v_coll_key = v_def.root_collection THEN
|
|
786
|
+
v_new_row := v_new_row || _delta_scope_row(_delta_cols(v_coll_key, v_coll.columns_def), v_scope->'values');
|
|
787
|
+
ELSIF v_coll.parent_collection = v_def.root_collection AND v_coll.parent_fk IS NOT NULL
|
|
788
|
+
AND v_doc_id IS NOT NULL THEN
|
|
789
|
+
v_new_row := v_new_row || jsonb_build_object(v_coll.parent_fk, v_doc_id);
|
|
790
|
+
END IF;
|
|
791
|
+
|
|
792
|
+
-- A direct child's FK was just injected above, so it is in scope by
|
|
793
|
+
-- construction. A grandchild's arrives verbatim from the client — without
|
|
794
|
+
-- this check it grafts the new row onto ANOTHER doc's parent. A single
|
|
795
|
+
-- document's root row is not under its parent: its value names it. A
|
|
796
|
+
-- list's rows each name theirs, a root row's child too: under a root a
|
|
797
|
+
-- scoped list does not hold, it is refused, as SQLite's parentHeld does.
|
|
798
|
+
IF v_coll.parent_collection IS NOT NULL AND NOT v_outside
|
|
799
|
+
AND (v_is_list OR (v_coll.parent_collection IS DISTINCT FROM v_def.root_collection
|
|
800
|
+
AND v_coll_key IS DISTINCT FROM v_def.root_collection))
|
|
801
|
+
AND NOT _delta_row_in_scope(
|
|
802
|
+
v_def, p_doc_name, v_coll.parent_collection,
|
|
803
|
+
(v_new_row->>v_coll.parent_fk)::BIGINT) THEN
|
|
804
|
+
RAISE EXCEPTION 'row not found: %/%',
|
|
805
|
+
v_coll.parent_collection, COALESCE(v_new_row->>v_coll.parent_fk, '')
|
|
806
|
+
USING ERRCODE = 'P0002';
|
|
807
|
+
END IF;
|
|
808
|
+
-- ... and whatever the document, the parent it names must be there (a list names it only by key)
|
|
809
|
+
IF v_coll.parent_fk IS NOT NULL AND v_new_row->>v_coll.parent_fk IS NOT NULL
|
|
810
|
+
AND NOT _delta_row_there(v_coll.parent_collection, (v_new_row->>v_coll.parent_fk)::BIGINT) THEN
|
|
811
|
+
RAISE EXCEPTION 'row not found: %/%', v_coll.parent_collection, v_new_row->>v_coll.parent_fk
|
|
812
|
+
USING ERRCODE = 'P0002';
|
|
813
|
+
END IF;
|
|
814
|
+
|
|
815
|
+
-- ... and a single document's own root, added back in the write that took
|
|
816
|
+
-- it out, goes back under the parent it had: another would be a move, and
|
|
817
|
+
-- the document holds no parent of its root (0.10.0 review).
|
|
818
|
+
IF NOT v_is_list AND v_coll_key = v_def.root_collection AND v_coll.parent_fk IS NOT NULL
|
|
819
|
+
AND v_root_was IS NOT NULL
|
|
820
|
+
AND (v_new_row->>v_coll.parent_fk)::BIGINT IS DISTINCT FROM (v_root_was->>v_coll.parent_fk)::BIGINT THEN
|
|
821
|
+
RAISE EXCEPTION 'row not found: %/%',
|
|
822
|
+
v_coll.parent_collection, COALESCE(v_new_row->>v_coll.parent_fk, '')
|
|
823
|
+
USING ERRCODE = 'P0002';
|
|
824
|
+
END IF;
|
|
825
|
+
|
|
826
|
+
-- A required column (not nullable, no declared default) the value leaves
|
|
827
|
+
-- out is the writer's mistake: refuse it (SQLSTATE 23502, 400 on the
|
|
828
|
+
-- wire) rather than store '' / 0 / false for it.
|
|
829
|
+
SELECT string_agg(col_key, ', ' ORDER BY col_key) INTO v_missing
|
|
830
|
+
FROM jsonb_each(_delta_cols(v_coll_key, v_coll.columns_def)) AS x(col_key, col_def)
|
|
831
|
+
WHERE NOT v_new_row ? col_key
|
|
832
|
+
AND NOT COALESCE((col_def->>'nullable')::boolean, false)
|
|
833
|
+
AND NOT col_def ? 'default';
|
|
834
|
+
IF v_missing IS NOT NULL THEN
|
|
835
|
+
RAISE EXCEPTION 'Required field missing: % (give it a value, or declare a default or make it nullable in the schema)', v_missing
|
|
836
|
+
USING ERRCODE = '23502';
|
|
837
|
+
END IF;
|
|
838
|
+
|
|
839
|
+
-- Declared column defaults, for what the value leaves out
|
|
840
|
+
SELECT v_new_row || COALESCE(jsonb_object_agg(col_key, col_def->'default'), '{}'::jsonb)
|
|
841
|
+
INTO v_new_row
|
|
842
|
+
FROM jsonb_each(_delta_cols(v_coll_key, v_coll.columns_def)) AS x(col_key, col_def)
|
|
843
|
+
WHERE NOT v_new_row ? col_key AND col_def ? 'default';
|
|
844
|
+
|
|
845
|
+
IF v_coll.temporal THEN
|
|
846
|
+
-- One moment per write: a version this write made and took back (its
|
|
847
|
+
-- valid_from and valid_to both the write's NOW()) never was outside it,
|
|
848
|
+
-- so an add of the same id takes its place rather than colliding with it
|
|
849
|
+
-- on (id, valid_from). SQLite's insertCollectionRow keeps the same rule.
|
|
850
|
+
EXECUTE format(
|
|
851
|
+
'DELETE FROM %I WHERE id = $1 AND valid_from = $2 AND valid_to = $2',
|
|
852
|
+
v_coll.table_name
|
|
853
|
+
) USING v_id, v_ts;
|
|
854
|
+
v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
|
|
855
|
+
END IF;
|
|
856
|
+
|
|
857
|
+
-- Every write here tells the row as stored (RETURNING), not as sent: a
|
|
858
|
+
-- value its column casts (a scope's '1' into an integer, a date into a
|
|
859
|
+
-- timestamptz) is told as a later open reads it, so the broadcast, the
|
|
860
|
+
-- ledger's entry and undo's guard agree with the table.
|
|
861
|
+
EXECUTE _delta_insert_sql(v_coll.table_name, v_new_row) || ' RETURNING to_jsonb(t.*)' INTO v_new_row USING v_new_row;
|
|
862
|
+
|
|
863
|
+
-- An id the add named moves the serial past it, so a later /- does not meet it.
|
|
864
|
+
IF v_id_text <> '-' AND v_id > COALESCE((v_passed->>v_coll.table_name)::BIGINT, 0) THEN
|
|
865
|
+
v_passed := v_passed || jsonb_build_object(v_coll.table_name, v_id);
|
|
866
|
+
END IF;
|
|
867
|
+
|
|
868
|
+
-- A root row added through a document is one it holds: a list's, given
|
|
869
|
+
-- its bindings, must meet the rest of its conditions too (a name like
|
|
870
|
+
-- `So`, a price up to 5), and a single document's own root added back,
|
|
871
|
+
-- the whole of its name's scope. Else it would be made where the writer
|
|
872
|
+
-- cannot read it -- told [] itself, and an add to the lists it does meet
|
|
873
|
+
-- -- so it is refused, and the write undone (0.10.0 review, todo #66).
|
|
874
|
+
IF v_coll_key = v_def.root_collection
|
|
875
|
+
AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
|
|
876
|
+
RAISE EXCEPTION 'row not found: %/% -- the add is not one % holds',
|
|
877
|
+
v_coll_key, v_id, p_doc_name
|
|
878
|
+
USING ERRCODE = 'P0002';
|
|
879
|
+
END IF;
|
|
880
|
+
|
|
881
|
+
-- Strip temporal columns from broadcast
|
|
882
|
+
IF v_coll.temporal THEN
|
|
883
|
+
v_new_row := _delta_strip_temporal(v_new_row);
|
|
884
|
+
END IF;
|
|
885
|
+
|
|
886
|
+
v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
|
|
887
|
+
jsonb_build_object('op', 'add', 'path', _delta_build_path(v_coll_key, v_id::text), 'value', v_new_row)
|
|
888
|
+
);
|
|
889
|
+
v_touched := v_touched || jsonb_build_array(jsonb_build_object(
|
|
890
|
+
'coll', v_coll_key, 'id', v_id, 'before', '[]'::jsonb, 'after', v_new_row));
|
|
891
|
+
CONTINUE;
|
|
892
|
+
END IF;
|
|
893
|
+
|
|
894
|
+
-- ---------------------------------------------------------------
|
|
895
|
+
-- Remove row: remove /<collection>/<id> (+ cascades)
|
|
896
|
+
-- ---------------------------------------------------------------
|
|
897
|
+
IF array_length(v_parts, 1) = 2 AND v_op->>'op' = 'remove' THEN
|
|
898
|
+
v_id := _delta_row_id(v_coll_key, v_parts[2]);
|
|
899
|
+
-- _delta_cascade_remove addresses rows by id alone, so without this gate a
|
|
900
|
+
-- client could name any id and delete a sibling doc's row.
|
|
901
|
+
IF NOT v_outside AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
|
|
902
|
+
RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
|
|
903
|
+
USING ERRCODE = 'P0002';
|
|
904
|
+
END IF;
|
|
905
|
+
-- who holds the row and every row the cascade takes, asked before any is gone
|
|
906
|
+
v_befores := '{}'::jsonb;
|
|
907
|
+
FOR v_r IN SELECT jsonb_array_elements(_delta_cascade_rows(v_coll_key, v_id)) LOOP
|
|
908
|
+
v_befores := v_befores || jsonb_build_object(
|
|
909
|
+
(v_r->>'coll') || '/' || (v_r->>'id'), to_jsonb(_delta_holders(v_r->>'coll', v_r->'row', p_doc_name)));
|
|
910
|
+
IF NOT v_is_list AND v_r->>'coll' = v_def.root_collection THEN v_root_was := v_r->'row'; END IF;
|
|
911
|
+
END LOOP;
|
|
912
|
+
v_removed := _delta_cascade_remove(v_coll_key, v_id);
|
|
913
|
+
-- A remove of a row that is not there is a 404, as on SQLite and ./json
|
|
914
|
+
-- (the same tables, kept in a JSON file; A6): the scope gate above lets any id of a collection a
|
|
915
|
+
-- document holds whole (one with no parent, a list's include) through,
|
|
916
|
+
-- and the cascade removes nothing (or RLS hid the row from this identity).
|
|
917
|
+
IF jsonb_array_length(v_removed) = 0 THEN
|
|
918
|
+
RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
|
|
919
|
+
USING ERRCODE = 'P0002';
|
|
920
|
+
END IF;
|
|
921
|
+
v_broadcast_ops := v_broadcast_ops || v_removed;
|
|
922
|
+
FOR v_r IN SELECT jsonb_array_elements(v_removed) LOOP
|
|
923
|
+
v_rp := _delta_split_path(v_r->>'path');
|
|
924
|
+
v_touched := v_touched || jsonb_build_array(jsonb_build_object(
|
|
925
|
+
'coll', v_rp[1], 'id', v_rp[2]::BIGINT,
|
|
926
|
+
'before', COALESCE(v_befores->(v_rp[1] || '/' || v_rp[2]), '[]'::jsonb), 'after', NULL));
|
|
927
|
+
END LOOP;
|
|
928
|
+
CONTINUE;
|
|
929
|
+
END IF;
|
|
930
|
+
|
|
931
|
+
-- ---------------------------------------------------------------
|
|
932
|
+
-- Row replace: replace /<collection>/<id> (partial row merge)
|
|
933
|
+
-- Field replace: replace /<collection>/<id>/field (single field shorthand)
|
|
934
|
+
-- ---------------------------------------------------------------
|
|
935
|
+
IF (array_length(v_parts, 1) = 2 OR array_length(v_parts, 1) = 3) AND v_op->>'op' = 'replace' THEN
|
|
936
|
+
v_id := _delta_row_id(v_coll_key, v_parts[2]);
|
|
937
|
+
|
|
938
|
+
-- 3-segment: wrap single field into partial row value. A row's id is
|
|
939
|
+
-- not a field: its path says it. What the op says is asked before its
|
|
940
|
+
-- row (400 before 404), as for an add, and as SQLite's validateOps asks.
|
|
941
|
+
IF array_length(v_parts, 1) = 3 THEN
|
|
942
|
+
IF v_parts[3] = 'id' THEN
|
|
943
|
+
RAISE EXCEPTION 'Unknown field: id (a row keeps the id its path names)' USING ERRCODE = '22023';
|
|
944
|
+
END IF;
|
|
945
|
+
v_op := jsonb_set(v_op, '{value}', jsonb_build_object(v_parts[3], v_op->'value'));
|
|
946
|
+
END IF;
|
|
947
|
+
PERFORM _delta_assert_fields(v_coll_key, v_cols, v_coll.parent_fk, v_op->'value');
|
|
948
|
+
v_blank := CASE WHEN array_length(v_parts, 1) = 2 THEN _delta_whole(v_cols, v_op->'value') ELSE '{}'::jsonb END;
|
|
949
|
+
|
|
950
|
+
-- Same gate as remove: the row is addressed by bare id, so it must belong
|
|
951
|
+
-- to this doc. (The single-mode root branch above never reaches here — it
|
|
952
|
+
-- targets v_doc_id directly.)
|
|
953
|
+
IF NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
|
|
954
|
+
RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id
|
|
955
|
+
USING ERRCODE = 'P0002';
|
|
956
|
+
END IF;
|
|
957
|
+
|
|
958
|
+
IF v_coll.temporal THEN
|
|
959
|
+
EXECUTE format(
|
|
960
|
+
'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 AND valid_to IS NULL FOR UPDATE',
|
|
961
|
+
v_coll.table_name
|
|
962
|
+
) INTO v_row USING v_id;
|
|
963
|
+
ELSE
|
|
964
|
+
EXECUTE format(
|
|
965
|
+
'SELECT to_jsonb(t.*) FROM %I t WHERE t.id = $1 FOR UPDATE',
|
|
966
|
+
v_coll.table_name
|
|
967
|
+
) INTO v_row USING v_id;
|
|
968
|
+
END IF;
|
|
969
|
+
|
|
970
|
+
IF v_row IS NULL THEN
|
|
971
|
+
RAISE EXCEPTION 'row not found: %/%', v_coll_key, v_id USING ERRCODE = 'P0002';
|
|
972
|
+
END IF;
|
|
973
|
+
PERFORM _delta_assert_parent_held(v_def, p_doc_name, v_coll.parent_fk, v_coll.parent_collection, v_row, v_op->'value');
|
|
974
|
+
v_before := _delta_holders(v_coll_key, _delta_strip_temporal(v_row), p_doc_name);
|
|
975
|
+
|
|
976
|
+
-- For single-item docs updating the root entity, broadcast as /collection
|
|
977
|
+
-- so the client replaces the direct object (not a Record entry)
|
|
978
|
+
IF NOT v_is_list AND v_coll_key = v_def.root_collection AND v_id = v_doc_id THEN
|
|
979
|
+
v_told_path := _delta_build_path(v_coll_key);
|
|
980
|
+
ELSE
|
|
981
|
+
v_told_path := _delta_build_path(v_coll_key, v_id::text);
|
|
982
|
+
END IF;
|
|
983
|
+
|
|
984
|
+
-- A row moved to another parent takes the rows under it along (todo #62):
|
|
985
|
+
-- who holds each is asked before it moves, once a run, and each is told
|
|
986
|
+
-- after the row, where it arrives or leaves (endRun in sqlite.ts).
|
|
987
|
+
v_hung := '[]'::jsonb;
|
|
988
|
+
IF NOT (v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path) THEN
|
|
989
|
+
v_run_carried := FALSE;
|
|
990
|
+
END IF;
|
|
991
|
+
IF NOT v_run_carried AND (v_op->'value') ? v_coll.parent_fk
|
|
992
|
+
AND (v_op->'value'->>v_coll.parent_fk) IS DISTINCT FROM (v_row->>v_coll.parent_fk) THEN
|
|
993
|
+
SELECT COALESCE(jsonb_agg(jsonb_build_object('coll', r->'coll', 'id', r->'id', 'after', r->'row', 'carried', TRUE,
|
|
994
|
+
'before', to_jsonb(_delta_holders(r->>'coll', r->'row', p_doc_name))) ORDER BY n), '[]'::jsonb)
|
|
995
|
+
INTO v_hung FROM jsonb_array_elements(_delta_hanging_rows(v_coll_key, v_id)) WITH ORDINALITY AS e(r, n);
|
|
996
|
+
END IF;
|
|
997
|
+
|
|
998
|
+
-- `/<coll>/<id>` is the row whole (_delta_whole); `/<coll>/<id>/<field>`
|
|
999
|
+
-- that field. The row keeps its id: one in the value is the path's, as
|
|
1000
|
+
-- an add's is (it renumbered the row).
|
|
1001
|
+
v_new_row := v_row || v_blank || (v_op->'value') || jsonb_build_object('id', v_id);
|
|
1002
|
+
|
|
1003
|
+
IF v_coll.temporal THEN
|
|
1004
|
+
-- One version per write: a version this write made (its valid_from is the
|
|
1005
|
+
-- write's NOW()) gives way to the next, rather than closing at the same
|
|
1006
|
+
-- instant and colliding with it on (id, valid_from).
|
|
1007
|
+
EXECUTE format(
|
|
1008
|
+
'DELETE FROM %I WHERE id = $1 AND valid_to IS NULL AND valid_from = $2',
|
|
1009
|
+
v_coll.table_name
|
|
1010
|
+
) USING v_id, v_ts;
|
|
1011
|
+
EXECUTE format(
|
|
1012
|
+
'UPDATE %I SET valid_to = $2 WHERE id = $1 AND valid_to IS NULL',
|
|
1013
|
+
v_coll.table_name
|
|
1014
|
+
) USING v_id, v_ts;
|
|
1015
|
+
|
|
1016
|
+
v_new_row := v_new_row || jsonb_build_object('valid_from', v_ts, 'valid_to', NULL);
|
|
1017
|
+
|
|
1018
|
+
EXECUTE format(
|
|
1019
|
+
'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
|
|
1020
|
+
v_coll.table_name, v_coll.table_name
|
|
1021
|
+
) INTO v_new_row USING v_new_row;
|
|
1022
|
+
ELSE
|
|
1023
|
+
-- Non-temporal: UPDATE with merged row
|
|
1024
|
+
EXECUTE format(
|
|
1025
|
+
'DELETE FROM %I WHERE id = $1', v_coll.table_name
|
|
1026
|
+
) USING v_id;
|
|
1027
|
+
EXECUTE format(
|
|
1028
|
+
'INSERT INTO %I AS t SELECT * FROM jsonb_populate_record(null::%I, $1) RETURNING to_jsonb(t.*)',
|
|
1029
|
+
v_coll.table_name, v_coll.table_name
|
|
1030
|
+
) INTO v_new_row USING v_new_row;
|
|
1031
|
+
END IF;
|
|
1032
|
+
|
|
1033
|
+
-- A list's root row written through the list stays in its scope, as an
|
|
1034
|
+
-- add through it is given its bindings: a field, a row or a merge that
|
|
1035
|
+
-- takes it out -- into another owner's list by its key, or out of a
|
|
1036
|
+
-- condition -- is refused, and the write undone (todo #6's review).
|
|
1037
|
+
-- Asked of the row as written, RLS or none; and of a single document's
|
|
1038
|
+
-- root named by its id, as above (0.10.0 review).
|
|
1039
|
+
IF v_coll_key = v_def.root_collection
|
|
1040
|
+
AND NOT _delta_row_in_scope(v_def, p_doc_name, v_coll_key, v_id) THEN
|
|
1041
|
+
RAISE EXCEPTION 'row not found: %/% -- the write would take it out of %',
|
|
1042
|
+
v_coll_key, v_id, p_doc_name
|
|
1043
|
+
USING ERRCODE = 'P0002';
|
|
1044
|
+
END IF;
|
|
1045
|
+
|
|
1046
|
+
-- Strip temporal from broadcast
|
|
1047
|
+
IF v_coll.temporal THEN
|
|
1048
|
+
v_new_row := _delta_strip_temporal(v_new_row);
|
|
1049
|
+
END IF;
|
|
1050
|
+
|
|
1051
|
+
-- a replace straight after a replace of the same row: one run, told once (as the root's, above)
|
|
1052
|
+
IF v_broadcast_ops->-1->>'op' = 'replace' AND v_broadcast_ops->-1->>'path' = v_told_path THEN
|
|
1053
|
+
v_broadcast_ops := jsonb_set(v_broadcast_ops, '{-1,value}', v_new_row);
|
|
1054
|
+
v_touched := jsonb_set(v_touched, ARRAY[v_run_at::text, 'after'], v_new_row);
|
|
1055
|
+
ELSE
|
|
1056
|
+
v_broadcast_ops := v_broadcast_ops || jsonb_build_array(
|
|
1057
|
+
jsonb_build_object('op', 'replace', 'path', v_told_path, 'value', v_new_row)
|
|
1058
|
+
);
|
|
1059
|
+
v_touched := v_touched || jsonb_build_array(jsonb_build_object(
|
|
1060
|
+
'coll', v_coll_key, 'id', v_id, 'before', to_jsonb(v_before), 'after', v_new_row, 'was', CASE WHEN v_coll.temporal THEN _delta_strip_temporal(v_row) ELSE v_row END));
|
|
1061
|
+
v_run_at := jsonb_array_length(v_touched) - 1;
|
|
1062
|
+
END IF;
|
|
1063
|
+
v_run_carried := v_run_carried OR jsonb_array_length(v_hung) > 0;
|
|
1064
|
+
v_touched := v_touched || v_hung;
|
|
1065
|
+
CONTINUE;
|
|
1066
|
+
END IF;
|
|
1067
|
+
|
|
1068
|
+
RAISE EXCEPTION 'invalid op: % %', v_op->>'op', v_op->>'path' USING ERRCODE = '22023';
|
|
1069
|
+
END LOOP;
|
|
1070
|
+
|
|
1071
|
+
-- Told: the writer's document and every other that holds a row changed,
|
|
1072
|
+
-- each what changed for it. The answer (and the ledger) keep the ops as
|
|
1073
|
+
-- written, so an undo walks back exactly what was done -- and so does the
|
|
1074
|
+
-- writer's entry in the log, once, for the custom documents (todo #44).
|
|
1075
|
+
v_version := _delta_tell(p_doc_name, v_touched, v_broadcast_ops);
|
|
1076
|
+
|
|
1077
|
+
-- The ids this write named move their serials past them, once it is done: a
|
|
1078
|
+
-- serial does not roll back, so a write refused part way burns none (only a
|
|
1079
|
+
-- failure at commit can).
|
|
1080
|
+
FOR v_r IN SELECT jsonb_build_object('t', key, 'id', value) FROM jsonb_each(v_passed) LOOP
|
|
1081
|
+
PERFORM _delta_serial_past(v_r->>'t', (v_r->>'id')::BIGINT);
|
|
1082
|
+
END LOOP;
|
|
1083
|
+
RETURN jsonb_build_object('version', v_version, 'ops', v_broadcast_ops);
|
|
1084
|
+
END;
|
|
1085
|
+
$$ LANGUAGE plpgsql;
|
|
1086
|
+
|
|
1087
|
+
CREATE OR REPLACE FUNCTION delta_apply(p_doc_name TEXT, p_ops JSONB)
|
|
1088
|
+
RETURNS JSONB LANGUAGE sql AS $$ SELECT delta_apply(p_doc_name, p_ops, FALSE); $$;
|