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
package/README.md
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
# DZQL
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**delta's compiled Postgres step.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
cd my-app
|
|
10
|
-
bun install
|
|
11
|
-
bun run db:rebuild
|
|
12
|
-
bun run dev
|
|
13
|
-
```
|
|
5
|
+
An app built on [@blueshed/delta](https://github.com/blueshed/delta) keeps its definitions
|
|
6
|
+
(`defineSchema`, `defineDoc`), its client and its wire. On Postgres, dzql keeps its documents and
|
|
7
|
+
compiles those definitions to SQL, each piece held equal to delta's own cases. Why, and where it
|
|
8
|
+
is going: [philo.md](https://github.com/blueshed/dzql/blob/main/philo.md).
|
|
14
9
|
|
|
15
10
|
## delta's Postgres step
|
|
16
11
|
|
|
17
|
-
dzql is
|
|
12
|
+
dzql is the Postgres step for apps built on [@blueshed/delta](https://github.com/blueshed/delta):
|
|
18
13
|
a delta app grows from a JSON file to SQLite to Postgres, and on Postgres its documents are kept by
|
|
19
14
|
dzql. Since 0.8.0 dzql carries delta's Postgres backend, with the same names, so moving is an
|
|
20
15
|
import change:
|
|
@@ -25,8 +20,29 @@ import { openPglite } from "dzql/pglite"; // Postgres in this process
|
|
|
25
20
|
import { jwtAuth } from "dzql/auth-jwt";
|
|
26
21
|
```
|
|
27
22
|
|
|
28
|
-
Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL
|
|
29
|
-
framework SQL
|
|
23
|
+
Its tests run delta's own cases (`@blueshed/delta/testing`) against it. Compiled SQL is replacing
|
|
24
|
+
the framework SQL a piece at a time: `generateSql` writes functions for each of your documents
|
|
25
|
+
(opening it, reading its name, who holds a row, a write's scope, what each document is told, the
|
|
26
|
+
fan-out), each held equal to the framework's; documents it did not compile go to the framework. Apply `generateSql`
|
|
27
|
+
after the framework, every time (`docs/delta/postgres.md`, "Compiled SQL"). The plan is in
|
|
28
|
+
Workshop's `docs/postgres-split-plan.md`.
|
|
29
|
+
|
|
30
|
+
## The whole-stack dzql (retiring)
|
|
31
|
+
|
|
32
|
+
Before delta, dzql was a framework of its own: a `domain.ts` compiled to SQL, a typed client and
|
|
33
|
+
Pinia stores, started with `bun create dzql`. It is kept working until step 5 of the plan retires
|
|
34
|
+
it (the runtime, the client, the stores and `create-dzql`); nothing new is added to it. A new app
|
|
35
|
+
starts from delta and moves to dzql when it moves to Postgres.
|
|
36
|
+
|
|
37
|
+
### Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bun create dzql my-app
|
|
41
|
+
cd my-app
|
|
42
|
+
bun install
|
|
43
|
+
bun run db:rebuild
|
|
44
|
+
bun run dev
|
|
45
|
+
```
|
|
30
46
|
|
|
31
47
|
## Documentation
|
|
32
48
|
|
package/docs/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# DZQL: Realtime Apps Without the Boilerplate
|
|
2
2
|
|
|
3
|
+
> **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
|
|
4
|
+
> typed client and the Pinia stores. They keep working until step 5 of Workshop's
|
|
5
|
+
> `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
|
|
6
|
+
> starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
|
|
7
|
+
> `dzql/postgres` (`philo.md`, at the repo's root).
|
|
8
|
+
|
|
3
9
|
DZQL compiles your data model into a complete realtime backend. Define entities and permissions in TypeScript, get a PostgreSQL database with CRUD operations, WebSocket sync, and type-safe client SDK - all generated.
|
|
4
10
|
|
|
5
11
|
## Why DZQL?
|
package/docs/for_ai.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# DZQL Domain Modeling Guide
|
|
2
2
|
|
|
3
|
+
> **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
|
|
4
|
+
> typed client and the Pinia stores. They keep working until step 5 of Workshop's
|
|
5
|
+
> `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
|
|
6
|
+
> starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
|
|
7
|
+
> `dzql/postgres` (`philo.md`, at the repo's root).
|
|
8
|
+
|
|
3
9
|
This guide defines patterns for generating valid DZQL domain definitions.
|
|
4
10
|
|
|
5
11
|
## Quick Reference
|
package/docs/project-setup.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# DZQL Project Setup Guide
|
|
2
2
|
|
|
3
|
+
> **The whole-stack dzql.** This describes dzql as it stood at 0.7: `domain.ts`, its runtime, the
|
|
4
|
+
> typed client and the Pinia stores. They keep working until step 5 of Workshop's
|
|
5
|
+
> `docs/postgres-split-plan.md` retires them. dzql is now delta's compiled Postgres step: a new app
|
|
6
|
+
> starts on [@blueshed/delta](https://github.com/blueshed/delta) and comes to dzql through
|
|
7
|
+
> `dzql/postgres` (`philo.md`, at the repo's root).
|
|
8
|
+
|
|
3
9
|
Complete guide for setting up a Vue/Pinia client project with DZQL.
|
|
4
10
|
|
|
5
11
|
## Quick Start
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "delta's compiled Postgres step: an app's delta definitions compiled to SQL",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/blueshed/dzql"
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"./namespace": "./src/runtime/namespace.ts",
|
|
18
18
|
"./postgres": "./src/postgres/index.ts",
|
|
19
19
|
"./pglite": "./src/postgres/pglite.ts",
|
|
20
|
-
"./auth-jwt": "./src/postgres/auth-jwt.ts"
|
|
20
|
+
"./auth-jwt": "./src/postgres/auth-jwt.ts",
|
|
21
|
+
"./compile": "./src/compile/opens.ts"
|
|
21
22
|
},
|
|
22
23
|
"files": [
|
|
23
24
|
"src",
|
|
@@ -29,7 +30,8 @@
|
|
|
29
30
|
"scripts": {
|
|
30
31
|
"test": "bun run test:setup && bun test; status=$?; bun run test:teardown; exit $status",
|
|
31
32
|
"test:setup": "docker compose down -v && docker compose up -d --wait",
|
|
32
|
-
"test:teardown": "docker compose down -v"
|
|
33
|
+
"test:teardown": "docker compose down -v",
|
|
34
|
+
"bench": "bun run bench/run.ts"
|
|
33
35
|
},
|
|
34
36
|
"keywords": [
|
|
35
37
|
"postgresql",
|
|
@@ -48,7 +50,6 @@
|
|
|
48
50
|
"author": "Peter Bunyan",
|
|
49
51
|
"license": "MIT",
|
|
50
52
|
"dependencies": {
|
|
51
|
-
"@blueshed/delta": "^0.16.0",
|
|
52
53
|
"@electric-sql/pglite": "^0.5.4",
|
|
53
54
|
"dotenv": "^16.4.5",
|
|
54
55
|
"jose": "^6.2.4",
|
|
@@ -56,14 +57,17 @@
|
|
|
56
57
|
"postgres": "^3.4.3"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|
|
60
|
+
"@blueshed/railroad": "^0.16.0",
|
|
59
61
|
"@types/bun": "latest",
|
|
60
62
|
"@types/pg": "^8.20.0",
|
|
61
63
|
"pinia": "^3.0.4",
|
|
62
64
|
"typescript": "^5.0.0",
|
|
63
|
-
"vue": "^3.5.26"
|
|
65
|
+
"vue": "^3.5.26",
|
|
66
|
+
"@blueshed/delta": "^1.0.0"
|
|
64
67
|
},
|
|
65
68
|
"peerDependencies": {
|
|
66
|
-
"typescript": "^5.0.0"
|
|
69
|
+
"typescript": "^5.0.0",
|
|
70
|
+
"@blueshed/delta": "^1.0.0"
|
|
67
71
|
},
|
|
68
72
|
"engines": {
|
|
69
73
|
"bun": ">=1.0.0"
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,13 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
|
14
14
|
const VERSION = packageJson.version;
|
|
15
15
|
|
|
16
16
|
const args = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
// delta's Postgres commands (dzql is delta's Postgres step): sql, init, migrate
|
|
19
|
+
if (["sql", "init", "migrate"].includes(args[0] ?? "")) {
|
|
20
|
+
const { main } = await import("../postgres/cli.ts");
|
|
21
|
+
await main(args);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
17
24
|
let command = args[0];
|
|
18
25
|
let input = args[1];
|
|
19
26
|
let outputDir = "dist"; // Default output directory
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled cascades: what a remove takes with it, and what a moved row
|
|
3
|
+
* carries. The framework walks `_delta_collections` at run time for a
|
|
4
|
+
* collection's children and the collections that cascade on it, and builds
|
|
5
|
+
* each query from their names; here the schema says them, and each is written.
|
|
6
|
+
*
|
|
7
|
+
* `compileCascades(schema)` writes, for every collection of the schema:
|
|
8
|
+
* - `dzql_cascade_remove__<slug>(id)`: the row closed (temporal) or deleted, and
|
|
9
|
+
* only if it was there, each child collection's rows under it, then each
|
|
10
|
+
* collection that cascades on it -- the removes, in that order;
|
|
11
|
+
* - `dzql_cascade_rows__<slug>(id)`: the rows that remove would take, read
|
|
12
|
+
* before, in the order it takes them;
|
|
13
|
+
* - `dzql_hanging_rows__<slug>(id)`: the rows that hang from it, and on down,
|
|
14
|
+
* parent first.
|
|
15
|
+
* And `_delta_cascade_remove`, `_delta_cascade_rows` and `_delta_hanging_rows`,
|
|
16
|
+
* sending each collection to its own and any other to the framework's
|
|
17
|
+
* (`_framework`). The order is the framework's: child collections by key, then
|
|
18
|
+
* the collections that cascade on it by key and key column, each in the
|
|
19
|
+
* database's byte order (`COLLATE "C"`), and each one's rows by id.
|
|
20
|
+
*/
|
|
21
|
+
import { q, type Schema } from "@blueshed/delta/backend";
|
|
22
|
+
import { lit, named, standingRows } from "./opens";
|
|
23
|
+
|
|
24
|
+
/** A collection's function: `dzql_<what>__<slug>`. */
|
|
25
|
+
const fn = (what: string, coll: string): string => named(what, coll);
|
|
26
|
+
|
|
27
|
+
/** As `COLLATE "C"` orders text: by its bytes. */
|
|
28
|
+
const bytes = (a: string, b: string): number => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
29
|
+
|
|
30
|
+
/** A collection's children, and the collections that cascade on it, in the framework's order: [collection, key column]. */
|
|
31
|
+
function reached(schema: Schema, coll: string): { children: [string, string][]; refs: [string, string][] } {
|
|
32
|
+
const table = schema.tables[coll]!;
|
|
33
|
+
const children = [...table.children].sort(bytes).map((c): [string, string] => [c, schema.tables[c]!.parent!.fkColumn]);
|
|
34
|
+
const refs = table.referencedBy
|
|
35
|
+
.filter((r) => r.collection !== coll) // as generateSql's cascade_on: never a collection's own
|
|
36
|
+
.map((r): [string, string] => [r.collection, r.fkColumn])
|
|
37
|
+
.sort((a, b) => bytes(a[0], b[0]) || bytes(a[1], b[1]));
|
|
38
|
+
return { children, refs };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every collection of `schema` its own slug: two that would share a function name are refused. */
|
|
42
|
+
function assertSlugs(schema: Schema): void {
|
|
43
|
+
const seen = new Map<string, string>();
|
|
44
|
+
for (const coll of Object.keys(schema.tables)) {
|
|
45
|
+
const slug = fn("", coll);
|
|
46
|
+
const other = seen.get(slug);
|
|
47
|
+
if (other) throw new Error(`[dzql compile] collections "${other}" and "${coll}" would share the function name ${slug}: rename one`);
|
|
48
|
+
seen.set(slug, coll);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeOf(schema: Schema, coll: string): string {
|
|
53
|
+
const t = schema.tables[coll]!;
|
|
54
|
+
const { children, refs } = reached(schema, coll);
|
|
55
|
+
const walk = [...children, ...refs].map(([c, fk]) =>
|
|
56
|
+
` FOR v_id IN SELECT id FROM ${standingRows(schema.tables[c]!)} WHERE ${q(fk)} = p_id ORDER BY id LOOP
|
|
57
|
+
v_ops := v_ops || ${fn("cascade_remove", c)}(v_id);
|
|
58
|
+
END LOOP;`);
|
|
59
|
+
return `CREATE OR REPLACE FUNCTION ${fn("cascade_remove", coll)}(p_id BIGINT)
|
|
60
|
+
RETURNS JSONB AS $$
|
|
61
|
+
DECLARE
|
|
62
|
+
v_affected BIGINT;
|
|
63
|
+
v_id BIGINT;
|
|
64
|
+
v_ops JSONB;
|
|
65
|
+
BEGIN
|
|
66
|
+
${t.temporal ? `UPDATE ${q(t.name)} SET valid_to = NOW() WHERE id = p_id AND valid_to IS NULL;` : `DELETE FROM ${q(t.name)} WHERE id = p_id;`}
|
|
67
|
+
GET DIAGNOSTICS v_affected = ROW_COUNT;
|
|
68
|
+
IF v_affected = 0 THEN RETURN '[]'::jsonb; END IF; -- only a removal that happened (closed already, or RLS filtered it)
|
|
69
|
+
v_ops := jsonb_build_array(jsonb_build_object('op', 'remove', 'path', _delta_build_path(${lit(coll)}, p_id::text)));
|
|
70
|
+
${walk.join("\n")}
|
|
71
|
+
RETURN v_ops;
|
|
72
|
+
END;
|
|
73
|
+
$$ LANGUAGE plpgsql;`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function rowsOf(schema: Schema, coll: string): string {
|
|
77
|
+
const t = schema.tables[coll]!;
|
|
78
|
+
const { children, refs } = reached(schema, coll);
|
|
79
|
+
const walk = [...children, ...refs].map(([c, fk]) =>
|
|
80
|
+
` FOR v_id IN SELECT id FROM ${standingRows(schema.tables[c]!)} WHERE ${q(fk)} = p_id ORDER BY id LOOP
|
|
81
|
+
v_rows := v_rows || ${fn("cascade_rows", c)}(v_id);
|
|
82
|
+
END LOOP;`);
|
|
83
|
+
return `CREATE OR REPLACE FUNCTION ${fn("cascade_rows", coll)}(p_id BIGINT)
|
|
84
|
+
RETURNS JSONB AS $$
|
|
85
|
+
DECLARE
|
|
86
|
+
v_row JSONB;
|
|
87
|
+
v_id BIGINT;
|
|
88
|
+
v_rows JSONB;
|
|
89
|
+
BEGIN
|
|
90
|
+
SELECT to_jsonb(t.*) INTO v_row FROM ${standingRows(t)} t WHERE t.id = p_id;
|
|
91
|
+
IF v_row IS NULL THEN RETURN '[]'::jsonb; END IF;
|
|
92
|
+
v_rows := jsonb_build_array(jsonb_build_object('coll', ${lit(coll)}, 'id', p_id, 'row', _delta_strip_temporal(v_row)));
|
|
93
|
+
${walk.join("\n")}
|
|
94
|
+
RETURN v_rows;
|
|
95
|
+
END;
|
|
96
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hangingOf(schema: Schema, coll: string): string {
|
|
100
|
+
const { children } = reached(schema, coll);
|
|
101
|
+
const parts = children.map(([c, fk]) => {
|
|
102
|
+
const below = schema.tables[c]!.children.length ? `${fn("hanging_rows", c)}(t.id)` : "'[]'::jsonb";
|
|
103
|
+
return ` SELECT COALESCE(jsonb_agg(e ORDER BY t.id, n), '[]'::jsonb) INTO v_part FROM ${standingRows(schema.tables[c]!)} t,
|
|
104
|
+
jsonb_array_elements(jsonb_build_array(jsonb_build_object('coll', ${lit(c)}, 'id', t.id, 'row', _delta_strip_temporal(to_jsonb(t.*)))) || ${below})
|
|
105
|
+
WITH ORDINALITY AS x(e, n)
|
|
106
|
+
WHERE t.${q(fk)} = p_id;
|
|
107
|
+
v_rows := v_rows || v_part;`;
|
|
108
|
+
});
|
|
109
|
+
return `CREATE OR REPLACE FUNCTION ${fn("hanging_rows", coll)}(p_id BIGINT)
|
|
110
|
+
RETURNS JSONB AS $$
|
|
111
|
+
DECLARE
|
|
112
|
+
v_part JSONB;
|
|
113
|
+
v_rows JSONB := '[]'::jsonb;
|
|
114
|
+
BEGIN
|
|
115
|
+
${parts.join("\n")}
|
|
116
|
+
RETURN v_rows;
|
|
117
|
+
END;
|
|
118
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A dispatcher: each collection of `schema` to its own, any other to the framework's. */
|
|
122
|
+
function dispatch(schema: Schema, name: string, what: string, volatility: string): string {
|
|
123
|
+
const colls = Object.keys(schema.tables);
|
|
124
|
+
const body = colls.length
|
|
125
|
+
? ` CASE p_collection_key
|
|
126
|
+
${colls.map((c) => ` WHEN ${lit(c)} THEN RETURN ${fn(what, c)}(p_id);`).join("\n")}
|
|
127
|
+
ELSE RETURN ${name}_framework(p_collection_key, p_id);
|
|
128
|
+
END CASE;`
|
|
129
|
+
: ` RETURN ${name}_framework(p_collection_key, p_id);`;
|
|
130
|
+
return `CREATE OR REPLACE FUNCTION ${name}(p_collection_key TEXT, p_id BIGINT)
|
|
131
|
+
RETURNS JSONB AS $$
|
|
132
|
+
BEGIN
|
|
133
|
+
${body}
|
|
134
|
+
END;
|
|
135
|
+
$$ LANGUAGE plpgsql${volatility};`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The cascades and hanging rows of every collection of `schema`, and the dispatch to the framework's for any other. */
|
|
139
|
+
export function compileCascades(schema: Schema): string {
|
|
140
|
+
assertSlugs(schema);
|
|
141
|
+
const colls = Object.keys(schema.tables);
|
|
142
|
+
return [
|
|
143
|
+
...colls.map((c) => removeOf(schema, c)),
|
|
144
|
+
...colls.map((c) => rowsOf(schema, c)),
|
|
145
|
+
...colls.map((c) => hangingOf(schema, c)),
|
|
146
|
+
dispatch(schema, "_delta_cascade_remove", "cascade_remove", ""),
|
|
147
|
+
dispatch(schema, "_delta_cascade_rows", "cascade_rows", " STABLE"),
|
|
148
|
+
dispatch(schema, "_delta_hanging_rows", "hanging_rows", " STABLE"),
|
|
149
|
+
"",
|
|
150
|
+
].join("\n\n");
|
|
151
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled columns and their checks: what a write may name, and what each
|
|
3
|
+
* value must be. Asked of every op of a write (`delta_apply`).
|
|
4
|
+
*
|
|
5
|
+
* The columns are the writing process's: `delta.declared`, set for the call by
|
|
6
|
+
* its listener, else as the database last declared them (the schema is the
|
|
7
|
+
* contract: two versions on one database each write by their own). So they
|
|
8
|
+
* stay read at run time, as the framework reads them, and what is compiled is
|
|
9
|
+
* what follows from the definitions:
|
|
10
|
+
*
|
|
11
|
+
* - `dzql_columns__<slug>(collection, columns_def)` for each document: the
|
|
12
|
+
* columns it writes, the ones it names (`columns`) where it names some,
|
|
13
|
+
* from the list written in. `_delta_write_columns` sends each compiled
|
|
14
|
+
* document to its own, any other to the framework
|
|
15
|
+
* (`_delta_write_columns_framework`).
|
|
16
|
+
* - `_delta_assert_fields` itself: for each collection, each set of columns
|
|
17
|
+
* the definitions give it (all of them, and each document's own), checked
|
|
18
|
+
* by written statements -- the fields there are, the parent key, each
|
|
19
|
+
* column's type -- with the framework's messages, put together in the
|
|
20
|
+
* framework's order (`ORDER BY` in SQL, so the database's collation orders
|
|
21
|
+
* both alike). Columns of another set (a walk's, another version's) are
|
|
22
|
+
* checked by the framework's (`_delta_assert_fields_framework`).
|
|
23
|
+
*/
|
|
24
|
+
import type { ColumnDef, DocDef, ResolvedTable, Schema } from "@blueshed/delta/backend";
|
|
25
|
+
import { lit, named, need } from "./opens";
|
|
26
|
+
|
|
27
|
+
/** A document's columns function: `dzql_columns__<slug>`. */
|
|
28
|
+
export const columnsFunction = (prefix: string): string => named("columns", prefix);
|
|
29
|
+
|
|
30
|
+
/** A set of columns as the database keeps it (`_delta_collections.columns_def`, generateSql's). */
|
|
31
|
+
const asStored = (columns: Record<string, ColumnDef>): string => `${lit(JSON.stringify(columns))}::jsonb`;
|
|
32
|
+
|
|
33
|
+
/** One document's columns function. */
|
|
34
|
+
function columnsOf(schema: Schema, doc: DocDef): string {
|
|
35
|
+
const declared = "COALESCE(NULLIF(current_setting('delta.declared', true), '')::jsonb -> p_coll, p_columns_def)";
|
|
36
|
+
const named = Object.entries(doc.columns ?? {});
|
|
37
|
+
for (const [coll] of named) need(schema, doc, coll);
|
|
38
|
+
const body = named.length === 0
|
|
39
|
+
? ` RETURN ${declared};`
|
|
40
|
+
: ` v_cols := ${declared};
|
|
41
|
+
CASE p_coll
|
|
42
|
+
${named.map(([coll, cols]) => ` WHEN ${lit(coll)} THEN RETURN ${cols.length
|
|
43
|
+
? `COALESCE((SELECT jsonb_object_agg(k, v) FROM jsonb_each(v_cols) AS e(k, v) WHERE k IN (${cols.map(lit).join(", ")})), '{}'::jsonb)`
|
|
44
|
+
: "'{}'::jsonb"};`).join("\n")}
|
|
45
|
+
ELSE RETURN v_cols;
|
|
46
|
+
END CASE;`;
|
|
47
|
+
return `CREATE OR REPLACE FUNCTION ${columnsFunction(doc.prefix)}(p_coll TEXT, p_columns_def JSONB)
|
|
48
|
+
RETURNS JSONB AS $$
|
|
49
|
+
DECLARE
|
|
50
|
+
v_cols JSONB;
|
|
51
|
+
BEGIN
|
|
52
|
+
${body}
|
|
53
|
+
END;
|
|
54
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a column's value must be, as the framework says it; none for a type it does not check. */
|
|
58
|
+
const MUST: Record<string, [string, (v: string) => string]> = {
|
|
59
|
+
text: ["a string", (v) => `jsonb_typeof(${v}) = 'string'`],
|
|
60
|
+
integer: ["an integer", (v) => `jsonb_typeof(${v}) = 'number' AND ${v}::text::numeric = trunc(${v}::text::numeric)`],
|
|
61
|
+
real: ["a number", (v) => `jsonb_typeof(${v}) = 'number'`],
|
|
62
|
+
boolean: ["a boolean", (v) => `jsonb_typeof(${v}) = 'boolean'`],
|
|
63
|
+
timestamptz: ["an ISO-8601 time", (v) =>
|
|
64
|
+
`jsonb_typeof(${v}) = 'string' AND (${v} #>> '{}') ~ '^\\d{4}-\\d{2}-\\d{2}([T ]([01]\\d|2[0-3]):[0-5]\\d(:[0-5]\\d(\\.\\d{1,6})?)?(Z|[+-]([01]\\d|2[0-3])(:?[0-5]\\d)?)?)?$'`],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The checks of one set of a collection's columns, as written statements. */
|
|
68
|
+
function checksOf(table: ResolvedTable, columns: Record<string, ColumnDef>): string {
|
|
69
|
+
const fk = table.parent?.fkColumn;
|
|
70
|
+
const known = ["id", "valid_from", "valid_to", ...(fk ? [fk] : []), ...Object.keys(columns)];
|
|
71
|
+
const typed = Object.entries(columns).filter(([, d]) => MUST[d.type]);
|
|
72
|
+
const lines = [
|
|
73
|
+
`IF p_value IS NULL OR jsonb_typeof(p_value) <> 'object' THEN
|
|
74
|
+
RAISE EXCEPTION 'a row value for % must be an object', p_collection USING ERRCODE = '22023';
|
|
75
|
+
END IF;`,
|
|
76
|
+
`SELECT string_agg(k, ', ' ORDER BY k) INTO v_bad FROM jsonb_object_keys(p_value) AS k WHERE k NOT IN (${[...new Set(known)].map(lit).join(", ")});
|
|
77
|
+
IF v_bad IS NOT NULL THEN
|
|
78
|
+
RAISE EXCEPTION 'Unknown field: % (not a column of %)', v_bad, p_collection USING ERRCODE = '22023';
|
|
79
|
+
END IF;`,
|
|
80
|
+
];
|
|
81
|
+
if (fk) {
|
|
82
|
+
const v = `p_value->${lit(fk)}`;
|
|
83
|
+
lines.push(`IF p_value ? ${lit(fk)} AND jsonb_typeof(${v}) <> 'null'
|
|
84
|
+
AND NOT (jsonb_typeof(${v}) = 'string' OR (jsonb_typeof(${v}) = 'number' AND (p_value->>${lit(fk)})::numeric = trunc((p_value->>${lit(fk)})::numeric))) THEN
|
|
85
|
+
RAISE EXCEPTION '% must be an id: an integer', ${lit(fk)} USING ERRCODE = '22023';
|
|
86
|
+
END IF;`);
|
|
87
|
+
}
|
|
88
|
+
if (typed.length) {
|
|
89
|
+
const rows = typed.map(([col, d]) => {
|
|
90
|
+
const v = `(p_value->${lit(col)})`; // in parentheses: `::` binds tighter than `->`
|
|
91
|
+
const [must, ok] = MUST[d.type]!;
|
|
92
|
+
return `(${lit(col)}, CASE WHEN jsonb_typeof(${v}) <> 'null' AND NOT (${ok(v)}) THEN ${lit(`${col} must be ${must}`)} END)`;
|
|
93
|
+
});
|
|
94
|
+
lines.push(`SELECT string_agg(m, '; ' ORDER BY k) INTO v_bad FROM (VALUES
|
|
95
|
+
${rows.join(",\n ")}) AS x(k, m) WHERE m IS NOT NULL;
|
|
96
|
+
IF v_bad IS NOT NULL THEN RAISE EXCEPTION '%', v_bad USING ERRCODE = '22023'; END IF;`);
|
|
97
|
+
}
|
|
98
|
+
return [...lines, "RETURN;"].join("\n ");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The SQL that gives and checks each of `docs`' columns: a function per document, `_delta_write_columns`, and `_delta_assert_fields`. */
|
|
102
|
+
export function compileColumns(schema: Schema, docs: DocDef[]): string {
|
|
103
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_write_columns(p_def _delta_docs, p_coll TEXT, p_columns_def JSONB)
|
|
104
|
+
RETURNS JSONB AS $$
|
|
105
|
+
BEGIN
|
|
106
|
+
CASE p_def.prefix
|
|
107
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${columnsFunction(d.prefix)}(p_coll, p_columns_def);`).join("\n")}
|
|
108
|
+
ELSE RETURN _delta_write_columns_framework(p_def, p_coll, p_columns_def);
|
|
109
|
+
END CASE;
|
|
110
|
+
END;
|
|
111
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
112
|
+
|
|
113
|
+
// each collection's sets of columns: all of them, and each document's own
|
|
114
|
+
const branches = Object.entries(schema.tables).map(([coll, table]) => {
|
|
115
|
+
const sets = new Map<string, Record<string, ColumnDef>>([[JSON.stringify(table.columns), table.columns]]);
|
|
116
|
+
for (const doc of docs) {
|
|
117
|
+
const named = doc.columns?.[coll];
|
|
118
|
+
if (!named) continue;
|
|
119
|
+
const own = Object.fromEntries(Object.entries(table.columns).filter(([c]) => named.includes(c)));
|
|
120
|
+
sets.set(JSON.stringify(own), own);
|
|
121
|
+
}
|
|
122
|
+
const fk = table.parent?.fkColumn;
|
|
123
|
+
return ` WHEN ${lit(coll)} THEN
|
|
124
|
+
IF p_parent_fk IS NOT DISTINCT FROM ${fk ? lit(fk) : "NULL"} THEN
|
|
125
|
+
${[...sets.values()].map((cols) => ` IF p_columns = ${asStored(cols)} THEN
|
|
126
|
+
${checksOf(table, cols)}
|
|
127
|
+
END IF;`).join("\n")}
|
|
128
|
+
END IF;`;
|
|
129
|
+
});
|
|
130
|
+
const checks = `CREATE OR REPLACE FUNCTION _delta_assert_fields(p_collection TEXT, p_columns JSONB, p_parent_fk TEXT, p_value JSONB)
|
|
131
|
+
RETURNS void AS $$
|
|
132
|
+
DECLARE
|
|
133
|
+
v_bad TEXT;
|
|
134
|
+
BEGIN
|
|
135
|
+
CASE p_collection
|
|
136
|
+
${branches.join("\n")}
|
|
137
|
+
ELSE NULL;
|
|
138
|
+
END CASE;
|
|
139
|
+
-- another set of columns (a walk's, another version's): the framework's
|
|
140
|
+
PERFORM _delta_assert_fields_framework(p_collection, p_columns, p_parent_fk, p_value);
|
|
141
|
+
END;
|
|
142
|
+
$$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
143
|
+
return [...docs.map((d) => columnsOf(schema, d)), dispatch, checks, ""].join("\n\n");
|
|
144
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled fan-out: which open documents hold a row. Asked by every write of
|
|
3
|
+
* every row it touches, as the row was before and as it is after
|
|
4
|
+
* (`_delta_holders`), and what each is told follows from it.
|
|
5
|
+
*
|
|
6
|
+
* `compileHolders(schema, docs)` writes `_delta_holders` itself, answering
|
|
7
|
+
* what the framework's answers, from the definitions: for the row's
|
|
8
|
+
* collection, each document that holds it, in turn -- its prefix's lock,
|
|
9
|
+
* shared; its open names, found as the framework finds them (by the root the
|
|
10
|
+
* row hangs from, its chain written as lookups, where the document is one row
|
|
11
|
+
* of an unscoped definition; else every open name of its prefix), and the
|
|
12
|
+
* writer's; each name its own prefix owns and whose document holds the row
|
|
13
|
+
* (`dzql_holds__<slug>`). Any other document (registered by hand, or by another
|
|
14
|
+
* `generateSql`) is asked by the framework's (`_delta_holders_framework`), told
|
|
15
|
+
* which prefixes were asked already. SECURITY DEFINER, with the search path it
|
|
16
|
+
* was applied with and pg_temp last, as the framework's.
|
|
17
|
+
*/
|
|
18
|
+
import type { DocDef, Schema } from "@blueshed/delta/backend";
|
|
19
|
+
import { chainRoot, holdsFunction } from "./holds";
|
|
20
|
+
import { lit, need } from "./opens";
|
|
21
|
+
|
|
22
|
+
/** One document's names that hold the row, for a collection it holds. */
|
|
23
|
+
function askOf(schema: Schema, doc: DocDef, coll: string): string {
|
|
24
|
+
const table = need(schema, doc, coll);
|
|
25
|
+
const all = Object.keys(doc.scope ?? {}).length > 0 || (coll !== doc.root && !table.parent);
|
|
26
|
+
const prefix = lit(doc.prefix);
|
|
27
|
+
return ` -- ${doc.prefix}
|
|
28
|
+
${all ? "" : ` v_rid := ${coll === doc.root ? "(p_row->>'id')::BIGINT" : chainRoot(schema, doc, coll)};\n`} PERFORM pg_advisory_xact_lock_shared(hashtext('_delta_hold'), hashtext(${prefix}));
|
|
29
|
+
FOR v_cand IN
|
|
30
|
+
SELECT doc_name FROM _delta_held WHERE ${all ? `starts_with(doc_name, ${prefix})` : `doc_name IN (${prefix}, ${prefix} || v_rid::text)`}
|
|
31
|
+
UNION
|
|
32
|
+
SELECT p_writer WHERE p_writer IS NOT NULL AND starts_with(p_writer, ${prefix})
|
|
33
|
+
LOOP
|
|
34
|
+
IF NOT (v_cand = ANY(v_names))
|
|
35
|
+
AND NOT EXISTS (SELECT 1 FROM _delta_docs d WHERE length(d.prefix) > ${doc.prefix.length} AND starts_with(v_cand, d.prefix)) -- the longest prefix owns the name
|
|
36
|
+
AND ${holdsFunction(doc.prefix)}(v_cand, ${lit(coll)}, p_row) THEN
|
|
37
|
+
v_names := v_names || v_cand;
|
|
38
|
+
END IF;
|
|
39
|
+
END LOOP;`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The SQL that answers who holds a row: `_delta_holders`, each of `docs` asked by its own lookups, any other by the framework's. */
|
|
43
|
+
export function compileHolders(schema: Schema, docs: DocDef[]): string {
|
|
44
|
+
const branches = Object.keys(schema.tables).flatMap((coll) => {
|
|
45
|
+
const holding = docs.filter((d) => d.root === coll || d.include.includes(coll));
|
|
46
|
+
return holding.length ? [` WHEN ${lit(coll)} THEN\n${holding.map((d) => askOf(schema, d, coll)).join("\n")}`] : [];
|
|
47
|
+
});
|
|
48
|
+
const asked = `ARRAY[${docs.map((d) => lit(d.prefix)).join(", ")}]::text[]`;
|
|
49
|
+
return `CREATE OR REPLACE FUNCTION _delta_holders(p_coll TEXT, p_row JSONB, p_writer TEXT)
|
|
50
|
+
RETURNS TEXT[] AS $$
|
|
51
|
+
DECLARE
|
|
52
|
+
v_names TEXT[] := ARRAY[]::text[];
|
|
53
|
+
v_rid BIGINT;
|
|
54
|
+
v_cand TEXT;
|
|
55
|
+
BEGIN
|
|
56
|
+
IF current_setting('transaction_isolation') <> 'read committed' THEN
|
|
57
|
+
RAISE EXCEPTION 'a delta write runs at READ COMMITTED, not %: a document opened while it ran would not be told of it',
|
|
58
|
+
upper(current_setting('transaction_isolation')) USING ERRCODE = '0A000';
|
|
59
|
+
END IF;
|
|
60
|
+
IF p_row IS NULL THEN RETURN v_names; END IF;
|
|
61
|
+
${branches.length ? ` CASE p_coll\n${branches.join("\n")}\n ELSE NULL;\n END CASE;\n` : ""} -- the documents not compiled here, as the framework asks them
|
|
62
|
+
IF EXISTS (SELECT 1 FROM _delta_docs WHERE (root_collection = p_coll OR p_coll = ANY(include)) AND NOT (prefix = ANY(${asked}))) THEN
|
|
63
|
+
v_names := v_names || _delta_holders_framework(p_coll, p_row, p_writer, ${asked});
|
|
64
|
+
END IF;
|
|
65
|
+
RETURN v_names;
|
|
66
|
+
END;
|
|
67
|
+
$$ LANGUAGE plpgsql VOLATILE SECURITY DEFINER;
|
|
68
|
+
DO $$ BEGIN
|
|
69
|
+
EXECUTE format('ALTER FUNCTION _delta_holders(TEXT, JSONB, TEXT) SET search_path = %s, pg_temp', COALESCE(NULLIF(current_setting('search_path'), ''), 'public'));
|
|
70
|
+
END $$;
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiled holds: does a document hold a row? Asked of every open document by
|
|
3
|
+
* every write (`_delta_holders`, the fan-out), on the row as it was before the
|
|
4
|
+
* write and as it is after.
|
|
5
|
+
*
|
|
6
|
+
* `compileHolds(schema, docs)` writes one function per document --
|
|
7
|
+
* `dzql_holds__<slug>(name, collection, row)` -- answering what the
|
|
8
|
+
* framework's `_delta_doc_holds` answers for it, from its definition: which
|
|
9
|
+
* collections it holds, its name's conditions tested on the row's own values
|
|
10
|
+
* (`scopeOf`, a name its columns cannot take holding nothing), a temporal row
|
|
11
|
+
* not told to a document named with a time past, and an included row's chain
|
|
12
|
+
* of parents up to the root, as written lookups. And `_delta_doc_holds`
|
|
13
|
+
* itself, which sends each compiled document to its function and any other
|
|
14
|
+
* (registered by hand, or by another `generateSql`) to the framework's
|
|
15
|
+
* (`_delta_doc_holds_framework`).
|
|
16
|
+
*/
|
|
17
|
+
import { q, type DocDef, type ResolvedTable, type Schema } from "@blueshed/delta/backend";
|
|
18
|
+
import { lit, named, need, scopeOf , currentOnly } from "./opens";
|
|
19
|
+
|
|
20
|
+
/** A document's holds function: `dzql_holds__<slug>`. */
|
|
21
|
+
export const holdsFunction = (prefix: string): string => named("holds", prefix);
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The id of the root row an included row hangs from, as `_delta_chain_root`
|
|
26
|
+
* finds it: its parent key, then each parent's, through the tables as they
|
|
27
|
+
* stand; NULL where the chain ends short of the root.
|
|
28
|
+
*/
|
|
29
|
+
export function chainRoot(schema: Schema, doc: DocDef, coll: string): string {
|
|
30
|
+
let table = need(schema, doc, coll);
|
|
31
|
+
let id = `(p_row->>${lit(table.parent!.fkColumn)})::BIGINT`;
|
|
32
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
33
|
+
const parentKey = table.parent!.collection;
|
|
34
|
+
if (parentKey === doc.root) return id;
|
|
35
|
+
const parent = need(schema, doc, parentKey);
|
|
36
|
+
if (!parent.parent) return "NULL::BIGINT";
|
|
37
|
+
id = `(SELECT p.${q(parent.parent.fkColumn)} FROM ${q(parent.name)} p WHERE p.id = ${id}${currentOnly(parent, "p")})`;
|
|
38
|
+
table = parent;
|
|
39
|
+
}
|
|
40
|
+
return "NULL::BIGINT";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One document's holds function. */
|
|
44
|
+
function holdsOf(schema: Schema, doc: DocDef): string {
|
|
45
|
+
const root = need(schema, doc, doc.root);
|
|
46
|
+
// a name its columns cannot take (one opened before a rule refused it) holds nothing
|
|
47
|
+
const scope = scopeOf(schema, doc, () => "RETURN FALSE;");
|
|
48
|
+
const colls = [doc.root, ...doc.include.filter((c) => c !== doc.root)];
|
|
49
|
+
const pastTime = (table: ResolvedTable) => (table.temporal ? `\n IF p_at <= now() THEN RETURN FALSE; END IF;` : "");
|
|
50
|
+
const branches = colls.map((coll) => {
|
|
51
|
+
const table = need(schema, doc, coll);
|
|
52
|
+
if (coll === doc.root) {
|
|
53
|
+
return ` WHEN ${lit(coll)} THEN${pastTime(table)}
|
|
54
|
+
IF v_single AND (p_row->>'id')::BIGINT IS DISTINCT FROM v_id THEN RETURN FALSE; END IF;
|
|
55
|
+
RETURN EXISTS (SELECT 1 FROM jsonb_populate_record(null::${q(root.name)}, p_row) t WHERE ${scope.conds || "TRUE"});`;
|
|
56
|
+
}
|
|
57
|
+
if (!table.parent) return ` WHEN ${lit(coll)} THEN${pastTime(table)}\n RETURN TRUE;`;
|
|
58
|
+
return ` WHEN ${lit(coll)} THEN${pastTime(table)}
|
|
59
|
+
IF NOT v_single THEN RETURN TRUE; END IF;
|
|
60
|
+
RETURN ${chainRoot(schema, doc, coll)} IS NOT DISTINCT FROM v_id;`;
|
|
61
|
+
});
|
|
62
|
+
return `CREATE OR REPLACE FUNCTION ${holdsFunction(doc.prefix)}(p_name TEXT, p_coll TEXT, p_row JSONB)
|
|
63
|
+
RETURNS BOOLEAN AS $$
|
|
64
|
+
DECLARE
|
|
65
|
+
p_at TIMESTAMPTZ;
|
|
66
|
+
v_single BOOLEAN;
|
|
67
|
+
v_id BIGINT;
|
|
68
|
+
${scope.declare.join("\n ")}
|
|
69
|
+
BEGIN
|
|
70
|
+
IF p_row IS NULL OR p_coll NOT IN (${colls.map(lit).join(", ")}) THEN RETURN FALSE; END IF;
|
|
71
|
+
${scope.body}
|
|
72
|
+
CASE p_coll
|
|
73
|
+
${branches.join("\n")}
|
|
74
|
+
END CASE;
|
|
75
|
+
END;
|
|
76
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The SQL that answers who holds a row for each of `docs`: a function per document, and `_delta_doc_holds` sending each to its own. */
|
|
80
|
+
export function compileHolds(schema: Schema, docs: DocDef[]): string {
|
|
81
|
+
const dispatch = `CREATE OR REPLACE FUNCTION _delta_doc_holds(p_def _delta_docs, p_doc_name TEXT, p_coll TEXT, p_row JSONB)
|
|
82
|
+
RETURNS BOOLEAN AS $$
|
|
83
|
+
BEGIN
|
|
84
|
+
CASE p_def.prefix
|
|
85
|
+
${docs.map((d) => ` WHEN ${lit(d.prefix)} THEN RETURN ${holdsFunction(d.prefix)}(p_doc_name, p_coll, p_row);`).join("\n")}
|
|
86
|
+
ELSE RETURN _delta_doc_holds_framework(p_def, p_doc_name, p_coll, p_row);
|
|
87
|
+
END CASE;
|
|
88
|
+
END;
|
|
89
|
+
$$ LANGUAGE plpgsql STABLE;`;
|
|
90
|
+
return [...docs.map((d) => holdsOf(schema, d)), dispatch, ""].join("\n\n");
|
|
91
|
+
}
|