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.
@@ -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 document's function name: its prefix, lower case, every other character `_`. */
31
- export function openFunction(prefix: string): string {
32
- return `dzql_open__${prefix.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
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
- /** Refuse a name's value: the framework's message, as a 400 (22P02). */
129
- const refuse = (message: string, value: string) => `RAISE EXCEPTION '${message.replace(/'/g, "''")}', ${value} USING ERRCODE = '22P02';`;
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 body: string[] = [`v_parts := string_to_array(substring(p_name FROM ${doc.prefix.length + 1}), ':');`, "v_single := FALSE;"];
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
- body.push(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
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
- body.push(dayOf(v, key), `IF ${v} IS NOT NULL THEN p_at := ${v}::TIMESTAMPTZ; END IF;`);
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
- body.push(`IF ${v} IS NOT NULL THEN
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
- body.push(`IF ${v} IS NOT NULL THEN
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
- body.push(`IF ${v} IS NOT NULL THEN
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
- body.push(dayOf(v, key));
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 { declare, body: body.join("\n "), conds: conds.join(" AND "), single: parsed.some((p) => p.key === "id" && p.op === "=") };
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 => openFunction(prefix).replace(/^dzql_open__/, "dzql_open_at__");
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, doc: DocDef, at: boolean): string {
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
+ }