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