dzql 0.5.32 → 0.6.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/.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 +293 -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 +641 -0
- package/docs/project-setup.md +432 -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 +164 -0
- package/src/client/index.ts +1 -0
- package/src/client/ws.ts +286 -0
- package/src/create/.env.example +8 -0
- package/src/create/README.md +101 -0
- package/src/create/compose.yml +14 -0
- package/src/create/domain.ts +153 -0
- package/src/create/package.json +24 -0
- package/src/create/server.ts +18 -0
- package/src/create/setup.sh +11 -0
- package/src/create/tsconfig.json +15 -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
|
@@ -1,827 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subscribable Code Generator
|
|
3
|
-
* Generates PostgreSQL functions for live query subscriptions
|
|
4
|
-
*
|
|
5
|
-
* For each subscribable, generates:
|
|
6
|
-
* 1. get_<name>(params, user_id) - Query function that builds the document
|
|
7
|
-
* 2. <name>_affected_documents(table, op, old, new) - Determines which subscription instances are affected
|
|
8
|
-
* 3. <name>_can_subscribe(user_id, params) - Access control check
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { PathParser } from '../parser/path-parser.js';
|
|
12
|
-
|
|
13
|
-
export class SubscribableCodegen {
|
|
14
|
-
constructor(subscribable) {
|
|
15
|
-
this.name = subscribable.name;
|
|
16
|
-
this.permissionPaths = subscribable.permissionPaths || {};
|
|
17
|
-
this.paramSchema = subscribable.paramSchema || {};
|
|
18
|
-
this.rootEntity = subscribable.rootEntity;
|
|
19
|
-
this.relations = subscribable.relations || {};
|
|
20
|
-
this.parser = new PathParser();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Generate all functions for this subscribable
|
|
25
|
-
* @returns {string} SQL for all subscribable functions
|
|
26
|
-
*/
|
|
27
|
-
generate() {
|
|
28
|
-
const sections = [];
|
|
29
|
-
|
|
30
|
-
// Header comment
|
|
31
|
-
sections.push(this._generateHeader());
|
|
32
|
-
|
|
33
|
-
// 1. Access control function
|
|
34
|
-
sections.push(this._generateAccessControlFunction());
|
|
35
|
-
|
|
36
|
-
// 2. Query function (builds the document)
|
|
37
|
-
sections.push(this._generateQueryFunction());
|
|
38
|
-
|
|
39
|
-
// 3. Affected documents function (determines which subscriptions to update)
|
|
40
|
-
sections.push(this._generateAffectedDocumentsFunction());
|
|
41
|
-
|
|
42
|
-
return sections.join('\n\n');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Generate header comment
|
|
47
|
-
* @private
|
|
48
|
-
*/
|
|
49
|
-
_generateHeader() {
|
|
50
|
-
return `-- ============================================================================
|
|
51
|
-
-- Subscribable: ${this.name}
|
|
52
|
-
-- Root Entity: ${this.rootEntity}
|
|
53
|
-
-- Generated: ${new Date().toISOString()}
|
|
54
|
-
-- ============================================================================`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Generate access control function
|
|
59
|
-
* @private
|
|
60
|
-
*/
|
|
61
|
-
_generateAccessControlFunction() {
|
|
62
|
-
let subscribePaths = this.permissionPaths.subscribe || [];
|
|
63
|
-
|
|
64
|
-
// Ensure it's an array
|
|
65
|
-
if (!Array.isArray(subscribePaths)) {
|
|
66
|
-
subscribePaths = [subscribePaths];
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// If no paths, it's public
|
|
70
|
-
if (subscribePaths.length === 0) {
|
|
71
|
-
return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
|
|
72
|
-
p_user_id INT,
|
|
73
|
-
p_params JSONB
|
|
74
|
-
) RETURNS BOOLEAN AS $$
|
|
75
|
-
BEGIN
|
|
76
|
-
RETURN TRUE; -- Public access
|
|
77
|
-
END;
|
|
78
|
-
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Check if any path references root entity fields (needs database lookup)
|
|
82
|
-
const needsEntityLookup = subscribePaths.some(path => {
|
|
83
|
-
const ast = this.parser.parse(path);
|
|
84
|
-
return ast.type === 'direct_field' || ast.type === 'field_ref';
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// Generate permission check logic
|
|
88
|
-
const checks = subscribePaths.map(path => {
|
|
89
|
-
const ast = this.parser.parse(path);
|
|
90
|
-
return this._generatePathCheck(ast, needsEntityLookup ? 'entity' : 'p_params', 'p_user_id');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const checkSQL = checks.join(' OR\n ');
|
|
94
|
-
|
|
95
|
-
// If we need entity lookup, fetch it first
|
|
96
|
-
if (needsEntityLookup) {
|
|
97
|
-
const params = Object.keys(this.paramSchema);
|
|
98
|
-
const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
|
|
99
|
-
const paramExtractions = params.map(p =>
|
|
100
|
-
` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
|
|
101
|
-
).join('\n');
|
|
102
|
-
|
|
103
|
-
const rootFilter = this._generateRootFilter();
|
|
104
|
-
|
|
105
|
-
return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
|
|
106
|
-
p_user_id INT,
|
|
107
|
-
p_params JSONB
|
|
108
|
-
) RETURNS BOOLEAN AS $$
|
|
109
|
-
DECLARE
|
|
110
|
-
${paramDeclarations}
|
|
111
|
-
entity RECORD;
|
|
112
|
-
BEGIN
|
|
113
|
-
-- Extract parameters
|
|
114
|
-
${paramExtractions}
|
|
115
|
-
|
|
116
|
-
-- Fetch entity
|
|
117
|
-
SELECT * INTO entity
|
|
118
|
-
FROM ${this.rootEntity} root
|
|
119
|
-
WHERE ${rootFilter};
|
|
120
|
-
|
|
121
|
-
-- Entity not found
|
|
122
|
-
IF NOT FOUND THEN
|
|
123
|
-
RETURN FALSE;
|
|
124
|
-
END IF;
|
|
125
|
-
|
|
126
|
-
-- Check permissions
|
|
127
|
-
RETURN (
|
|
128
|
-
${checkSQL}
|
|
129
|
-
);
|
|
130
|
-
END;
|
|
131
|
-
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
|
|
135
|
-
p_user_id INT,
|
|
136
|
-
p_params JSONB
|
|
137
|
-
) RETURNS BOOLEAN AS $$
|
|
138
|
-
BEGIN
|
|
139
|
-
RETURN (
|
|
140
|
-
${checkSQL}
|
|
141
|
-
);
|
|
142
|
-
END;
|
|
143
|
-
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Generate path check SQL from AST
|
|
148
|
-
* @private
|
|
149
|
-
*/
|
|
150
|
-
_generatePathCheck(ast, recordVar, userIdVar) {
|
|
151
|
-
// Handle direct field reference: @owner_id
|
|
152
|
-
if (ast.type === 'direct_field' || ast.type === 'field_ref') {
|
|
153
|
-
// If recordVar is 'entity' (RECORD type), access directly
|
|
154
|
-
if (recordVar === 'entity') {
|
|
155
|
-
return `${recordVar}.${ast.field} = ${userIdVar}`;
|
|
156
|
-
}
|
|
157
|
-
// Otherwise it's p_params (JSONB type)
|
|
158
|
-
return `(${recordVar}->>'${ast.field}')::int = ${userIdVar}`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Handle traversal with steps: @org_id->acts_for[org_id=$]{active}.user_id
|
|
162
|
-
if (ast.type === 'traversal' && ast.steps) {
|
|
163
|
-
const fieldRef = ast.steps[0]; // First step is the field reference
|
|
164
|
-
const tableRef = ast.steps[1]; // Second step is the table reference
|
|
165
|
-
|
|
166
|
-
if (!fieldRef || !tableRef || tableRef.type !== 'table_ref') {
|
|
167
|
-
return 'FALSE';
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const targetTable = tableRef.table;
|
|
171
|
-
const targetField = tableRef.targetField;
|
|
172
|
-
|
|
173
|
-
// Build WHERE clause
|
|
174
|
-
const whereClauses = [];
|
|
175
|
-
|
|
176
|
-
// Add filter conditions from the table_ref
|
|
177
|
-
if (tableRef.filter && tableRef.filter.length > 0) {
|
|
178
|
-
for (const filterCondition of tableRef.filter) {
|
|
179
|
-
const field = filterCondition.field;
|
|
180
|
-
if (filterCondition.value.type === 'param') {
|
|
181
|
-
// Parameter reference: org_id=$ means use the filter field name as the param key
|
|
182
|
-
// e.g., acts_for[organisation_id=$] -> (p_params->>'organisation_id')::int
|
|
183
|
-
const paramValue = `(${recordVar}->>'${field}')::int`;
|
|
184
|
-
whereClauses.push(`${targetTable}.${field} = ${paramValue}`);
|
|
185
|
-
} else {
|
|
186
|
-
// Literal value
|
|
187
|
-
whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Add temporal marker if present
|
|
193
|
-
if (tableRef.temporal) {
|
|
194
|
-
whereClauses.push(`${targetTable}.valid_to IS NULL`);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return `EXISTS (
|
|
198
|
-
SELECT 1 FROM ${targetTable}
|
|
199
|
-
WHERE ${whereClauses.join('\n AND ')}
|
|
200
|
-
AND ${targetTable}.${targetField} = ${userIdVar}
|
|
201
|
-
)`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return 'FALSE';
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Generate filter SQL
|
|
209
|
-
* @private
|
|
210
|
-
*/
|
|
211
|
-
_generateFilterSQL(filter, tableAlias) {
|
|
212
|
-
const conditions = [];
|
|
213
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
214
|
-
if (value === '$') {
|
|
215
|
-
// Placeholder - will be replaced with actual value
|
|
216
|
-
conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
|
|
217
|
-
} else {
|
|
218
|
-
conditions.push(`${tableAlias}.${key} = '${value}'`);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return conditions.join(' AND ');
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Generate query function that builds the document
|
|
226
|
-
* @private
|
|
227
|
-
*/
|
|
228
|
-
_generateQueryFunction() {
|
|
229
|
-
const params = Object.keys(this.paramSchema);
|
|
230
|
-
const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
|
|
231
|
-
const paramExtractions = params.map(p =>
|
|
232
|
-
` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
|
|
233
|
-
).join('\n');
|
|
234
|
-
|
|
235
|
-
// Build root WHERE clause based on params
|
|
236
|
-
const rootFilter = this._generateRootFilter();
|
|
237
|
-
|
|
238
|
-
// Build relation subqueries
|
|
239
|
-
const relationSelects = this._generateRelationSelects();
|
|
240
|
-
|
|
241
|
-
// Build schema with path mapping and scope tables (baked in at compile time)
|
|
242
|
-
const pathMapping = this.buildPathMapping();
|
|
243
|
-
const scopeTables = this.extractScopeTables();
|
|
244
|
-
const schemaJson = JSON.stringify({
|
|
245
|
-
root: this.rootEntity,
|
|
246
|
-
paths: pathMapping,
|
|
247
|
-
scopeTables: scopeTables
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// Pure collection mode: no rootEntity, only relations
|
|
251
|
-
if (!this.rootEntity) {
|
|
252
|
-
// Build relation-only selects (strip leading comma)
|
|
253
|
-
const collectionSelects = this._generateCollectionOnlySelects();
|
|
254
|
-
|
|
255
|
-
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
256
|
-
p_params JSONB,
|
|
257
|
-
p_user_id INT
|
|
258
|
-
) RETURNS JSONB AS $$
|
|
259
|
-
DECLARE
|
|
260
|
-
v_data JSONB;
|
|
261
|
-
BEGIN
|
|
262
|
-
-- Check access control
|
|
263
|
-
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
264
|
-
RAISE EXCEPTION 'Permission denied';
|
|
265
|
-
END IF;
|
|
266
|
-
|
|
267
|
-
-- Build document with collections only (no root entity)
|
|
268
|
-
SELECT jsonb_build_object(
|
|
269
|
-
${collectionSelects}
|
|
270
|
-
)
|
|
271
|
-
INTO v_data;
|
|
272
|
-
|
|
273
|
-
-- Return data with embedded schema for atomic updates
|
|
274
|
-
RETURN jsonb_build_object(
|
|
275
|
-
'data', v_data,
|
|
276
|
-
'schema', '${schemaJson}'::jsonb
|
|
277
|
-
);
|
|
278
|
-
END;
|
|
279
|
-
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Dashboard mode: empty paramSchema with rootEntity means aggregate root as array
|
|
283
|
-
if (params.length === 0) {
|
|
284
|
-
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
285
|
-
p_params JSONB,
|
|
286
|
-
p_user_id INT
|
|
287
|
-
) RETURNS JSONB AS $$
|
|
288
|
-
DECLARE
|
|
289
|
-
v_data JSONB;
|
|
290
|
-
BEGIN
|
|
291
|
-
-- Check access control
|
|
292
|
-
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
293
|
-
RAISE EXCEPTION 'Permission denied';
|
|
294
|
-
END IF;
|
|
295
|
-
|
|
296
|
-
-- Build document with root as array (dashboard mode)
|
|
297
|
-
SELECT jsonb_build_object(
|
|
298
|
-
'${this.rootEntity}', COALESCE((
|
|
299
|
-
SELECT jsonb_agg(row_to_json(root.*))
|
|
300
|
-
FROM ${this.rootEntity} root
|
|
301
|
-
), '[]'::jsonb)${relationSelects}
|
|
302
|
-
)
|
|
303
|
-
INTO v_data;
|
|
304
|
-
|
|
305
|
-
-- Return data with embedded schema for atomic updates
|
|
306
|
-
RETURN jsonb_build_object(
|
|
307
|
-
'data', v_data,
|
|
308
|
-
'schema', '${schemaJson}'::jsonb
|
|
309
|
-
);
|
|
310
|
-
END;
|
|
311
|
-
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
315
|
-
p_params JSONB,
|
|
316
|
-
p_user_id INT
|
|
317
|
-
) RETURNS JSONB AS $$
|
|
318
|
-
DECLARE
|
|
319
|
-
${paramDeclarations}
|
|
320
|
-
v_data JSONB;
|
|
321
|
-
BEGIN
|
|
322
|
-
-- Extract parameters
|
|
323
|
-
${paramExtractions}
|
|
324
|
-
|
|
325
|
-
-- Check access control
|
|
326
|
-
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
327
|
-
RAISE EXCEPTION 'Permission denied';
|
|
328
|
-
END IF;
|
|
329
|
-
|
|
330
|
-
-- Build document with root and all relations
|
|
331
|
-
SELECT jsonb_build_object(
|
|
332
|
-
'${this.rootEntity}', row_to_json(root.*)${relationSelects}
|
|
333
|
-
)
|
|
334
|
-
INTO v_data
|
|
335
|
-
FROM ${this.rootEntity} root
|
|
336
|
-
WHERE ${rootFilter};
|
|
337
|
-
|
|
338
|
-
-- Return data with embedded schema for atomic updates
|
|
339
|
-
RETURN jsonb_build_object(
|
|
340
|
-
'data', v_data,
|
|
341
|
-
'schema', '${schemaJson}'::jsonb
|
|
342
|
-
);
|
|
343
|
-
END;
|
|
344
|
-
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Generate root filter based on params
|
|
349
|
-
* @private
|
|
350
|
-
*/
|
|
351
|
-
_generateRootFilter() {
|
|
352
|
-
const params = Object.keys(this.paramSchema);
|
|
353
|
-
|
|
354
|
-
// Assume first param is the root entity ID
|
|
355
|
-
// TODO: Make this more flexible based on param naming conventions
|
|
356
|
-
if (params.length > 0) {
|
|
357
|
-
const firstParam = params[0];
|
|
358
|
-
// Convention: venue_id -> id, org_id -> id, etc.
|
|
359
|
-
return `root.id = v_${firstParam}`;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
return 'TRUE';
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Generate relation subqueries
|
|
367
|
-
* @private
|
|
368
|
-
*/
|
|
369
|
-
_generateRelationSelects() {
|
|
370
|
-
if (Object.keys(this.relations).length === 0) {
|
|
371
|
-
return '';
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
|
|
375
|
-
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
376
|
-
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
377
|
-
const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
378
|
-
const relVia = typeof relConfig === 'object' ? relConfig.via : null;
|
|
379
|
-
|
|
380
|
-
// Build nested includes if any
|
|
381
|
-
let nestedSelect = 'row_to_json(rel.*)';
|
|
382
|
-
if (relIncludes) {
|
|
383
|
-
const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
|
|
384
|
-
return `'${nestedName}', (
|
|
385
|
-
SELECT COALESCE(jsonb_agg(row_to_json(nested.*)), '[]'::jsonb)
|
|
386
|
-
FROM ${nestedEntity} nested
|
|
387
|
-
WHERE nested.${relEntity}_id = rel.id
|
|
388
|
-
)`;
|
|
389
|
-
}).join(',\n ');
|
|
390
|
-
|
|
391
|
-
nestedSelect = `jsonb_build_object(
|
|
392
|
-
'${relEntity}', row_to_json(rel.*),
|
|
393
|
-
${nestedFields}
|
|
394
|
-
)`;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Handle via relations with JOINs
|
|
398
|
-
if (relVia) {
|
|
399
|
-
const { joinClause, whereClause } = this._generateViaJoin(relConfig);
|
|
400
|
-
return `,
|
|
401
|
-
'${relName}', COALESCE((
|
|
402
|
-
SELECT jsonb_agg(${nestedSelect})
|
|
403
|
-
FROM ${relEntity} rel
|
|
404
|
-
${joinClause}
|
|
405
|
-
WHERE ${whereClause}
|
|
406
|
-
), '[]'::jsonb)`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Direct relation (no via)
|
|
410
|
-
let filterSQL = this._generateRelationFilter(relFilter, relEntity, relConfig);
|
|
411
|
-
|
|
412
|
-
return `,
|
|
413
|
-
'${relName}', COALESCE((
|
|
414
|
-
SELECT jsonb_agg(${nestedSelect})
|
|
415
|
-
FROM ${relEntity} rel
|
|
416
|
-
WHERE ${filterSQL}
|
|
417
|
-
), '[]'::jsonb)`;
|
|
418
|
-
}).join('');
|
|
419
|
-
|
|
420
|
-
return selects;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
* Generate collection-only selects (no root entity)
|
|
425
|
-
* Used when rootEntity is null/empty - pure collection mode
|
|
426
|
-
* @private
|
|
427
|
-
*/
|
|
428
|
-
_generateCollectionOnlySelects() {
|
|
429
|
-
if (Object.keys(this.relations).length === 0) {
|
|
430
|
-
return "'_empty', '{}'::jsonb";
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
|
|
434
|
-
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
435
|
-
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
436
|
-
|
|
437
|
-
// For collection mode, filter should be TRUE or we fetch all
|
|
438
|
-
const filterSQL = relFilter === 'TRUE' ? 'TRUE' : 'TRUE';
|
|
439
|
-
|
|
440
|
-
return `'${relName}', COALESCE((
|
|
441
|
-
SELECT jsonb_agg(row_to_json(rel.*))
|
|
442
|
-
FROM ${relEntity} rel
|
|
443
|
-
WHERE ${filterSQL}
|
|
444
|
-
), '[]'::jsonb)`;
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
return selects.join(',\n ');
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Generate JOIN clause for via relations
|
|
452
|
-
* Handles multi-hop via chains by looking up each intermediate table
|
|
453
|
-
* @private
|
|
454
|
-
* @param {Object} relConfig - Relation config with via property
|
|
455
|
-
* @returns {Object} { joinClause, whereClause }
|
|
456
|
-
*/
|
|
457
|
-
_generateViaJoin(relConfig) {
|
|
458
|
-
const via = relConfig.via;
|
|
459
|
-
const fk = relConfig.foreignKey;
|
|
460
|
-
|
|
461
|
-
// Parse via: "products.id" -> table: products, column: id
|
|
462
|
-
const [viaTable, viaColumn] = via.split('.');
|
|
463
|
-
|
|
464
|
-
// Build the join chain by following via references
|
|
465
|
-
const joinClauses = [];
|
|
466
|
-
const viaChain = this._buildViaChain(viaTable);
|
|
467
|
-
|
|
468
|
-
// First join: rel -> first via table
|
|
469
|
-
joinClauses.push(`JOIN ${viaTable} via_${viaTable} ON rel.${fk} = via_${viaTable}.${viaColumn}`);
|
|
470
|
-
|
|
471
|
-
// Additional joins for multi-hop
|
|
472
|
-
for (let i = 0; i < viaChain.length; i++) {
|
|
473
|
-
const current = viaChain[i];
|
|
474
|
-
const next = viaChain[i + 1];
|
|
475
|
-
|
|
476
|
-
if (next) {
|
|
477
|
-
// Join current via table to next via table
|
|
478
|
-
joinClauses.push(`JOIN ${next.table} via_${next.table} ON via_${current.table}.${current.fk} = via_${next.table}.${next.column}`);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Final WHERE clause connects to root
|
|
483
|
-
const lastVia = viaChain.length > 0 ? viaChain[viaChain.length - 1] : { table: viaTable };
|
|
484
|
-
const params = Object.keys(this.paramSchema);
|
|
485
|
-
const rootFK = params[0] || `${this.rootEntity}_id`;
|
|
486
|
-
const whereClause = `via_${lastVia.table}.${rootFK} = root.id`;
|
|
487
|
-
|
|
488
|
-
return { joinClause: joinClauses.join('\n '), whereClause };
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/**
|
|
492
|
-
* Build the via chain from a starting table back to root
|
|
493
|
-
* @private
|
|
494
|
-
* @param {string} startTable - The table to start from
|
|
495
|
-
* @returns {Array} Chain of {table, fk, column} objects
|
|
496
|
-
*/
|
|
497
|
-
_buildViaChain(startTable) {
|
|
498
|
-
const chain = [];
|
|
499
|
-
let currentTable = startTable;
|
|
500
|
-
|
|
501
|
-
// Find the relation config for this table
|
|
502
|
-
const findRelConfig = (tableName) => {
|
|
503
|
-
for (const [relName, relConfig] of Object.entries(this.relations)) {
|
|
504
|
-
if (typeof relConfig === 'object' && relConfig.entity === tableName) {
|
|
505
|
-
return relConfig;
|
|
506
|
-
}
|
|
507
|
-
if (relName === tableName && typeof relConfig === 'object') {
|
|
508
|
-
return relConfig;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
return null;
|
|
512
|
-
};
|
|
513
|
-
|
|
514
|
-
// Follow via chain until we reach a table without via (direct to root)
|
|
515
|
-
let relConfig = findRelConfig(currentTable);
|
|
516
|
-
while (relConfig && relConfig.via) {
|
|
517
|
-
const [nextTable, nextColumn] = relConfig.via.split('.');
|
|
518
|
-
chain.push({
|
|
519
|
-
table: currentTable,
|
|
520
|
-
fk: relConfig.foreignKey,
|
|
521
|
-
column: nextColumn
|
|
522
|
-
});
|
|
523
|
-
currentTable = nextTable;
|
|
524
|
-
relConfig = findRelConfig(currentTable);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// Add final table (direct connection to root)
|
|
528
|
-
if (currentTable !== startTable) {
|
|
529
|
-
const finalConfig = findRelConfig(currentTable);
|
|
530
|
-
chain.push({
|
|
531
|
-
table: currentTable,
|
|
532
|
-
fk: finalConfig ? finalConfig.foreignKey : `${this.rootEntity}_id`,
|
|
533
|
-
column: 'id'
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
return chain;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Generate filter for relation subquery
|
|
542
|
-
* @private
|
|
543
|
-
* @param {string|null} filter - Optional filter expression
|
|
544
|
-
* @param {string} relEntity - Related entity table name
|
|
545
|
-
* @param {Object} relConfig - Full relation config (may contain foreignKey)
|
|
546
|
-
*/
|
|
547
|
-
_generateRelationFilter(filter, relEntity, relConfig) {
|
|
548
|
-
if (!filter) {
|
|
549
|
-
// Use explicit foreignKey from config, or default to root_id
|
|
550
|
-
const fk = (typeof relConfig === 'object' && relConfig.foreignKey)
|
|
551
|
-
? relConfig.foreignKey
|
|
552
|
-
: `${this.rootEntity}_id`;
|
|
553
|
-
return `rel.${fk} = root.id`;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Dashboard collection: filter="TRUE" means fetch ALL rows (no FK filter)
|
|
557
|
-
if (filter === 'TRUE') {
|
|
558
|
-
return 'TRUE';
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Parse filter expression like "venue_id=$venue_id"
|
|
562
|
-
// Replace $param with v_param variable
|
|
563
|
-
return filter.replace(/\$(\w+)/g, 'v_$1');
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Generate affected documents function
|
|
568
|
-
* @private
|
|
569
|
-
*/
|
|
570
|
-
_generateAffectedDocumentsFunction() {
|
|
571
|
-
const cases = [];
|
|
572
|
-
const seenEntities = new Set();
|
|
573
|
-
|
|
574
|
-
// Case 1: Root entity changed (only if rootEntity exists)
|
|
575
|
-
if (this.rootEntity) {
|
|
576
|
-
cases.push(this._generateRootAffectedCase());
|
|
577
|
-
seenEntities.add(this.rootEntity);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Case 2: Related entities changed (skip duplicates)
|
|
581
|
-
for (const [relName, relConfig] of Object.entries(this.relations)) {
|
|
582
|
-
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
583
|
-
if (!seenEntities.has(relEntity)) {
|
|
584
|
-
cases.push(this._generateRelationAffectedCase(relName, relConfig));
|
|
585
|
-
seenEntities.add(relEntity);
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const casesSQL = cases.join('\n\n ');
|
|
590
|
-
|
|
591
|
-
return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
|
|
592
|
-
p_table_name TEXT,
|
|
593
|
-
p_op TEXT,
|
|
594
|
-
p_data JSONB
|
|
595
|
-
) RETURNS JSONB[] AS $$
|
|
596
|
-
DECLARE
|
|
597
|
-
v_affected JSONB[];
|
|
598
|
-
BEGIN
|
|
599
|
-
CASE p_table_name
|
|
600
|
-
${casesSQL}
|
|
601
|
-
|
|
602
|
-
ELSE
|
|
603
|
-
v_affected := ARRAY[]::JSONB[];
|
|
604
|
-
END CASE;
|
|
605
|
-
|
|
606
|
-
RETURN v_affected;
|
|
607
|
-
END;
|
|
608
|
-
$$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* Generate case for root entity changes
|
|
613
|
-
* @private
|
|
614
|
-
*/
|
|
615
|
-
_generateRootAffectedCase() {
|
|
616
|
-
const params = Object.keys(this.paramSchema);
|
|
617
|
-
|
|
618
|
-
// Dashboard mode: empty paramSchema means notify ALL subscribers
|
|
619
|
-
if (params.length === 0) {
|
|
620
|
-
return `-- Root entity (${this.rootEntity}) changed - dashboard mode, notify all
|
|
621
|
-
WHEN '${this.rootEntity}' THEN
|
|
622
|
-
v_affected := ARRAY['{}'::jsonb];`;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const firstParam = params[0];
|
|
626
|
-
|
|
627
|
-
return `-- Root entity (${this.rootEntity}) changed
|
|
628
|
-
WHEN '${this.rootEntity}' THEN
|
|
629
|
-
v_affected := ARRAY[
|
|
630
|
-
jsonb_build_object('${firstParam}', (p_data->>'id')::int)
|
|
631
|
-
];`;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
/**
|
|
635
|
-
* Generate case for related entity changes
|
|
636
|
-
* @private
|
|
637
|
-
*/
|
|
638
|
-
_generateRelationAffectedCase(relName, relConfig) {
|
|
639
|
-
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
640
|
-
const relFK = typeof relConfig === 'object' && relConfig.foreignKey
|
|
641
|
-
? relConfig.foreignKey
|
|
642
|
-
: `${this.rootEntity}_id`;
|
|
643
|
-
const relVia = typeof relConfig === 'object' ? relConfig.via : null;
|
|
644
|
-
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
645
|
-
|
|
646
|
-
const params = Object.keys(this.paramSchema);
|
|
647
|
-
const firstParam = params[0] || 'id';
|
|
648
|
-
|
|
649
|
-
// Dashboard collection: filter="TRUE" means notify ALL subscribers
|
|
650
|
-
// This relation is independent from the root entity
|
|
651
|
-
if (relFilter === 'TRUE') {
|
|
652
|
-
return `-- Dashboard collection (${relEntity}) - notify all subscribers
|
|
653
|
-
WHEN '${relEntity}' THEN
|
|
654
|
-
v_affected := ARRAY['{}'::jsonb];`;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Check if this is a nested relation (has parent FK)
|
|
658
|
-
const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
659
|
-
|
|
660
|
-
// Handle via relations: need to traverse via path to root
|
|
661
|
-
if (relVia) {
|
|
662
|
-
const [viaTable, viaColumn] = relVia.split('.');
|
|
663
|
-
const viaChain = this._buildViaChain(viaTable);
|
|
664
|
-
|
|
665
|
-
// Build multi-hop join chain for affected documents
|
|
666
|
-
if (viaChain.length > 1) {
|
|
667
|
-
// Multi-hop: need to join through intermediate tables
|
|
668
|
-
const joins = [];
|
|
669
|
-
const pathDesc = [relEntity, ...viaChain.map(v => v.table), this.rootEntity].join(' -> ');
|
|
670
|
-
|
|
671
|
-
// First table in chain
|
|
672
|
-
let prevAlias = 'via_0';
|
|
673
|
-
joins.push(`FROM ${viaTable} ${prevAlias}`);
|
|
674
|
-
|
|
675
|
-
// Join through chain
|
|
676
|
-
for (let i = 0; i < viaChain.length - 1; i++) {
|
|
677
|
-
const current = viaChain[i];
|
|
678
|
-
const next = viaChain[i + 1];
|
|
679
|
-
const nextAlias = `via_${i + 1}`;
|
|
680
|
-
joins.push(`JOIN ${next.table} ${nextAlias} ON ${prevAlias}.${current.fk} = ${nextAlias}.${next.column}`);
|
|
681
|
-
prevAlias = nextAlias;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
return `-- Via relation (${relEntity} via chain) changed
|
|
685
|
-
WHEN '${relEntity}' THEN
|
|
686
|
-
-- Traverse via path: ${pathDesc}
|
|
687
|
-
SELECT ARRAY_AGG(DISTINCT jsonb_build_object('${firstParam}', ${prevAlias}.${firstParam}))
|
|
688
|
-
INTO v_affected
|
|
689
|
-
${joins.join('\n ')}
|
|
690
|
-
WHERE via_0.${viaColumn} = (p_data->>'${relFK}')::int;`;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Single-hop via
|
|
694
|
-
return `-- Via relation (${relEntity} via ${viaTable}) changed
|
|
695
|
-
WHEN '${relEntity}' THEN
|
|
696
|
-
-- Traverse via path: ${relEntity} -> ${viaTable} -> ${this.rootEntity}
|
|
697
|
-
SELECT ARRAY_AGG(DISTINCT jsonb_build_object('${firstParam}', via_tbl.${firstParam}))
|
|
698
|
-
INTO v_affected
|
|
699
|
-
FROM ${viaTable} via_tbl
|
|
700
|
-
WHERE via_tbl.${viaColumn} = (p_data->>'${relFK}')::int;`;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
if (nestedIncludes) {
|
|
704
|
-
// Nested relation: need to traverse up to root
|
|
705
|
-
return `-- Nested relation (${relEntity}) changed
|
|
706
|
-
WHEN '${relEntity}' THEN
|
|
707
|
-
-- Find parent and then root
|
|
708
|
-
SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
|
|
709
|
-
INTO v_affected
|
|
710
|
-
FROM ${relEntity} rel
|
|
711
|
-
JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
|
|
712
|
-
WHERE rel.id = (p_data->>'id')::int;`;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
return `-- Related entity (${relEntity}) changed
|
|
716
|
-
WHEN '${relEntity}' THEN
|
|
717
|
-
v_affected := ARRAY[
|
|
718
|
-
jsonb_build_object('${firstParam}', (p_data->>'${relFK}')::int)
|
|
719
|
-
];`;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* Extract all tables in scope for this subscribable
|
|
724
|
-
* Used for efficient event filtering - only events from these tables need consideration
|
|
725
|
-
* @returns {string[]} Array of table names
|
|
726
|
-
*/
|
|
727
|
-
extractScopeTables() {
|
|
728
|
-
const tables = new Set();
|
|
729
|
-
if (this.rootEntity) {
|
|
730
|
-
tables.add(this.rootEntity);
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
const extractFromRelations = (relations) => {
|
|
734
|
-
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
735
|
-
const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
736
|
-
if (entity) tables.add(entity);
|
|
737
|
-
|
|
738
|
-
// Extract table from via path: "products.id" -> "products"
|
|
739
|
-
if (typeof relConfig === 'object' && relConfig.via) {
|
|
740
|
-
const viaTable = relConfig.via.split('.')[0];
|
|
741
|
-
tables.add(viaTable);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// Handle nested relations (include or relations)
|
|
745
|
-
if (typeof relConfig === 'object') {
|
|
746
|
-
if (relConfig.include) {
|
|
747
|
-
extractFromRelations(relConfig.include);
|
|
748
|
-
}
|
|
749
|
-
if (relConfig.relations) {
|
|
750
|
-
extractFromRelations(relConfig.relations);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
};
|
|
755
|
-
|
|
756
|
-
extractFromRelations(this.relations);
|
|
757
|
-
return Array.from(tables);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Build path mapping for client-side patching
|
|
762
|
-
* Maps table names to their path in the document structure
|
|
763
|
-
* @returns {Object} Map of table name -> document path
|
|
764
|
-
*/
|
|
765
|
-
buildPathMapping() {
|
|
766
|
-
const paths = {};
|
|
767
|
-
|
|
768
|
-
// Root entity maps to top level (only if it exists)
|
|
769
|
-
if (this.rootEntity) {
|
|
770
|
-
paths[this.rootEntity] = '.';
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const buildPaths = (relations, parentPath = '') => {
|
|
774
|
-
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
775
|
-
const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
776
|
-
const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
|
|
777
|
-
|
|
778
|
-
if (entity) {
|
|
779
|
-
paths[entity] = currentPath;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// Handle nested relations
|
|
783
|
-
if (typeof relConfig === 'object') {
|
|
784
|
-
if (relConfig.include) {
|
|
785
|
-
buildPaths(relConfig.include, currentPath);
|
|
786
|
-
}
|
|
787
|
-
if (relConfig.relations) {
|
|
788
|
-
buildPaths(relConfig.relations, currentPath);
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
};
|
|
793
|
-
|
|
794
|
-
buildPaths(this.relations);
|
|
795
|
-
return paths;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/**
|
|
800
|
-
* Generate subscribable functions from config
|
|
801
|
-
* @param {Object} subscribable - Subscribable configuration
|
|
802
|
-
* @returns {string} Generated SQL
|
|
803
|
-
*/
|
|
804
|
-
export function generateSubscribable(subscribable) {
|
|
805
|
-
const codegen = new SubscribableCodegen(subscribable);
|
|
806
|
-
return codegen.generate();
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
/**
|
|
810
|
-
* Extract scope tables from subscribable config
|
|
811
|
-
* @param {Object} subscribable - Subscribable configuration
|
|
812
|
-
* @returns {string[]} Array of table names in scope
|
|
813
|
-
*/
|
|
814
|
-
export function extractScopeTables(subscribable) {
|
|
815
|
-
const codegen = new SubscribableCodegen(subscribable);
|
|
816
|
-
return codegen.extractScopeTables();
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* Build path mapping from subscribable config
|
|
821
|
-
* @param {Object} subscribable - Subscribable configuration
|
|
822
|
-
* @returns {Object} Map of table name -> document path
|
|
823
|
-
*/
|
|
824
|
-
export function buildPathMapping(subscribable) {
|
|
825
|
-
const codegen = new SubscribableCodegen(subscribable);
|
|
826
|
-
return codegen.buildPathMapping();
|
|
827
|
-
}
|