dzql 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -13
- package/package.json +2 -2
- package/src/compile/cascades.ts +151 -0
- package/src/compile/columns.ts +143 -0
- package/src/compile/holders.ts +66 -0
- package/src/compile/holds.ts +90 -0
- package/src/compile/ledger.ts +72 -0
- package/src/compile/lookups.ts +118 -0
- package/src/compile/opens.ts +69 -23
- package/src/compile/resolve.ts +75 -0
- package/src/compile/root.ts +61 -0
- package/src/compile/rows.ts +123 -0
- package/src/compile/scope.ts +88 -0
- package/src/compile/told.ts +95 -0
- package/src/postgres/auth-jwt-sql.ts +1 -1
- package/src/postgres/auth-jwt.ts +2 -2
- package/src/postgres/bootstrap.ts +2 -2
- package/src/postgres/codegen.ts +13 -2
- package/src/postgres/index.ts +2 -2
- package/src/postgres/listener.ts +50 -35
- package/src/postgres/pglite.ts +5 -2
- package/src/sql/001a-delta-meta.sql +55 -15
- package/src/sql/001b-delta-scope.sql +19 -451
- package/src/sql/001c-delta-read.sql +18 -296
- package/src/sql/001d-delta-write.sql +104 -330
- package/src/sql/001e-delta-ops.sql +4 -62
- package/src/sql/001g-delta-ledger.sql +51 -30
- package/src/sql/auth-jwt.sql +1 -1
|
@@ -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
|
+
}
|
package/src/compile/opens.ts
CHANGED
|
@@ -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
|
|
31
|
-
export
|
|
32
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =>
|
|
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,
|
|
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
|
+
}
|