dzql 0.9.0 → 0.10.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 +144 -0
- package/src/compile/holders.ts +72 -0
- package/src/compile/holds.ts +91 -0
- package/src/compile/ledger.ts +73 -0
- package/src/compile/opens.ts +63 -23
- package/src/compile/resolve.ts +75 -0
- package/src/compile/root.ts +61 -0
- package/src/compile/rows.ts +122 -0
- package/src/compile/scope.ts +87 -0
- package/src/compile/told.ts +94 -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 +12 -2
- package/src/postgres/index.ts +2 -2
- package/src/postgres/pglite.ts +1 -1
- package/src/sql/001a-delta-meta.sql +8 -1
- package/src/sql/001b-delta-scope.sql +32 -5
- package/src/sql/001c-delta-read.sql +10 -11
- package/src/sql/001d-delta-write.sql +212 -123
- package/src/sql/001g-delta-ledger.sql +67 -23
- package/src/sql/auth-jwt.sql +1 -1
package/src/compile/opens.ts
CHANGED
|
@@ -25,12 +25,20 @@ import { q, type ColumnDef, type DocDef, type ResolvedTable, type Schema } from
|
|
|
25
25
|
import { columnSqlType } from "../postgres/sql";
|
|
26
26
|
|
|
27
27
|
/** A string as an SQL literal. */
|
|
28
|
-
const lit = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
28
|
+
export const lit = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
29
29
|
|
|
30
|
-
/** A
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/** A compiled function's name: `dzql_<kind>__` and the document's prefix (or a collection's key), lower case, every other character `_`. */
|
|
31
|
+
export const named = (kind: string, name: string): string =>
|
|
32
|
+
`dzql_${kind}__${name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
|
|
33
|
+
|
|
34
|
+
/** A document's open: `dzql_open__<slug>`. */
|
|
35
|
+
export const openFunction = (prefix: string): string => named("open", prefix);
|
|
36
|
+
|
|
37
|
+
/** A table's rows as they stand: its current view where temporal, else the table. */
|
|
38
|
+
export const standingRows = (table: ResolvedTable): string => q(table.temporal ? `current_${table.name}` : table.name);
|
|
39
|
+
|
|
40
|
+
/** A temporal table's rows as they stand, as a condition on `alias`: ` AND <alias>.valid_to IS NULL`; nothing for a table that is not. */
|
|
41
|
+
export const currentOnly = (table: ResolvedTable, alias: string): string => (table.temporal ? ` AND ${alias}.valid_to IS NULL` : "");
|
|
34
42
|
|
|
35
43
|
/**
|
|
36
44
|
* A row as a document reads it: as stored (a temporal row without its
|
|
@@ -102,7 +110,7 @@ function includedList(schema: Schema, doc: DocDef, coll: string): string {
|
|
|
102
110
|
return `(SELECT COALESCE(jsonb_object_agg(t.id, ${rowOf(table, doc, coll, "t")}), '{}'::jsonb) FROM ${q(table.name)} t${standing(table, "t").replace(" AND", " WHERE")})`;
|
|
103
111
|
}
|
|
104
112
|
|
|
105
|
-
function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
|
|
113
|
+
export function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
|
|
106
114
|
const table = schema.tables[coll];
|
|
107
115
|
if (!table) throw new Error(`[dzql compile] document ${doc.prefix} names a collection the schema does not have: ${coll}`);
|
|
108
116
|
return table;
|
|
@@ -118,6 +126,10 @@ interface CompiledScope {
|
|
|
118
126
|
conds: string;
|
|
119
127
|
/** Whether a name can make it one row: no scope (a name with an id), or an `id` equality. */
|
|
120
128
|
single: boolean;
|
|
129
|
+
/** `body` without what it sets for the reads (v_single, v_id, p_at): reading the values and checking each. */
|
|
130
|
+
checks: string;
|
|
131
|
+
/** After `checks`: plpgsql building `_delta_resolve_scope`'s answer as it builds it -- v_where (text), v_values, v_at. */
|
|
132
|
+
resolve: string;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
const REAL = String.raw`^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$`;
|
|
@@ -125,8 +137,10 @@ const WHOLE = String.raw`^\s*[+-]?[0-9]+\s*$`;
|
|
|
125
137
|
const SAFE = String.raw`^\s*[+-]?0*[0-9]{1,16}\s*$`;
|
|
126
138
|
const DAY = String.raw`^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$`;
|
|
127
139
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
140
|
+
/** What a value its column cannot take does: given the message and the value's variable, the plpgsql. */
|
|
141
|
+
type Refuse = (message: string, value: string) => string;
|
|
142
|
+
/** An open refuses it: the framework's message, as a 400 (22P02). */
|
|
143
|
+
const raiseRefusal: Refuse = (message, value) => `RAISE EXCEPTION '${message.replace(/'/g, "''")}', ${value} USING ERRCODE = '22P02';`;
|
|
130
144
|
|
|
131
145
|
/**
|
|
132
146
|
* A document's scope, compiled from its definition as `_delta_resolve_scope`
|
|
@@ -136,7 +150,7 @@ const refuse = (message: string, value: string) => `RAISE EXCEPTION '${message.r
|
|
|
136
150
|
* value its column cannot take refused with the same message, and an `id`
|
|
137
151
|
* equality making the document one row (single mode).
|
|
138
152
|
*/
|
|
139
|
-
function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
|
|
153
|
+
export function scopeOf(schema: Schema, doc: DocDef, refuse: Refuse = raiseRefusal): CompiledScope {
|
|
140
154
|
const root = need(schema, doc, doc.root);
|
|
141
155
|
const entries = Object.entries(doc.scope ?? {});
|
|
142
156
|
if (entries.length === 0) {
|
|
@@ -147,6 +161,8 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
|
|
|
147
161
|
IF v_single THEN v_id := v_rest::BIGINT; END IF;`,
|
|
148
162
|
conds: "",
|
|
149
163
|
single: true,
|
|
164
|
+
checks: "",
|
|
165
|
+
resolve: "",
|
|
150
166
|
};
|
|
151
167
|
}
|
|
152
168
|
const parsed = entries
|
|
@@ -163,28 +179,38 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
|
|
|
163
179
|
.sort((a, b) => a.key.length - b.key.length || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)); // jsonb's order of keys
|
|
164
180
|
const params = [...new Set(parsed.map((p) => p.param))].sort((a, b) => (a === "id" ? -1 : b === "id" ? 1 : a < b ? -1 : a > b ? 1 : 0));
|
|
165
181
|
const declare = ["v_parts TEXT[];", "v_date TEXT[];"];
|
|
166
|
-
const
|
|
182
|
+
const read = `v_parts := string_to_array(substring(p_name FROM ${doc.prefix.length + 1}), ':');`;
|
|
183
|
+
const body: string[] = [read, "v_single := FALSE;"];
|
|
184
|
+
const checks: string[] = [read];
|
|
185
|
+
const resolve: string[] = [];
|
|
167
186
|
const conds: string[] = [];
|
|
187
|
+
// a check goes in both; what the reads set, in body alone
|
|
188
|
+
const check = (...lines: string[]) => { body.push(...lines); checks.push(...lines); };
|
|
189
|
+
// the framework's where, in its words: `%I <op> %L`, joined by AND
|
|
190
|
+
const where = (v: string, expr: string) => resolve.push(`IF ${v} IS NOT NULL THEN v_where := v_where || CASE WHEN v_where <> '' THEN ' AND ' ELSE '' END || ${expr}; END IF;`);
|
|
168
191
|
parsed.forEach(({ key, op, param }, i) => {
|
|
169
192
|
const v = `v_s${i + 1}`;
|
|
170
193
|
declare.push(`${v} TEXT;`);
|
|
171
|
-
|
|
194
|
+
check(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
|
|
172
195
|
const isKey = op !== "at" && (key === "id" || key === root.parent?.fkColumn);
|
|
173
196
|
const def = root.columns[key];
|
|
174
197
|
if (op === "at") {
|
|
175
|
-
|
|
198
|
+
check(dayOf(v, key, refuse));
|
|
199
|
+
body.push(`IF ${v} IS NOT NULL THEN p_at := ${v}::TIMESTAMPTZ; END IF;`);
|
|
200
|
+
resolve.push(`IF ${v} IS NOT NULL THEN v_at := ${v}; END IF;`);
|
|
176
201
|
return;
|
|
177
202
|
}
|
|
178
203
|
if (!isKey && !def) throw new Error(`[dzql compile] document ${doc.prefix}: scope key "${key}" is not a column of "${doc.root}"`);
|
|
179
204
|
if (op === "like") {
|
|
180
205
|
conds.push(`(${v} IS NULL OR t.${q(key)} ILIKE ${v} || '%')`);
|
|
206
|
+
where(v, `format('%I ILIKE %L', ${lit(key)}, ${v} || '%')`);
|
|
181
207
|
return;
|
|
182
208
|
}
|
|
183
209
|
const sqlOp = op === "!=" ? "<>" : op;
|
|
184
210
|
if (isKey) {
|
|
185
211
|
const n = `v_n${i + 1}`;
|
|
186
212
|
declare.push(`${n} BOOLEAN;`);
|
|
187
|
-
|
|
213
|
+
check(`IF ${v} IS NOT NULL THEN
|
|
188
214
|
IF ${v} ~ '${WHOLE}' THEN
|
|
189
215
|
IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
|
|
190
216
|
ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
|
|
@@ -200,16 +226,18 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
|
|
|
200
226
|
const asText = ["!=", "<", "<="].includes(op) ? `t.${q(key)} IS NOT NULL` : "FALSE";
|
|
201
227
|
conds.push(`(${v} IS NULL OR (CASE WHEN ${n} THEN t.${q(key)} ${sqlOp} ${v}::BIGINT ELSE ${asText} END))`);
|
|
202
228
|
if (key === "id" && op === "=") body.push(`IF ${v} IS NOT NULL THEN v_single := TRUE; v_id := ${v}::BIGINT; END IF;`);
|
|
229
|
+
where(v, `CASE WHEN ${n} THEN format('%I ${op} %L', ${lit(key)}, ${v}) ELSE ${asText === "FALSE" ? "'FALSE'" : `format('%I IS NOT NULL', ${lit(key)})`} END`);
|
|
230
|
+
if (op === "=") resolve.push(`IF ${v} IS NOT NULL THEN v_values := v_values || jsonb_build_object(${lit(key)}, ${v}); END IF;`);
|
|
203
231
|
return;
|
|
204
232
|
}
|
|
205
233
|
if (def!.type === "integer") {
|
|
206
|
-
|
|
234
|
+
check(`IF ${v} IS NOT NULL THEN
|
|
207
235
|
IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} must be an integer, not "%"`, v)}
|
|
208
236
|
ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} must be an integer, not "%"`, v)}
|
|
209
237
|
END IF;
|
|
210
238
|
END IF;`);
|
|
211
239
|
} else if (def!.type === "real") {
|
|
212
|
-
|
|
240
|
+
check(`IF ${v} IS NOT NULL THEN
|
|
213
241
|
IF ${v} !~* '${REAL}' THEN ${refuse(`${key} must be a number, not "%"`, v)} END IF;
|
|
214
242
|
BEGIN
|
|
215
243
|
PERFORM ${v}::float8;
|
|
@@ -218,15 +246,20 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
|
|
|
218
246
|
END;
|
|
219
247
|
END IF;`);
|
|
220
248
|
} else if (def!.type === "timestamptz") {
|
|
221
|
-
|
|
249
|
+
check(dayOf(v, key, refuse));
|
|
222
250
|
}
|
|
223
251
|
conds.push(`(${v} IS NULL OR t.${q(key)} ${sqlOp} ${v}::${columnSqlType(def!)})`);
|
|
252
|
+
where(v, `format('%I ${op} %L', ${lit(key)}, ${v})`);
|
|
253
|
+
if (op === "=") resolve.push(`IF ${v} IS NOT NULL THEN v_values := v_values || jsonb_build_object(${lit(key)}, ${v}); END IF;`);
|
|
224
254
|
});
|
|
225
|
-
return {
|
|
255
|
+
return {
|
|
256
|
+
declare, body: body.join("\n "), conds: conds.join(" AND "), single: parsed.some((p) => p.key === "id" && p.op === "="),
|
|
257
|
+
checks: checks.join("\n "), resolve: resolve.join("\n "),
|
|
258
|
+
};
|
|
226
259
|
}
|
|
227
260
|
|
|
228
261
|
/** A date, YYYY-MM-DD, that the calendar has, read as the instant midnight UTC begins it; anything else refused. */
|
|
229
|
-
function dayOf(v: string, key: string): string {
|
|
262
|
+
function dayOf(v: string, key: string, refuse: Refuse): string {
|
|
230
263
|
return `IF ${v} IS NOT NULL THEN
|
|
231
264
|
v_date := regexp_match(${v}, '${DAY}');
|
|
232
265
|
IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
|
|
@@ -239,20 +272,27 @@ function dayOf(v: string, key: string): string {
|
|
|
239
272
|
}
|
|
240
273
|
|
|
241
274
|
/** A document's function for `open_at`: the document as it stood at a time the caller gives. */
|
|
242
|
-
export const openAtFunction = (prefix: string): string =>
|
|
275
|
+
export const openAtFunction = (prefix: string): string => named("open_at", prefix);
|
|
276
|
+
|
|
277
|
+
/** A document's whole read, for the ledger: `dzql_open_whole__<slug>`. */
|
|
278
|
+
export const openWholeFunction = (prefix: string): string => named("open_whole", prefix);
|
|
243
279
|
|
|
244
280
|
/**
|
|
245
281
|
* One document's read, as `open` (`delta_open`: the time is the name's, an implied
|
|
246
282
|
* root opens with its defaults, the version is kept) or as `open_at`
|
|
247
283
|
* (`delta_open_at`: the time is the caller's, a root not there is null, no version).
|
|
284
|
+
* `whole`: as `open`, every column of every row whatever columns the document
|
|
285
|
+
* names (`_delta_open(name, TRUE)`: what the ledger's inverse and a walk's guard
|
|
286
|
+
* read), with no `_as`.
|
|
248
287
|
*/
|
|
249
|
-
function readOf(schema: Schema,
|
|
288
|
+
export function readOf(schema: Schema, given: DocDef, at: boolean, whole = false): string {
|
|
289
|
+
const doc: DocDef = whole ? { ...given, columns: undefined } : given;
|
|
250
290
|
const root = need(schema, doc, doc.root);
|
|
251
291
|
const rootJson = rowOf(root, doc, doc.root, "t");
|
|
252
292
|
const fields = (included: (coll: string) => string) =>
|
|
253
293
|
doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, ${included(c)})`).join("");
|
|
254
294
|
const emptyIncludes = doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, '{}'::jsonb)`).join("");
|
|
255
|
-
const fn = at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
|
|
295
|
+
const fn = whole ? openWholeFunction(doc.prefix) : at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
|
|
256
296
|
const scope = scopeOf(schema, doc);
|
|
257
297
|
const where = (first: string[]) => [...first, ...(scope.conds ? [scope.conds] : [])].join(" AND ") || "TRUE";
|
|
258
298
|
const params = at ? "p_name TEXT, p_given TIMESTAMPTZ" : "p_name TEXT";
|
|
@@ -287,7 +327,7 @@ BEGIN
|
|
|
287
327
|
END IF;` : ""}
|
|
288
328
|
${done}
|
|
289
329
|
END;
|
|
290
|
-
$$ LANGUAGE plpgsql
|
|
330
|
+
$$ LANGUAGE plpgsql;${whole ? "" : `
|
|
291
331
|
|
|
292
332
|
CREATE OR REPLACE FUNCTION ${fn}_as(p_user_id TEXT, ${params})
|
|
293
333
|
RETURNS JSONB AS $$
|
|
@@ -295,7 +335,7 @@ BEGIN
|
|
|
295
335
|
PERFORM set_config('app.user_id', p_user_id, true);
|
|
296
336
|
RETURN ${fn}(${at ? "p_name, p_given" : "p_name"});
|
|
297
337
|
END
|
|
298
|
-
$$ LANGUAGE plpgsql
|
|
338
|
+
$$ LANGUAGE plpgsql;`}`;
|
|
299
339
|
}
|
|
300
340
|
|
|
301
341
|
/** One document's reads: its open and its open_at, each with its `_as`. */
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled scope resolution: a document's name read as its definition's scope
|
|
3
|
+
* reads it. Asked by every write (`delta_apply`: its mode, its root's id, the
|
|
4
|
+
* values an add to a list is given, the conditions its root must meet), by
|
|
5
|
+
* the framework's reads and by the ops log -- `_delta_resolve_scope`.
|
|
6
|
+
*
|
|
7
|
+
* `compileResolve(schema, docs)` writes `dzql_scope__<slug>(name, refuse)` for
|
|
8
|
+
* each document, giving the framework's answer exactly, as JSON: `where` (the
|
|
9
|
+
* conditions as SQL text, in the framework's words), `values` (the equality
|
|
10
|
+
* bindings), `mode` and `at`; a value its column cannot take refused with the
|
|
11
|
+
* same message (`scopeOf`), raised, or with `refuse` false answered as the
|
|
12
|
+
* scope that holds nothing. And `_delta_resolve_scope` itself, which sends each
|
|
13
|
+
* compiled document to its own and any other to the framework
|
|
14
|
+
* (`_delta_resolve_scope_framework`).
|
|
15
|
+
*/
|
|
16
|
+
import type { DocDef, Schema } from "@blueshed/delta/backend";
|
|
17
|
+
import { lit, named, need, scopeOf } from "./opens";
|
|
18
|
+
|
|
19
|
+
/** A document's scope function: `dzql_scope__<slug>`. */
|
|
20
|
+
export const resolveFunction = (prefix: string): string => named("scope", prefix);
|
|
21
|
+
|
|
22
|
+
/** Refused: raised (22P02), or with `p_refuse` false, the scope that holds nothing, naming why. */
|
|
23
|
+
const refuse = (message: string, value: string): string => {
|
|
24
|
+
const m = message.replace(/'/g, "''");
|
|
25
|
+
return `IF p_refuse THEN RAISE EXCEPTION '${m}', ${value} USING ERRCODE = '22P02'; END IF;
|
|
26
|
+
RETURN jsonb_build_object('where', 'FALSE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL, 'refused', format('${m.replace(/%/g, "%s")}', ${value}));`;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** One document's scope function. */
|
|
30
|
+
function resolveOf(schema: Schema, doc: DocDef): string {
|
|
31
|
+
need(schema, doc, doc.root);
|
|
32
|
+
const head = `CREATE OR REPLACE FUNCTION ${resolveFunction(doc.prefix)}(p_name TEXT, p_refuse BOOLEAN)
|
|
33
|
+
RETURNS JSONB AS $$`;
|
|
34
|
+
if (Object.keys(doc.scope ?? {}).length === 0) {
|
|
35
|
+
// no scope: a name with nothing after its prefix is the list; else the one row its id names
|
|
36
|
+
return `${head}
|
|
37
|
+
DECLARE
|
|
38
|
+
v_rest TEXT := substring(p_name FROM ${doc.prefix.length + 1});
|
|
39
|
+
BEGIN
|
|
40
|
+
IF v_rest = '' THEN
|
|
41
|
+
RETURN jsonb_build_object('where', 'TRUE', 'values', '{}'::jsonb, 'mode', 'list', 'at', NULL);
|
|
42
|
+
END IF;
|
|
43
|
+
RETURN jsonb_build_object('where', format('id = %L', v_rest), 'values', jsonb_build_object('id', v_rest::BIGINT), 'mode', 'single', 'at', NULL);
|
|
44
|
+
END;
|
|
45
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
46
|
+
}
|
|
47
|
+
const scope = scopeOf(schema, doc, refuse);
|
|
48
|
+
return `${head}
|
|
49
|
+
DECLARE
|
|
50
|
+
v_where TEXT := '';
|
|
51
|
+
v_values JSONB := '{}'::jsonb;
|
|
52
|
+
v_at TEXT;
|
|
53
|
+
${scope.declare.join("\n ")}
|
|
54
|
+
BEGIN
|
|
55
|
+
${scope.checks}
|
|
56
|
+
${scope.resolve}
|
|
57
|
+
IF v_where = '' THEN v_where := 'TRUE'; END IF;
|
|
58
|
+
RETURN jsonb_build_object('where', v_where, 'values', v_values, 'mode', CASE WHEN v_values ? 'id' THEN 'single' ELSE 'list' END, 'at', v_at);
|
|
59
|
+
END;
|
|
60
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The SQL that resolves each of `docs`' names: a function per document, and `_delta_resolve_scope` sending each to its own. */
|
|
64
|
+
export function compileResolve(schema: Schema, docs: DocDef[]): string {
|
|
65
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_resolve_scope(p_def _delta_docs, p_doc_name TEXT, p_refuse BOOLEAN)
|
|
66
|
+
RETURNS JSONB AS $$
|
|
67
|
+
BEGIN
|
|
68
|
+
CASE p_def.prefix
|
|
69
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${resolveFunction(d.prefix)}(p_doc_name, p_refuse);`).join("\n")}
|
|
70
|
+
ELSE RETURN _delta_resolve_scope_framework(p_def, p_doc_name, p_refuse);
|
|
71
|
+
END CASE;
|
|
72
|
+
END;
|
|
73
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
74
|
+
return [...docs.map((d) => resolveOf(schema, d)), dispatch, ""].join("\n\n");
|
|
75
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled root check: is a single document's root row there now, meeting the
|
|
3
|
+
* whole of its name's scope? Asked before each op of a write (`delta_apply`),
|
|
4
|
+
* so a document whose root is gone takes no writes.
|
|
5
|
+
*
|
|
6
|
+
* `compileRootThere(schema, docs)` writes `dzql_root_there__<slug>(name, id)`
|
|
7
|
+
* for each document, answering what the framework's `_delta_root_there`
|
|
8
|
+
* answers, from its definition: the name's values read and checked
|
|
9
|
+
* (`scopeOf`, refused with the same message), and the root's current rows
|
|
10
|
+
* asked with its conditions written as typed SQL, where the framework runs
|
|
11
|
+
* the resolver's conditions as text. And `_delta_root_there` itself, which
|
|
12
|
+
* sends each compiled document to its own and any other to the framework
|
|
13
|
+
* (`_delta_root_there_framework`).
|
|
14
|
+
*/
|
|
15
|
+
import type { DocDef, Schema } from "@blueshed/delta/backend";
|
|
16
|
+
import { lit, named, need, scopeOf, standingRows } from "./opens";
|
|
17
|
+
|
|
18
|
+
/** A document's root check: `dzql_root_there__<slug>`. */
|
|
19
|
+
export const rootThereFunction = (prefix: string): string => named("root_there", prefix);
|
|
20
|
+
|
|
21
|
+
/** One document's root check. */
|
|
22
|
+
function rootThereOf(schema: Schema, doc: DocDef): string {
|
|
23
|
+
const root = need(schema, doc, doc.root);
|
|
24
|
+
const rows = standingRows(root); // the root's rows as they stand, as the framework's view reads them
|
|
25
|
+
const head = `CREATE OR REPLACE FUNCTION ${rootThereFunction(doc.prefix)}(p_name TEXT, p_id BIGINT)
|
|
26
|
+
RETURNS BOOLEAN AS $$`;
|
|
27
|
+
if (Object.keys(doc.scope ?? {}).length === 0) {
|
|
28
|
+
// no scope: a name with an id is that row (the framework's `id = '<id>'`), one without, any
|
|
29
|
+
return `${head}
|
|
30
|
+
DECLARE
|
|
31
|
+
v_rest TEXT := substring(p_name FROM ${doc.prefix.length + 1});
|
|
32
|
+
BEGIN
|
|
33
|
+
IF v_rest = '' THEN RETURN EXISTS (SELECT 1 FROM ${rows} t WHERE t.id = p_id); END IF;
|
|
34
|
+
RETURN EXISTS (SELECT 1 FROM ${rows} t WHERE t.id = p_id AND t.id = v_rest::BIGINT);
|
|
35
|
+
END;
|
|
36
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
37
|
+
}
|
|
38
|
+
const scope = scopeOf(schema, doc);
|
|
39
|
+
return `${head}
|
|
40
|
+
DECLARE
|
|
41
|
+
${scope.declare.join("\n ")}
|
|
42
|
+
BEGIN
|
|
43
|
+
${scope.checks}
|
|
44
|
+
RETURN EXISTS (SELECT 1 FROM ${rows} t WHERE t.id = p_id${scope.conds ? ` AND ${scope.conds}` : ""});
|
|
45
|
+
END;
|
|
46
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The SQL that answers whether each of `docs`' root is there: a function per document, and `_delta_root_there` sending each to its own. */
|
|
50
|
+
export function compileRootThere(schema: Schema, docs: DocDef[]): string {
|
|
51
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_root_there(p_def _delta_docs, p_doc_name TEXT, p_scope JSONB, p_view TEXT, p_id BIGINT)
|
|
52
|
+
RETURNS BOOLEAN AS $$
|
|
53
|
+
BEGIN
|
|
54
|
+
CASE p_def.prefix
|
|
55
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${rootThereFunction(d.prefix)}(p_doc_name, p_id);`).join("\n")}
|
|
56
|
+
ELSE RETURN _delta_root_there_framework(p_def, p_doc_name, p_scope, p_view, p_id);
|
|
57
|
+
END CASE;
|
|
58
|
+
END;
|
|
59
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
60
|
+
return [...docs.map((d) => rootThereOf(schema, d)), dispatch, ""].join("\n\n");
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled row SQL: the statements a write runs on a row, written for each
|
|
3
|
+
* collection. The framework builds each from the table's name at run time
|
|
4
|
+
* (`EXECUTE format(...)`), planned again on every call; written out, each is a
|
|
5
|
+
* statement whose plan the database keeps.
|
|
6
|
+
*
|
|
7
|
+
* `compileRows(schema)` writes, for every collection of the schema:
|
|
8
|
+
* - `_delta_row_lock`: the row as it stands (a temporal one's open version),
|
|
9
|
+
* locked for the write;
|
|
10
|
+
* - `_delta_row_put`: a replace's row stored -- a temporal one a new version
|
|
11
|
+
* (one this write made gives way to it, the standing one closes), else the
|
|
12
|
+
* row again -- answered as stored;
|
|
13
|
+
* - `_delta_next_id`: an add's id, from the collection's sequence;
|
|
14
|
+
* - `_delta_row_clear_moment`: a temporal version this write made and took
|
|
15
|
+
* back, so an add of its id takes its place;
|
|
16
|
+
* - `_delta_row_there`: is the row there now (a temporal one's open version)?
|
|
17
|
+
* - `_delta_row_read`: the row as it stands, without its validity;
|
|
18
|
+
* - `_delta_row_add`: an add's row stored. The framework's INSERT names the
|
|
19
|
+
* columns the row gives (`_delta_insert_sql`), so one it leaves out takes its
|
|
20
|
+
* database default: where the row gives exactly every column the schema
|
|
21
|
+
* declares (its id, parent key, columns and validity), that INSERT is one
|
|
22
|
+
* statement, written here; a row with any other keys is the framework's.
|
|
23
|
+
*
|
|
24
|
+
* Each answers what the framework's (`_framework`) answers, and sends any
|
|
25
|
+
* collection it was not compiled for there.
|
|
26
|
+
*/
|
|
27
|
+
import { q, type Schema } from "@blueshed/delta/backend";
|
|
28
|
+
import { lit, standingRows } from "./opens";
|
|
29
|
+
|
|
30
|
+
/** A CASE over the collections, each branch from `branch`, any other collection to `fallback`. */
|
|
31
|
+
function dispatch(schema: Schema, branch: (coll: string, table: Schema["tables"][string]) => string | null, fallback: string): string {
|
|
32
|
+
const branches = Object.entries(schema.tables).flatMap(([coll, table]) => {
|
|
33
|
+
const b = branch(coll, table);
|
|
34
|
+
return b === null ? [] : [` WHEN ${lit(coll)} THEN\n ${b}`];
|
|
35
|
+
});
|
|
36
|
+
return branches.length ? ` CASE p_coll\n${branches.join("\n")}\n ELSE ${fallback}\n END CASE;` : ` ${fallback}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The row SQL of every collection of `schema`, and the dispatch to the framework's for any other. */
|
|
40
|
+
export function compileRows(schema: Schema): string {
|
|
41
|
+
const lock = `CREATE OR REPLACE FUNCTION _delta_row_lock(p_coll TEXT, p_table TEXT, p_temporal BOOLEAN, p_id BIGINT)
|
|
42
|
+
RETURNS JSONB AS $$
|
|
43
|
+
DECLARE
|
|
44
|
+
v_row JSONB;
|
|
45
|
+
BEGIN
|
|
46
|
+
${dispatch(schema, (_, t) => `SELECT to_jsonb(t.*) INTO v_row FROM ${q(t.name)} t WHERE t.id = p_id${t.temporal ? " AND valid_to IS NULL" : ""} FOR UPDATE;
|
|
47
|
+
RETURN v_row;`, "RETURN _delta_row_lock_framework(p_coll, p_table, p_temporal, p_id);")}
|
|
48
|
+
END;
|
|
49
|
+
$$ LANGUAGE plpgsql;`;
|
|
50
|
+
|
|
51
|
+
const put = `CREATE OR REPLACE FUNCTION _delta_row_put(p_coll TEXT, p_table TEXT, p_temporal BOOLEAN, p_id BIGINT, p_row JSONB, p_ts TIMESTAMPTZ)
|
|
52
|
+
RETURNS JSONB AS $$
|
|
53
|
+
DECLARE
|
|
54
|
+
v_row JSONB;
|
|
55
|
+
BEGIN
|
|
56
|
+
${dispatch(schema, (_, t) => (t.temporal
|
|
57
|
+
? `DELETE FROM ${q(t.name)} WHERE id = p_id AND valid_to IS NULL AND valid_from = p_ts;
|
|
58
|
+
UPDATE ${q(t.name)} SET valid_to = p_ts WHERE id = p_id AND valid_to IS NULL;
|
|
59
|
+
INSERT INTO ${q(t.name)} AS t SELECT * FROM jsonb_populate_record(null::${q(t.name)}, p_row || jsonb_build_object('valid_from', p_ts, 'valid_to', NULL))
|
|
60
|
+
RETURNING to_jsonb(t.*) INTO v_row;
|
|
61
|
+
RETURN v_row;`
|
|
62
|
+
: `DELETE FROM ${q(t.name)} WHERE id = p_id;
|
|
63
|
+
INSERT INTO ${q(t.name)} AS t SELECT * FROM jsonb_populate_record(null::${q(t.name)}, p_row) RETURNING to_jsonb(t.*) INTO v_row;
|
|
64
|
+
RETURN v_row;`), "RETURN _delta_row_put_framework(p_coll, p_table, p_temporal, p_id, p_row, p_ts);")}
|
|
65
|
+
END;
|
|
66
|
+
$$ LANGUAGE plpgsql;`;
|
|
67
|
+
|
|
68
|
+
// the sequence named as the framework names it: nextval of the text 'seq_<table>'
|
|
69
|
+
const next = `CREATE OR REPLACE FUNCTION _delta_next_id(p_coll TEXT, p_table TEXT)
|
|
70
|
+
RETURNS BIGINT AS $$
|
|
71
|
+
BEGIN
|
|
72
|
+
${dispatch(schema, (_, t) => `RETURN nextval(${lit(`seq_${t.name}`)});`, "RETURN _delta_next_id_framework(p_coll, p_table);")}
|
|
73
|
+
END;
|
|
74
|
+
$$ LANGUAGE plpgsql;`;
|
|
75
|
+
|
|
76
|
+
const clear = `CREATE OR REPLACE FUNCTION _delta_row_clear_moment(p_coll TEXT, p_table TEXT, p_id BIGINT, p_ts TIMESTAMPTZ)
|
|
77
|
+
RETURNS void AS $$
|
|
78
|
+
BEGIN
|
|
79
|
+
${dispatch(schema, (_, t) => (t.temporal ? `DELETE FROM ${q(t.name)} WHERE id = p_id AND valid_from = p_ts AND valid_to = p_ts;
|
|
80
|
+
RETURN;` : null), "PERFORM _delta_row_clear_moment_framework(p_coll, p_table, p_id, p_ts);")}
|
|
81
|
+
END;
|
|
82
|
+
$$ LANGUAGE plpgsql;`;
|
|
83
|
+
|
|
84
|
+
const there = `CREATE OR REPLACE FUNCTION _delta_row_there(p_collection_key TEXT, p_id BIGINT)
|
|
85
|
+
RETURNS BOOLEAN AS $$
|
|
86
|
+
DECLARE
|
|
87
|
+
p_coll TEXT := p_collection_key;
|
|
88
|
+
BEGIN
|
|
89
|
+
${dispatch(schema, (_, t) => `RETURN p_id IS NOT NULL AND EXISTS (SELECT 1 FROM ${standingRows(t)} WHERE id = p_id);`,
|
|
90
|
+
"RETURN _delta_row_there_framework(p_collection_key, p_id);")}
|
|
91
|
+
END;
|
|
92
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
93
|
+
|
|
94
|
+
const read = `CREATE OR REPLACE FUNCTION _delta_row_read(p_coll TEXT, p_view TEXT, p_id BIGINT)
|
|
95
|
+
RETURNS JSONB AS $$
|
|
96
|
+
DECLARE
|
|
97
|
+
v_row JSONB;
|
|
98
|
+
BEGIN
|
|
99
|
+
${dispatch(schema, (_, t) => `SELECT _delta_strip_temporal(to_jsonb(t.*)) INTO v_row FROM ${standingRows(t)} t WHERE t.id = p_id;
|
|
100
|
+
RETURN v_row;`, "RETURN _delta_row_read_framework(p_coll, p_view, p_id);")}
|
|
101
|
+
END;
|
|
102
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
103
|
+
|
|
104
|
+
const add = `CREATE OR REPLACE FUNCTION _delta_row_add(p_coll TEXT, p_table TEXT, p_row JSONB)
|
|
105
|
+
RETURNS JSONB AS $$
|
|
106
|
+
DECLARE
|
|
107
|
+
v_row JSONB;
|
|
108
|
+
BEGIN
|
|
109
|
+
${dispatch(schema, (_, t) => {
|
|
110
|
+
const keys = [...new Set(["id", ...(t.parent ? [t.parent.fkColumn] : []), ...Object.keys(t.columns), ...(t.temporal ? ["valid_from", "valid_to"] : [])])];
|
|
111
|
+
const cols = keys.map((k) => q(k)).join(", ");
|
|
112
|
+
return `IF p_row ?& ARRAY[${keys.map(lit).join(", ")}] AND (SELECT count(*) FROM jsonb_object_keys(p_row)) = ${keys.length} THEN
|
|
113
|
+
INSERT INTO ${q(t.name)} AS t (${cols}) SELECT ${cols} FROM jsonb_populate_record(null::${q(t.name)}, p_row) RETURNING to_jsonb(t.*) INTO v_row;
|
|
114
|
+
RETURN v_row;
|
|
115
|
+
END IF;
|
|
116
|
+
RETURN _delta_row_add_framework(p_coll, p_table, p_row);`;
|
|
117
|
+
}, "RETURN _delta_row_add_framework(p_coll, p_table, p_row);")}
|
|
118
|
+
END;
|
|
119
|
+
$$ LANGUAGE plpgsql;`;
|
|
120
|
+
|
|
121
|
+
return [lock, put, next, clear, there, read, add, ""].join("\n\n");
|
|
122
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled write scope: may a document write a row? "You may write what you
|
|
3
|
+
* may read": every row-addressed op in a write is asked first (`delta_apply`,
|
|
4
|
+
* `_delta_row_in_scope`), and a row it may not read is answered as one that is
|
|
5
|
+
* not there.
|
|
6
|
+
*
|
|
7
|
+
* `compileInScope(schema, docs)` writes `dzql_in_scope__<slug>(name,
|
|
8
|
+
* collection, id)` for each document, answering what the framework's
|
|
9
|
+
* `_delta_row_in_scope` answers, from its definition: a list document holds
|
|
10
|
+
* its included collections whole and its root rows by the name's conditions;
|
|
11
|
+
* a single document holds a row whose parent chain, walked up through the
|
|
12
|
+
* tables as they stand, reaches its root, which meets the rest of its name's
|
|
13
|
+
* scope. And `_delta_row_in_scope` itself, which sends each compiled document
|
|
14
|
+
* to its own and any other to the framework (`_delta_row_in_scope_framework`).
|
|
15
|
+
*/
|
|
16
|
+
import { q, type DocDef, type Schema } from "@blueshed/delta/backend";
|
|
17
|
+
import { lit, named, need, scopeOf , currentOnly } from "./opens";
|
|
18
|
+
|
|
19
|
+
/** A document's write-scope function: `dzql_in_scope__<slug>`. */
|
|
20
|
+
export const inScopeFunction = (prefix: string): string => named("in_scope", prefix);
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
/** One document's write-scope function. */
|
|
24
|
+
function inScopeOf(schema: Schema, doc: DocDef): string {
|
|
25
|
+
const root = need(schema, doc, doc.root);
|
|
26
|
+
const scope = scopeOf(schema, doc); // a value its columns cannot take: refused, as the framework's resolver refuses it
|
|
27
|
+
const conds = scope.conds ? ` AND ${scope.conds}` : "";
|
|
28
|
+
// the root row of a single document: its id, and (where the name scopes more than the id) the rest of its scope
|
|
29
|
+
const others = Object.keys(doc.scope ?? {}).some((k) => k !== "id");
|
|
30
|
+
const rootMeets = (id: string) => others
|
|
31
|
+
? `COALESCE(${id} = v_id, FALSE) AND EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = v_id${currentOnly(root, "t")}${conds})`
|
|
32
|
+
: `COALESCE(${id} = v_id, FALSE)`;
|
|
33
|
+
const included = new Set(doc.include);
|
|
34
|
+
|
|
35
|
+
const singles = Object.entries(schema.tables).map(([coll, table]) => {
|
|
36
|
+
if (coll === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets("p_id")};`;
|
|
37
|
+
// unparented: held whole where included; else it ends a chain short of the root
|
|
38
|
+
if (!table.parent) return ` WHEN ${lit(coll)} THEN RETURN ${included.has(coll) ? "TRUE" : "FALSE"};`;
|
|
39
|
+
let id = "p_id";
|
|
40
|
+
let t = table;
|
|
41
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
42
|
+
id = `(SELECT x.${q(t.parent!.fkColumn)} FROM ${q(t.name)} x WHERE x.id = ${id}${currentOnly(t, "x")})`;
|
|
43
|
+
const parentKey = t.parent!.collection;
|
|
44
|
+
if (parentKey === doc.root) return ` WHEN ${lit(coll)} THEN RETURN ${rootMeets(id)};`;
|
|
45
|
+
const parent = schema.tables[parentKey];
|
|
46
|
+
if (!parent?.parent) return ` WHEN ${lit(coll)} THEN RETURN FALSE;`; // the chain ends short of the root
|
|
47
|
+
t = parent;
|
|
48
|
+
}
|
|
49
|
+
return ` WHEN ${lit(coll)} THEN RETURN FALSE;`;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return `CREATE OR REPLACE FUNCTION ${inScopeFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_id BIGINT)
|
|
53
|
+
RETURNS BOOLEAN AS $$
|
|
54
|
+
DECLARE
|
|
55
|
+
p_at TIMESTAMPTZ;
|
|
56
|
+
v_single BOOLEAN;
|
|
57
|
+
v_id BIGINT;
|
|
58
|
+
${scope.declare.join("\n ")}
|
|
59
|
+
BEGIN
|
|
60
|
+
IF p_id IS NULL THEN RETURN FALSE; END IF;
|
|
61
|
+
${scope.body}
|
|
62
|
+
IF NOT v_single THEN
|
|
63
|
+
-- a list holds its included collections whole; its root rows by the name's conditions
|
|
64
|
+
IF p_coll IS DISTINCT FROM ${lit(doc.root)} THEN RETURN TRUE; END IF;
|
|
65
|
+
RETURN EXISTS (SELECT 1 FROM ${q(root.name)} t WHERE t.id = p_id${currentOnly(root, "t")}${conds});
|
|
66
|
+
END IF;
|
|
67
|
+
CASE p_coll
|
|
68
|
+
${singles.join("\n")}
|
|
69
|
+
ELSE RETURN FALSE;
|
|
70
|
+
END CASE;
|
|
71
|
+
END;
|
|
72
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The SQL that answers a write's scope for each of `docs`: a function per document, and `_delta_row_in_scope` sending each to its own. */
|
|
76
|
+
export function compileInScope(schema: Schema, docs: DocDef[]): string {
|
|
77
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_row_in_scope(p_def _delta_docs, p_doc_name TEXT, p_collection_key TEXT, p_id BIGINT)
|
|
78
|
+
RETURNS BOOLEAN AS $$
|
|
79
|
+
BEGIN
|
|
80
|
+
CASE p_def.prefix
|
|
81
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${inScopeFunction(d.prefix)}(p_doc_name, p_collection_key, p_id);`).join("\n")}
|
|
82
|
+
ELSE RETURN _delta_row_in_scope_framework(p_def, p_doc_name, p_collection_key, p_id);
|
|
83
|
+
END CASE;
|
|
84
|
+
END;
|
|
85
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
86
|
+
return [...docs.map((d) => inScopeOf(schema, d)), dispatch, ""].join("\n\n");
|
|
87
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled telling: what a document is told of a write. Asked by `_delta_tell`
|
|
3
|
+
* of the writer and of every document that held a row the write touched, or
|
|
4
|
+
* holds it now, with the write's touches and their holders.
|
|
5
|
+
*
|
|
6
|
+
* `compileTold(schema, docs)` writes `dzql_told__<slug>(name, rows)` for each
|
|
7
|
+
* document, answering what the framework's `_delta_told` answers, from its
|
|
8
|
+
* definition: whether the name makes it one row (`scopeOf`), its root, the
|
|
9
|
+
* columns it shows of each collection it names them for (`columns`, the row's
|
|
10
|
+
* id and parent key with them), and the empty root an implied one opens with
|
|
11
|
+
* when its root leaves. And `_delta_told` itself, which sends each compiled
|
|
12
|
+
* document to its own and any other to the framework (`_delta_told_framework`).
|
|
13
|
+
*/
|
|
14
|
+
import type { DocDef, Schema } from "@blueshed/delta/backend";
|
|
15
|
+
import { lit, named, need, scopeOf } from "./opens";
|
|
16
|
+
|
|
17
|
+
/** A document's telling function: `dzql_told__<slug>`. */
|
|
18
|
+
export const toldFunction = (prefix: string): string => named("told", prefix);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A row (SQL `row`) of collection (SQL `coll`) as the document shows it, as
|
|
22
|
+
* `_delta_show` does: where it names the collection's columns, those, the id
|
|
23
|
+
* and the parent key; else whole.
|
|
24
|
+
*/
|
|
25
|
+
function shown(schema: Schema, doc: DocDef, coll: string, row: string): string {
|
|
26
|
+
const named = Object.entries(doc.columns ?? {});
|
|
27
|
+
if (named.length === 0) return row;
|
|
28
|
+
const branches = named.map(([c, cols]) => {
|
|
29
|
+
const fk = schema.tables[c]?.parent?.fkColumn;
|
|
30
|
+
const keep = [...new Set(["id", ...(fk ? [fk] : []), ...cols])].map(lit).join(", ");
|
|
31
|
+
return `WHEN ${lit(c)} THEN COALESCE((SELECT jsonb_object_agg(f, v) FROM jsonb_each(${row}) AS e(f, v) WHERE f IN (${keep})), '{}'::jsonb)`;
|
|
32
|
+
});
|
|
33
|
+
return `(CASE WHEN jsonb_typeof(${row}) IS DISTINCT FROM 'object' THEN ${row} ELSE CASE ${coll} ${branches.join(" ")} ELSE ${row} END END)`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One document's telling function. */
|
|
37
|
+
function toldOf(schema: Schema, doc: DocDef): string {
|
|
38
|
+
need(schema, doc, doc.root);
|
|
39
|
+
const scope = scopeOf(schema, doc); // a name its columns cannot take: refused, as the framework's resolver refuses it
|
|
40
|
+
const after = shown(schema, doc, "v_t->>'coll'", "v_t->'after'");
|
|
41
|
+
const gone = doc.implied
|
|
42
|
+
? shown(schema, doc, "v_t->>'coll'", `_delta_implied_root((SELECT d FROM _delta_docs d WHERE d.prefix = ${lit(doc.prefix)}), (v_t->>'id')::BIGINT)`)
|
|
43
|
+
: "'null'::jsonb";
|
|
44
|
+
return `CREATE OR REPLACE FUNCTION ${toldFunction(doc.prefix)}(p_name TEXT, p_rows JSONB)
|
|
45
|
+
RETURNS JSONB AS $$
|
|
46
|
+
DECLARE
|
|
47
|
+
p_at TIMESTAMPTZ;
|
|
48
|
+
v_single BOOLEAN;
|
|
49
|
+
v_id BIGINT;
|
|
50
|
+
v_t JSONB;
|
|
51
|
+
v_told JSONB := '[]'::jsonb;
|
|
52
|
+
v_was BOOLEAN;
|
|
53
|
+
v_is BOOLEAN;
|
|
54
|
+
v_root BOOLEAN;
|
|
55
|
+
v_path TEXT;
|
|
56
|
+
${scope.declare.join("\n ")}
|
|
57
|
+
BEGIN
|
|
58
|
+
${scope.body}
|
|
59
|
+
FOR v_t IN SELECT jsonb_array_elements(p_rows) LOOP
|
|
60
|
+
v_was := (v_t->'before') ? p_name;
|
|
61
|
+
v_is := (v_t->'holders') ? p_name;
|
|
62
|
+
CONTINUE WHEN NOT v_was AND NOT v_is;
|
|
63
|
+
CONTINUE WHEN v_was AND v_is AND (v_t ? 'carried'); -- carried along, and its either way: unchanged for it
|
|
64
|
+
v_root := v_single AND (v_t->>'coll') = ${lit(doc.root)};
|
|
65
|
+
v_path := CASE WHEN v_root THEN _delta_build_path(v_t->>'coll') ELSE _delta_build_path(v_t->>'coll', v_t->>'id') END;
|
|
66
|
+
IF v_was AND v_is THEN -- stayed: what changed
|
|
67
|
+
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")
|
|
68
|
+
FROM jsonb_each(${after}) AS e(f, v) WHERE v IS DISTINCT FROM v_t->'was'->f), '[]'::jsonb);
|
|
69
|
+
ELSIF v_is THEN
|
|
70
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', CASE WHEN v_root THEN 'replace' ELSE 'add' END, 'path', v_path, 'value', ${after}));
|
|
71
|
+
ELSIF v_root THEN -- gone: ${doc.implied ? "the empty root it then opens with" : "null"}
|
|
72
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'replace', 'path', v_path, 'value', ${gone}));
|
|
73
|
+
ELSE
|
|
74
|
+
v_told := v_told || jsonb_build_array(jsonb_build_object('op', 'remove', 'path', v_path));
|
|
75
|
+
END IF;
|
|
76
|
+
END LOOP;
|
|
77
|
+
RETURN v_told;
|
|
78
|
+
END;
|
|
79
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The SQL that answers what each of `docs` is told of a write: a function per document, and `_delta_told` sending each to its own. */
|
|
83
|
+
export function compileTold(schema: Schema, docs: DocDef[]): string {
|
|
84
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_told(p_def _delta_docs, p_target TEXT, p_rows JSONB)
|
|
85
|
+
RETURNS JSONB AS $$
|
|
86
|
+
BEGIN
|
|
87
|
+
CASE p_def.prefix
|
|
88
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${toldFunction(d.prefix)}(p_target, p_rows);`).join("\n")}
|
|
89
|
+
ELSE RETURN _delta_told_framework(p_def, p_target, p_rows);
|
|
90
|
+
END CASE;
|
|
91
|
+
END;
|
|
92
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
93
|
+
return [...docs.map((d) => toldOf(schema, d)), dispatch, ""].join("\n\n");
|
|
94
|
+
}
|