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.
@@ -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
+ }
@@ -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
+ }