dzql 0.8.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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Compiled ledger reads: what the ledger reads to write an entry's inverse and
3
+ * to guard a walk of it (undo, redo). The framework read the document whole
4
+ * (`_delta_open(name, TRUE)`), a field's type and the rows beyond the document
5
+ * from the definitions at run time; here each is written from them.
6
+ *
7
+ * `compileLedger(schema, docs)` writes:
8
+ * - `dzql_open_whole__<slug>(name)` for each document: its open with every
9
+ * column of every row, whatever columns it names (`readOf`, whole), and
10
+ * `_delta_open` itself, sending each compiled document to its own open, shown
11
+ * (`dzql_open__<slug>`) or whole, and any other to the framework's
12
+ * (`_delta_open_framework`);
13
+ * - `_delta_same(collection, field, a, b)`: two values of a field the same, a
14
+ * time by the instant it names. The field's type is the writing process's
15
+ * (`delta.declared`), else the schema's, written in for each collection;
16
+ * any other collection is the framework's (`_delta_same_framework`);
17
+ * - `_delta_row_standing(collection, id)`: a row as it stands, as `{row}`, for
18
+ * every collection of the schema; any other the framework's.
19
+ */
20
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
21
+ import { lit, openFunction, openWholeFunction, readOf, standingRows } from "./opens";
22
+
23
+ /** The SQL the ledger reads by, for `schema` and each of `docs`. */
24
+ export function compileLedger(schema: Schema, docs: DocDef[]): string {
25
+ const whole = docs.map((d) => readOf(schema, d, false, true));
26
+
27
+ const open = `CREATE OR REPLACE FUNCTION _delta_open(p_doc_name TEXT, p_whole BOOLEAN)
28
+ RETURNS JSONB AS $$
29
+ DECLARE
30
+ v_def _delta_docs := _delta_find_doc(p_doc_name);
31
+ BEGIN
32
+ CASE v_def.prefix
33
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN CASE WHEN p_whole THEN ${openWholeFunction(d.prefix)}(p_doc_name) ELSE ${openFunction(d.prefix)}(p_doc_name) END;`).join("\n")}
34
+ ELSE RETURN _delta_open_framework(p_doc_name, p_whole); -- a name no document compiled here holds (none at all: the framework refuses it)
35
+ END CASE;
36
+ END;
37
+ $$ LANGUAGE plpgsql;`;
38
+
39
+ const colls = Object.entries(schema.tables);
40
+ const same = `CREATE OR REPLACE FUNCTION _delta_same(p_coll TEXT, p_field TEXT, a JSONB, b JSONB)
41
+ RETURNS BOOLEAN AS $$
42
+ DECLARE
43
+ v_declared JSONB;
44
+ v_type TEXT;
45
+ BEGIN
46
+ IF jsonb_typeof(a) = 'string' AND jsonb_typeof(b) = 'string' THEN
47
+ CASE p_coll
48
+ ${colls.map(([coll, t]) => {
49
+ const times = Object.entries(t.columns).filter(([, d]) => d.type === "timestamptz").map(([c]) => c);
50
+ return ` WHEN ${lit(coll)} THEN
51
+ v_declared := NULLIF(current_setting('delta.declared', true), '')::jsonb -> ${lit(coll)};
52
+ v_type := CASE WHEN v_declared IS NOT NULL THEN v_declared -> p_field ->> 'type'${times.length ? ` WHEN p_field IN (${times.map(lit).join(", ")}) THEN 'timestamptz'` : ""} END;`;
53
+ }).join("\n")}
54
+ ELSE RETURN _delta_same_framework(p_coll, p_field, a, b);
55
+ END CASE;
56
+ IF v_type = 'timestamptz' THEN RETURN (a #>> '{}')::timestamptz = (b #>> '{}')::timestamptz; END IF;
57
+ END IF;
58
+ RETURN COALESCE(a, 'null'::jsonb) = COALESCE(b, 'null'::jsonb);
59
+ END;
60
+ $$ LANGUAGE plpgsql STABLE;`;
61
+
62
+ const standing = `CREATE OR REPLACE FUNCTION _delta_row_standing(p_coll TEXT, p_id BIGINT)
63
+ RETURNS JSONB AS $$
64
+ BEGIN
65
+ CASE p_coll
66
+ ${colls.map(([coll, t]) => ` WHEN ${lit(coll)} THEN RETURN jsonb_build_object('row', (SELECT _delta_strip_temporal(to_jsonb(t.*)) FROM ${standingRows(t)} t WHERE t.id = p_id));`).join("\n")}
67
+ ELSE RETURN _delta_row_standing_framework(p_coll, p_id);
68
+ END CASE;
69
+ END;
70
+ $$ LANGUAGE plpgsql STABLE;`;
71
+
72
+ return [...whole, open, same, standing, ""].join("\n\n");
73
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Compiled opens: delta's documents, read by SQL written for each one.
3
+ *
4
+ * `compileOpens(schema, docs)` turns an app's `defineSchema` / `defineDoc`
5
+ * into the reads of each document -- `dzql_open__<slug>(name)` and
6
+ * `dzql_open_at__<slug>(name, at)`, each with an `_as` for a signed-in caller --
7
+ * which the listener opens that document's names through (`docTypeFromDef`). Each answers what `delta_open`
8
+ * answers (the listener then shows it by the same schema), but its queries
9
+ * are written out: which
10
+ * table, which columns, how a collection hangs from the root, whether a row
11
+ * is temporal, what an implied root opens with. Nothing reads
12
+ * `_delta_collections` to find them.
13
+ *
14
+ * A name's scope is compiled too (`scopeOf`): its values read by position,
15
+ * each checked as its column takes it with the framework's own messages
16
+ * (`_delta_resolve_scope`), and its conditions written as typed SQL, so no
17
+ * query is built from text at run time. The version is still the
18
+ * framework's (`_delta_versions`).
19
+ *
20
+ * A pure function: the same definitions give the same text, byte for byte.
21
+ * A definition it cannot compile is an error naming the document, never SQL
22
+ * that half works.
23
+ */
24
+ import { q, type ColumnDef, type DocDef, type ResolvedTable, type Schema } from "@blueshed/delta/backend";
25
+ import { columnSqlType } from "../postgres/sql";
26
+
27
+ /** A string as an SQL literal. */
28
+ export const lit = (s: string): string => `'${s.replace(/'/g, "''")}'`;
29
+
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` : "");
42
+
43
+ /**
44
+ * A row as a document reads it: as stored (a temporal row without its
45
+ * validity), so a column a later version added is there for the process that
46
+ * declares it -- each listener shows its own schema's (the schema is the
47
+ * contract; a database may hold more). Where the document names the columns it
48
+ * holds, those, its id and its parent key.
49
+ */
50
+ function rowOf(table: ResolvedTable, doc: DocDef, coll: string, alias: string): string {
51
+ const held = doc.columns?.[coll];
52
+ if (held) {
53
+ const cols = ["id", ...(table.parent ? [table.parent.fkColumn] : []), ...Object.keys(table.columns).filter((c) => held.includes(c))];
54
+ return `jsonb_build_object(${cols.map((c) => `${lit(c)}, ${alias}.${q(c)}`).join(", ")})`;
55
+ }
56
+ return table.temporal ? `(to_jsonb(${alias}.*) - 'valid_from' - 'valid_to' - 'override')` : `to_jsonb(${alias}.*)`;
57
+ }
58
+
59
+ /** The rows of a temporal table that stand now, or at `at` (a time in the name): ` AND <condition>`; nothing for a table that is not temporal. */
60
+ const standing = (table: ResolvedTable, alias: string, at = "p_at"): string =>
61
+ table.temporal
62
+ ? ` AND (CASE WHEN ${at} IS NULL THEN ${alias}.valid_to IS NULL ELSE ${alias}.valid_from <= ${at} AND (${alias}.valid_to IS NULL OR ${alias}.valid_to > ${at}) END)`
63
+ : "";
64
+
65
+ /** An implied document's root before its row is there: its id and each column's default, as `_delta_implied_root` gives it. */
66
+ function impliedRoot(table: ResolvedTable, doc: DocDef): string {
67
+ const held = doc.columns?.[doc.root];
68
+ const value = (d: ColumnDef): unknown =>
69
+ d.default !== undefined && d.default !== null ? d.default
70
+ : d.nullable ? null
71
+ : d.type === "text" ? ""
72
+ : d.type === "integer" || d.type === "real" ? 0
73
+ : d.type === "boolean" ? false
74
+ : null;
75
+ const fields = Object.entries(table.columns).filter(([c]) => !held || held.includes(c)).map(([c, d]) => [c, value(d)]);
76
+ return `jsonb_build_object('id', v_id) || ${lit(JSON.stringify(Object.fromEntries(fields)))}::jsonb`;
77
+ }
78
+
79
+ /**
80
+ * A collection a single document includes, held under its root row `v_id`:
81
+ * its rows joined up their parent chain to the root. A collection with no
82
+ * parent is held whole; one whose chain ends short of the root holds nothing.
83
+ */
84
+ function includedSingle(schema: Schema, doc: DocDef, coll: string): string {
85
+ const table = need(schema, doc, coll);
86
+ if (!table.parent) return includedList(schema, doc, coll);
87
+ const joins: string[] = [];
88
+ let where = "";
89
+ let child = table;
90
+ let alias = "t";
91
+ for (let hop = 1; ; hop++) {
92
+ const parentKey = child.parent!.collection;
93
+ if (parentKey === doc.root) {
94
+ where = `${alias}.${q(child.parent!.fkColumn)} = v_id${standing(table, "t")}`;
95
+ break;
96
+ }
97
+ const parent = need(schema, doc, parentKey);
98
+ if (!parent.parent) return `'{}'::jsonb`; // the chain ends short of the root: nothing of it is in the document
99
+ const p = `p${hop}`;
100
+ joins.push(`JOIN ${q(parent.name)} ${p} ON ${p}.id = ${alias}.${q(child.parent!.fkColumn)}${standing(parent, p)}`);
101
+ child = parent;
102
+ alias = p;
103
+ }
104
+ return `(SELECT COALESCE(jsonb_object_agg(t.id, ${rowOf(table, doc, coll, "t")}), '{}'::jsonb) FROM ${[`${q(table.name)} t`, ...joins].join(" ")} WHERE ${where})`;
105
+ }
106
+
107
+ /** A collection a list document includes: every row of it, as `_delta_load_collection_all` reads it. */
108
+ function includedList(schema: Schema, doc: DocDef, coll: string): string {
109
+ const table = need(schema, doc, coll);
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")})`;
111
+ }
112
+
113
+ export function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
114
+ const table = schema.tables[coll];
115
+ if (!table) throw new Error(`[dzql compile] document ${doc.prefix} names a collection the schema does not have: ${coll}`);
116
+ return table;
117
+ }
118
+
119
+ /** The scope's parts: how a name's values are read and checked, and the conditions they set. */
120
+ interface CompiledScope {
121
+ /** plpgsql variables. */
122
+ declare: string[];
123
+ /** plpgsql statements: read the name's values, check each, set v_single, v_id and p_at. */
124
+ body: string;
125
+ /** The conditions on the root's rows, as SQL over alias t ("" when none). */
126
+ conds: string;
127
+ /** Whether a name can make it one row: no scope (a name with an id), or an `id` equality. */
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;
133
+ }
134
+
135
+ const REAL = String.raw`^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$`;
136
+ const WHOLE = String.raw`^\s*[+-]?[0-9]+\s*$`;
137
+ const SAFE = String.raw`^\s*[+-]?0*[0-9]{1,16}\s*$`;
138
+ const DAY = String.raw`^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$`;
139
+
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';`;
144
+
145
+ /**
146
+ * A document's scope, compiled from its definition as `_delta_resolve_scope`
147
+ * reads it at run time: the name after the prefix split on ":" into its
148
+ * params' values (`id` first, the rest in alphabetical order), each binding
149
+ * taken in the order Postgres keeps a JSON object's keys (shorter first), a
150
+ * value its column cannot take refused with the same message, and an `id`
151
+ * equality making the document one row (single mode).
152
+ */
153
+ export function scopeOf(schema: Schema, doc: DocDef, refuse: Refuse = raiseRefusal): CompiledScope {
154
+ const root = need(schema, doc, doc.root);
155
+ const entries = Object.entries(doc.scope ?? {});
156
+ if (entries.length === 0) {
157
+ return {
158
+ declare: ["v_rest TEXT;"],
159
+ body: `v_rest := substring(p_name FROM ${doc.prefix.length + 1});
160
+ v_single := v_rest <> '';
161
+ IF v_single THEN v_id := v_rest::BIGINT; END IF;`,
162
+ conds: "",
163
+ single: true,
164
+ checks: "",
165
+ resolve: "",
166
+ };
167
+ }
168
+ const parsed = entries
169
+ .map(([key, binding]) => {
170
+ let m: RegExpExecArray | null;
171
+ if ((m = /^at:(.+)$/.exec(binding))) return { key, op: "at", param: m[1]! };
172
+ if ((m = /^like:(.+)$/.exec(binding))) return { key, op: "like", param: m[1]! };
173
+ if ((m = /^([<>=!]+):(.+)$/.exec(binding))) {
174
+ if (!["=", ">=", "<=", ">", "<", "!="].includes(m[1]!)) throw new Error(`[dzql compile] document ${doc.prefix}: invalid scope operator "${m[1]}" (valid: =, >=, <=, >, <, !=, like, at)`);
175
+ return { key, op: m[1]!, param: m[2]! };
176
+ }
177
+ return { key, op: "=", param: binding.replace(/^:+/, "") };
178
+ })
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
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));
181
+ const declare = ["v_parts TEXT[];", "v_date TEXT[];"];
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[] = [];
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;`);
191
+ parsed.forEach(({ key, op, param }, i) => {
192
+ const v = `v_s${i + 1}`;
193
+ declare.push(`${v} TEXT;`);
194
+ check(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
195
+ const isKey = op !== "at" && (key === "id" || key === root.parent?.fkColumn);
196
+ const def = root.columns[key];
197
+ if (op === "at") {
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;`);
201
+ return;
202
+ }
203
+ if (!isKey && !def) throw new Error(`[dzql compile] document ${doc.prefix}: scope key "${key}" is not a column of "${doc.root}"`);
204
+ if (op === "like") {
205
+ conds.push(`(${v} IS NULL OR t.${q(key)} ILIKE ${v} || '%')`);
206
+ where(v, `format('%I ILIKE %L', ${lit(key)}, ${v} || '%')`);
207
+ return;
208
+ }
209
+ const sqlOp = op === "!=" ? "<>" : op;
210
+ if (isKey) {
211
+ const n = `v_n${i + 1}`;
212
+ declare.push(`${n} BOOLEAN;`);
213
+ check(`IF ${v} IS NOT NULL THEN
214
+ IF ${v} ~ '${WHOLE}' THEN
215
+ IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
216
+ ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
217
+ END IF;
218
+ ${n} := TRUE;
219
+ ELSIF ${v} ~* '${REAL}' THEN
220
+ ${refuse(`${key} must be an id -- a whole number, or text -- not "%"`, v)}
221
+ ELSE
222
+ ${n} := FALSE;
223
+ END IF;
224
+ END IF;`);
225
+ // text no row has as an id: after every number, as SQLite orders it
226
+ const asText = ["!=", "<", "<="].includes(op) ? `t.${q(key)} IS NOT NULL` : "FALSE";
227
+ conds.push(`(${v} IS NULL OR (CASE WHEN ${n} THEN t.${q(key)} ${sqlOp} ${v}::BIGINT ELSE ${asText} END))`);
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;`);
231
+ return;
232
+ }
233
+ if (def!.type === "integer") {
234
+ check(`IF ${v} IS NOT NULL THEN
235
+ IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} must be an integer, not "%"`, v)}
236
+ ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} must be an integer, not "%"`, v)}
237
+ END IF;
238
+ END IF;`);
239
+ } else if (def!.type === "real") {
240
+ check(`IF ${v} IS NOT NULL THEN
241
+ IF ${v} !~* '${REAL}' THEN ${refuse(`${key} must be a number, not "%"`, v)} END IF;
242
+ BEGIN
243
+ PERFORM ${v}::float8;
244
+ EXCEPTION WHEN numeric_value_out_of_range THEN
245
+ ${refuse(`${key} must be a number, not "%"`, v)}
246
+ END;
247
+ END IF;`);
248
+ } else if (def!.type === "timestamptz") {
249
+ check(dayOf(v, key, refuse));
250
+ }
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;`);
254
+ });
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
+ };
259
+ }
260
+
261
+ /** A date, YYYY-MM-DD, that the calendar has, read as the instant midnight UTC begins it; anything else refused. */
262
+ function dayOf(v: string, key: string, refuse: Refuse): string {
263
+ return `IF ${v} IS NOT NULL THEN
264
+ v_date := regexp_match(${v}, '${DAY}');
265
+ IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
266
+ ${refuse(`${key} must be a date, YYYY-MM-DD, not "%"`, v)}
267
+ 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
268
+ ${refuse(`${key} must be a date, YYYY-MM-DD, not "%"`, v)}
269
+ END IF;
270
+ ${v} := format('%s-%s-%s 00:00:00+00', v_date[1], v_date[2], v_date[3]);
271
+ END IF;`;
272
+ }
273
+
274
+ /** A document's function for `open_at`: the document as it stood at a time the caller gives. */
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);
279
+
280
+ /**
281
+ * One document's read, as `open` (`delta_open`: the time is the name's, an implied
282
+ * root opens with its defaults, the version is kept) or as `open_at`
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`.
287
+ */
288
+ export function readOf(schema: Schema, given: DocDef, at: boolean, whole = false): string {
289
+ const doc: DocDef = whole ? { ...given, columns: undefined } : given;
290
+ const root = need(schema, doc, doc.root);
291
+ const rootJson = rowOf(root, doc, doc.root, "t");
292
+ const fields = (included: (coll: string) => string) =>
293
+ doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, ${included(c)})`).join("");
294
+ const emptyIncludes = doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, '{}'::jsonb)`).join("");
295
+ const fn = whole ? openWholeFunction(doc.prefix) : at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
296
+ const scope = scopeOf(schema, doc);
297
+ const where = (first: string[]) => [...first, ...(scope.conds ? [scope.conds] : [])].join(" AND ") || "TRUE";
298
+ const params = at ? "p_name TEXT, p_given TIMESTAMPTZ" : "p_name TEXT";
299
+ const missing = !at && doc.implied ? `v_doc := jsonb_build_object(${lit(doc.root)}, ${impliedRoot(root, doc)})${emptyIncludes};` : "RETURN NULL;";
300
+ const done = at
301
+ ? "RETURN v_doc;"
302
+ : `INSERT INTO _delta_versions (doc_name, version) VALUES (p_name, 0) ON CONFLICT (doc_name) DO NOTHING;
303
+ SELECT version INTO v_version FROM _delta_versions WHERE doc_name = p_name;
304
+ RETURN v_doc || jsonb_build_object('_version', v_version);`;
305
+ return `CREATE OR REPLACE FUNCTION ${fn}(${params})
306
+ RETURNS JSONB AS $$
307
+ DECLARE
308
+ p_at TIMESTAMPTZ;
309
+ v_single BOOLEAN;
310
+ v_id BIGINT;
311
+ v_rows JSONB;
312
+ v_root JSONB;
313
+ v_doc JSONB;
314
+ v_version BIGINT;
315
+ ${scope.declare.join("\n ")}
316
+ BEGIN
317
+ ${scope.body}${at ? "\n p_at := p_given;" : ""}
318
+ ${scope.single ? "IF NOT v_single THEN\n " : ""}SELECT COALESCE(jsonb_object_agg(t.id, ${rootJson}), '{}'::jsonb) INTO v_rows FROM ${q(root.name)} t WHERE ${where([])}${standing(root, "t")};
319
+ ${scope.single ? " " : ""}v_doc := jsonb_build_object(${lit(doc.root)}, v_rows)${fields((c) => includedList(schema, doc, c))};${scope.single ? `
320
+ ELSE
321
+ SELECT ${rootJson} INTO v_root FROM ${q(root.name)} t WHERE ${where(["t.id = v_id"])}${standing(root, "t")};
322
+ IF v_root IS NULL THEN
323
+ ${missing}
324
+ ELSE
325
+ v_doc := jsonb_build_object(${lit(doc.root)}, v_root)${fields((c) => includedSingle(schema, doc, c))};
326
+ END IF;
327
+ END IF;` : ""}
328
+ ${done}
329
+ END;
330
+ $$ LANGUAGE plpgsql;${whole ? "" : `
331
+
332
+ CREATE OR REPLACE FUNCTION ${fn}_as(p_user_id TEXT, ${params})
333
+ RETURNS JSONB AS $$
334
+ BEGIN
335
+ PERFORM set_config('app.user_id', p_user_id, true);
336
+ RETURN ${fn}(${at ? "p_name, p_given" : "p_name"});
337
+ END
338
+ $$ LANGUAGE plpgsql;`}`;
339
+ }
340
+
341
+ /** One document's reads: its open and its open_at, each with its `_as`. */
342
+ function openOf(schema: Schema, doc: DocDef): string {
343
+ return `-- ${doc.prefix}: root ${doc.root}${doc.include.length ? `, includes ${doc.include.join(", ")}` : ""}${doc.implied ? ", implied" : ""}${doc.columns ? `, holds ${Object.entries(doc.columns).map(([c, cs]) => `${c}(${cs.join(", ")})`).join(" ")}` : ""}
344
+ ${readOf(schema, doc, false)}
345
+
346
+ ${readOf(schema, doc, true)}`;
347
+ }
348
+
349
+ /** The SQL that reads each of `docs` over `schema`: its open and its open_at, each with an `_as` for a signed-in caller. */
350
+ export function compileOpens(schema: Schema, docs: DocDef[]): string {
351
+ const names = new Map<string, string>();
352
+ for (const doc of docs) {
353
+ const fn = openFunction(doc.prefix);
354
+ const other = names.get(fn);
355
+ if (other !== undefined) throw new Error(`[dzql compile] documents ${other} and ${doc.prefix} would share the function ${fn}`);
356
+ names.set(fn, doc.prefix);
357
+ }
358
+ return [
359
+ "-- Compiled by dzql from the app's defineSchema / defineDoc. Do not edit: compile again.",
360
+ ...docs.map((d) => openOf(schema, d)),
361
+ "",
362
+ ].join("\n\n");
363
+ }
@@ -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
+ }