dzql 0.9.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.
- package/README.md +29 -13
- package/package.json +2 -2
- package/src/compile/cascades.ts +151 -0
- package/src/compile/columns.ts +143 -0
- package/src/compile/holders.ts +66 -0
- package/src/compile/holds.ts +90 -0
- package/src/compile/ledger.ts +72 -0
- package/src/compile/lookups.ts +118 -0
- package/src/compile/opens.ts +69 -23
- package/src/compile/resolve.ts +75 -0
- package/src/compile/root.ts +61 -0
- package/src/compile/rows.ts +123 -0
- package/src/compile/scope.ts +88 -0
- package/src/compile/told.ts +95 -0
- package/src/postgres/auth-jwt-sql.ts +1 -1
- package/src/postgres/auth-jwt.ts +2 -2
- package/src/postgres/bootstrap.ts +2 -2
- package/src/postgres/codegen.ts +13 -2
- package/src/postgres/index.ts +2 -2
- package/src/postgres/listener.ts +50 -35
- package/src/postgres/pglite.ts +5 -2
- package/src/sql/001a-delta-meta.sql +55 -15
- package/src/sql/001b-delta-scope.sql +19 -451
- package/src/sql/001c-delta-read.sql +18 -296
- package/src/sql/001d-delta-write.sql +104 -330
- package/src/sql/001e-delta-ops.sql +4 -62
- package/src/sql/001g-delta-ledger.sql +51 -30
- package/src/sql/auth-jwt.sql +1 -1
|
@@ -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,239 +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
|
|
|
49
|
+
-- generateSql writes each document's resolver (compileResolve), and sends a
|
|
50
|
+
-- name to its own; before it is applied there is no document.
|
|
54
51
|
CREATE OR REPLACE FUNCTION _delta_resolve_scope(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;
|
|
52
|
+
RETURNS JSONB LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
|
|
287
53
|
|
|
288
54
|
-- The resolver as every caller but the fan-out asks it: a value refused is raised.
|
|
289
55
|
CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT)
|
|
@@ -312,8 +78,8 @@ $$;
|
|
|
312
78
|
-- ---------------------------------------------------------------------------
|
|
313
79
|
-- _delta_row_in_scope: may this doc address this row?
|
|
314
80
|
--
|
|
315
|
-
--
|
|
316
|
-
--
|
|
81
|
+
-- As the document's read filters, so the rule is simply "you may write what
|
|
82
|
+
-- you may read".
|
|
317
83
|
--
|
|
318
84
|
-- Reads were always scoped, but writes addressed rows by bare id: a client
|
|
319
85
|
-- holding `tenant:1` could name a row id belonging to `tenant:2` and remove or
|
|
@@ -323,95 +89,10 @@ $$;
|
|
|
323
89
|
-- so the gate can't be used to probe for ids.
|
|
324
90
|
-- ---------------------------------------------------------------------------
|
|
325
91
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
p_id BIGINT
|
|
331
|
-
) RETURNS BOOLEAN AS $$
|
|
332
|
-
DECLARE
|
|
333
|
-
v_scope JSONB;
|
|
334
|
-
v_is_list BOOLEAN;
|
|
335
|
-
v_root_id BIGINT;
|
|
336
|
-
v_coll RECORD;
|
|
337
|
-
v_cur_coll TEXT;
|
|
338
|
-
v_cur_id BIGINT;
|
|
339
|
-
v_view TEXT;
|
|
340
|
-
v_exists BOOLEAN;
|
|
341
|
-
v_depth INT := 0;
|
|
342
|
-
BEGIN
|
|
343
|
-
IF p_id IS NULL THEN RETURN FALSE; END IF;
|
|
344
|
-
|
|
345
|
-
v_scope := _delta_resolve_scope(p_def, p_doc_name);
|
|
346
|
-
v_is_list := (v_scope->>'mode') = 'list';
|
|
347
|
-
|
|
348
|
-
IF v_is_list THEN
|
|
349
|
-
-- List mode: `include`d collections are loaded in FULL by
|
|
350
|
-
-- _delta_load_collection_all (no FK filter), so every row of them is
|
|
351
|
-
-- readable and therefore writable. Only the root is narrowed, by the
|
|
352
|
-
-- resolved WHERE.
|
|
353
|
-
IF p_collection_key IS DISTINCT FROM p_def.root_collection THEN
|
|
354
|
-
RETURN TRUE;
|
|
355
|
-
END IF;
|
|
356
|
-
SELECT * INTO v_coll FROM _delta_collections
|
|
357
|
-
WHERE collection_key = p_collection_key;
|
|
358
|
-
IF NOT FOUND THEN RETURN FALSE; END IF;
|
|
359
|
-
v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
|
|
360
|
-
EXECUTE format(
|
|
361
|
-
'SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
|
|
362
|
-
v_view, COALESCE(v_scope->>'where', 'TRUE')
|
|
363
|
-
) INTO v_exists USING p_id;
|
|
364
|
-
RETURN v_exists;
|
|
365
|
-
END IF;
|
|
366
|
-
|
|
367
|
-
-- Single mode: walk the parent-FK chain up to the root collection, the same
|
|
368
|
-
-- traversal _delta_load_collection makes on the way down.
|
|
369
|
-
v_root_id := (v_scope->'values'->>'id')::BIGINT;
|
|
370
|
-
v_cur_coll := p_collection_key;
|
|
371
|
-
v_cur_id := p_id;
|
|
372
|
-
|
|
373
|
-
LOOP
|
|
374
|
-
v_depth := v_depth + 1;
|
|
375
|
-
IF v_depth > 32 THEN RETURN FALSE; END IF; -- malformed / cyclic parent chain
|
|
376
|
-
|
|
377
|
-
IF v_cur_coll = p_def.root_collection THEN
|
|
378
|
-
IF v_cur_id IS DISTINCT FROM v_root_id THEN RETURN FALSE; END IF;
|
|
379
|
-
-- ... and the root meets the rest of its name's scope -- a column beside
|
|
380
|
-
-- its id, as fo-guest:<id>:<email> names one -- as delta_open reads it
|
|
381
|
-
-- (0.10.0 review). A scope of the id alone asks nothing more.
|
|
382
|
-
IF NOT EXISTS (SELECT 1 FROM jsonb_object_keys(COALESCE(p_def.scope, '{}'::jsonb)) k WHERE k <> 'id') THEN
|
|
383
|
-
RETURN TRUE;
|
|
384
|
-
END IF;
|
|
385
|
-
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur_coll;
|
|
386
|
-
IF NOT FOUND THEN RETURN FALSE; END IF;
|
|
387
|
-
EXECUTE format('SELECT EXISTS(SELECT 1 FROM %I t WHERE t.id = $1 AND %s)',
|
|
388
|
-
_delta_source_view(v_coll.table_name, v_coll.temporal), COALESCE(v_scope->>'where', 'TRUE'))
|
|
389
|
-
INTO v_exists USING v_root_id;
|
|
390
|
-
RETURN v_exists;
|
|
391
|
-
END IF;
|
|
392
|
-
|
|
393
|
-
SELECT * INTO v_coll FROM _delta_collections
|
|
394
|
-
WHERE collection_key = v_cur_coll;
|
|
395
|
-
IF NOT FOUND THEN RETURN FALSE; END IF;
|
|
396
|
-
|
|
397
|
-
-- Unparented collection: included, _delta_load_collection loads it whole,
|
|
398
|
-
-- so it is shared by every doc of this type and every row of it is in
|
|
399
|
-
-- scope. Met on the way up from a row, or not included (the parent of a
|
|
400
|
-
-- row being added), it ends a chain that never reaches the root: nothing
|
|
401
|
-
-- under it is in the document (todo #32).
|
|
402
|
-
IF v_coll.parent_collection IS NULL THEN
|
|
403
|
-
RETURN v_depth = 1 AND v_cur_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]));
|
|
404
|
-
END IF;
|
|
405
|
-
|
|
406
|
-
v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
|
|
407
|
-
EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
|
|
408
|
-
INTO v_cur_id USING v_cur_id;
|
|
409
|
-
IF v_cur_id IS NULL THEN RETURN FALSE; END IF; -- absent, or invisible to us
|
|
410
|
-
|
|
411
|
-
v_cur_coll := v_coll.parent_collection;
|
|
412
|
-
END LOOP;
|
|
413
|
-
END;
|
|
414
|
-
$$ LANGUAGE plpgsql STABLE;
|
|
92
|
+
-- generateSql sends each document to its own (dzql_in_scope__<slug>); before
|
|
93
|
+
-- it is applied there is no document.
|
|
94
|
+
CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
|
|
95
|
+
RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
|
|
415
96
|
|
|
416
97
|
-- ---------------------------------------------------------------------------
|
|
417
98
|
-- Who holds a row: the fan-out (todo #28)
|
|
@@ -423,85 +104,13 @@ $$ LANGUAGE plpgsql STABLE;
|
|
|
423
104
|
-- hold it; delta_apply tells each what changed for it.
|
|
424
105
|
-- ---------------------------------------------------------------------------
|
|
425
106
|
|
|
426
|
-
-- _delta_chain_root: the id of the `p_root` row that `p_row` (of `p_coll`)
|
|
427
|
-
-- hangs from, following parent keys up through the tables as they stand;
|
|
428
|
-
-- NULL when the chain does not reach p_root (an unparented collection on the
|
|
429
|
-
-- way, or a row not there).
|
|
430
|
-
CREATE OR REPLACE FUNCTION _delta_chain_root(p_coll TEXT, p_row JSONB, p_root TEXT)
|
|
431
|
-
RETURNS BIGINT AS $$
|
|
432
|
-
DECLARE
|
|
433
|
-
v_coll RECORD;
|
|
434
|
-
v_cur TEXT;
|
|
435
|
-
v_id BIGINT;
|
|
436
|
-
v_view TEXT;
|
|
437
|
-
v_depth INT := 0;
|
|
438
|
-
BEGIN
|
|
439
|
-
IF p_row IS NULL THEN RETURN NULL; END IF;
|
|
440
|
-
IF p_coll = p_root THEN RETURN (p_row->>'id')::BIGINT; END IF;
|
|
441
|
-
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
|
|
442
|
-
IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
|
|
443
|
-
v_id := (p_row->>v_coll.parent_fk)::BIGINT;
|
|
444
|
-
v_cur := v_coll.parent_collection;
|
|
445
|
-
LOOP
|
|
446
|
-
v_depth := v_depth + 1;
|
|
447
|
-
IF v_depth > 32 OR v_id IS NULL THEN RETURN NULL; END IF; -- malformed chain, or a row not there
|
|
448
|
-
IF v_cur = p_root THEN RETURN v_id; END IF;
|
|
449
|
-
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = v_cur;
|
|
450
|
-
IF NOT FOUND OR v_coll.parent_collection IS NULL THEN RETURN NULL; END IF;
|
|
451
|
-
v_view := _delta_source_view(v_coll.table_name, v_coll.temporal);
|
|
452
|
-
EXECUTE format('SELECT t.%I FROM %I t WHERE t.id = $1', v_coll.parent_fk, v_view)
|
|
453
|
-
INTO v_id USING v_id;
|
|
454
|
-
v_cur := v_coll.parent_collection;
|
|
455
|
-
END LOOP;
|
|
456
|
-
END;
|
|
457
|
-
$$ LANGUAGE plpgsql STABLE;
|
|
458
|
-
|
|
459
107
|
-- _delta_doc_holds: does the document `p_doc_name` (of `p_def`) hold `p_row`
|
|
460
108
|
-- of `p_coll`? Judged on the row's values, so a row as it was before a write
|
|
461
109
|
-- is judged as well as one as it is after -- the same rule delta_open reads by.
|
|
110
|
+
-- generateSql sends each document to its own (dzql_holds__<slug>); before it
|
|
111
|
+
-- is applied there is no document.
|
|
462
112
|
CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
|
|
463
|
-
RETURNS BOOLEAN AS $$
|
|
464
|
-
DECLARE
|
|
465
|
-
v_scope JSONB;
|
|
466
|
-
v_coll RECORD;
|
|
467
|
-
v_ok BOOLEAN;
|
|
468
|
-
BEGIN
|
|
469
|
-
IF p_row IS NULL THEN RETURN FALSE; END IF;
|
|
470
|
-
IF p_coll IS DISTINCT FROM p_def.root_collection
|
|
471
|
-
AND NOT (p_coll = ANY(COALESCE(p_def.include, ARRAY[]::text[]))) THEN
|
|
472
|
-
RETURN FALSE;
|
|
473
|
-
END IF;
|
|
474
|
-
-- a name its root's columns cannot take (one opened before a rule refused it) holds nothing
|
|
475
|
-
v_scope := _delta_resolve_scope(p_def, p_doc_name, FALSE);
|
|
476
|
-
IF v_scope ? 'refused' THEN RETURN FALSE; END IF;
|
|
477
|
-
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
|
|
478
|
-
IF NOT FOUND THEN RETURN FALSE; END IF;
|
|
479
|
-
-- Named with a time past, it holds a temporal row as it stood then, which no
|
|
480
|
-
-- write now changes (a table that keeps no history it reads as it is).
|
|
481
|
-
IF v_coll.temporal AND (v_scope->>'at')::TIMESTAMPTZ <= now() THEN RETURN FALSE; END IF;
|
|
482
|
-
|
|
483
|
-
-- The root: the one row a single-mode document is named for, or the rows a
|
|
484
|
-
-- list-mode document's condition reads -- tested on the row's own values.
|
|
485
|
-
IF p_coll = p_def.root_collection THEN
|
|
486
|
-
IF (v_scope->>'mode') = 'single'
|
|
487
|
-
AND (p_row->>'id')::BIGINT IS DISTINCT FROM (v_scope->'values'->>'id')::BIGINT THEN
|
|
488
|
-
RETURN FALSE;
|
|
489
|
-
END IF;
|
|
490
|
-
EXECUTE format(
|
|
491
|
-
'SELECT EXISTS (SELECT 1 FROM jsonb_populate_record(null::%I, $1) t WHERE %s)',
|
|
492
|
-
v_coll.table_name, COALESCE(v_scope->>'where', 'TRUE')
|
|
493
|
-
) INTO v_ok USING p_row;
|
|
494
|
-
RETURN v_ok;
|
|
495
|
-
END IF;
|
|
496
|
-
|
|
497
|
-
-- An included collection: a list-mode document, or an unparented
|
|
498
|
-
-- collection, holds every row of it (loaded in full); else the row hangs
|
|
499
|
-
-- from this document's root.
|
|
500
|
-
IF (v_scope->>'mode') = 'list' OR v_coll.parent_collection IS NULL THEN RETURN TRUE; END IF;
|
|
501
|
-
RETURN _delta_chain_root(p_coll, p_row, p_def.root_collection)
|
|
502
|
-
IS NOT DISTINCT FROM (v_scope->'values'->>'id')::BIGINT;
|
|
503
|
-
END;
|
|
504
|
-
$$ LANGUAGE plpgsql STABLE;
|
|
113
|
+
RETURNS BOOLEAN LANGUAGE plpgsql STABLE AS $$ BEGIN RAISE EXCEPTION 'no doc def for: %', p_doc_name USING ERRCODE = 'P0001'; END $$;
|
|
505
114
|
|
|
506
115
|
-- _delta_holders: the documents that hold `p_row` of `p_coll` -- every one
|
|
507
116
|
-- open now (a name in _delta_held, todo #73), and the writer's own `p_writer`.
|
|
@@ -527,51 +136,10 @@ $$ LANGUAGE plpgsql STABLE;
|
|
|
527
136
|
-- that applied the framework, owning the tables without FORCE ROW LEVEL
|
|
528
137
|
-- SECURITY, or with BYPASSRLS; applied as the app's own role, it sees what
|
|
529
138
|
-- the writer sees, as before.
|
|
139
|
+
-- generateSql writes it (compileHolders), each document asked by its own
|
|
140
|
+
-- lookups; before it is applied no document holds a row.
|
|
530
141
|
CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
|
|
531
|
-
RETURNS TEXT[] AS $$
|
|
532
|
-
DECLARE
|
|
533
|
-
v_def _delta_docs;
|
|
534
|
-
v_coll RECORD;
|
|
535
|
-
v_names TEXT[] := ARRAY[]::text[];
|
|
536
|
-
v_rid BIGINT;
|
|
537
|
-
v_all BOOLEAN;
|
|
538
|
-
v_cand TEXT;
|
|
539
|
-
BEGIN
|
|
540
|
-
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
|
541
|
-
RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
|
|
542
|
-
upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
|
|
543
|
-
END IF;
|
|
544
|
-
IF p_row IS NULL THEN RETURN v_names; END IF;
|
|
545
|
-
SELECT * INTO v_coll FROM _delta_collections WHERE collection_key = p_coll;
|
|
546
|
-
IF NOT FOUND THEN RETURN v_names; END IF;
|
|
547
|
-
|
|
548
|
-
FOR v_def IN
|
|
549
|
-
SELECT * FROM _delta_docs
|
|
550
|
-
WHERE root_collection = p_coll OR p_coll = ANY(include)
|
|
551
|
-
LOOP
|
|
552
|
-
v_all := v_def.scope <> '{}'::jsonb
|
|
553
|
-
OR (p_coll IS DISTINCT FROM v_def.root_collection AND v_coll.parent_collection IS NULL);
|
|
554
|
-
v_rid := CASE WHEN v_all THEN NULL ELSE _delta_chain_root(p_coll, p_row, v_def.root_collection) END;
|
|
555
|
-
PERFORM pg_advisory_xact_lock_shared(hashtext('_delta_hold'), hashtext(v_def.prefix));
|
|
556
|
-
FOR v_cand IN
|
|
557
|
-
SELECT doc_name FROM _delta_held
|
|
558
|
-
WHERE (v_all AND starts_with(doc_name, v_def.prefix))
|
|
559
|
-
OR (NOT v_all AND doc_name IN (v_def.prefix, v_def.prefix || v_rid::text))
|
|
560
|
-
UNION
|
|
561
|
-
SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, v_def.prefix)
|
|
562
|
-
LOOP
|
|
563
|
-
IF NOT (v_cand = ANY(v_names))
|
|
564
|
-
AND (_delta_find_doc(v_cand)).prefix = v_def.prefix -- the longest prefix owns the name
|
|
565
|
-
AND _delta_doc_holds(v_def, v_cand, p_coll, p_row) THEN
|
|
566
|
-
v_names := v_names || v_cand;
|
|
567
|
-
END IF;
|
|
568
|
-
END LOOP;
|
|
569
|
-
END LOOP;
|
|
570
|
-
RETURN v_names;
|
|
571
|
-
END;
|
|
572
|
-
$$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
|
|
573
|
-
-- the search path it was applied with, and pg_temp last: a caller's temporary
|
|
574
|
-
-- tables never stand in for delta's own (pg_temp is searched first unless named)
|
|
142
|
+
RETURNS TEXT[] LANGUAGE sql VOLATILE SECURITY DEFINER AS $$ SELECT ARRAY[]::text[]; $$;
|
|
575
143
|
DO $$ BEGIN
|
|
576
144
|
EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
|
|
577
145
|
END $$;
|