dzql 0.6.37 → 0.8.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 +16 -0
- package/docs/README.md +1 -1
- package/docs/for_ai.md +39 -9
- package/package.json +12 -5
- package/src/cli/codegen/notification.ts +3 -3
- package/src/cli/codegen/sql/core.ts +21 -5
- package/src/cli/codegen/sql/delete.ts +5 -7
- package/src/cli/codegen/sql/get.ts +9 -11
- package/src/cli/codegen/sql/index.ts +7 -4
- package/src/cli/codegen/sql/lookup.ts +4 -5
- package/src/cli/codegen/sql/save.ts +58 -39
- package/src/cli/codegen/sql/search.ts +44 -10
- package/src/cli/codegen/sql/utils.ts +16 -1
- package/src/cli/codegen/subscribable_sql.ts +116 -124
- package/src/cli/compile.ts +263 -0
- package/src/cli/compiler/path-validator.ts +115 -83
- package/src/cli/compiler/paths.ts +299 -379
- package/src/cli/compiler/permissions.ts +26 -26
- package/src/cli/index.ts +17 -219
- package/src/client/ws.ts +20 -10
- package/src/postgres/auth-jwt-sql.ts +32 -0
- package/src/postgres/auth-jwt.ts +209 -0
- package/src/postgres/auth.ts +50 -0
- package/src/postgres/bootstrap.ts +55 -0
- package/src/postgres/carry.ts +40 -0
- package/src/postgres/codegen.ts +172 -0
- package/src/postgres/index.ts +58 -0
- package/src/postgres/listener.ts +706 -0
- package/src/postgres/migrate.ts +143 -0
- package/src/postgres/pglite.ts +142 -0
- package/src/postgres/registry.ts +253 -0
- package/src/postgres/schema.ts +171 -0
- package/src/postgres/sql.ts +42 -0
- package/src/runtime/auth.ts +33 -2
- package/src/runtime/index.ts +47 -6
- package/src/runtime/manifest_loader.ts +10 -0
- package/src/runtime/projection.ts +19 -0
- package/src/sql/001a-delta-meta.sql +204 -0
- package/src/sql/001b-delta-scope.sql +619 -0
- package/src/sql/001c-delta-read.sql +506 -0
- package/src/sql/001d-delta-write.sql +1088 -0
- package/src/sql/001e-delta-ops.sql +142 -0
- package/src/sql/001f-delta-as.sql +41 -0
- package/src/sql/001g-delta-ledger.sql +564 -0
- package/src/sql/auth-jwt.sql +62 -0
|
@@ -1,19 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { compileRuleList } from "../../compiler/permissions.js";
|
|
2
2
|
import type { EntityIR, ManyToManyIR, ColumnInfo } from "./types.js";
|
|
3
|
-
import { buildVisibleJsonb } from "./utils.js";
|
|
3
|
+
import { buildVisibleJsonb, buildVisibleJsonbForEntity } from "./utils.js";
|
|
4
4
|
|
|
5
5
|
// === SEARCH FUNCTION ===
|
|
6
|
-
export function generateSearchFunction(name: string, entityIR: EntityIR): string {
|
|
6
|
+
export function generateSearchFunction(name: string, entityIR: EntityIR, entities: Record<string, EntityIR> = {}): string {
|
|
7
7
|
const pk = entityIR.primaryKey[0] || 'id';
|
|
8
8
|
const softDelete = entityIR.softDelete || false;
|
|
9
9
|
const hidden = entityIR.hidden || [];
|
|
10
10
|
const temporal = entityIR.temporal;
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
// Allow-list of columns a client may filter or sort on: every real column
|
|
13
|
+
// except hidden ones. These names come from the domain at compile time, never
|
|
14
|
+
// from the request, so embedding them as SQL literals is safe. Fixes two leaks:
|
|
15
|
+
// (1) filtering/sorting on a hidden column (e.g. password_hash) as an oracle,
|
|
16
|
+
// (2) an unvalidated sort_order string being spliced into the ORDER BY.
|
|
17
|
+
const allowedCols = entityIR.columns
|
|
18
|
+
.map((c: ColumnInfo) => c.name)
|
|
19
|
+
.filter((n: string) => !hidden.includes(n));
|
|
20
|
+
const allowedColsSql = `ARRAY[${allowedCols.map(n => `'${n}'`).join(', ')}]::text[]`;
|
|
21
|
+
|
|
22
|
+
// The view rule is spliced into a format() string run by EXECUTE: p_user_id
|
|
23
|
+
// becomes the $1 bind, quotes are doubled for the string literal, and % is
|
|
24
|
+
// escaped for format(). An empty view list denies everyone.
|
|
25
|
+
const viewPerm = compileRuleList(name, entityIR.permissions?.view, name)
|
|
26
|
+
.replace(/\bp_user_id\b/g, '$1')
|
|
27
|
+
.replace(/'/g, "''")
|
|
28
|
+
.replace(/%/g, '%%');
|
|
17
29
|
|
|
18
30
|
// Soft delete filter
|
|
19
31
|
const softDeleteFilter = softDelete ? ' AND deleted_at IS NULL' : '';
|
|
@@ -37,9 +49,12 @@ export function generateSearchFunction(name: string, entityIR: EntityIR): string
|
|
|
37
49
|
) m2m_${config.idField} ON true`;
|
|
38
50
|
|
|
39
51
|
if (config.expand) {
|
|
52
|
+
// Project the expanded target through its visible columns (single quotes
|
|
53
|
+
// doubled because this becomes part of a dynamic-SQL format string).
|
|
54
|
+
const targetSelect = buildVisibleJsonbForEntity('target', entities[config.targetEntity]).replace(/'/g, "''");
|
|
40
55
|
sql += `
|
|
41
56
|
LEFT JOIN LATERAL (
|
|
42
|
-
SELECT COALESCE(jsonb_agg(
|
|
57
|
+
SELECT COALESCE(jsonb_agg(${targetSelect} ORDER BY target.id), ''[]''::jsonb) as ${key}
|
|
43
58
|
FROM ${config.junctionTable} jt
|
|
44
59
|
JOIN ${config.targetEntity} target ON target.id = jt.${config.foreignKey}
|
|
45
60
|
WHERE jt.${config.localKey} = t.${pk}
|
|
@@ -80,17 +95,36 @@ DECLARE
|
|
|
80
95
|
v_filter jsonb;
|
|
81
96
|
v_operator text;
|
|
82
97
|
v_value jsonb;
|
|
98
|
+
v_allowed_cols text[] := ${allowedColsSql};
|
|
83
99
|
BEGIN
|
|
84
100
|
-- Extract query parameters
|
|
85
101
|
v_filters := COALESCE(p_query->'filters', '{}'::jsonb);
|
|
86
102
|
v_sort_field := COALESCE(p_query->>'sort_field', '${labelField}');
|
|
87
|
-
v_sort_order := COALESCE(p_query->>'sort_order', 'asc');
|
|
103
|
+
v_sort_order := lower(COALESCE(p_query->>'sort_order', 'asc'));
|
|
104
|
+
|
|
105
|
+
-- Validate sort against the compile-time allow-list. sort_order reaches the
|
|
106
|
+
-- ORDER BY via %s, so it must be exactly asc/desc; sort_field must be a real,
|
|
107
|
+
-- non-hidden column. Fail closed rather than splice an untrusted string.
|
|
108
|
+
IF v_sort_order NOT IN ('asc', 'desc') THEN
|
|
109
|
+
RAISE EXCEPTION 'dzql: invalid sort_order %', v_sort_order
|
|
110
|
+
USING ERRCODE = '22023';
|
|
111
|
+
END IF;
|
|
112
|
+
IF NOT (v_sort_field = ANY(v_allowed_cols)) THEN
|
|
113
|
+
RAISE EXCEPTION 'dzql: invalid sort_field %', v_sort_field
|
|
114
|
+
USING ERRCODE = '22023';
|
|
115
|
+
END IF;
|
|
88
116
|
|
|
89
117
|
-- Build WHERE clause from filters
|
|
90
118
|
-- Note: We avoid ::TEXT casts to preserve index usage on numeric/date columns
|
|
91
119
|
-- The %L format specifier handles proper escaping for all types
|
|
92
120
|
FOR v_field, v_filter IN SELECT * FROM jsonb_each(v_filters)
|
|
93
121
|
LOOP
|
|
122
|
+
-- Reject filters on columns that are not in the visible allow-list, so a
|
|
123
|
+
-- hidden column (e.g. password_hash) can never be probed via filter result.
|
|
124
|
+
IF NOT (v_field = ANY(v_allowed_cols)) THEN
|
|
125
|
+
RAISE EXCEPTION 'dzql: invalid filter field %', v_field
|
|
126
|
+
USING ERRCODE = '22023';
|
|
127
|
+
END IF;
|
|
94
128
|
-- Handle simple value (exact match)
|
|
95
129
|
IF jsonb_typeof(v_filter) = 'number' THEN
|
|
96
130
|
-- Numeric: compare without casting
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ColumnInfo } from "./types.js";
|
|
1
|
+
import type { ColumnInfo, EntityIR } from "./types.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Generate a jsonb_build_object expression that excludes hidden fields.
|
|
@@ -17,6 +17,21 @@ export function buildVisibleJsonb(alias: string, columns: ColumnInfo[], hidden:
|
|
|
17
17
|
return `jsonb_build_object(${pairs})`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Project a RELATED entity (FK/M2M expansion target) through its own visible
|
|
22
|
+
* columns, so a related object never leaks the target's hidden fields (e.g. a
|
|
23
|
+
* post's `author` must not expose users.password_hash). Falls back to
|
|
24
|
+
* to_jsonb(alias.*) only when the target entity is unknown to the generator.
|
|
25
|
+
* @param alias - Table alias the related row is selected under (e.g. 't', 'target')
|
|
26
|
+
* @param targetEntity - IR of the related entity, if available
|
|
27
|
+
*/
|
|
28
|
+
export function buildVisibleJsonbForEntity(alias: string, targetEntity?: EntityIR): string {
|
|
29
|
+
if (!targetEntity) {
|
|
30
|
+
return `to_jsonb(${alias}.*)`;
|
|
31
|
+
}
|
|
32
|
+
return buildVisibleJsonb(alias, targetEntity.columns, targetEntity.hidden || []);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Get the SQL cast for a column type
|
|
22
37
|
*/
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
|
|
12
|
-
import { parsePath } from "../compiler/paths.js";
|
|
12
|
+
import { parsePath, PathCompileError } from "../compiler/paths.js";
|
|
13
|
+
import { compileRuleList } from "../compiler/permissions.js";
|
|
13
14
|
|
|
14
15
|
export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
|
|
15
16
|
// Validate: if root.key is set, it must exist in params or start with '@'
|
|
@@ -28,6 +29,7 @@ export function generateSubscribableSQL(name: string, sub: SubscribableIR, entit
|
|
|
28
29
|
sections.push(generateHeader(name, sub));
|
|
29
30
|
sections.push(generateCanSubscribeFunction(name, sub));
|
|
30
31
|
sections.push(generateGetFunction(name, sub, entities));
|
|
32
|
+
sections.push(generateCanReceiveFunction(name, sub, entities));
|
|
31
33
|
sections.push(generateAffectedKeysFunction(name, sub));
|
|
32
34
|
|
|
33
35
|
return sections.join('\n\n');
|
|
@@ -43,13 +45,11 @@ function generateHeader(name: string, sub: SubscribableIR): string {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function generateCanSubscribeFunction(name: string, sub: SubscribableIR): string {
|
|
46
|
-
const
|
|
48
|
+
const rules = sub.canSubscribe || [];
|
|
47
49
|
const rootKey = sub.root.key;
|
|
48
50
|
const isList = !rootKey; // No key means it's a list subscribable
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
if (subscribePaths.length === 0) {
|
|
52
|
-
return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
52
|
+
const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
53
53
|
p_user_id INT,
|
|
54
54
|
p_params JSONB
|
|
55
55
|
) RETURNS BOOLEAN
|
|
@@ -57,45 +57,40 @@ LANGUAGE plpgsql
|
|
|
57
57
|
STABLE
|
|
58
58
|
SECURITY DEFINER
|
|
59
59
|
SET search_path = dzql_v2, public
|
|
60
|
-
AS
|
|
60
|
+
AS $$`;
|
|
61
|
+
|
|
62
|
+
// No rules: nobody may subscribe. Use TRUE for public, AUTHENTICATED for any signed-in user.
|
|
63
|
+
if (rules.length === 0) {
|
|
64
|
+
return `${header}
|
|
61
65
|
BEGIN
|
|
62
|
-
RETURN
|
|
66
|
+
RETURN FALSE; -- canSubscribe is empty: nobody may subscribe
|
|
63
67
|
END;
|
|
64
68
|
$$;`;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
//
|
|
68
|
-
// (
|
|
71
|
+
// A list subscribable has no root row for a path to start from, so only
|
|
72
|
+
// literal rules (TRUE, FALSE, AUTHENTICATED) can be enforced. Anything else is
|
|
73
|
+
// a build error rather than a silent fallback.
|
|
69
74
|
if (isList) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
for (const rule of rules) {
|
|
76
|
+
if (!parsePath(rule).literal) {
|
|
77
|
+
throw new PathCompileError(
|
|
78
|
+
`${name}.canSubscribe: a list subscribable has no root row to follow a path from; use TRUE, FALSE or AUTHENTICATED`,
|
|
79
|
+
rule
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return `${header}
|
|
79
84
|
BEGIN
|
|
80
|
-
|
|
81
|
-
-- Row-level access is filtered in the get function
|
|
82
|
-
RETURN p_user_id IS NOT NULL;
|
|
85
|
+
RETURN (${compileRuleList(name, rules)});
|
|
83
86
|
END;
|
|
84
87
|
$$;`;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
// Single-entity subscribable:
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
compileSubscribePermission(sub.root.entity, path, rootKey)
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
// If all checks compile to FALSE (unsupported paths), fall back to authenticated users
|
|
95
|
-
const allFalse = compiledChecks.every(c => c === 'FALSE');
|
|
96
|
-
const checks = allFalse
|
|
97
|
-
? 'p_user_id IS NOT NULL -- Fallback: multi-level paths not yet supported'
|
|
98
|
-
: compiledChecks.join(' OR\n ');
|
|
90
|
+
// Single-entity subscribable: rules are checked against the root row (v_root).
|
|
91
|
+
// A rule's reference to the key param means the root's id.
|
|
92
|
+
const fieldMap: Record<string, string> = rootKey.startsWith('@') ? {} : { [rootKey]: 'id' };
|
|
93
|
+
const checks = compileRuleList(name, rules, { kind: 'row', name: 'v_root', fieldMap });
|
|
99
94
|
|
|
100
95
|
// Extract params
|
|
101
96
|
const paramNames = Object.keys(sub.params);
|
|
@@ -109,15 +104,7 @@ $$;`;
|
|
|
109
104
|
? `p_${rootKey.slice(1)}` // @user_id -> p_user_id
|
|
110
105
|
: `v_${rootKey}`; // venue_id -> v_venue_id
|
|
111
106
|
|
|
112
|
-
return
|
|
113
|
-
p_user_id INT,
|
|
114
|
-
p_params JSONB
|
|
115
|
-
) RETURNS BOOLEAN
|
|
116
|
-
LANGUAGE plpgsql
|
|
117
|
-
STABLE
|
|
118
|
-
SECURITY DEFINER
|
|
119
|
-
SET search_path = dzql_v2, public
|
|
120
|
-
AS $$
|
|
107
|
+
return `${header}
|
|
121
108
|
DECLARE
|
|
122
109
|
${paramDecls}
|
|
123
110
|
v_root RECORD;
|
|
@@ -143,94 +130,91 @@ $$;`;
|
|
|
143
130
|
}
|
|
144
131
|
|
|
145
132
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
133
|
+
* <name>_can_receive(p_user_id, p_params, p_table, p_op, p_data): may this
|
|
134
|
+
* subscriber be pushed this change? The runtime calls it before every push.
|
|
135
|
+
* Returns 'row' (send the change), 'pk' (send only its key: a delete), or
|
|
136
|
+
* NULL (send nothing).
|
|
149
137
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
138
|
+
* - Nobody who fails can_subscribe receives anything.
|
|
139
|
+
* - A single-document subscription: canSubscribe is the whole gate.
|
|
140
|
+
* - A list: canSubscribe can only be a literal, so rows are filtered here, the
|
|
141
|
+
* same way the list query filters them. A root row needs the root's view
|
|
142
|
+
* rules; an included row must appear in one of the subscriber's documents.
|
|
143
|
+
* An included row that was deleted is in no document any more, so its
|
|
144
|
+
* delete goes to every subscriber as its key alone, which is all a client
|
|
145
|
+
* uses to remove it.
|
|
154
146
|
*/
|
|
155
|
-
function
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (parsed.hops.length === 1 && parsed.hops[0].type === 'field') {
|
|
176
|
-
const field = parsed.hops[0].field!;
|
|
177
|
-
const rootField = resolveField(field);
|
|
178
|
-
return `EXISTS (SELECT 1 FROM acts_for WHERE acts_for.${field} = v_root.${rootField} AND acts_for.user_id = p_user_id AND acts_for.active)`;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Traversal path - build EXISTS with nested subqueries for unlimited hops
|
|
182
|
-
// Start with the field from v_root
|
|
183
|
-
const startField = parsed.startField!;
|
|
184
|
-
const rootField = resolveField(startField);
|
|
185
|
-
|
|
186
|
-
// Build value expression starting from v_root
|
|
187
|
-
let valueExpr = `v_root.${rootField}`;
|
|
188
|
-
|
|
189
|
-
// Process intermediate hops (all but first field and last final)
|
|
190
|
-
for (let i = 1; i < parsed.hops.length - 1; i++) {
|
|
191
|
-
const hop = parsed.hops[i];
|
|
192
|
-
if (hop.type === 'table' && hop.table && hop.outputField) {
|
|
193
|
-
valueExpr = `(SELECT ${hop.outputField} FROM ${hop.table} WHERE id = ${valueExpr})`;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Final hop - build EXISTS check
|
|
198
|
-
const finalHop = parsed.hops[parsed.hops.length - 1];
|
|
199
|
-
if (finalHop.type !== 'final' || !finalHop.table) {
|
|
200
|
-
return 'FALSE';
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const conditions: string[] = [];
|
|
147
|
+
function generateCanReceiveFunction(name: string, sub: SubscribableIR, entities: Record<string, EntityIR>): string {
|
|
148
|
+
const isList = !sub.root.key;
|
|
149
|
+
const header = `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_receive(
|
|
150
|
+
p_user_id INT,
|
|
151
|
+
p_params JSONB,
|
|
152
|
+
p_table TEXT,
|
|
153
|
+
p_op TEXT,
|
|
154
|
+
p_data JSONB
|
|
155
|
+
) RETURNS TEXT
|
|
156
|
+
LANGUAGE plpgsql
|
|
157
|
+
STABLE
|
|
158
|
+
SECURITY DEFINER
|
|
159
|
+
SET search_path = dzql_v2, public
|
|
160
|
+
AS $$
|
|
161
|
+
BEGIN
|
|
162
|
+
IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
|
|
163
|
+
RETURN NULL;
|
|
164
|
+
END IF;
|
|
165
|
+
`;
|
|
166
|
+
const kind = `CASE WHEN p_op = 'delete' THEN 'pk' ELSE 'row' END`;
|
|
204
167
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
168
|
+
if (!isList) {
|
|
169
|
+
return `${header}
|
|
170
|
+
RETURN ${kind};
|
|
171
|
+
END;
|
|
172
|
+
$$;`;
|
|
210
173
|
}
|
|
211
174
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
conditions.push(`${finalHop.table}.user_id = p_user_id`);
|
|
215
|
-
} else if (finalHop.outputField) {
|
|
216
|
-
conditions.push(`${finalHop.table}.${finalHop.outputField} = p_user_id`);
|
|
217
|
-
}
|
|
175
|
+
const root = sub.root.entity;
|
|
176
|
+
const viewRules = compileRuleList(root, entities[root]?.permissions?.view, { kind: 'jsonb', name: 'p_data' });
|
|
218
177
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else {
|
|
227
|
-
const value = cond.value!.match(/^\d+$/) ? cond.value : `'${cond.value}'`;
|
|
228
|
-
conditions.push(`${finalHop.table}.${cond.field} = ${value}`);
|
|
229
|
-
}
|
|
178
|
+
// Where each included table sits in a list document: root columns || { rel: ... }
|
|
179
|
+
const pathsByTable: Record<string, string[]> = {};
|
|
180
|
+
const collect = (incl: Record<string, IncludeIR>, prefix: string) => {
|
|
181
|
+
for (const [rel, cfg] of Object.entries(incl || {})) {
|
|
182
|
+
const path = `${prefix}."${rel}"`;
|
|
183
|
+
(pathsByTable[cfg.entity] ??= []).push(path);
|
|
184
|
+
if (cfg.includes) collect(cfg.includes, path);
|
|
230
185
|
}
|
|
231
|
-
}
|
|
186
|
+
};
|
|
187
|
+
collect(sub.includes, '$');
|
|
188
|
+
delete pathsByTable[root];
|
|
189
|
+
|
|
190
|
+
const includeCases = Object.entries(pathsByTable).map(([table, paths]) => {
|
|
191
|
+
const shown = paths
|
|
192
|
+
.map(path => `jsonb_path_exists(d, '${path} ? (@.id == $id)', jsonb_build_object('id', p_data->'id'))`)
|
|
193
|
+
.join(' OR ');
|
|
194
|
+
return ` IF p_table = '${table}' THEN
|
|
195
|
+
IF p_op = 'delete' THEN
|
|
196
|
+
RETURN 'pk';
|
|
197
|
+
END IF;
|
|
198
|
+
IF EXISTS (SELECT 1 FROM dzql_v2.get_${name}(p_params, p_user_id) d WHERE ${shown}) THEN
|
|
199
|
+
RETURN 'row';
|
|
200
|
+
END IF;
|
|
201
|
+
RETURN NULL;
|
|
202
|
+
END IF;
|
|
203
|
+
`;
|
|
204
|
+
}).join('\n');
|
|
205
|
+
|
|
206
|
+
return `${header}
|
|
207
|
+
IF p_table = '${root}' THEN
|
|
208
|
+
IF ${viewRules} THEN
|
|
209
|
+
RETURN ${kind};
|
|
210
|
+
END IF;
|
|
211
|
+
RETURN NULL;
|
|
212
|
+
END IF;
|
|
232
213
|
|
|
233
|
-
|
|
214
|
+
${includeCases}
|
|
215
|
+
RETURN NULL;
|
|
216
|
+
END;
|
|
217
|
+
$$;`;
|
|
234
218
|
}
|
|
235
219
|
|
|
236
220
|
/** Column info from EntityIR */
|
|
@@ -307,6 +291,13 @@ function generateGetFunction(name: string, sub: SubscribableIR, entities: Record
|
|
|
307
291
|
}
|
|
308
292
|
}
|
|
309
293
|
}
|
|
294
|
+
if (isList) {
|
|
295
|
+
// canSubscribe on a list can only be a literal, so it can't tell rows apart.
|
|
296
|
+
// Return just the rows the subscriber may view: the root's view rules,
|
|
297
|
+
// checked against each root row ([] shows nobody any row).
|
|
298
|
+
const viewRules = entities[sub.root.entity]?.permissions?.view;
|
|
299
|
+
whereConditions.push(`(${compileRuleList(sub.root.entity, viewRules, { kind: 'row', name: 'root' })})`);
|
|
300
|
+
}
|
|
310
301
|
const whereClause = whereConditions.length > 0
|
|
311
302
|
? `WHERE ${whereConditions.join(' AND ')}`
|
|
312
303
|
: '';
|
|
@@ -334,9 +325,10 @@ ${paramExtracts}
|
|
|
334
325
|
RAISE EXCEPTION 'permission_denied';
|
|
335
326
|
END IF;
|
|
336
327
|
|
|
337
|
-
-- Return stream of documents
|
|
328
|
+
-- Return stream of documents (root projected to visible columns; ::jsonb so
|
|
329
|
+
-- the || merge works even when the projection is row_to_json)
|
|
338
330
|
RETURN QUERY
|
|
339
|
-
SELECT
|
|
331
|
+
SELECT (${rootSelectExpr})::jsonb || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
|
|
340
332
|
FROM ${sub.root.entity} root
|
|
341
333
|
${whereClause};
|
|
342
334
|
END;
|