dzql 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Compiled lookups: a collection's definition, its table's serial, and a
3
+ * document read as it stood. What the write, the reads and the ledger still
4
+ * looked up at run time -- `_delta_collections` for a collection's table,
5
+ * parent and columns, a table's sequence by its name, the framework's read as
6
+ * it stood -- written from the definitions.
7
+ *
8
+ * `compileLookups(schema, docs)` writes:
9
+ * - `_delta_collection(key)`: each collection's definition, as generateSql
10
+ * records it in `_delta_collections`, written in; NULL fields for a key of
11
+ * none;
12
+ * - `_delta_serial_new(table, id)` and `_delta_serial_past(table, id)`: each
13
+ * table's sequence named, read or moved as the framework's do (as the
14
+ * functions' owner, for a caller with the table's privilege); for a table
15
+ * there is not, no id is new and nothing moves;
16
+ * - `_delta_find_doc(name)`: the document a name is of, by the longest prefix
17
+ * it starts with, as generateSql records it in `_delta_docs`, written in;
18
+ * NULL for a name of none;
19
+ * - `delta_open_at(name, at)`: each compiled document to its own read as it
20
+ * stood (`dzql_open_at__<slug>`), refusing a name of none.
21
+ *
22
+ * Each answers what the framework's did (recorded: tests/compile/lookups.test.ts).
23
+ */
24
+ import { q, type DocDef, type Schema } from "@blueshed/delta/backend";
25
+ import { findCascadeOn } from "../postgres/sql";
26
+ import { lit, noDoc, openAtFunction } from "./opens";
27
+
28
+ /** The caller, as the framework's serials ask: the role set, else the session's. */
29
+ const CALLER = "COALESCE(NULLIF(current_setting('role'), 'none'), session_user)";
30
+
31
+ /** The search path a definer function runs with: the schemas applied in, and pg_temp last (as the framework's serials). */
32
+ const searchPath = (signature: string) => `DO $$
33
+ DECLARE
34
+ v_path TEXT := COALESCE((SELECT string_agg(quote_ident(s), ', ') FROM unnest(current_schemas(false)) AS s), 'public') || ', pg_temp';
35
+ BEGIN
36
+ EXECUTE format('ALTER FUNCTION ${signature} SET search_path = %s', v_path);
37
+ END $$;`;
38
+
39
+ export function compileLookups(schema: Schema, docs: DocDef[]): string {
40
+ const tables = Object.entries(schema.tables);
41
+
42
+ const collection = `CREATE OR REPLACE FUNCTION _delta_collection(p_key TEXT)
43
+ RETURNS _delta_collections AS $$
44
+ BEGIN
45
+ CASE p_key
46
+ ${tables.map(([key, t]) => {
47
+ const row = {
48
+ collection_key: key,
49
+ table_name: t.name,
50
+ columns_def: t.columns,
51
+ parent_collection: t.parent?.collection ?? null,
52
+ parent_fk: t.parent?.fkColumn ?? null,
53
+ temporal: t.temporal,
54
+ cascade_on: findCascadeOn(key, schema),
55
+ };
56
+ return ` WHEN ${lit(key)} THEN RETURN jsonb_populate_record(null::_delta_collections, ${lit(JSON.stringify(row))}::jsonb);`;
57
+ }).join("\n")}
58
+ ELSE RETURN NULL; -- a collection there is not
59
+ END CASE;
60
+ END;
61
+ $$ LANGUAGE plpgsql STABLE;`;
62
+
63
+ const serialNew = `CREATE OR REPLACE FUNCTION _delta_serial_new(p_table TEXT, p_id BIGINT)
64
+ RETURNS BOOLEAN AS $$
65
+ DECLARE
66
+ v_new BOOLEAN;
67
+ BEGIN
68
+ CASE p_table
69
+ ${tables.map(([, t]) => ` WHEN ${lit(t.name)} THEN
70
+ IF NOT has_table_privilege(${CALLER}, p_table, 'SELECT') THEN RETURN FALSE; END IF;
71
+ SELECT p_id >= last_value + is_called::int INTO v_new FROM ${q(`seq_${t.name}`)};
72
+ RETURN v_new;`).join("\n")}
73
+ ELSE RETURN FALSE; -- a table there is not: no id is new
74
+ END CASE;
75
+ END;
76
+ $$ LANGUAGE plpgsql SECURITY DEFINER;
77
+ ${searchPath("_delta_serial_new(text, bigint)")}`;
78
+
79
+ const serialPast = `CREATE OR REPLACE FUNCTION _delta_serial_past(p_table TEXT, p_id BIGINT)
80
+ RETURNS void AS $$
81
+ BEGIN
82
+ CASE p_table
83
+ ${tables.map(([, t]) => ` WHEN ${lit(t.name)} THEN
84
+ IF p_id > 9007199254740991 OR NOT has_table_privilege(${CALLER}, p_table, 'INSERT') THEN RETURN; END IF;
85
+ PERFORM setval(${lit(`seq_${t.name}`)}, p_id) FROM ${q(`seq_${t.name}`)} WHERE p_id >= last_value + is_called::int;`).join("\n")}
86
+ ELSE NULL; -- a table there is not: nothing to move
87
+ END CASE;
88
+ END;
89
+ $$ LANGUAGE plpgsql SECURITY DEFINER;
90
+ ${searchPath("_delta_serial_past(text, bigint)")}`;
91
+
92
+ // longest first, as the framework's ORDER BY length(prefix) DESC: a name is of the longest prefix it starts with
93
+ const byLength = [...docs].sort((a, b) => b.prefix.length - a.prefix.length || (a.prefix < b.prefix ? -1 : 1));
94
+ const findDoc = `CREATE OR REPLACE FUNCTION _delta_find_doc(p_doc_name TEXT)
95
+ RETURNS _delta_docs AS $$
96
+ BEGIN
97
+ ${byLength.map((d) => {
98
+ const row = { prefix: d.prefix, root_collection: d.root, include: d.include, scope: d.scope, implied: !!d.implied, held: d.columns ?? {} };
99
+ return ` IF starts_with(p_doc_name, ${lit(d.prefix)}) THEN RETURN jsonb_populate_record(null::_delta_docs, ${lit(JSON.stringify(row))}::jsonb); END IF;`;
100
+ }).join("\n")}
101
+ RETURN NULL; -- a name no document holds
102
+ END;
103
+ $$ LANGUAGE plpgsql STABLE;`;
104
+
105
+ const openAt = `CREATE OR REPLACE FUNCTION delta_open_at(p_doc_name TEXT, p_at TIMESTAMPTZ)
106
+ RETURNS JSONB AS $$
107
+ DECLARE
108
+ v_def _delta_docs := _delta_find_doc(p_doc_name);
109
+ BEGIN
110
+ CASE v_def.prefix
111
+ ${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${openAtFunction(d.prefix)}(p_doc_name, p_at);`).join("\n")}
112
+ ELSE ${noDoc("p_doc_name")}
113
+ END CASE;
114
+ END;
115
+ $$ LANGUAGE plpgsql STABLE;`;
116
+
117
+ return [collection, findDoc, serialNew, serialPast, openAt, ""].join("\n\n");
118
+ }
@@ -25,12 +25,26 @@ 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 name no document compiled here holds: there is none (the framework that read the rest retired, todo #26). */
31
+ export const noDoc = (name: string): string => `RAISE EXCEPTION 'no doc def for: %', ${name} USING ERRCODE = 'P0001';`;
32
+
33
+ /** A collection none was compiled for: the write asks only its own, and delta_apply refuses any other first. */
34
+ export const noCollection = (key: string): string => `RAISE EXCEPTION 'unknown collection: %', ${key} USING ERRCODE = '22023';`;
35
+
36
+ /** A compiled function's name: `dzql_<kind>__` and the document's prefix (or a collection's key), lower case, every other character `_`. */
37
+ export const named = (kind: string, name: string): string =>
38
+ `dzql_${kind}__${name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")}`;
39
+
40
+ /** A document's open: `dzql_open__<slug>`. */
41
+ export const openFunction = (prefix: string): string => named("open", prefix);
42
+
43
+ /** A table's rows as they stand: its current view where temporal, else the table. */
44
+ export const standingRows = (table: ResolvedTable): string => q(table.temporal ? `current_${table.name}` : table.name);
45
+
46
+ /** 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. */
47
+ export const currentOnly = (table: ResolvedTable, alias: string): string => (table.temporal ? ` AND ${alias}.valid_to IS NULL` : "");
34
48
 
35
49
  /**
36
50
  * A row as a document reads it: as stored (a temporal row without its
@@ -102,7 +116,7 @@ function includedList(schema: Schema, doc: DocDef, coll: string): string {
102
116
  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
117
  }
104
118
 
105
- function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
119
+ export function need(schema: Schema, doc: DocDef, coll: string): ResolvedTable {
106
120
  const table = schema.tables[coll];
107
121
  if (!table) throw new Error(`[dzql compile] document ${doc.prefix} names a collection the schema does not have: ${coll}`);
108
122
  return table;
@@ -118,6 +132,10 @@ interface CompiledScope {
118
132
  conds: string;
119
133
  /** Whether a name can make it one row: no scope (a name with an id), or an `id` equality. */
120
134
  single: boolean;
135
+ /** `body` without what it sets for the reads (v_single, v_id, p_at): reading the values and checking each. */
136
+ checks: string;
137
+ /** After `checks`: plpgsql building `_delta_resolve_scope`'s answer as it builds it -- v_where (text), v_values, v_at. */
138
+ resolve: string;
121
139
  }
122
140
 
123
141
  const REAL = String.raw`^\s*[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)(e[+-]?[0-9]+)?\s*$`;
@@ -125,8 +143,10 @@ const WHOLE = String.raw`^\s*[+-]?[0-9]+\s*$`;
125
143
  const SAFE = String.raw`^\s*[+-]?0*[0-9]{1,16}\s*$`;
126
144
  const DAY = String.raw`^\s*([0-9]{4})-([0-9]{2})-([0-9]{2})\s*$`;
127
145
 
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';`;
146
+ /** What a value its column cannot take does: given the message and the value's variable, the plpgsql. */
147
+ type Refuse = (message: string, value: string) => string;
148
+ /** An open refuses it: the framework's message, as a 400 (22P02). */
149
+ const raiseRefusal: Refuse = (message, value) => `RAISE EXCEPTION '${message.replace(/'/g, "''")}', ${value} USING ERRCODE = '22P02';`;
130
150
 
131
151
  /**
132
152
  * A document's scope, compiled from its definition as `_delta_resolve_scope`
@@ -136,7 +156,7 @@ const refuse = (message: string, value: string) => `RAISE EXCEPTION '${message.r
136
156
  * value its column cannot take refused with the same message, and an `id`
137
157
  * equality making the document one row (single mode).
138
158
  */
139
- function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
159
+ export function scopeOf(schema: Schema, doc: DocDef, refuse: Refuse = raiseRefusal): CompiledScope {
140
160
  const root = need(schema, doc, doc.root);
141
161
  const entries = Object.entries(doc.scope ?? {});
142
162
  if (entries.length === 0) {
@@ -147,6 +167,8 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
147
167
  IF v_single THEN v_id := v_rest::BIGINT; END IF;`,
148
168
  conds: "",
149
169
  single: true,
170
+ checks: "",
171
+ resolve: "",
150
172
  };
151
173
  }
152
174
  const parsed = entries
@@ -163,28 +185,38 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
163
185
  .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
186
  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
187
  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;"];
188
+ const read = `v_parts := string_to_array(substring(p_name FROM ${doc.prefix.length + 1}), ':');`;
189
+ const body: string[] = [read, "v_single := FALSE;"];
190
+ const checks: string[] = [read];
191
+ const resolve: string[] = [];
167
192
  const conds: string[] = [];
193
+ // a check goes in both; what the reads set, in body alone
194
+ const check = (...lines: string[]) => { body.push(...lines); checks.push(...lines); };
195
+ // the framework's where, in its words: `%I <op> %L`, joined by AND
196
+ 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
197
  parsed.forEach(({ key, op, param }, i) => {
169
198
  const v = `v_s${i + 1}`;
170
199
  declare.push(`${v} TEXT;`);
171
- body.push(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
200
+ check(`${v} := NULLIF(v_parts[${params.indexOf(param) + 1}], '');`);
172
201
  const isKey = op !== "at" && (key === "id" || key === root.parent?.fkColumn);
173
202
  const def = root.columns[key];
174
203
  if (op === "at") {
175
- body.push(dayOf(v, key), `IF ${v} IS NOT NULL THEN p_at := ${v}::TIMESTAMPTZ; END IF;`);
204
+ check(dayOf(v, key, refuse));
205
+ body.push(`IF ${v} IS NOT NULL THEN p_at := ${v}::TIMESTAMPTZ; END IF;`);
206
+ resolve.push(`IF ${v} IS NOT NULL THEN v_at := ${v}; END IF;`);
176
207
  return;
177
208
  }
178
209
  if (!isKey && !def) throw new Error(`[dzql compile] document ${doc.prefix}: scope key "${key}" is not a column of "${doc.root}"`);
179
210
  if (op === "like") {
180
211
  conds.push(`(${v} IS NULL OR t.${q(key)} ILIKE ${v} || '%')`);
212
+ where(v, `format('%I ILIKE %L', ${lit(key)}, ${v} || '%')`);
181
213
  return;
182
214
  }
183
215
  const sqlOp = op === "!=" ? "<>" : op;
184
216
  if (isKey) {
185
217
  const n = `v_n${i + 1}`;
186
218
  declare.push(`${n} BOOLEAN;`);
187
- body.push(`IF ${v} IS NOT NULL THEN
219
+ check(`IF ${v} IS NOT NULL THEN
188
220
  IF ${v} ~ '${WHOLE}' THEN
189
221
  IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
190
222
  ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} % is past 2^53 - 1, which no number holds exactly`, v)}
@@ -200,16 +232,18 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
200
232
  const asText = ["!=", "<", "<="].includes(op) ? `t.${q(key)} IS NOT NULL` : "FALSE";
201
233
  conds.push(`(${v} IS NULL OR (CASE WHEN ${n} THEN t.${q(key)} ${sqlOp} ${v}::BIGINT ELSE ${asText} END))`);
202
234
  if (key === "id" && op === "=") body.push(`IF ${v} IS NOT NULL THEN v_single := TRUE; v_id := ${v}::BIGINT; END IF;`);
235
+ 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`);
236
+ if (op === "=") resolve.push(`IF ${v} IS NOT NULL THEN v_values := v_values || jsonb_build_object(${lit(key)}, ${v}); END IF;`);
203
237
  return;
204
238
  }
205
239
  if (def!.type === "integer") {
206
- body.push(`IF ${v} IS NOT NULL THEN
240
+ check(`IF ${v} IS NOT NULL THEN
207
241
  IF ${v} !~ '${SAFE}' THEN ${refuse(`${key} must be an integer, not "%"`, v)}
208
242
  ELSIF abs(${v}::numeric) > 9007199254740991 THEN ${refuse(`${key} must be an integer, not "%"`, v)}
209
243
  END IF;
210
244
  END IF;`);
211
245
  } else if (def!.type === "real") {
212
- body.push(`IF ${v} IS NOT NULL THEN
246
+ check(`IF ${v} IS NOT NULL THEN
213
247
  IF ${v} !~* '${REAL}' THEN ${refuse(`${key} must be a number, not "%"`, v)} END IF;
214
248
  BEGIN
215
249
  PERFORM ${v}::float8;
@@ -218,15 +252,20 @@ function scopeOf(schema: Schema, doc: DocDef): CompiledScope {
218
252
  END;
219
253
  END IF;`);
220
254
  } else if (def!.type === "timestamptz") {
221
- body.push(dayOf(v, key));
255
+ check(dayOf(v, key, refuse));
222
256
  }
223
257
  conds.push(`(${v} IS NULL OR t.${q(key)} ${sqlOp} ${v}::${columnSqlType(def!)})`);
258
+ where(v, `format('%I ${op} %L', ${lit(key)}, ${v})`);
259
+ if (op === "=") resolve.push(`IF ${v} IS NOT NULL THEN v_values := v_values || jsonb_build_object(${lit(key)}, ${v}); END IF;`);
224
260
  });
225
- return { declare, body: body.join("\n "), conds: conds.join(" AND "), single: parsed.some((p) => p.key === "id" && p.op === "=") };
261
+ return {
262
+ declare, body: body.join("\n "), conds: conds.join(" AND "), single: parsed.some((p) => p.key === "id" && p.op === "="),
263
+ checks: checks.join("\n "), resolve: resolve.join("\n "),
264
+ };
226
265
  }
227
266
 
228
267
  /** 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 {
268
+ function dayOf(v: string, key: string, refuse: Refuse): string {
230
269
  return `IF ${v} IS NOT NULL THEN
231
270
  v_date := regexp_match(${v}, '${DAY}');
232
271
  IF v_date IS NULL OR v_date[1]::int < 1 OR v_date[2]::int NOT BETWEEN 1 AND 12 THEN
@@ -239,20 +278,27 @@ function dayOf(v: string, key: string): string {
239
278
  }
240
279
 
241
280
  /** 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__");
281
+ export const openAtFunction = (prefix: string): string => named("open_at", prefix);
282
+
283
+ /** A document's whole read, for the ledger: `dzql_open_whole__<slug>`. */
284
+ export const openWholeFunction = (prefix: string): string => named("open_whole", prefix);
243
285
 
244
286
  /**
245
287
  * One document's read, as `open` (`delta_open`: the time is the name's, an implied
246
288
  * root opens with its defaults, the version is kept) or as `open_at`
247
289
  * (`delta_open_at`: the time is the caller's, a root not there is null, no version).
290
+ * `whole`: as `open`, every column of every row whatever columns the document
291
+ * names (`_delta_open(name, TRUE)`: what the ledger's inverse and a walk's guard
292
+ * read), with no `_as`.
248
293
  */
249
- function readOf(schema: Schema, doc: DocDef, at: boolean): string {
294
+ export function readOf(schema: Schema, given: DocDef, at: boolean, whole = false): string {
295
+ const doc: DocDef = whole ? { ...given, columns: undefined } : given;
250
296
  const root = need(schema, doc, doc.root);
251
297
  const rootJson = rowOf(root, doc, doc.root, "t");
252
298
  const fields = (included: (coll: string) => string) =>
253
299
  doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, ${included(c)})`).join("");
254
300
  const emptyIncludes = doc.include.map((c) => ` || jsonb_build_object(${lit(c)}, '{}'::jsonb)`).join("");
255
- const fn = at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
301
+ const fn = whole ? openWholeFunction(doc.prefix) : at ? openAtFunction(doc.prefix) : openFunction(doc.prefix);
256
302
  const scope = scopeOf(schema, doc);
257
303
  const where = (first: string[]) => [...first, ...(scope.conds ? [scope.conds] : [])].join(" AND ") || "TRUE";
258
304
  const params = at ? "p_name TEXT, p_given TIMESTAMPTZ" : "p_name TEXT";
@@ -287,7 +333,7 @@ BEGIN
287
333
  END IF;` : ""}
288
334
  ${done}
289
335
  END;
290
- $$ LANGUAGE plpgsql;
336
+ $$ LANGUAGE plpgsql;${whole ? "" : `
291
337
 
292
338
  CREATE OR REPLACE FUNCTION ${fn}_as(p_user_id TEXT, ${params})
293
339
  RETURNS JSONB AS $$
@@ -295,7 +341,7 @@ BEGIN
295
341
  PERFORM set_config('app.user_id', p_user_id, true);
296
342
  RETURN ${fn}(${at ? "p_name, p_given" : "p_name"});
297
343
  END
298
- $$ LANGUAGE plpgsql;`;
344
+ $$ LANGUAGE plpgsql;`}`;
299
345
  }
300
346
 
301
347
  /** 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
+ * document to its own and refuses a name of none (`no doc def for`: the framework that read the rest
14
+ * retired, todo #26).
15
+ */
16
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
17
+ import { lit, named, need, noDoc, 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 ${noDoc("p_doc_name")}
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 document to its own and refuses a name of none (`no doc def for`: the framework that read the rest
13
+ * retired, todo #26).
14
+ */
15
+ import type { DocDef, Schema } from "@blueshed/delta/backend";
16
+ import { lit, named, need, noDoc, 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 ${noDoc("p_doc_name")}
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,123 @@
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. An INSERT names the columns the row
19
+ * gives (`_delta_insert_sql`), so one it leaves out takes its database
20
+ * default: where the row gives exactly every column the schema declares (its
21
+ * id, parent key, columns and validity), that INSERT is one statement,
22
+ * written here; a row with any other keys is stored by the columns it gives
23
+ * (`_delta_row_add_given`).
24
+ *
25
+ * Each answers what the framework's did. A collection none was compiled for is
26
+ * refused (`unknown collection`), and a row of it is not there.
27
+ */
28
+ import { q, type Schema } from "@blueshed/delta/backend";
29
+ import { lit, noCollection, standingRows } from "./opens";
30
+
31
+ /** A CASE over the collections, each branch from `branch`, any other collection to `fallback`. */
32
+ function dispatch(schema: Schema, branch: (coll: string, table: Schema["tables"][string]) => string | null, fallback: string): string {
33
+ const branches = Object.entries(schema.tables).flatMap(([coll, table]) => {
34
+ const b = branch(coll, table);
35
+ return b === null ? [] : [` WHEN ${lit(coll)} THEN\n ${b}`];
36
+ });
37
+ return branches.length ? ` CASE p_coll\n${branches.join("\n")}\n ELSE ${fallback}\n END CASE;` : ` ${fallback}`;
38
+ }
39
+
40
+ /** The row SQL of every collection of `schema`, and its dispatch. */
41
+ export function compileRows(schema: Schema): string {
42
+ const lock = `CREATE OR REPLACE FUNCTION _delta_row_lock(p_coll TEXT, p_table TEXT, p_temporal BOOLEAN, p_id BIGINT)
43
+ RETURNS JSONB AS $$
44
+ DECLARE
45
+ v_row JSONB;
46
+ BEGIN
47
+ ${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;
48
+ RETURN v_row;`, noCollection("p_coll"))}
49
+ END;
50
+ $$ LANGUAGE plpgsql;`;
51
+
52
+ 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)
53
+ RETURNS JSONB AS $$
54
+ DECLARE
55
+ v_row JSONB;
56
+ BEGIN
57
+ ${dispatch(schema, (_, t) => (t.temporal
58
+ ? `DELETE FROM ${q(t.name)} WHERE id = p_id AND valid_to IS NULL AND valid_from = p_ts;
59
+ UPDATE ${q(t.name)} SET valid_to = p_ts WHERE id = p_id AND valid_to IS NULL;
60
+ 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))
61
+ RETURNING to_jsonb(t.*) INTO v_row;
62
+ RETURN v_row;`
63
+ : `DELETE FROM ${q(t.name)} WHERE id = p_id;
64
+ 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;
65
+ RETURN v_row;`), noCollection("p_coll"))}
66
+ END;
67
+ $$ LANGUAGE plpgsql;`;
68
+
69
+ // the sequence named as the framework names it: nextval of the text 'seq_<table>'
70
+ const next = `CREATE OR REPLACE FUNCTION _delta_next_id(p_coll TEXT, p_table TEXT)
71
+ RETURNS BIGINT AS $$
72
+ BEGIN
73
+ ${dispatch(schema, (_, t) => `RETURN nextval(${lit(`seq_${t.name}`)});`, noCollection("p_coll"))}
74
+ END;
75
+ $$ LANGUAGE plpgsql;`;
76
+
77
+ const clear = `CREATE OR REPLACE FUNCTION _delta_row_clear_moment(p_coll TEXT, p_table TEXT, p_id BIGINT, p_ts TIMESTAMPTZ)
78
+ RETURNS void AS $$
79
+ BEGIN
80
+ ${dispatch(schema, (_, t) => (t.temporal ? `DELETE FROM ${q(t.name)} WHERE id = p_id AND valid_from = p_ts AND valid_to = p_ts;
81
+ RETURN;` : null), noCollection("p_coll"))}
82
+ END;
83
+ $$ LANGUAGE plpgsql;`;
84
+
85
+ const there = `CREATE OR REPLACE FUNCTION _delta_row_there(p_collection_key TEXT, p_id BIGINT)
86
+ RETURNS BOOLEAN AS $$
87
+ DECLARE
88
+ p_coll TEXT := p_collection_key;
89
+ BEGIN
90
+ ${dispatch(schema, (_, t) => `RETURN p_id IS NOT NULL AND EXISTS (SELECT 1 FROM ${standingRows(t)} WHERE id = p_id);`,
91
+ "RETURN FALSE; -- a collection there is not: no row of it is there")}
92
+ END;
93
+ $$ LANGUAGE plpgsql STABLE;`;
94
+
95
+ const read = `CREATE OR REPLACE FUNCTION _delta_row_read(p_coll TEXT, p_view TEXT, p_id BIGINT)
96
+ RETURNS JSONB AS $$
97
+ DECLARE
98
+ v_row JSONB;
99
+ BEGIN
100
+ ${dispatch(schema, (_, t) => `SELECT _delta_strip_temporal(to_jsonb(t.*)) INTO v_row FROM ${standingRows(t)} t WHERE t.id = p_id;
101
+ RETURN v_row;`, noCollection("p_coll"))}
102
+ END;
103
+ $$ LANGUAGE plpgsql STABLE;`;
104
+
105
+ const add = `CREATE OR REPLACE FUNCTION _delta_row_add(p_coll TEXT, p_table TEXT, p_row JSONB)
106
+ RETURNS JSONB AS $$
107
+ DECLARE
108
+ v_row JSONB;
109
+ BEGIN
110
+ ${dispatch(schema, (_, t) => {
111
+ const keys = [...new Set(["id", ...(t.parent ? [t.parent.fkColumn] : []), ...Object.keys(t.columns), ...(t.temporal ? ["valid_from", "valid_to"] : [])])];
112
+ const cols = keys.map((k) => q(k)).join(", ");
113
+ return `IF p_row ?& ARRAY[${keys.map(lit).join(", ")}] AND (SELECT count(*) FROM jsonb_object_keys(p_row)) = ${keys.length} THEN
114
+ 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;
115
+ RETURN v_row;
116
+ END IF;
117
+ RETURN _delta_row_add_given(p_table, p_row); -- some other set of keys: the INSERT names those it gives`;
118
+ }, noCollection("p_coll"))}
119
+ END;
120
+ $$ LANGUAGE plpgsql;`;
121
+
122
+ return [lock, put, next, clear, there, read, add, ""].join("\n\n");
123
+ }