dzql 0.10.0 → 0.11.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.
@@ -6,16 +6,11 @@
6
6
  -- _delta_find_doc: match a doc name to its definition
7
7
  -- ---------------------------------------------------------------------------
8
8
 
9
+ -- The document a name is of: of the longest prefix it starts with (exactly, not
10
+ -- LIKE: a prefix with `_` or `%` in it means those). generateSql writes each
11
+ -- document in (compileLookups); before it is applied there is none.
9
12
  CREATE OR REPLACE FUNCTION _delta_find_doc(p_doc_name TEXT)
10
- RETURNS _delta_docs AS $$
11
- -- Exact prefix match (NOT LIKE): a prefix containing `_` or `%` would
12
- -- otherwise be treated as a LIKE wildcard and over-match. Mirrors the
13
- -- TypeScript resolver's startsWith.
14
- SELECT * FROM _delta_docs
15
- WHERE left(p_doc_name, length(prefix)) = prefix
16
- ORDER BY length(prefix) DESC
17
- LIMIT 1;
18
- $$ LANGUAGE sql STABLE;
13
+ RETURNS _delta_docs LANGUAGE sql STABLE AS $$ SELECT NULL::_delta_docs; $$;
19
14
 
20
15
  -- ---------------------------------------------------------------------------
21
16
  -- _delta_resolve_scope: turn a doc name + def into WHERE clause + metadata
@@ -51,245 +46,10 @@ $$ LANGUAGE sql STABLE;
51
46
  -- how the fan-out reads a name opened before a rule refused it (_delta_doc_holds).
52
47
  -- ---------------------------------------------------------------------------
53
48
 
54
- CREATE OR REPLACE FUNCTION _delta_resolve_scope_framework(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
55
- RETURNS JSONB AS $$
56
- DECLARE
57
- v_doc_id TEXT;
58
- v_scope JSONB;
59
- v_parts TEXT[];
60
- v_key TEXT;
61
- v_val TEXT;
62
- v_op TEXT;
63
- v_param TEXT;
64
- v_resolved TEXT;
65
- v_where TEXT := '';
66
- v_values JSONB := '{}'::jsonb;
67
- v_at TEXT := NULL;
68
- v_mode TEXT;
69
- v_param_map JSONB := '{}'::jsonb;
70
- v_root_coll RECORD;
71
- v_type TEXT;
72
- v_refused TEXT;
73
- v_cond TEXT;
74
- v_date TEXT[];
75
- BEGIN
76
- v_doc_id := substring(p_doc_name FROM length(p_def.prefix) + 1);
77
- v_scope := p_def.scope;
78
-
79
- -- Fail-fast: every scope key must be a real column of the root collection.
80
- -- Catches typos and the "dotted key" footgun (e.g. "kanban_boards.id")
81
- -- before they silently produce an always-empty WHERE.
82
- IF v_scope IS NOT NULL AND v_scope != '{}'::jsonb THEN
83
- DECLARE
84
- v_bad_key TEXT;
85
- v_valid_keys_txt TEXT;
86
- BEGIN
87
- SELECT * INTO v_root_coll FROM _delta_collections
88
- WHERE collection_key = p_def.root_collection;
89
- IF NOT FOUND THEN
90
- RAISE EXCEPTION 'doc "%" references unknown root collection: %',
91
- p_def.prefix, p_def.root_collection
92
- USING ERRCODE = 'P0001';
93
- END IF;
94
-
95
- -- "id" is implicit; parent_fk is implicit if set. `at:`-typed values
96
- -- feed the temporal snapshot marker and don't go through the WHERE,
97
- -- so their keys (typically `valid_from`) don't need to be real columns.
98
- SELECT k INTO v_bad_key
99
- FROM jsonb_each_text(v_scope) AS x(k, val)
100
- WHERE k != 'id'
101
- AND k IS DISTINCT FROM v_root_coll.parent_fk
102
- AND NOT (_delta_cols(v_root_coll.collection_key, v_root_coll.columns_def) ? k)
103
- AND val !~ '^at:'
104
- LIMIT 1;
105
-
106
- IF v_bad_key IS NOT NULL THEN
107
- SELECT 'id'
108
- || CASE WHEN v_root_coll.parent_fk IS NOT NULL
109
- THEN ', ' || v_root_coll.parent_fk
110
- ELSE '' END
111
- || COALESCE(
112
- (SELECT ', ' || string_agg(kk, ', ')
113
- FROM jsonb_object_keys(_delta_cols(v_root_coll.collection_key, v_root_coll.columns_def)) kk),
114
- '')
115
- INTO v_valid_keys_txt;
116
- RAISE EXCEPTION
117
- 'scope key "%" is not a column of "%" (valid keys: %)',
118
- v_bad_key, v_root_coll.collection_key, v_valid_keys_txt
119
- USING ERRCODE = 'P0001';
120
- END IF;
121
- END;
122
- END IF;
123
-
124
- -- Empty scope: default behaviour
125
- IF v_scope = '{}'::jsonb OR v_scope IS NULL THEN
126
- IF v_doc_id = '' THEN
127
- RETURN jsonb_build_object(
128
- 'where', 'TRUE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL
129
- );
130
- ELSE
131
- RETURN jsonb_build_object(
132
- 'where', format('id = %L', v_doc_id),
133
- 'values', jsonb_build_object('id', v_doc_id::BIGINT),
134
- 'mode', 'single',
135
- 'at', NULL
136
- );
137
- END IF;
138
- END IF;
139
-
140
- -- Build positional param map from colon-separated doc ID parts.
141
- -- Params named "id" always get position 1. Remaining params are assigned
142
- -- in alphabetical order to subsequent positions.
143
- v_parts := string_to_array(v_doc_id, ':');
144
-
145
- SELECT jsonb_object_agg(p, to_jsonb(COALESCE(v_parts[pos], '')))
146
- INTO v_param_map
147
- FROM (
148
- SELECT DISTINCT param AS p,
149
- row_number() OVER (ORDER BY (param != 'id'), param) AS pos
150
- FROM (
151
- SELECT CASE
152
- WHEN val ~ '^(at|like):' THEN substring(val FROM '[a-z]+:(.+)$')
153
- WHEN val ~ '^[<>=!]+:' THEN substring(val FROM '[<>=!]+:(.+)$')
154
- ELSE ltrim(val, ':')
155
- END AS param
156
- FROM jsonb_each_text(v_scope) AS x(key, val)
157
- ) params
158
- ) ordered;
159
-
160
- -- Second pass: process each scope entry using the resolved param map
161
- FOR v_key, v_val IN SELECT * FROM jsonb_each_text(v_scope)
162
- LOOP
163
- -- Parse operator and param name
164
- IF v_val ~ '^(at):' THEN
165
- v_op := 'at';
166
- v_param := substring(v_val FROM 'at:(.+)$');
167
- ELSIF v_val ~ '^(like):' THEN
168
- v_op := 'like';
169
- v_param := substring(v_val FROM 'like:(.+)$');
170
- ELSIF v_val ~ '^[<>=!]+:' THEN
171
- v_op := substring(v_val FROM '^([<>=!]+):');
172
- v_param := substring(v_val FROM '[<>=!]+:(.+)$');
173
- -- Defence in depth: the regex above only matches `<`, `>`, `=`, `!`
174
- -- characters, and the scope value is app-controlled (from _delta_docs),
175
- -- but we still whitelist the final operator so any future exposure of
176
- -- the scope DSL to user input can't smuggle arbitrary SQL into
177
- -- `format('%I %s %L', ...)`.
178
- IF v_op NOT IN ('=', '>=', '<=', '>', '<', '!=') THEN
179
- RAISE EXCEPTION
180
- 'invalid scope operator "%" (valid: =, >=, <=, >, <, !=, like, at)',
181
- v_op USING ERRCODE = 'P0001';
182
- END IF;
183
- ELSE
184
- v_op := '=';
185
- v_param := ltrim(v_val, ':');
186
- END IF;
187
-
188
- v_resolved := v_param_map ->> v_param;
189
-
190
- -- Skip empty resolved values (no filter)
191
- IF v_resolved IS NULL OR v_resolved = '' THEN CONTINUE; END IF;
192
-
193
- -- The value as its column takes it: one its column cannot take is refused.
194
- -- A time in the name (at:) is a date, as a time column's condition is.
195
- v_cond := NULL;
196
- IF v_op <> 'like' THEN
197
- v_type := CASE WHEN v_op = 'at' THEN 'timestamptz' ELSE _delta_cols(v_root_coll.collection_key, v_root_coll.columns_def)->v_key->>'type' END;
198
- IF v_op <> 'at' AND (v_key = 'id' OR v_key = v_root_coll.parent_fk) THEN
199
- -- An id, as SQLite reads one: a whole number, signed, up to 2^53 - 1;
200
- -- one not whole is none (1.5, 1e0); other text is an id SQLite keeps as
201
- -- text (a session's token). No row here has one -- every id is a number --
202
- -- so it compares as SQLite orders it, after every number: = > >= read no
203
- -- row, != < <= every row. (Single mode's id is the root's: still a 400.)
204
- IF v_resolved ~ '^\s*[+-]?[0-9]+\s*$' THEN
205
- IF v_resolved !~ '^\s*[+-]?0*[0-9]{1,16}\s*$' THEN
206
- v_refused := format('%s %s is past 2^53 - 1, which no number holds exactly', v_key, v_resolved);
207
- ELSIF abs(v_resolved::numeric) > 9007199254740991 THEN
208
- v_refused := format('%s %s is past 2^53 - 1, which no number holds exactly', v_key, v_resolved);
209
- END IF;
210
- ELSIF v_resolved ~* '^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$' THEN
211
- v_refused := format('%s must be an id -- a whole number, or text -- not "%s"', v_key, v_resolved);
212
- ELSE
213
- v_cond := CASE WHEN v_op IN ('!=', '<', '<=') THEN format('%I IS NOT NULL', v_key) ELSE 'FALSE' END;
214
- END IF;
215
- ELSIF v_type = 'integer' THEN
216
- -- at most 16 digits past any zeros (2^53 - 1 has 16), so the cast that follows cannot fail
217
- IF v_resolved !~ '^\s*[+-]?0*[0-9]{1,16}\s*$' THEN
218
- v_refused := format('%s must be an integer, not "%s"', v_key, v_resolved);
219
- ELSIF abs(v_resolved::numeric) > 9007199254740991 THEN
220
- v_refused := format('%s must be an integer, not "%s"', v_key, v_resolved);
221
- END IF;
222
- ELSIF v_type = 'real' THEN
223
- IF v_resolved !~* '^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$' THEN
224
- v_refused := format('%s must be a number, not "%s"', v_key, v_resolved);
225
- ELSE
226
- BEGIN
227
- PERFORM v_resolved::float8;
228
- EXCEPTION WHEN numeric_value_out_of_range THEN -- 1e400, 1e-400: past what a double holds
229
- v_refused := format('%s must be a number, not "%s"', v_key, v_resolved);
230
- END;
231
- END IF;
232
- ELSIF v_type = 'timestamptz' THEN
233
- -- A date, YYYY-MM-DD (a name's ":" separates its values, so it holds no
234
- -- time of day), read as the instant midnight UTC begins it, whatever
235
- -- the session's TimeZone -- as SQLite reads it; not now, 20260101 or
236
- -- Jan 1 2026, which Postgres's own cast takes. (The day a statement of
237
- -- its own, so make_date is asked only of a month there is.)
238
- v_date := regexp_match(v_resolved, '^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$');
239
- IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
240
- v_refused := format('%s must be a date, YYYY-MM-DD, not "%s"', v_key, v_resolved);
241
- ELSIF v_date[3]::int NOT BETWEEN 1 AND extract(day FROM make_date(v_date[1]::int, v_date[2]::int, 1) + interval '1 month - 1 day')::int THEN
242
- v_refused := format('%s must be a date, YYYY-MM-DD, not "%s"', v_key, v_resolved);
243
- ELSE
244
- v_resolved := format('%s-%s-%s 00:00:00+00', v_date[1], v_date[2], v_date[3]);
245
- END IF;
246
- END IF;
247
- IF v_refused IS NOT NULL THEN
248
- IF p_refuse THEN RAISE EXCEPTION '%', v_refused USING ERRCODE = '22P02'; END IF;
249
- RETURN jsonb_build_object('where', 'FALSE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL, 'refused', v_refused);
250
- END IF;
251
- END IF;
252
-
253
- -- Handle each operator
254
- CASE v_op
255
- WHEN 'at' THEN
256
- v_at := v_resolved;
257
-
258
- WHEN 'like' THEN
259
- IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
260
- v_where := v_where || format('%I ILIKE %L', v_key, v_resolved || '%');
261
-
262
- WHEN '=' THEN
263
- IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
264
- v_where := v_where || COALESCE(v_cond, format('%I = %L', v_key, v_resolved));
265
- v_values := v_values || jsonb_build_object(v_key, v_resolved);
266
-
267
- ELSE
268
- -- Range operators: >=, <=, >, <, !=
269
- IF v_where != '' THEN v_where := v_where || ' AND '; END IF;
270
- v_where := v_where || COALESCE(v_cond, format('%I %s %L', v_key, v_op, v_resolved));
271
- END CASE;
272
- END LOOP;
273
-
274
- -- Default to no filter if nothing was added
275
- IF v_where = '' THEN v_where := 'TRUE'; END IF;
276
-
277
- -- Mode: single if we have an id equality, list otherwise
278
- IF v_values ? 'id' THEN
279
- v_mode := 'single';
280
- ELSE
281
- v_mode := 'list';
282
- END IF;
283
-
284
- RETURN jsonb_build_object('where', v_where, 'values', v_values, 'mode', v_mode, 'at', v_at);
285
- END;
286
- $$ LANGUAGE plpgsql STABLE;
287
-
288
- -- The resolver every caller asks: the framework's, until dzql's compiler
289
- -- (`compileResolve`) replaces it with one sending each document it compiled
290
- -- to its own.
49
+ -- generateSql writes each document's resolver (compileResolve), and sends a
50
+ -- name to its own; before it is applied there is no document.
291
51
  CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
292
- RETURNS JSONB LANGUAGE sql STABLE AS $$ SELECT _delta_resolve_scope_framework(p_def, p_doc_name, p_refuse); $$;
52
+ RETURNS JSONB LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
293
53
 
294
54
  -- The resolver as every caller but the fan-out asks it: a value refused is raised.
295
55
  CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT)
@@ -318,8 +78,8 @@ $$;
318
78
  -- ---------------------------------------------------------------------------
319
79
  -- _delta_row_in_scope: may this doc address this row?
320
80
  --
321
- -- Mirrors the READ filtering in _delta_load_collection / delta_open exactly,
322
- -- so the rule is simply "you may write what you may read".
81
+ -- As the document's read filters, so the rule is simply "you may write what
82
+ -- you may read".
323
83
  --
324
84
  -- Reads were always scoped, but writes addressed rows by bare id: a client
325
85
  -- holding `tenant:1` could name a row id belonging to `tenant:2` and remove or
@@ -329,101 +89,10 @@ $$;
329
89
  -- so the gate can't be used to probe for ids.
330
90
  -- ---------------------------------------------------------------------------
331
91
 
332
- CREATE OR REPLACE FUNCTION _delta_row_in_scope_framework(
333
- p_def _delta_docs,
334
- p_doc_name TEXT,
335
- p_collection_key TEXT,
336
- p_id BIGINT
337
- ) RETURNS BOOLEAN AS $$
338
- DECLARE
339
- v_scope JSONB;
340
- v_is_list BOOLEAN;
341
- v_root_id BIGINT;
342
- v_coll RECORD;
343
- v_cur_coll TEXT;
344
- v_cur_id BIGINT;
345
- v_view TEXT;
346
- v_exists BOOLEAN;
347
- v_depth INT := 0;
348
- BEGIN
349
- IF p_id IS NULL THEN RETURN FALSE; END IF;
350
-
351
- v_scope := _delta_resolve_scope(p_def, p_doc_name);
352
- v_is_list := (v_scope->>'mode') = 'list';
353
-
354
- IF v_is_list THEN
355
- -- List mode: `include`d collections are loaded in FULL by
356
- -- _delta_load_collection_all (no FK filter), so every row of them is
357
- -- readable and therefore writable. Only the root is narrowed, by the
358
- -- resolved WHERE.
359
- IF p_collection_key IS DISTINCT FROM p_def.root_collection THEN
360
- RETURN TRUE;
361
- END IF;
362
- SELECT * INTO v_coll FROM _delta_collections
363
- WHERE collection_key = p_collection_key;
364
- IF NOT FOUND THEN RETURN FALSE; END IF;
365
- v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
366
- EXECUTE format(
367
- 'SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
368
- v_view, COALESCE(v_scope->>'where', 'TRUE')
369
- ) INTO v_exists USING p_id;
370
- RETURN v_exists;
371
- END IF;
372
-
373
- -- Single mode: walk the parent-FK chain up to the root collection, the same
374
- -- traversal _delta_load_collection makes on the way down.
375
- v_root_id := (v_scope->'values'->>'id')::BIGINT;
376
- v_cur_coll := p_collection_key;
377
- v_cur_id := p_id;
378
-
379
- LOOP
380
- v_depth := v_depth + 1;
381
- IF v_depth > 32 THEN RETURN FALSE; END IF; -- malformed / cyclic parent chain
382
-
383
- IF v_cur_coll = p_def.root_collection THEN
384
- IF v_cur_id IS DISTINCT FROM v_root_id THEN RETURN FALSE; END IF;
385
- -- ... and the root meets the rest of its name's scope -- a column beside
386
- -- its id, as fo-guest:<id>:<email> names one -- as delta_open reads it
387
- -- (0.10.0 review). A scope of the id alone asks nothing more.
388
- IF NOT EXISTS (SELECT 1 FROM jsonb_object_keys(COALESCE(p_def.scope, '{}'::jsonb)) k WHERE k <> 'id') THEN
389
- RETURN TRUE;
390
- END IF;
391
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur_coll;
392
- IF NOT FOUND THEN RETURN FALSE; END IF;
393
- EXECUTE format('SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
394
- _delta_source_view(v_coll.table_name, v_coll.temporal), COALESCE(v_scope->>'where', 'TRUE'))
395
- INTO v_exists USING v_root_id;
396
- RETURN v_exists;
397
- END IF;
398
-
399
- SELECT * INTO v_coll FROM _delta_collections
400
- WHERE collection_key = v_cur_coll;
401
- IF NOT FOUND THEN RETURN FALSE; END IF;
402
-
403
- -- Unparented collection: included, _delta_load_collection loads it whole,
404
- -- so it is shared by every doc of this type and every row of it is in
405
- -- scope. Met on the way up from a row, or not included (the parent of a
406
- -- row being added), it ends a chain that never reaches the root: nothing
407
- -- under it is in the document (todo #32).
408
- IF v_coll.parent_collection IS NULL THEN
409
- RETURN v_depth = 1 AND v_cur_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]));
410
- END IF;
411
-
412
- v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
413
- EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
414
- INTO v_cur_id USING v_cur_id;
415
- IF v_cur_id IS NULL THEN RETURN FALSE; END IF; -- absent, or invisible to us
416
-
417
- v_cur_coll := v_coll.parent_collection;
418
- END LOOP;
419
- END;
420
- $$ LANGUAGE plpgsql STABLE;
421
-
422
- -- _delta_row_in_scope: as the framework reads a definition (above), until dzql's generateSql
423
- -- compiles the app's documents: it then sends each to its own dzql_in_scope__<prefix>, and any
424
- -- other (registered by hand, or by another generateSql) here.
92
+ -- generateSql sends each document to its own (dzql_in_scope__<slug>); before
93
+ -- it is applied there is no document.
425
94
  CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
426
- RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_row_in_scope_framework(p_def, p_doc_name, p_collection_key, p_id); $$;
95
+ RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
427
96
 
428
97
  -- ---------------------------------------------------------------------------
429
98
  -- Who holds a row: the fan-out (todo #28)
@@ -435,91 +104,13 @@ RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_row_in_scope_framework(p
435
104
  -- hold it; delta_apply tells each what changed for it.
436
105
  -- ---------------------------------------------------------------------------
437
106
 
438
- -- _delta_chain_root: the id of the `p_root` row that `p_row` (of `p_coll`)
439
- -- hangs from, following parent keys up through the tables as they stand;
440
- -- NULL when the chain does not reach p_root (an unparented collection on the
441
- -- way, or a row not there).
442
- CREATE OR REPLACE FUNCTION _delta_chain_root(p_coll TEXT, p_row JSONB, p_root TEXT)
443
- RETURNS BIGINT AS $$
444
- DECLARE
445
- v_coll RECORD;
446
- v_cur TEXT;
447
- v_id BIGINT;
448
- v_view TEXT;
449
- v_depth INT := 0;
450
- BEGIN
451
- IF p_row IS NULL THEN RETURN NULL; END IF;
452
- IF p_coll = p_root THEN RETURN (p_row->>'id')::BIGINT; END IF;
453
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
454
- IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
455
- v_id := (p_row->>v_coll.parent_fk)::BIGINT;
456
- v_cur := v_coll.parent_collection;
457
- LOOP
458
- v_depth := v_depth + 1;
459
- IF v_depth > 32 OR v_id IS NULL THEN RETURN NULL; END IF; -- malformed chain, or a row not there
460
- IF v_cur = p_root THEN RETURN v_id; END IF;
461
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur;
462
- IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
463
- v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
464
- EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
465
- INTO v_id USING v_id;
466
- v_cur := v_coll.parent_collection;
467
- END LOOP;
468
- END;
469
- $$ LANGUAGE plpgsql STABLE;
470
-
471
107
  -- _delta_doc_holds: does the document `p_doc_name` (of `p_def`) hold `p_row`
472
108
  -- of `p_coll`? Judged on the row's values, so a row as it was before a write
473
109
  -- is judged as well as one as it is after -- the same rule delta_open reads by.
474
- CREATE OR REPLACE FUNCTION _delta_doc_holds_framework(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
475
- RETURNS BOOLEAN AS $$
476
- DECLARE
477
- v_scope JSONB;
478
- v_coll RECORD;
479
- v_ok BOOLEAN;
480
- BEGIN
481
- IF p_row IS NULL THEN RETURN FALSE; END IF;
482
- IF p_coll IS DISTINCT FROM p_def.root_collection
483
- AND NOT (p_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]))) THEN
484
- RETURN FALSE;
485
- END IF;
486
- -- a name its root's columns cannot take (one opened before a rule refused it) holds nothing
487
- v_scope := _delta_resolve_scope(p_def, p_doc_name, FALSE);
488
- IF v_scope ? 'refused' THEN RETURN FALSE; END IF;
489
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
490
- IF NOT FOUND THEN RETURN FALSE; END IF;
491
- -- Named with a time past, it holds a temporal row as it stood then, which no
492
- -- write now changes (a table that keeps no history it reads as it is).
493
- IF v_coll.temporal AND (v_scope->>'at')::TIMESTAMPTZ <= now() THEN RETURN FALSE; END IF;
494
-
495
- -- The root: the one row a single-mode document is named for, or the rows a
496
- -- list-mode document's condition reads -- tested on the row's own values.
497
- IF p_coll = p_def.root_collection THEN
498
- IF (v_scope->>'mode') = 'single'
499
- AND (p_row->>'id')::BIGINT IS DISTINCT FROM (v_scope->'values'->>'id')::BIGINT THEN
500
- RETURN FALSE;
501
- END IF;
502
- EXECUTE format(
503
- 'SELECT EXISTS (SELECT 1 FROM jsonb_populate_record(null::%I, $1) t WHERE %s)',
504
- v_coll.table_name, COALESCE(v_scope->>'where', 'TRUE')
505
- ) INTO v_ok USING p_row;
506
- RETURN v_ok;
507
- END IF;
508
-
509
- -- An included collection: a list-mode document, or an unparented
510
- -- collection, holds every row of it (loaded in full); else the row hangs
511
- -- from this document's root.
512
- IF (v_scope->>'mode') = 'list' OR v_coll.parent_collection IS NULL THEN RETURN TRUE; END IF;
513
- RETURN _delta_chain_root(p_coll, p_row, p_def.root_collection)
514
- IS NOT DISTINCT FROM (v_scope->'values'->>'id')::BIGINT;
515
- END;
516
- $$ LANGUAGE plpgsql STABLE;
517
-
518
- -- _delta_doc_holds: as the framework reads a definition (above), until dzql's generateSql
519
- -- compiles the app's documents: it then sends each to its own dzql_holds__<prefix>, and any
520
- -- other (registered by hand, or by another generateSql) here.
110
+ -- generateSql sends each document to its own (dzql_holds__<slug>); before it
111
+ -- is applied there is no document.
521
112
  CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
522
- RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_doc_holds_framework(p_def, p_doc_name, p_coll, p_row); $$;
113
+ RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
523
114
 
524
115
  -- _delta_holders: the documents that hold `p_row` of `p_coll` -- every one
525
116
  -- open now (a name in _delta_held, todo #73), and the writer's own `p_writer`.
@@ -545,60 +136,10 @@ RETURNS BOOLEAN LANGUAGE sql STABLE AS $$ SELECT _delta_doc_holds_framework(p_de
545
136
  -- that applied the framework, owning the tables without FORCE ROW LEVEL
546
137
  -- SECURITY, or with BYPASSRLS; applied as the app's own role, it sees what
547
138
  -- the writer sees, as before.
548
- CREATE OR REPLACE FUNCTION _delta_holders_framework(p_coll TEXT, p_row JSONB, p_writer TEXT, p_skip TEXT[])
549
- RETURNS TEXT[] AS $$
550
- DECLARE
551
- v_def _delta_docs;
552
- v_coll RECORD;
553
- v_names TEXT[] := ARRAY[]::text[];
554
- v_rid BIGINT;
555
- v_all BOOLEAN;
556
- v_cand TEXT;
557
- BEGIN
558
- IF current_setting('transaction_isolation') <> 'read committed' THEN
559
- RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
560
- upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
561
- END IF;
562
- IF p_row IS NULL THEN RETURN v_names; END IF;
563
- SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
564
- IF NOT FOUND THEN RETURN v_names; END IF;
565
-
566
- FOR v_def IN
567
- SELECT * FROM _delta_docs
568
- WHERE (root_collection = p_coll OR p_coll = ANY(include)) AND NOT (prefix = ANY(p_skip))
569
- LOOP
570
- v_all := v_def.scope <> '{}'::jsonb
571
- OR (p_coll IS DISTINCT FROM v_def.root_collection AND v_coll.parent_collection IS NULL);
572
- v_rid := CASE WHEN v_all THEN NULL ELSE _delta_chain_root(p_coll, p_row, v_def.root_collection) END;
573
- PERFORM pg_advisory_xact_lock_shared(hashtext('_delta_hold'), hashtext(v_def.prefix));
574
- FOR v_cand IN
575
- SELECT doc_name FROM _delta_held
576
- WHERE (v_all AND starts_with(doc_name, v_def.prefix))
577
- OR (NOT v_all AND doc_name IN (v_def.prefix, v_def.prefix || v_rid::text))
578
- UNION
579
- SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, v_def.prefix)
580
- LOOP
581
- IF NOT (v_cand = ANY(v_names))
582
- AND (_delta_find_doc(v_cand)).prefix = v_def.prefix -- the longest prefix owns the name
583
- AND _delta_doc_holds(v_def, v_cand, p_coll, p_row) THEN
584
- v_names := v_names || v_cand;
585
- END IF;
586
- END LOOP;
587
- END LOOP;
588
- RETURN v_names;
589
- END;
590
- $$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
591
- -- the search path it was applied with, and pg_temp last: a caller's temporary
592
- -- tables never stand in for delta's own (pg_temp is searched first unless named)
593
- DO $$ BEGIN
594
- EXECUTE format('ALTER FUNCTION _delta_holders_framework(TEXT, JSONB, TEXT, TEXT[]) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
595
- END $$;
596
-
597
- -- `p_skip`: the prefixes asked already, by the compiled `_delta_holders`
598
- -- (dzql's `compileHolders`), which replaces this one: each document it
599
- -- compiled asked by its own lookups, the rest by the framework's.
139
+ -- generateSql writes it (compileHolders), each document asked by its own
140
+ -- lookups; before it is applied no document holds a row.
600
141
  CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
601
- RETURNS TEXT[] LANGUAGE sql VOLATILE SECURITY DEFINER AS $$ SELECT _delta_holders_framework(p_coll, p_row, p_writer, ARRAY[]::text[]); $$;
142
+ RETURNS TEXT[] LANGUAGE sql VOLATILE SECURITY DEFINER AS $$ SELECT ARRAY[]::text[]; $$;
602
143
  DO $$ BEGIN
603
144
  EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
604
145
  END $$;