dzql 0.1.5 → 0.2.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.
@@ -0,0 +1,396 @@
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
+ // Generate permission check logic
82
+ const checks = subscribePaths.map(path => {
83
+ const ast = this.parser.parse(path);
84
+ return this._generatePathCheck(ast, 'p_params', 'p_user_id');
85
+ });
86
+
87
+ const checkSQL = checks.join(' OR\n ');
88
+
89
+ return `CREATE OR REPLACE FUNCTION ${this.name}_can_subscribe(
90
+ p_user_id INT,
91
+ p_params JSONB
92
+ ) RETURNS BOOLEAN AS $$
93
+ BEGIN
94
+ RETURN (
95
+ ${checkSQL}
96
+ );
97
+ END;
98
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
99
+ }
100
+
101
+ /**
102
+ * Generate path check SQL from AST
103
+ * @private
104
+ */
105
+ _generatePathCheck(ast, recordVar, userIdVar) {
106
+ // Handle direct field reference: @owner_id
107
+ if (ast.type === 'field_ref') {
108
+ return `(${recordVar}->>'${ast.field}')::int = ${userIdVar}`;
109
+ }
110
+
111
+ // Handle traversal with steps: @org_id->acts_for[org_id=$]{active}.user_id
112
+ if (ast.type === 'traversal' && ast.steps) {
113
+ const fieldRef = ast.steps[0]; // First step is the field reference
114
+ const tableRef = ast.steps[1]; // Second step is the table reference
115
+
116
+ if (!fieldRef || !tableRef || tableRef.type !== 'table_ref') {
117
+ return 'FALSE';
118
+ }
119
+
120
+ const startField = fieldRef.field;
121
+ const targetTable = tableRef.table;
122
+ const targetField = tableRef.targetField;
123
+
124
+ const startValue = `(${recordVar}->>'${startField}')::int`;
125
+
126
+ // Build WHERE clause
127
+ const whereClauses = [];
128
+
129
+ // Add filter conditions from the table_ref
130
+ if (tableRef.filter && tableRef.filter.length > 0) {
131
+ for (const filterCondition of tableRef.filter) {
132
+ const field = filterCondition.field;
133
+ if (filterCondition.value.type === 'param') {
134
+ // Parameter reference: org_id=$
135
+ whereClauses.push(`${targetTable}.${field} = ${startValue}`);
136
+ } else {
137
+ // Literal value
138
+ whereClauses.push(`${targetTable}.${field} = '${filterCondition.value}'`);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Add temporal marker if present
144
+ if (tableRef.temporal) {
145
+ whereClauses.push(`${targetTable}.valid_to IS NULL`);
146
+ }
147
+
148
+ return `EXISTS (
149
+ SELECT 1 FROM ${targetTable}
150
+ WHERE ${whereClauses.join('\n AND ')}
151
+ AND ${targetTable}.${targetField} = ${userIdVar}
152
+ )`;
153
+ }
154
+
155
+ return 'FALSE';
156
+ }
157
+
158
+ /**
159
+ * Generate filter SQL
160
+ * @private
161
+ */
162
+ _generateFilterSQL(filter, tableAlias) {
163
+ const conditions = [];
164
+ for (const [key, value] of Object.entries(filter)) {
165
+ if (value === '$') {
166
+ // Placeholder - will be replaced with actual value
167
+ conditions.push(`${tableAlias}.${key} = ${tableAlias}.${key}`);
168
+ } else {
169
+ conditions.push(`${tableAlias}.${key} = '${value}'`);
170
+ }
171
+ }
172
+ return conditions.join(' AND ');
173
+ }
174
+
175
+ /**
176
+ * Generate query function that builds the document
177
+ * @private
178
+ */
179
+ _generateQueryFunction() {
180
+ const params = Object.keys(this.paramSchema);
181
+ const paramDeclarations = params.map(p => ` v_${p} ${this.paramSchema[p]};`).join('\n');
182
+ const paramExtractions = params.map(p =>
183
+ ` v_${p} := (p_params->>'${p}')::${this.paramSchema[p]};`
184
+ ).join('\n');
185
+
186
+ // Build root WHERE clause based on params
187
+ const rootFilter = this._generateRootFilter();
188
+
189
+ // Build relation subqueries
190
+ const relationSelects = this._generateRelationSelects();
191
+
192
+ return `CREATE OR REPLACE FUNCTION get_${this.name}(
193
+ p_params JSONB,
194
+ p_user_id INT
195
+ ) RETURNS JSONB AS $$
196
+ DECLARE
197
+ ${paramDeclarations}
198
+ v_result JSONB;
199
+ BEGIN
200
+ -- Extract parameters
201
+ ${paramExtractions}
202
+
203
+ -- Check access control
204
+ IF NOT ${this.name}_can_subscribe(p_user_id, p_params) THEN
205
+ RAISE EXCEPTION 'Permission denied';
206
+ END IF;
207
+
208
+ -- Build document with root and all relations
209
+ SELECT jsonb_build_object(
210
+ '${this.rootEntity}', row_to_json(root.*)${relationSelects}
211
+ )
212
+ INTO v_result
213
+ FROM ${this.rootEntity} root
214
+ WHERE ${rootFilter};
215
+
216
+ RETURN v_result;
217
+ END;
218
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
219
+ }
220
+
221
+ /**
222
+ * Generate root filter based on params
223
+ * @private
224
+ */
225
+ _generateRootFilter() {
226
+ const params = Object.keys(this.paramSchema);
227
+
228
+ // Assume first param is the root entity ID
229
+ // TODO: Make this more flexible based on param naming conventions
230
+ if (params.length > 0) {
231
+ const firstParam = params[0];
232
+ // Convention: venue_id -> id, org_id -> id, etc.
233
+ return `root.id = v_${firstParam}`;
234
+ }
235
+
236
+ return 'TRUE';
237
+ }
238
+
239
+ /**
240
+ * Generate relation subqueries
241
+ * @private
242
+ */
243
+ _generateRelationSelects() {
244
+ if (Object.keys(this.relations).length === 0) {
245
+ return '';
246
+ }
247
+
248
+ const selects = Object.entries(this.relations).map(([relName, relConfig]) => {
249
+ const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
250
+ const relFilter = typeof relConfig === 'object' ? relConfig.filter : null;
251
+ const relIncludes = typeof relConfig === 'object' ? relConfig.include : null;
252
+
253
+ // Build filter condition
254
+ let filterSQL = this._generateRelationFilter(relFilter, relEntity);
255
+
256
+ // Build nested includes if any
257
+ let nestedSelect = 'row_to_json(rel.*)';
258
+ if (relIncludes) {
259
+ const nestedFields = Object.entries(relIncludes).map(([nestedName, nestedEntity]) => {
260
+ return `'${nestedName}', (
261
+ SELECT jsonb_agg(row_to_json(nested.*))
262
+ FROM ${nestedEntity} nested
263
+ WHERE nested.${relEntity}_id = rel.id
264
+ )`;
265
+ }).join(',\n ');
266
+
267
+ nestedSelect = `jsonb_build_object(
268
+ '${relEntity}', row_to_json(rel.*),
269
+ ${nestedFields}
270
+ )`;
271
+ }
272
+
273
+ return `,
274
+ '${relName}', (
275
+ SELECT jsonb_agg(${nestedSelect})
276
+ FROM ${relEntity} rel
277
+ WHERE ${filterSQL}
278
+ )`;
279
+ }).join('');
280
+
281
+ return selects;
282
+ }
283
+
284
+ /**
285
+ * Generate filter for relation subquery
286
+ * @private
287
+ */
288
+ _generateRelationFilter(filter, relEntity) {
289
+ if (!filter) {
290
+ // Default: foreign key to root
291
+ return `rel.${this.rootEntity}_id = root.id`;
292
+ }
293
+
294
+ // Parse filter expression like "venue_id=$venue_id"
295
+ // Replace $param with v_param variable
296
+ return filter.replace(/\$(\w+)/g, 'v_$1');
297
+ }
298
+
299
+ /**
300
+ * Generate affected documents function
301
+ * @private
302
+ */
303
+ _generateAffectedDocumentsFunction() {
304
+ const cases = [];
305
+
306
+ // Case 1: Root entity changed
307
+ cases.push(this._generateRootAffectedCase());
308
+
309
+ // Case 2: Related entities changed
310
+ for (const [relName, relConfig] of Object.entries(this.relations)) {
311
+ cases.push(this._generateRelationAffectedCase(relName, relConfig));
312
+ }
313
+
314
+ const casesSQL = cases.join('\n\n ');
315
+
316
+ return `CREATE OR REPLACE FUNCTION ${this.name}_affected_documents(
317
+ p_table_name TEXT,
318
+ p_op TEXT,
319
+ p_old JSONB,
320
+ p_new JSONB
321
+ ) RETURNS JSONB[] AS $$
322
+ DECLARE
323
+ v_affected JSONB[];
324
+ BEGIN
325
+ CASE p_table_name
326
+ ${casesSQL}
327
+
328
+ ELSE
329
+ v_affected := ARRAY[]::JSONB[];
330
+ END CASE;
331
+
332
+ RETURN v_affected;
333
+ END;
334
+ $$ LANGUAGE plpgsql IMMUTABLE;`;
335
+ }
336
+
337
+ /**
338
+ * Generate case for root entity changes
339
+ * @private
340
+ */
341
+ _generateRootAffectedCase() {
342
+ const params = Object.keys(this.paramSchema);
343
+ const firstParam = params[0] || 'id';
344
+
345
+ return `-- Root entity (${this.rootEntity}) changed
346
+ WHEN '${this.rootEntity}' THEN
347
+ v_affected := ARRAY[
348
+ jsonb_build_object('${firstParam}', COALESCE((p_new->>'id')::int, (p_old->>'id')::int))
349
+ ];`;
350
+ }
351
+
352
+ /**
353
+ * Generate case for related entity changes
354
+ * @private
355
+ */
356
+ _generateRelationAffectedCase(relName, relConfig) {
357
+ const relEntity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
358
+ const relFK = typeof relConfig === 'object' && relConfig.foreignKey
359
+ ? relConfig.foreignKey
360
+ : `${this.rootEntity}_id`;
361
+
362
+ const params = Object.keys(this.paramSchema);
363
+ const firstParam = params[0] || 'id';
364
+
365
+ // Check if this is a nested relation (has parent FK)
366
+ const nestedIncludes = typeof relConfig === 'object' ? relConfig.include : null;
367
+
368
+ if (nestedIncludes) {
369
+ // Nested relation: need to traverse up to root
370
+ return `-- Nested relation (${relEntity}) changed
371
+ WHEN '${relEntity}' THEN
372
+ -- Find parent and then root
373
+ SELECT ARRAY_AGG(jsonb_build_object('${firstParam}', parent.${this.rootEntity}_id))
374
+ INTO v_affected
375
+ FROM ${relEntity} rel
376
+ JOIN ${Object.keys(nestedIncludes)[0]} parent ON parent.id = rel.${Object.keys(nestedIncludes)[0]}_id
377
+ WHERE rel.id = COALESCE((p_new->>'id')::int, (p_old->>'id')::int);`;
378
+ }
379
+
380
+ return `-- Related entity (${relEntity}) changed
381
+ WHEN '${relEntity}' THEN
382
+ v_affected := ARRAY[
383
+ jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
384
+ ];`;
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Generate subscribable functions from config
390
+ * @param {Object} subscribable - Subscribable configuration
391
+ * @returns {string} Generated SQL
392
+ */
393
+ export function generateSubscribable(subscribable) {
394
+ const codegen = new SubscribableCodegen(subscribable);
395
+ return codegen.generate();
396
+ }
@@ -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
+ }