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.
Files changed (45) hide show
  1. package/README.md +16 -0
  2. package/docs/README.md +1 -1
  3. package/docs/for_ai.md +39 -9
  4. package/package.json +12 -5
  5. package/src/cli/codegen/notification.ts +3 -3
  6. package/src/cli/codegen/sql/core.ts +21 -5
  7. package/src/cli/codegen/sql/delete.ts +5 -7
  8. package/src/cli/codegen/sql/get.ts +9 -11
  9. package/src/cli/codegen/sql/index.ts +7 -4
  10. package/src/cli/codegen/sql/lookup.ts +4 -5
  11. package/src/cli/codegen/sql/save.ts +58 -39
  12. package/src/cli/codegen/sql/search.ts +44 -10
  13. package/src/cli/codegen/sql/utils.ts +16 -1
  14. package/src/cli/codegen/subscribable_sql.ts +116 -124
  15. package/src/cli/compile.ts +263 -0
  16. package/src/cli/compiler/path-validator.ts +115 -83
  17. package/src/cli/compiler/paths.ts +299 -379
  18. package/src/cli/compiler/permissions.ts +26 -26
  19. package/src/cli/index.ts +17 -219
  20. package/src/client/ws.ts +20 -10
  21. package/src/postgres/auth-jwt-sql.ts +32 -0
  22. package/src/postgres/auth-jwt.ts +209 -0
  23. package/src/postgres/auth.ts +50 -0
  24. package/src/postgres/bootstrap.ts +55 -0
  25. package/src/postgres/carry.ts +40 -0
  26. package/src/postgres/codegen.ts +172 -0
  27. package/src/postgres/index.ts +58 -0
  28. package/src/postgres/listener.ts +706 -0
  29. package/src/postgres/migrate.ts +143 -0
  30. package/src/postgres/pglite.ts +142 -0
  31. package/src/postgres/registry.ts +253 -0
  32. package/src/postgres/schema.ts +171 -0
  33. package/src/postgres/sql.ts +42 -0
  34. package/src/runtime/auth.ts +33 -2
  35. package/src/runtime/index.ts +47 -6
  36. package/src/runtime/manifest_loader.ts +10 -0
  37. package/src/runtime/projection.ts +19 -0
  38. package/src/sql/001a-delta-meta.sql +204 -0
  39. package/src/sql/001b-delta-scope.sql +619 -0
  40. package/src/sql/001c-delta-read.sql +506 -0
  41. package/src/sql/001d-delta-write.sql +1088 -0
  42. package/src/sql/001e-delta-ops.sql +142 -0
  43. package/src/sql/001f-delta-as.sql +41 -0
  44. package/src/sql/001g-delta-ledger.sql +564 -0
  45. package/src/sql/auth-jwt.sql +62 -0
@@ -1,19 +1,31 @@
1
- import { compilePermission } from "../../compiler/permissions.js";
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
- // For dynamic SQL in EXECUTE, we need to use $1 instead of p_user_id
13
- const viewPermRaw = entityIR.permissions?.view?.length > 0
14
- ? entityIR.permissions.view.map((rule: string) => compilePermission(name, rule, null, name)).join(' OR ')
15
- : 'TRUE';
16
- const viewPerm = viewPermRaw.replace(/p_user_id/g, '$1');
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(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${key}
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 subscribePaths = sub.canSubscribe || [];
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
- // If no paths, it's public
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 TRUE; -- Public access
66
+ RETURN FALSE; -- canSubscribe is empty: nobody may subscribe
63
67
  END;
64
68
  $$;`;
65
69
  }
66
70
 
67
- // List subscribables with permission paths: check authentication only
68
- // (Row-level filtering happens in the query, not the permission check)
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
- return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
71
- p_user_id INT,
72
- p_params JSONB
73
- ) RETURNS BOOLEAN
74
- LANGUAGE plpgsql
75
- STABLE
76
- SECURITY DEFINER
77
- SET search_path = dzql_v2, public
78
- AS $$
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
- -- List subscribable: user must be authenticated
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: check permissions against specific root entity
88
- // Generate permission checks using RECORD dot notation
89
- // Pass the root key so we can map param references to root entity's id
90
- const compiledChecks = subscribePaths.map(path =>
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 `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
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
- * Compile permission for subscribable can_subscribe function.
147
- * Uses RECORD dot notation since v_root is a RECORD, not JSONB.
148
- * Supports unlimited traversal depth using the shared path parser.
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
- * @param entityName - The root entity name
151
- * @param rule - The permission rule (e.g., "@org_id->acts_for[org_id=$]{active}.user_id")
152
- * @param rootKey - The param key used to look up the root entity (e.g., "org_id")
153
- * When @field matches rootKey, it maps to v_root.id (the root entity's PK)
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 compileSubscribePermission(entityName: string, rule: string, rootKey: string = ''): string {
156
- // Helper to resolve field reference - if field matches rootKey param, use 'id' instead
157
- const resolveField = (field: string): string => {
158
- return field === rootKey ? 'id' : field;
159
- };
160
-
161
- // Parse the path using the shared parser
162
- const parsed = parsePath(rule);
163
-
164
- if (!parsed.isValid) {
165
- console.warn(`[Compiler] Warning: Invalid permission path '${rule}': ${parsed.error}`);
166
- return 'FALSE';
167
- }
168
-
169
- // TRUE/FALSE literals
170
- if (parsed.hops.length === 0) {
171
- return rule === 'TRUE' || rule === 'true' ? 'TRUE' : 'FALSE';
172
- }
173
-
174
- // Simple field reference: @org_id implies checking acts_for membership
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
- // Join condition
206
- if (finalHop.filter && finalHop.filter.value === '$') {
207
- conditions.push(`${finalHop.table}.${finalHop.filter.field} = ${valueExpr}`);
208
- } else {
209
- conditions.push(`${finalHop.table}.id = ${valueExpr}`);
168
+ if (!isList) {
169
+ return `${header}
170
+ RETURN ${kind};
171
+ END;
172
+ $$;`;
210
173
  }
211
174
 
212
- // User check
213
- if (finalHop.outputField === 'user_id') {
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
- // Additional conditions from {active} or {role=admin}
220
- for (const cond of finalHop.conditions || []) {
221
- if (cond.type === 'boolean') {
222
- conditions.push(`${finalHop.table}.${cond.field} = true`);
223
- } else if (cond.type === 'equality') {
224
- if (cond.value === 'NULL') {
225
- conditions.push(`${finalHop.table}.${cond.field} IS NULL`);
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
- return `EXISTS (SELECT 1 FROM ${finalHop.table} WHERE ${conditions.join(' AND ')})`;
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 to_jsonb(root.*) || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
331
+ SELECT (${rootSelectExpr})::jsonb || jsonb_build_object(${relationSelects ? relationSelects.slice(1) : ''})
340
332
  FROM ${sub.root.entity} root
341
333
  ${whereClause};
342
334
  END;