dzql 0.5.33 → 0.6.1
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/.env.sample +28 -0
- package/compose.yml +28 -0
- package/dist/client/index.ts +1 -0
- package/dist/client/stores/useMyProfileStore.ts +114 -0
- package/dist/client/stores/useOrgDashboardStore.ts +131 -0
- package/dist/client/stores/useVenueDetailStore.ts +117 -0
- package/dist/client/ws.ts +716 -0
- package/dist/db/migrations/000_core.sql +92 -0
- package/dist/db/migrations/20251229T212912022Z_schema.sql +3020 -0
- package/dist/db/migrations/20251229T212912022Z_subscribables.sql +371 -0
- package/dist/runtime/manifest.json +1562 -0
- package/docs/README.md +309 -36
- package/docs/feature-requests/applyPatch-bug-report.md +85 -0
- package/docs/feature-requests/connection-ready-profile.md +57 -0
- package/docs/feature-requests/hidden-bug-report.md +111 -0
- package/docs/feature-requests/hidden-fields-subscribables.md +34 -0
- package/docs/feature-requests/subscribable-param-key-bug.md +38 -0
- package/docs/feature-requests/todo.md +146 -0
- package/docs/for_ai.md +653 -0
- package/docs/project-setup.md +456 -0
- package/examples/blog.ts +50 -0
- package/examples/invalid.ts +18 -0
- package/examples/venues.js +485 -0
- package/package.json +23 -60
- package/src/cli/codegen/client.ts +99 -0
- package/src/cli/codegen/manifest.ts +95 -0
- package/src/cli/codegen/pinia.ts +174 -0
- package/src/cli/codegen/realtime.ts +58 -0
- package/src/cli/codegen/sql.ts +698 -0
- package/src/cli/codegen/subscribable_sql.ts +547 -0
- package/src/cli/codegen/subscribable_store.ts +184 -0
- package/src/cli/codegen/types.ts +142 -0
- package/src/cli/compiler/analyzer.ts +52 -0
- package/src/cli/compiler/graph_rules.ts +251 -0
- package/src/cli/compiler/ir.ts +233 -0
- package/src/cli/compiler/loader.ts +132 -0
- package/src/cli/compiler/permissions.ts +227 -0
- package/src/cli/index.ts +166 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/runtime/auth.ts +39 -0
- package/src/runtime/db.ts +33 -0
- package/src/runtime/errors.ts +51 -0
- package/src/runtime/index.ts +98 -0
- package/src/runtime/js_functions.ts +63 -0
- package/src/runtime/manifest_loader.ts +29 -0
- package/src/runtime/namespace.ts +483 -0
- package/src/runtime/server.ts +87 -0
- package/src/runtime/ws.ts +197 -0
- package/src/shared/ir.ts +197 -0
- package/tests/client.test.ts +38 -0
- package/tests/codegen.test.ts +71 -0
- package/tests/compiler.test.ts +45 -0
- package/tests/graph_rules.test.ts +173 -0
- package/tests/integration/db.test.ts +174 -0
- package/tests/integration/e2e.test.ts +65 -0
- package/tests/integration/features.test.ts +922 -0
- package/tests/integration/full_stack.test.ts +262 -0
- package/tests/integration/setup.ts +45 -0
- package/tests/ir.test.ts +32 -0
- package/tests/namespace.test.ts +395 -0
- package/tests/permissions.test.ts +55 -0
- package/tests/pinia.test.ts +48 -0
- package/tests/realtime.test.ts +22 -0
- package/tests/runtime.test.ts +80 -0
- package/tests/subscribable_gen.test.ts +72 -0
- package/tests/subscribable_reactivity.test.ts +258 -0
- package/tests/venues_gen.test.ts +25 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/README.md +0 -90
- package/bin/cli.js +0 -727
- package/docs/compiler/ADVANCED_FILTERS.md +0 -183
- package/docs/compiler/CODING_STANDARDS.md +0 -415
- package/docs/compiler/COMPARISON.md +0 -673
- package/docs/compiler/QUICKSTART.md +0 -326
- package/docs/compiler/README.md +0 -134
- package/docs/examples/README.md +0 -38
- package/docs/examples/blog.sql +0 -160
- package/docs/examples/venue-detail-simple.sql +0 -8
- package/docs/examples/venue-detail-subscribable.sql +0 -45
- package/docs/for-ai/claude-guide.md +0 -1210
- package/docs/getting-started/quickstart.md +0 -125
- package/docs/getting-started/subscriptions-quick-start.md +0 -203
- package/docs/getting-started/tutorial.md +0 -1104
- package/docs/guides/atomic-updates.md +0 -299
- package/docs/guides/client-stores.md +0 -730
- package/docs/guides/composite-primary-keys.md +0 -158
- package/docs/guides/custom-functions.md +0 -362
- package/docs/guides/drop-semantics.md +0 -554
- package/docs/guides/field-defaults.md +0 -240
- package/docs/guides/interpreter-vs-compiler.md +0 -237
- package/docs/guides/many-to-many.md +0 -929
- package/docs/guides/subscriptions.md +0 -537
- package/docs/reference/api.md +0 -1373
- package/docs/reference/client.md +0 -224
- package/src/client/stores/index.js +0 -8
- package/src/client/stores/useAppStore.js +0 -285
- package/src/client/stores/useWsStore.js +0 -289
- package/src/client/ws.js +0 -762
- package/src/compiler/cli/compile-example.js +0 -33
- package/src/compiler/cli/compile-subscribable.js +0 -43
- package/src/compiler/cli/debug-compile.js +0 -44
- package/src/compiler/cli/debug-parse.js +0 -26
- package/src/compiler/cli/debug-path-parser.js +0 -18
- package/src/compiler/cli/debug-subscribable-parser.js +0 -21
- package/src/compiler/cli/index.js +0 -174
- package/src/compiler/codegen/auth-codegen.js +0 -153
- package/src/compiler/codegen/drop-semantics-codegen.js +0 -553
- package/src/compiler/codegen/graph-rules-codegen.js +0 -450
- package/src/compiler/codegen/notification-codegen.js +0 -232
- package/src/compiler/codegen/operation-codegen.js +0 -1382
- package/src/compiler/codegen/permission-codegen.js +0 -318
- package/src/compiler/codegen/subscribable-codegen.js +0 -827
- package/src/compiler/compiler.js +0 -371
- package/src/compiler/index.js +0 -11
- package/src/compiler/parser/entity-parser.js +0 -440
- package/src/compiler/parser/path-parser.js +0 -290
- package/src/compiler/parser/subscribable-parser.js +0 -244
- package/src/database/dzql-core.sql +0 -161
- package/src/database/migrations/001_schema.sql +0 -60
- package/src/database/migrations/002_functions.sql +0 -890
- package/src/database/migrations/003_operations.sql +0 -1135
- package/src/database/migrations/004_search.sql +0 -581
- package/src/database/migrations/005_entities.sql +0 -730
- package/src/database/migrations/006_auth.sql +0 -94
- package/src/database/migrations/007_events.sql +0 -133
- package/src/database/migrations/008_hello.sql +0 -18
- package/src/database/migrations/008a_meta.sql +0 -172
- package/src/database/migrations/009_subscriptions.sql +0 -240
- package/src/database/migrations/010_atomic_updates.sql +0 -157
- package/src/database/migrations/010_fix_m2m_events.sql +0 -94
- package/src/index.js +0 -40
- package/src/server/api.js +0 -9
- package/src/server/db.js +0 -442
- package/src/server/index.js +0 -317
- package/src/server/logger.js +0 -259
- package/src/server/mcp.js +0 -594
- package/src/server/meta-route.js +0 -251
- package/src/server/namespace.js +0 -292
- package/src/server/subscriptions.js +0 -351
- package/src/server/ws.js +0 -573
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribable SQL Code Generator
|
|
3
|
+
* Generates PostgreSQL functions for live query subscriptions
|
|
4
|
+
*
|
|
5
|
+
* For each subscribable, generates:
|
|
6
|
+
* 1. get_<name>(p_params, p_user_id) - Query function that builds the document
|
|
7
|
+
* 2. <name>_can_subscribe(p_user_id, p_params) - Access control check
|
|
8
|
+
* 3. <name>_affected_keys(p_table, p_op, p_data) - Determines which subscriptions are affected
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SubscribableIR, IncludeIR, EntityIR } from "../../shared/ir.js";
|
|
12
|
+
|
|
13
|
+
export function generateSubscribableSQL(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
|
|
14
|
+
const sections: string[] = [];
|
|
15
|
+
|
|
16
|
+
sections.push(generateHeader(name, sub));
|
|
17
|
+
sections.push(generateCanSubscribeFunction(name, sub));
|
|
18
|
+
sections.push(generateGetFunction(name, sub, entities));
|
|
19
|
+
sections.push(generateAffectedKeysFunction(name, sub));
|
|
20
|
+
|
|
21
|
+
return sections.join('\n\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function generateHeader(name: string, sub: SubscribableIR): string {
|
|
25
|
+
return `-- ============================================================================
|
|
26
|
+
-- Subscribable: ${name}
|
|
27
|
+
-- Root Entity: ${sub.root.entity}
|
|
28
|
+
-- Scope Tables: ${sub.scopeTables.join(', ')}
|
|
29
|
+
-- Generated: ${new Date().toISOString()}
|
|
30
|
+
-- ============================================================================`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function generateCanSubscribeFunction(name: string, sub: SubscribableIR): string {
|
|
34
|
+
const subscribePaths = sub.canSubscribe || [];
|
|
35
|
+
|
|
36
|
+
// If no paths, it's public
|
|
37
|
+
if (subscribePaths.length === 0) {
|
|
38
|
+
return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
39
|
+
p_user_id INT,
|
|
40
|
+
p_params JSONB
|
|
41
|
+
) RETURNS BOOLEAN
|
|
42
|
+
LANGUAGE plpgsql
|
|
43
|
+
STABLE
|
|
44
|
+
SECURITY DEFINER
|
|
45
|
+
SET search_path = dzql_v2, public
|
|
46
|
+
AS $$
|
|
47
|
+
BEGIN
|
|
48
|
+
RETURN TRUE; -- Public access
|
|
49
|
+
END;
|
|
50
|
+
$$;`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Generate permission checks using RECORD dot notation
|
|
54
|
+
// Pass the root key so we can map param references to root entity's id
|
|
55
|
+
const compiledChecks = subscribePaths.map(path =>
|
|
56
|
+
compileSubscribePermission(sub.root.entity, path, sub.root.key)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// If all checks compile to FALSE (unsupported paths), fall back to authenticated users
|
|
60
|
+
const allFalse = compiledChecks.every(c => c === 'FALSE');
|
|
61
|
+
const checks = allFalse
|
|
62
|
+
? 'p_user_id IS NOT NULL -- Fallback: multi-level paths not yet supported'
|
|
63
|
+
: compiledChecks.join(' OR\n ');
|
|
64
|
+
|
|
65
|
+
// Extract params
|
|
66
|
+
const paramNames = Object.keys(sub.params);
|
|
67
|
+
const paramDecls = paramNames.map(p => ` v_${p} ${sub.params[p]};`).join('\n');
|
|
68
|
+
const paramExtracts = paramNames.map(p =>
|
|
69
|
+
` v_${p} := (p_params->>'${p}')::${sub.params[p]};`
|
|
70
|
+
).join('\n');
|
|
71
|
+
|
|
72
|
+
return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_can_subscribe(
|
|
73
|
+
p_user_id INT,
|
|
74
|
+
p_params JSONB
|
|
75
|
+
) RETURNS BOOLEAN
|
|
76
|
+
LANGUAGE plpgsql
|
|
77
|
+
STABLE
|
|
78
|
+
SECURITY DEFINER
|
|
79
|
+
SET search_path = dzql_v2, public
|
|
80
|
+
AS $$
|
|
81
|
+
DECLARE
|
|
82
|
+
${paramDecls}
|
|
83
|
+
v_root RECORD;
|
|
84
|
+
BEGIN
|
|
85
|
+
-- Extract parameters
|
|
86
|
+
${paramExtracts}
|
|
87
|
+
|
|
88
|
+
-- Fetch root entity
|
|
89
|
+
SELECT * INTO v_root
|
|
90
|
+
FROM ${sub.root.entity}
|
|
91
|
+
WHERE id = v_${sub.root.key};
|
|
92
|
+
|
|
93
|
+
IF NOT FOUND THEN
|
|
94
|
+
RETURN FALSE;
|
|
95
|
+
END IF;
|
|
96
|
+
|
|
97
|
+
-- Check permissions
|
|
98
|
+
RETURN (
|
|
99
|
+
${checks}
|
|
100
|
+
);
|
|
101
|
+
END;
|
|
102
|
+
$$;`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compile permission for subscribable can_subscribe function.
|
|
107
|
+
* Uses RECORD dot notation since v_root is a RECORD, not JSONB.
|
|
108
|
+
*
|
|
109
|
+
* @param entityName - The root entity name
|
|
110
|
+
* @param rule - The permission rule (e.g., "@org_id->acts_for[org_id=$]{active}.user_id")
|
|
111
|
+
* @param rootKey - The param key used to look up the root entity (e.g., "org_id")
|
|
112
|
+
* When @field matches rootKey, it maps to v_root.id (the root entity's PK)
|
|
113
|
+
*/
|
|
114
|
+
function compileSubscribePermission(entityName: string, rule: string, rootKey: string = ''): string {
|
|
115
|
+
// Helper to resolve field reference - if field matches rootKey param, use 'id' instead
|
|
116
|
+
const resolveField = (field: string): string => {
|
|
117
|
+
return field === rootKey ? 'id' : field;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Case 1: Simple Field Check (@org_id)
|
|
121
|
+
// Implies: EXISTS (SELECT 1 FROM acts_for WHERE acts_for.org_id = v_root.id AND acts_for.user_id = p_user_id)
|
|
122
|
+
if (rule.match(/^@[a-zA-Z0-9_]+$/)) {
|
|
123
|
+
const field = rule.substring(1);
|
|
124
|
+
const rootField = resolveField(field);
|
|
125
|
+
// For simple field checks, we check acts_for membership
|
|
126
|
+
// The acts_for join uses the original field name (org_id), but v_root uses resolved field (id)
|
|
127
|
+
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)`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Case 2: Graph Traversal (@field->table[filter]{condition}.user_id)
|
|
131
|
+
if (rule.includes('->')) {
|
|
132
|
+
const parts = rule.split('->');
|
|
133
|
+
const startField = parts[0].substring(1); // Remove @
|
|
134
|
+
const rootField = resolveField(startField);
|
|
135
|
+
|
|
136
|
+
// Multi-level paths not fully supported
|
|
137
|
+
if (parts.length > 2) {
|
|
138
|
+
console.warn(`[Compiler] Warning: Multi-level permission path '${rule}' not fully supported.`);
|
|
139
|
+
return 'FALSE';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const targetPart = parts[1];
|
|
143
|
+
|
|
144
|
+
const tableMatch = targetPart.match(/^([a-zA-Z0-9_]+)/);
|
|
145
|
+
if (!tableMatch) return 'FALSE';
|
|
146
|
+
const targetTable = tableMatch[1];
|
|
147
|
+
|
|
148
|
+
// Extract filter: [org_id=$]
|
|
149
|
+
const filterMatch = targetPart.match(/\[([a-zA-Z0-9_]+)=\$\]/);
|
|
150
|
+
const joinField = filterMatch ? filterMatch[1] : null;
|
|
151
|
+
|
|
152
|
+
// Extract condition: {active}
|
|
153
|
+
const condMatch = targetPart.match(/\{([^}]+)\}/);
|
|
154
|
+
const condition = condMatch ? condMatch[1] : null;
|
|
155
|
+
|
|
156
|
+
let sql = `EXISTS (SELECT 1 FROM ${targetTable} WHERE `;
|
|
157
|
+
|
|
158
|
+
// Join Clause - use RECORD dot notation
|
|
159
|
+
// The target table uses its own column name (e.g., acts_for.org_id)
|
|
160
|
+
// The root uses the resolved field (e.g., v_root.id when startField matches rootKey)
|
|
161
|
+
if (joinField) {
|
|
162
|
+
sql += `${targetTable}.${joinField} = v_root.${rootField}`;
|
|
163
|
+
} else {
|
|
164
|
+
// Implicit join: table.id = v_root.field
|
|
165
|
+
sql += `${targetTable}.id = v_root.${rootField}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// User Check
|
|
169
|
+
if (rule.endsWith('.user_id')) {
|
|
170
|
+
sql += ` AND ${targetTable}.user_id = p_user_id`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Condition
|
|
174
|
+
if (condition) {
|
|
175
|
+
sql += ` AND ${targetTable}.${condition}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
sql += `)`;
|
|
179
|
+
return sql;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return 'FALSE'; // Default deny
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Column info from EntityIR */
|
|
186
|
+
interface ColumnInfo {
|
|
187
|
+
name: string;
|
|
188
|
+
type: string;
|
|
189
|
+
isArray: boolean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build a SQL expression to select visible columns (excluding hidden) from a table.
|
|
194
|
+
* Returns row_to_json(alias.*) if no hidden fields, otherwise builds explicit jsonb_build_object.
|
|
195
|
+
*/
|
|
196
|
+
function buildVisibleRowJson(alias: string, entityName: string, entities: Record<string, EntityIR>): string {
|
|
197
|
+
const entity = entities[entityName];
|
|
198
|
+
const hidden = entity?.hidden || [];
|
|
199
|
+
|
|
200
|
+
if (hidden.length === 0) {
|
|
201
|
+
return `row_to_json(${alias}.*)`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build explicit column list excluding hidden fields
|
|
205
|
+
const columns: ColumnInfo[] = entity?.columns || [];
|
|
206
|
+
const visibleCols = columns.filter((c: ColumnInfo) => !hidden.includes(c.name));
|
|
207
|
+
|
|
208
|
+
if (visibleCols.length === 0) {
|
|
209
|
+
return `row_to_json(${alias}.*)`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pairs = visibleCols.map((c: ColumnInfo) => `'${c.name}', ${alias}.${c.name}`).join(', ');
|
|
213
|
+
return `jsonb_build_object(${pairs})`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function generateGetFunction(name: string, sub: SubscribableIR, entities: Record<string, EntityIR> = {}): string {
|
|
217
|
+
const paramNames = Object.keys(sub.params);
|
|
218
|
+
const paramDecls = paramNames.map(p => ` v_${p} ${sub.params[p]};`).join('\n');
|
|
219
|
+
const paramExtracts = paramNames.map(p =>
|
|
220
|
+
` v_${p} := (p_params->>'${p}')::${sub.params[p]};`
|
|
221
|
+
).join('\n');
|
|
222
|
+
|
|
223
|
+
// Handle special @user_id root key
|
|
224
|
+
const rootKey = sub.root.key;
|
|
225
|
+
const isUserIdRoot = rootKey === '@user_id';
|
|
226
|
+
const rootWhereValue = isUserIdRoot ? 'p_user_id' : `v_${rootKey}`;
|
|
227
|
+
|
|
228
|
+
// Build root select expression excluding hidden fields
|
|
229
|
+
const rootSelectExpr = buildVisibleRowJson('root', sub.root.entity, entities);
|
|
230
|
+
|
|
231
|
+
// Build relation subqueries, passing param names and entities for reference resolution
|
|
232
|
+
const relationSelects = generateRelationSelects(sub.includes, sub.root.entity, 'root', paramNames, entities);
|
|
233
|
+
|
|
234
|
+
// Build schema with path mapping and scope tables
|
|
235
|
+
const pathMapping = buildPathMapping(sub.root.entity, sub.includes);
|
|
236
|
+
const schemaJson = JSON.stringify({
|
|
237
|
+
root: sub.root.entity,
|
|
238
|
+
paths: pathMapping,
|
|
239
|
+
scopeTables: sub.scopeTables
|
|
240
|
+
}).replace(/'/g, "''"); // Escape single quotes for SQL
|
|
241
|
+
|
|
242
|
+
return `CREATE OR REPLACE FUNCTION dzql_v2.get_${name}(
|
|
243
|
+
p_params JSONB,
|
|
244
|
+
p_user_id INT
|
|
245
|
+
) RETURNS JSONB
|
|
246
|
+
LANGUAGE plpgsql
|
|
247
|
+
SECURITY DEFINER
|
|
248
|
+
SET search_path = dzql_v2, public
|
|
249
|
+
AS $$
|
|
250
|
+
DECLARE
|
|
251
|
+
${paramDecls}
|
|
252
|
+
v_data JSONB;
|
|
253
|
+
BEGIN
|
|
254
|
+
-- Extract parameters
|
|
255
|
+
${paramExtracts}
|
|
256
|
+
|
|
257
|
+
-- Check access control
|
|
258
|
+
IF NOT dzql_v2.${name}_can_subscribe(p_user_id, p_params) THEN
|
|
259
|
+
RAISE EXCEPTION 'permission_denied';
|
|
260
|
+
END IF;
|
|
261
|
+
|
|
262
|
+
-- Build document with root and all relations
|
|
263
|
+
SELECT jsonb_build_object(
|
|
264
|
+
'${sub.root.entity}', ${rootSelectExpr}${relationSelects}
|
|
265
|
+
)
|
|
266
|
+
INTO v_data
|
|
267
|
+
FROM ${sub.root.entity} root
|
|
268
|
+
WHERE root.id = ${rootWhereValue};
|
|
269
|
+
|
|
270
|
+
-- Return data with embedded schema for atomic updates
|
|
271
|
+
RETURN jsonb_build_object(
|
|
272
|
+
'data', v_data,
|
|
273
|
+
'schema', '${schemaJson}'::jsonb
|
|
274
|
+
);
|
|
275
|
+
END;
|
|
276
|
+
$$;`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function singularize(name: string): string {
|
|
280
|
+
// Simple singularization: remove trailing 's'
|
|
281
|
+
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
|
282
|
+
if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
|
|
283
|
+
return name;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function generateRelationSelects(
|
|
287
|
+
includes: Record<string, IncludeIR>,
|
|
288
|
+
rootEntity: string,
|
|
289
|
+
parentAlias: string = 'root',
|
|
290
|
+
paramNames: string[] = [],
|
|
291
|
+
entities: Record<string, EntityIR> = {}
|
|
292
|
+
): string {
|
|
293
|
+
if (!includes || Object.keys(includes).length === 0) {
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const selects = Object.entries(includes).map(([relName, relConfig]) => {
|
|
298
|
+
const relEntity = relConfig.entity;
|
|
299
|
+
const filter = relConfig.filter || {};
|
|
300
|
+
const hasNestedIncludes = relConfig.includes && Object.keys(relConfig.includes).length > 0;
|
|
301
|
+
|
|
302
|
+
// Detect relation type:
|
|
303
|
+
// - If relation name != entity name (e.g., "org" vs "organisations"), it's a PARENT relation
|
|
304
|
+
// - If relation name == entity name (e.g., "sites" == "sites"), it's a CHILD collection
|
|
305
|
+
// Parent relations use FK like root.org_id -> organisations.id
|
|
306
|
+
// Child relations use FK like sites.venue_id -> root.id
|
|
307
|
+
const isParentRelation = relName !== relEntity && !hasNestedIncludes && Object.keys(filter).length === 0;
|
|
308
|
+
|
|
309
|
+
// Build WHERE clause from filter
|
|
310
|
+
const whereClauses: string[] = [];
|
|
311
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
312
|
+
if (value.startsWith('@')) {
|
|
313
|
+
const refName = value.substring(1);
|
|
314
|
+
// Check if this is a param reference or parent field reference
|
|
315
|
+
if (paramNames.includes(refName) || refName === 'user_id') {
|
|
316
|
+
// Param reference: use v_param_name or p_user_id
|
|
317
|
+
const varName = refName === 'user_id' ? 'p_user_id' : `v_${refName}`;
|
|
318
|
+
whereClauses.push(`rel.${field} = ${varName}`);
|
|
319
|
+
} else {
|
|
320
|
+
// Parent field reference: @id -> root.id
|
|
321
|
+
whereClauses.push(`rel.${field} = ${parentAlias}.${refName}`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
whereClauses.push(`rel.${field} = '${value}'`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Default FK based on relation type
|
|
329
|
+
if (whereClauses.length === 0) {
|
|
330
|
+
if (isParentRelation) {
|
|
331
|
+
// Parent relation: root.{relName}_id = rel.id
|
|
332
|
+
whereClauses.push(`rel.id = ${parentAlias}.${relName}_id`);
|
|
333
|
+
} else {
|
|
334
|
+
// Child relation: rel.{singularRoot}_id = root.id
|
|
335
|
+
const singularRoot = singularize(rootEntity);
|
|
336
|
+
whereClauses.push(`rel.${singularRoot}_id = ${parentAlias}.id`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const whereSQL = whereClauses.join(' AND ');
|
|
341
|
+
|
|
342
|
+
// Build select expression excluding hidden fields
|
|
343
|
+
const relSelectExpr = buildVisibleRowJson('rel', relEntity, entities);
|
|
344
|
+
|
|
345
|
+
// Handle nested includes recursively
|
|
346
|
+
let nestedSelects = '';
|
|
347
|
+
if (relConfig.includes) {
|
|
348
|
+
nestedSelects = generateNestedSelects(relConfig.includes, relEntity, entities);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (isParentRelation) {
|
|
352
|
+
// Parent relation returns single object, not array
|
|
353
|
+
return `,
|
|
354
|
+
'${relName}', (
|
|
355
|
+
SELECT ${relSelectExpr}
|
|
356
|
+
FROM ${relEntity} rel
|
|
357
|
+
WHERE ${whereSQL}
|
|
358
|
+
)`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (nestedSelects) {
|
|
362
|
+
// Flatten: merge entity fields with nested includes into one object
|
|
363
|
+
// Use to_jsonb() for consistent jsonb types (row_to_json returns json, not jsonb)
|
|
364
|
+
const relSelectJsonb = relSelectExpr.replace('row_to_json', 'to_jsonb');
|
|
365
|
+
return `,
|
|
366
|
+
'${relName}', COALESCE((
|
|
367
|
+
SELECT jsonb_agg(
|
|
368
|
+
${relSelectJsonb} || jsonb_build_object(${nestedSelects.substring(1)})
|
|
369
|
+
)
|
|
370
|
+
FROM ${relEntity} rel
|
|
371
|
+
WHERE ${whereSQL}
|
|
372
|
+
), '[]'::jsonb)`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return `,
|
|
376
|
+
'${relName}', COALESCE((
|
|
377
|
+
SELECT jsonb_agg(${relSelectExpr})
|
|
378
|
+
FROM ${relEntity} rel
|
|
379
|
+
WHERE ${whereSQL}
|
|
380
|
+
), '[]'::jsonb)`;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
return selects.join('');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function generateNestedSelects(
|
|
387
|
+
includes: Record<string, IncludeIR>,
|
|
388
|
+
parentEntity: string,
|
|
389
|
+
entities: Record<string, EntityIR> = {}
|
|
390
|
+
): string {
|
|
391
|
+
if (!includes || Object.keys(includes).length === 0) {
|
|
392
|
+
return '';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const selects = Object.entries(includes).map(([relName, relConfig]) => {
|
|
396
|
+
const relEntity = relConfig.entity;
|
|
397
|
+
const filter = relConfig.filter || {};
|
|
398
|
+
const hasNestedIncludes = relConfig.includes && Object.keys(relConfig.includes).length > 0;
|
|
399
|
+
|
|
400
|
+
// Detect relation type: parent (single FK lookup) vs child (array)
|
|
401
|
+
// Parent: relation name != entity name (e.g., "org" vs "organisations")
|
|
402
|
+
// Child: relation name == entity name (e.g., "allocations" == "allocations")
|
|
403
|
+
const isParentRelation = relName !== relEntity && !hasNestedIncludes && Object.keys(filter).length === 0;
|
|
404
|
+
|
|
405
|
+
// Build WHERE clause
|
|
406
|
+
const whereClauses: string[] = [];
|
|
407
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
408
|
+
if (value.startsWith('@')) {
|
|
409
|
+
const parentField = value.substring(1);
|
|
410
|
+
whereClauses.push(`nested.${field} = rel.${parentField}`);
|
|
411
|
+
} else {
|
|
412
|
+
whereClauses.push(`nested.${field} = '${value}'`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (whereClauses.length === 0) {
|
|
417
|
+
if (isParentRelation) {
|
|
418
|
+
// Parent relation: rel.{relName}_id = nested.id
|
|
419
|
+
whereClauses.push(`nested.id = rel.${relName}_id`);
|
|
420
|
+
} else {
|
|
421
|
+
// Child relation: FK is singular form of parent entity
|
|
422
|
+
const singularParent = singularize(parentEntity);
|
|
423
|
+
whereClauses.push(`nested.${singularParent}_id = rel.id`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Build select expression excluding hidden fields
|
|
428
|
+
const nestedSelectExpr = buildVisibleRowJson('nested', relEntity, entities);
|
|
429
|
+
|
|
430
|
+
if (isParentRelation) {
|
|
431
|
+
// Parent relation returns single object
|
|
432
|
+
return `,
|
|
433
|
+
'${relName}', (
|
|
434
|
+
SELECT ${nestedSelectExpr}
|
|
435
|
+
FROM ${relEntity} nested
|
|
436
|
+
WHERE ${whereClauses.join(' AND ')}
|
|
437
|
+
)`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return `,
|
|
441
|
+
'${relName}', COALESCE((
|
|
442
|
+
SELECT jsonb_agg(${nestedSelectExpr})
|
|
443
|
+
FROM ${relEntity} nested
|
|
444
|
+
WHERE ${whereClauses.join(' AND ')}
|
|
445
|
+
), '[]'::jsonb)`;
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
return selects.join('');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function generateAffectedKeysFunction(name: string, sub: SubscribableIR): string {
|
|
452
|
+
const cases: string[] = [];
|
|
453
|
+
const paramKey = sub.root.key;
|
|
454
|
+
|
|
455
|
+
// Root entity case
|
|
456
|
+
cases.push(` WHEN '${sub.root.entity}' THEN
|
|
457
|
+
RETURN ARRAY['${name}:' || (p_data->>'id')];`);
|
|
458
|
+
|
|
459
|
+
// Get singular form of root entity for FK fields
|
|
460
|
+
const singularRootEntity = singularize(sub.root.entity);
|
|
461
|
+
|
|
462
|
+
// Related entity cases
|
|
463
|
+
const addRelationCase = (relName: string, relConfig: IncludeIR, parentEntity: string) => {
|
|
464
|
+
const relEntity = relConfig.entity;
|
|
465
|
+
const filter = relConfig.filter || {};
|
|
466
|
+
|
|
467
|
+
// Find the FK field that points to root (use singular form)
|
|
468
|
+
let fkField = `${singularRootEntity}_id`;
|
|
469
|
+
for (const [field, value] of Object.entries(filter)) {
|
|
470
|
+
if (value === '@id' || value === `@${paramKey}`) {
|
|
471
|
+
fkField = field;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const singularParent = singularize(parentEntity);
|
|
477
|
+
|
|
478
|
+
// For nested relations, we need to traverse up
|
|
479
|
+
if (parentEntity !== sub.root.entity) {
|
|
480
|
+
cases.push(` WHEN '${relEntity}' THEN
|
|
481
|
+
-- Nested: traverse via ${parentEntity}
|
|
482
|
+
SELECT ARRAY_AGG('${name}:' || parent.${singularRootEntity}_id)
|
|
483
|
+
INTO v_keys
|
|
484
|
+
FROM ${parentEntity} parent
|
|
485
|
+
WHERE parent.id = (p_data->>'${singularParent}_id')::int;
|
|
486
|
+
RETURN COALESCE(v_keys, ARRAY[]::text[]);`);
|
|
487
|
+
} else {
|
|
488
|
+
cases.push(` WHEN '${relEntity}' THEN
|
|
489
|
+
RETURN ARRAY['${name}:' || (p_data->>'${fkField}')];`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Recurse for nested includes
|
|
493
|
+
if (relConfig.includes) {
|
|
494
|
+
for (const [nestedName, nestedConfig] of Object.entries(relConfig.includes)) {
|
|
495
|
+
addRelationCase(nestedName, nestedConfig, relEntity);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
for (const [relName, relConfig] of Object.entries(sub.includes)) {
|
|
501
|
+
addRelationCase(relName, relConfig, sub.root.entity);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return `CREATE OR REPLACE FUNCTION dzql_v2.${name}_affected_keys(
|
|
505
|
+
p_table TEXT,
|
|
506
|
+
p_op TEXT,
|
|
507
|
+
p_data JSONB
|
|
508
|
+
) RETURNS TEXT[]
|
|
509
|
+
LANGUAGE plpgsql
|
|
510
|
+
IMMUTABLE
|
|
511
|
+
AS $$
|
|
512
|
+
DECLARE
|
|
513
|
+
v_keys TEXT[];
|
|
514
|
+
BEGIN
|
|
515
|
+
CASE p_table
|
|
516
|
+
${cases.join('\n')}
|
|
517
|
+
ELSE
|
|
518
|
+
RETURN ARRAY[]::text[];
|
|
519
|
+
END CASE;
|
|
520
|
+
END;
|
|
521
|
+
$$;`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function buildPathMapping(
|
|
525
|
+
rootEntity: string,
|
|
526
|
+
includes: Record<string, IncludeIR>,
|
|
527
|
+
parentPath: string = ''
|
|
528
|
+
): Record<string, string> {
|
|
529
|
+
const paths: Record<string, string> = {};
|
|
530
|
+
|
|
531
|
+
// Root entity maps to top level
|
|
532
|
+
paths[rootEntity] = '.';
|
|
533
|
+
|
|
534
|
+
const buildPaths = (incl: Record<string, IncludeIR>, parent: string) => {
|
|
535
|
+
for (const [relName, relConfig] of Object.entries(incl)) {
|
|
536
|
+
const currentPath = parent ? `${parent}.${relName}` : relName;
|
|
537
|
+
paths[relConfig.entity] = currentPath;
|
|
538
|
+
|
|
539
|
+
if (relConfig.includes) {
|
|
540
|
+
buildPaths(relConfig.includes, currentPath);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
buildPaths(includes, '');
|
|
546
|
+
return paths;
|
|
547
|
+
}
|