dzql 0.1.6 → 0.2.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/README.md +23 -1
- package/docs/LIVE_QUERY_SUBSCRIPTIONS.md +535 -0
- package/docs/LIVE_QUERY_SUBSCRIPTIONS_STRATEGY.md +488 -0
- package/docs/REFERENCE.md +139 -0
- package/docs/SUBSCRIPTIONS_QUICK_START.md +203 -0
- package/package.json +2 -3
- package/src/client/ws.js +87 -2
- package/src/compiler/cli/compile-example.js +33 -0
- package/src/compiler/cli/compile-subscribable.js +43 -0
- package/src/compiler/cli/debug-compile.js +44 -0
- package/src/compiler/cli/debug-parse.js +26 -0
- package/src/compiler/cli/debug-path-parser.js +18 -0
- package/src/compiler/cli/debug-subscribable-parser.js +21 -0
- package/src/compiler/codegen/subscribable-codegen.js +446 -0
- package/src/compiler/compiler.js +115 -0
- package/src/compiler/parser/subscribable-parser.js +242 -0
- package/src/database/migrations/009_subscriptions.sql +230 -0
- package/src/server/index.js +90 -1
- package/src/server/subscriptions.js +209 -0
- package/src/server/ws.js +78 -2
- package/src/client/stores/README.md +0 -95
|
@@ -0,0 +1,446 @@
|
|
|
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 startField = fieldRef.field;
|
|
171
|
+
const targetTable = tableRef.table;
|
|
172
|
+
const targetField = tableRef.targetField;
|
|
173
|
+
|
|
174
|
+
const startValue = `(${recordVar}->>'${startField}')::int`;
|
|
175
|
+
|
|
176
|
+
// Build WHERE clause
|
|
177
|
+
const whereClauses = [];
|
|
178
|
+
|
|
179
|
+
// Add filter conditions from the table_ref
|
|
180
|
+
if (tableRef.filter && tableRef.filter.length > 0) {
|
|
181
|
+
for (const filterCondition of tableRef.filter) {
|
|
182
|
+
const field = filterCondition.field;
|
|
183
|
+
if (filterCondition.value.type === 'param') {
|
|
184
|
+
// Parameter reference: org_id=$
|
|
185
|
+
whereClauses.push(`${targetTable}.${field} = ${startValue}`);
|
|
186
|
+
} else {
|
|
187
|
+
// Literal value
|
|
188
|
+
whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Add temporal marker if present
|
|
194
|
+
if (tableRef.temporal) {
|
|
195
|
+
whereClauses.push(`${targetTable}.valid_to IS NULL`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `EXISTS (
|
|
199
|
+
SELECT 1 FROM ${targetTable}
|
|
200
|
+
WHERE ${whereClauses.join('\n AND ')}
|
|
201
|
+
AND ${targetTable}.${targetField} = ${userIdVar}
|
|
202
|
+
)`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return 'FALSE';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate filter SQL
|
|
210
|
+
* @private
|
|
211
|
+
*/
|
|
212
|
+
_generateFilterSQL(filter, tableAlias) {
|
|
213
|
+
const conditions = [];
|
|
214
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
215
|
+
if (value === '$') {
|
|
216
|
+
// Placeholder - will be replaced with actual value
|
|
217
|
+
conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
|
|
218
|
+
} else {
|
|
219
|
+
conditions.push(`${tableAlias}.${key} = '${value}'`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return conditions.join(' AND ');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Generate query function that builds the document
|
|
227
|
+
* @private
|
|
228
|
+
*/
|
|
229
|
+
_generateQueryFunction() {
|
|
230
|
+
const params = Object.keys(this.paramSchema);
|
|
231
|
+
const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
|
|
232
|
+
const paramExtractions = params.map(p =>
|
|
233
|
+
` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
|
|
234
|
+
).join('\n');
|
|
235
|
+
|
|
236
|
+
// Build root WHERE clause based on params
|
|
237
|
+
const rootFilter = this._generateRootFilter();
|
|
238
|
+
|
|
239
|
+
// Build relation subqueries
|
|
240
|
+
const relationSelects = this._generateRelationSelects();
|
|
241
|
+
|
|
242
|
+
return `CREATE OR REPLACE FUNCTION get_${this.name}(
|
|
243
|
+
p_params JSONB,
|
|
244
|
+
p_user_id INT
|
|
245
|
+
) RETURNS JSONB AS $$
|
|
246
|
+
DECLARE
|
|
247
|
+
${paramDeclarations}
|
|
248
|
+
v_result JSONB;
|
|
249
|
+
BEGIN
|
|
250
|
+
-- Extract parameters
|
|
251
|
+
${paramExtractions}
|
|
252
|
+
|
|
253
|
+
-- Check access control
|
|
254
|
+
IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
|
|
255
|
+
RAISE EXCEPTION 'Permission denied';
|
|
256
|
+
END IF;
|
|
257
|
+
|
|
258
|
+
-- Build document with root and all relations
|
|
259
|
+
SELECT jsonb_build_object(
|
|
260
|
+
'${this.rootEntity}', row_to_json(root.*)${relationSelects}
|
|
261
|
+
)
|
|
262
|
+
INTO v_result
|
|
263
|
+
FROM ${this.rootEntity} root
|
|
264
|
+
WHERE ${rootFilter};
|
|
265
|
+
|
|
266
|
+
RETURN v_result;
|
|
267
|
+
END;
|
|
268
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generate root filter based on params
|
|
273
|
+
* @private
|
|
274
|
+
*/
|
|
275
|
+
_generateRootFilter() {
|
|
276
|
+
const params = Object.keys(this.paramSchema);
|
|
277
|
+
|
|
278
|
+
// Assume first param is the root entity ID
|
|
279
|
+
// TODO: Make this more flexible based on param naming conventions
|
|
280
|
+
if (params.length > 0) {
|
|
281
|
+
const firstParam = params[0];
|
|
282
|
+
// Convention: venue_id -> id, org_id -> id, etc.
|
|
283
|
+
return `root.id = v_${firstParam}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return 'TRUE';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Generate relation subqueries
|
|
291
|
+
* @private
|
|
292
|
+
*/
|
|
293
|
+
_generateRelationSelects() {
|
|
294
|
+
if (Object.keys(this.relations).length === 0) {
|
|
295
|
+
return '';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
|
|
299
|
+
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
300
|
+
const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
|
|
301
|
+
const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
302
|
+
|
|
303
|
+
// Build filter condition
|
|
304
|
+
let filterSQL = this._generateRelationFilter(relFilter, relEntity);
|
|
305
|
+
|
|
306
|
+
// Build nested includes if any
|
|
307
|
+
let nestedSelect = 'row_to_json(rel.*)';
|
|
308
|
+
if (relIncludes) {
|
|
309
|
+
const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
|
|
310
|
+
return `'${nestedName}', (
|
|
311
|
+
SELECT jsonb_agg(row_to_json(nested.*))
|
|
312
|
+
FROM ${nestedEntity} nested
|
|
313
|
+
WHERE nested.${relEntity}_id = rel.id
|
|
314
|
+
)`;
|
|
315
|
+
}).join(',\n ');
|
|
316
|
+
|
|
317
|
+
nestedSelect = `jsonb_build_object(
|
|
318
|
+
'${relEntity}', row_to_json(rel.*),
|
|
319
|
+
${nestedFields}
|
|
320
|
+
)`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return `,
|
|
324
|
+
'${relName}', (
|
|
325
|
+
SELECT jsonb_agg(${nestedSelect})
|
|
326
|
+
FROM ${relEntity} rel
|
|
327
|
+
WHERE ${filterSQL}
|
|
328
|
+
)`;
|
|
329
|
+
}).join('');
|
|
330
|
+
|
|
331
|
+
return selects;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Generate filter for relation subquery
|
|
336
|
+
* @private
|
|
337
|
+
*/
|
|
338
|
+
_generateRelationFilter(filter, relEntity) {
|
|
339
|
+
if (!filter) {
|
|
340
|
+
// Default: foreign key to root
|
|
341
|
+
return `rel.${this.rootEntity}_id = root.id`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Parse filter expression like "venue_id=$venue_id"
|
|
345
|
+
// Replace $param with v_param variable
|
|
346
|
+
return filter.replace(/\$(\w+)/g, 'v_$1');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Generate affected documents function
|
|
351
|
+
* @private
|
|
352
|
+
*/
|
|
353
|
+
_generateAffectedDocumentsFunction() {
|
|
354
|
+
const cases = [];
|
|
355
|
+
|
|
356
|
+
// Case 1: Root entity changed
|
|
357
|
+
cases.push(this._generateRootAffectedCase());
|
|
358
|
+
|
|
359
|
+
// Case 2: Related entities changed
|
|
360
|
+
for (const [relName, relConfig] of Object.entries(this.relations)) {
|
|
361
|
+
cases.push(this._generateRelationAffectedCase(relName, relConfig));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const casesSQL = cases.join('\n\n ');
|
|
365
|
+
|
|
366
|
+
return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
|
|
367
|
+
p_table_name TEXT,
|
|
368
|
+
p_op TEXT,
|
|
369
|
+
p_old JSONB,
|
|
370
|
+
p_new JSONB
|
|
371
|
+
) RETURNS JSONB[] AS $$
|
|
372
|
+
DECLARE
|
|
373
|
+
v_affected JSONB[];
|
|
374
|
+
BEGIN
|
|
375
|
+
CASE p_table_name
|
|
376
|
+
${casesSQL}
|
|
377
|
+
|
|
378
|
+
ELSE
|
|
379
|
+
v_affected := ARRAY[]::JSONB[];
|
|
380
|
+
END CASE;
|
|
381
|
+
|
|
382
|
+
RETURN v_affected;
|
|
383
|
+
END;
|
|
384
|
+
$$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Generate case for root entity changes
|
|
389
|
+
* @private
|
|
390
|
+
*/
|
|
391
|
+
_generateRootAffectedCase() {
|
|
392
|
+
const params = Object.keys(this.paramSchema);
|
|
393
|
+
const firstParam = params[0] || 'id';
|
|
394
|
+
|
|
395
|
+
return `-- Root entity (${this.rootEntity}) changed
|
|
396
|
+
WHEN '${this.rootEntity}' THEN
|
|
397
|
+
v_affected := ARRAY[
|
|
398
|
+
jsonb_build_object('${firstParam}', COALESCE((p_new->>'id')::int, (p_old->>'id')::int))
|
|
399
|
+
];`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Generate case for related entity changes
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
406
|
+
_generateRelationAffectedCase(relName, relConfig) {
|
|
407
|
+
const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
408
|
+
const relFK = typeof relConfig === 'object' && relConfig.foreignKey
|
|
409
|
+
? relConfig.foreignKey
|
|
410
|
+
: `${this.rootEntity}_id`;
|
|
411
|
+
|
|
412
|
+
const params = Object.keys(this.paramSchema);
|
|
413
|
+
const firstParam = params[0] || 'id';
|
|
414
|
+
|
|
415
|
+
// Check if this is a nested relation (has parent FK)
|
|
416
|
+
const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
|
|
417
|
+
|
|
418
|
+
if (nestedIncludes) {
|
|
419
|
+
// Nested relation: need to traverse up to root
|
|
420
|
+
return `-- Nested relation (${relEntity}) changed
|
|
421
|
+
WHEN '${relEntity}' THEN
|
|
422
|
+
-- Find parent and then root
|
|
423
|
+
SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
|
|
424
|
+
INTO v_affected
|
|
425
|
+
FROM ${relEntity} rel
|
|
426
|
+
JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
|
|
427
|
+
WHERE rel.id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return `-- Related entity (${relEntity}) changed
|
|
431
|
+
WHEN '${relEntity}' THEN
|
|
432
|
+
v_affected := ARRAY[
|
|
433
|
+
jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
|
|
434
|
+
];`;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Generate subscribable functions from config
|
|
440
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
441
|
+
* @returns {string} Generated SQL
|
|
442
|
+
*/
|
|
443
|
+
export function generateSubscribable(subscribable) {
|
|
444
|
+
const codegen = new SubscribableCodegen(subscribable);
|
|
445
|
+
return codegen.generate();
|
|
446
|
+
}
|
package/src/compiler/compiler.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { EntityParser } from './parser/entity-parser.js';
|
|
7
|
+
import { SubscribableParser } from './parser/subscribable-parser.js';
|
|
7
8
|
import { generatePermissionFunctions } from './codegen/permission-codegen.js';
|
|
8
9
|
import { generateOperations } from './codegen/operation-codegen.js';
|
|
9
10
|
import { generateNotificationFunction } from './codegen/notification-codegen.js';
|
|
10
11
|
import { generateGraphRuleFunctions } from './codegen/graph-rules-codegen.js';
|
|
12
|
+
import { generateSubscribable } from './codegen/subscribable-codegen.js';
|
|
11
13
|
import crypto from 'crypto';
|
|
12
14
|
|
|
13
15
|
export class DZQLCompiler {
|
|
@@ -18,6 +20,7 @@ export class DZQLCompiler {
|
|
|
18
20
|
...options
|
|
19
21
|
};
|
|
20
22
|
this.parser = new EntityParser();
|
|
23
|
+
this.subscribableParser = new SubscribableParser();
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -79,6 +82,34 @@ export class DZQLCompiler {
|
|
|
79
82
|
return result;
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Compile a subscribable definition to SQL
|
|
87
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
88
|
+
* @returns {Object} Compilation result
|
|
89
|
+
*/
|
|
90
|
+
compileSubscribable(subscribable) {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
|
|
93
|
+
// Normalize subscribable configuration
|
|
94
|
+
const normalized = typeof subscribable.name === 'string'
|
|
95
|
+
? subscribable
|
|
96
|
+
: this.subscribableParser.parseFromObject(subscribable);
|
|
97
|
+
|
|
98
|
+
// Generate SQL
|
|
99
|
+
const sql = generateSubscribable(normalized);
|
|
100
|
+
|
|
101
|
+
// Calculate checksum
|
|
102
|
+
const checksum = this._calculateChecksum(sql);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: normalized.name,
|
|
106
|
+
sql,
|
|
107
|
+
checksum,
|
|
108
|
+
compilationTime: Date.now() - startTime,
|
|
109
|
+
generatedAt: new Date().toISOString()
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
82
113
|
/**
|
|
83
114
|
* Compile multiple entities
|
|
84
115
|
* @param {Array} entities - Array of entity configurations
|
|
@@ -111,6 +142,38 @@ export class DZQLCompiler {
|
|
|
111
142
|
};
|
|
112
143
|
}
|
|
113
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Compile multiple subscribables
|
|
147
|
+
* @param {Array} subscribables - Array of subscribable configurations
|
|
148
|
+
* @returns {Object} Compilation results
|
|
149
|
+
*/
|
|
150
|
+
compileAllSubscribables(subscribables) {
|
|
151
|
+
const results = [];
|
|
152
|
+
const errors = [];
|
|
153
|
+
|
|
154
|
+
for (const subscribable of subscribables) {
|
|
155
|
+
try {
|
|
156
|
+
const result = this.compileSubscribable(subscribable);
|
|
157
|
+
results.push(result);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
errors.push({
|
|
160
|
+
subscribable: subscribable.name || 'unknown',
|
|
161
|
+
error: error.message
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
results,
|
|
168
|
+
errors,
|
|
169
|
+
summary: {
|
|
170
|
+
total: subscribables.length,
|
|
171
|
+
successful: results.length,
|
|
172
|
+
failed: errors.length
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
114
177
|
/**
|
|
115
178
|
* Compile from SQL file
|
|
116
179
|
* @param {string} sqlContent - SQL file content
|
|
@@ -140,6 +203,25 @@ export class DZQLCompiler {
|
|
|
140
203
|
return this.compileAll(entities);
|
|
141
204
|
}
|
|
142
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Compile subscribables from SQL file
|
|
208
|
+
* @param {string} sqlContent - SQL file content
|
|
209
|
+
* @returns {Object} Compilation results
|
|
210
|
+
*/
|
|
211
|
+
compileSubscribablesFromSQL(sqlContent) {
|
|
212
|
+
const subscribables = this.subscribableParser.parseAllFromSQL(sqlContent);
|
|
213
|
+
|
|
214
|
+
if (subscribables.length === 0) {
|
|
215
|
+
return {
|
|
216
|
+
results: [],
|
|
217
|
+
errors: [],
|
|
218
|
+
summary: { total: 0, successful: 0, failed: 0 }
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return this.compileAllSubscribables(subscribables);
|
|
223
|
+
}
|
|
224
|
+
|
|
143
225
|
/**
|
|
144
226
|
* Generate file header
|
|
145
227
|
* @private
|
|
@@ -226,3 +308,36 @@ export function compileFromSQL(sqlContent, options = {}) {
|
|
|
226
308
|
const compiler = new DZQLCompiler(options);
|
|
227
309
|
return compiler.compileFromSQL(sqlContent);
|
|
228
310
|
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Compile a single subscribable
|
|
314
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
315
|
+
* @param {Object} options - Compiler options
|
|
316
|
+
* @returns {Object} Compilation result
|
|
317
|
+
*/
|
|
318
|
+
export function compileSubscribable(subscribable, options = {}) {
|
|
319
|
+
const compiler = new DZQLCompiler(options);
|
|
320
|
+
return compiler.compileSubscribable(subscribable);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Compile multiple subscribables
|
|
325
|
+
* @param {Array} subscribables - Array of subscribable configurations
|
|
326
|
+
* @param {Object} options - Compiler options
|
|
327
|
+
* @returns {Object} Compilation results
|
|
328
|
+
*/
|
|
329
|
+
export function compileAllSubscribables(subscribables, options = {}) {
|
|
330
|
+
const compiler = new DZQLCompiler(options);
|
|
331
|
+
return compiler.compileAllSubscribables(subscribables);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Compile subscribables from SQL file content
|
|
336
|
+
* @param {string} sqlContent - SQL file content
|
|
337
|
+
* @param {Object} options - Compiler options
|
|
338
|
+
* @returns {Object} Compilation results
|
|
339
|
+
*/
|
|
340
|
+
export function compileSubscribablesFromSQL(sqlContent, options = {}) {
|
|
341
|
+
const compiler = new DZQLCompiler(options);
|
|
342
|
+
return compiler.compileSubscribablesFromSQL(sqlContent);
|
|
343
|
+
}
|