dzql 0.5.3 → 0.5.5

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.
@@ -368,6 +368,31 @@ jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id'
368
368
  jsonb_build_object('id', v_result.id)
369
369
  ```
370
370
 
371
+ **API Usage with Composite Keys:**
372
+
373
+ For entities with composite primary keys, the `get` and `delete` operations accept a JSONB object instead of an integer `id`:
374
+
375
+ ```javascript
376
+ // Get by composite key
377
+ const dependency = await ws.api.get.product_task_template_dependencies({
378
+ template_id: 1,
379
+ depends_on_template_id: 2
380
+ });
381
+
382
+ // Delete by composite key
383
+ await ws.api.delete.product_task_template_dependencies({
384
+ template_id: 1,
385
+ depends_on_template_id: 2
386
+ });
387
+
388
+ // Or using explicit pk object
389
+ await ws.api.delete.product_task_template_dependencies({
390
+ pk: { template_id: 1, depends_on_template_id: 2 }
391
+ });
392
+ ```
393
+
394
+ **Note:** Entities with simple `id` primary keys continue to use `{id: 1}` for backwards compatibility.
395
+
371
396
  ### Many-to-Many Relationships
372
397
 
373
398
  Configure M2M relationships via `graph_rules.many_to_many`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "PostgreSQL-powered framework with zero boilerplate CRUD operations and real-time WebSocket synchronization",
5
5
  "type": "module",
6
6
  "main": "src/server/index.js",
@@ -7,6 +7,8 @@ export class OperationCodegen {
7
7
  constructor(entity) {
8
8
  this.entity = entity;
9
9
  this.tableName = entity.tableName;
10
+ this.primaryKey = entity.primaryKey || ['id'];
11
+ this.isCompositePK = this.primaryKey.length > 1 || this.primaryKey[0] !== 'id';
10
12
  }
11
13
 
12
14
  /**
@@ -31,6 +33,49 @@ export class OperationCodegen {
31
33
  const m2mExpansionForGet = this._generateM2MExpansionForGet();
32
34
  const filterSensitiveFields = this._generateSensitiveFieldFilter();
33
35
 
36
+ // For composite PKs, accept JSONB containing all PK fields
37
+ // For simple PKs, accept INT for backwards compatibility
38
+ if (this.isCompositePK) {
39
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
40
+ const pkDescription = this.primaryKey.join(', ');
41
+
42
+ return `-- GET operation for ${this.tableName} (composite primary key: ${pkDescription})
43
+ CREATE OR REPLACE FUNCTION get_${this.tableName}(
44
+ p_user_id INT,
45
+ p_pk JSONB,
46
+ p_on_date TIMESTAMPTZ DEFAULT NULL
47
+ ) RETURNS JSONB AS $$
48
+ DECLARE
49
+ v_result JSONB;
50
+ v_record ${this.tableName}%ROWTYPE;
51
+ BEGIN
52
+ -- Fetch the record by composite primary key
53
+ SELECT * INTO v_record
54
+ FROM ${this.tableName}
55
+ WHERE ${whereClause}${this._generateTemporalFilter()};
56
+
57
+ IF NOT FOUND THEN
58
+ RAISE EXCEPTION 'Record not found: % with pk=%', '${this.tableName}', p_pk;
59
+ END IF;
60
+
61
+ -- Convert to JSONB
62
+ v_result := to_jsonb(v_record);
63
+
64
+ -- Check view permission
65
+ IF NOT can_view_${this.tableName}(p_user_id, v_result) THEN
66
+ RAISE EXCEPTION 'Permission denied: view on ${this.tableName}';
67
+ END IF;
68
+
69
+ ${fkExpansions}
70
+ ${m2mExpansionForGet}
71
+ ${filterSensitiveFields}
72
+
73
+ RETURN v_result;
74
+ END;
75
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
76
+ }
77
+
78
+ // Simple PK (id column) - original signature for backwards compatibility
34
79
  return `-- GET operation for ${this.tableName}
35
80
  CREATE OR REPLACE FUNCTION get_${this.tableName}(
36
81
  p_user_id INT,
@@ -177,6 +222,56 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
177
222
  const notificationSQL = this._generateNotificationSQL('delete');
178
223
  const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
179
224
 
225
+ // For composite PKs, accept JSONB containing all PK fields
226
+ if (this.isCompositePK) {
227
+ const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')::int`).join(' AND ');
228
+ const pkDescription = this.primaryKey.join(', ');
229
+
230
+ const deleteSQL = this.entity.softDelete
231
+ ? `UPDATE ${this.tableName} SET deleted_at = NOW() WHERE ${whereClause} RETURNING * INTO v_result;`
232
+ : `DELETE FROM ${this.tableName} WHERE ${whereClause} RETURNING * INTO v_result;`;
233
+
234
+ return `-- DELETE operation for ${this.tableName} (composite primary key: ${pkDescription})
235
+ CREATE OR REPLACE FUNCTION delete_${this.tableName}(
236
+ p_user_id INT,
237
+ p_pk JSONB
238
+ ) RETURNS JSONB AS $$
239
+ DECLARE
240
+ v_result ${this.tableName}%ROWTYPE;
241
+ v_output JSONB;
242
+ v_notify_users INT[];
243
+ BEGIN
244
+ -- Fetch record first by composite primary key
245
+ SELECT * INTO v_result
246
+ FROM ${this.tableName}
247
+ WHERE ${whereClause};
248
+
249
+ IF NOT FOUND THEN
250
+ RAISE EXCEPTION 'Record not found: % with pk=%', '${this.tableName}', p_pk;
251
+ END IF;
252
+
253
+ -- Check delete permission
254
+ IF NOT can_delete_${this.tableName}(p_user_id, to_jsonb(v_result)) THEN
255
+ RAISE EXCEPTION 'Permission denied: delete on ${this.tableName}';
256
+ END IF;
257
+
258
+ ${graphRulesCall}
259
+
260
+ -- Perform delete
261
+ ${deleteSQL}
262
+
263
+ ${notificationSQL}
264
+
265
+ -- Prepare output (removing sensitive fields)
266
+ v_output := to_jsonb(v_result);
267
+ ${filterSensitiveFields}
268
+
269
+ RETURN v_output;
270
+ END;
271
+ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
272
+ }
273
+
274
+ // Simple PK (id column) - original signature for backwards compatibility
180
275
  const deleteSQL = this.entity.softDelete
181
276
  ? `UPDATE ${this.tableName} SET deleted_at = NOW() WHERE id = p_id RETURNING * INTO v_result;`
182
277
  : `DELETE FROM ${this.tableName} WHERE id = p_id RETURNING * INTO v_result;`;
@@ -171,55 +171,62 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
171
171
 
172
172
  /**
173
173
  * Generate traversal check: @org_id->acts_for[org_id=$]{active}.user_id
174
+ * Supports multi-hop paths like: @product_id->products.organisation_id->acts_for[organisation_id=$].user_id
174
175
  * @private
175
176
  */
176
177
  _generateTraversalCheck(ast) {
177
178
  const steps = ast.steps;
178
179
 
179
- // Extract components from the path
180
- let sourceField = null;
181
- let targetTable = null;
182
- let targetField = null;
183
- let filters = [];
184
- let temporal = false;
185
-
186
- for (const step of steps) {
187
- if (step.type === 'field_ref') {
188
- if (!sourceField) {
189
- // First field reference is the source
190
- sourceField = step.field;
191
- } else {
192
- // Last field reference is the target
193
- targetField = step.field;
194
- }
195
- } else if (step.type === 'table_ref') {
196
- targetTable = step.table;
180
+ // First step should be the source field reference
181
+ if (steps.length === 0 || steps[0].type !== 'field_ref') {
182
+ return 'false';
183
+ }
197
184
 
198
- // Collect filter conditions
199
- if (step.filter) {
200
- filters = step.filter;
201
- }
185
+ const sourceField = steps[0].field;
202
186
 
203
- // Check for temporal marker
204
- if (step.temporal) {
205
- temporal = true;
206
- }
187
+ // Collect all table_ref steps (these are the hops)
188
+ const tableSteps = steps.filter(s => s.type === 'table_ref');
207
189
 
208
- // Get target field if specified in table ref
209
- if (step.targetField) {
210
- targetField = step.targetField;
211
- }
190
+ if (tableSteps.length === 0) {
191
+ return 'false';
192
+ }
193
+
194
+ // Build the value expression that resolves through intermediate tables
195
+ // Start with the record's source field
196
+ let valueExpr = `(p_record->>'${sourceField}')::int`;
197
+
198
+ // Process intermediate hops (all but the last table_ref)
199
+ // Each intermediate hop needs a subquery to resolve to the next field
200
+ for (let i = 0; i < tableSteps.length - 1; i++) {
201
+ const hop = tableSteps[i];
202
+ const table = hop.table;
203
+ const targetField = hop.targetField;
204
+
205
+ if (!targetField) {
206
+ // If no target field specified, assume 'id' for the lookup
207
+ // This shouldn't normally happen in well-formed paths
208
+ continue;
212
209
  }
210
+
211
+ // Build subquery: (SELECT targetField FROM table WHERE id = previousValue)
212
+ valueExpr = `(SELECT ${targetField} FROM ${table} WHERE id = ${valueExpr})`;
213
213
  }
214
214
 
215
- // Build WHERE conditions
215
+ // The last table_ref is where we do the EXISTS check
216
+ const finalStep = tableSteps[tableSteps.length - 1];
217
+ const targetTable = finalStep.table;
218
+ const targetField = finalStep.targetField;
219
+ const filters = finalStep.filter || [];
220
+ const temporal = finalStep.temporal || false;
221
+
222
+ // Build WHERE conditions for the final EXISTS query
216
223
  const conditions = [];
217
224
 
218
225
  // Add filter conditions
219
226
  for (const filter of filters) {
220
227
  if (filter.operator === '=' && filter.value.type === 'param') {
221
- // field=$ means match the record's field value
222
- conditions.push(`${targetTable}.${filter.field} = (p_record->>'${sourceField}')::int`);
228
+ // field=$ means match the resolved value from the path
229
+ conditions.push(`${targetTable}.${filter.field} = ${valueExpr}`);
223
230
  } else if (filter.operator === '=') {
224
231
  const value = this._formatValue(filter.value);
225
232
  conditions.push(`${targetTable}.${filter.field} = ${value}`);
@@ -228,9 +235,6 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
228
235
 
229
236
  // Add temporal condition
230
237
  if (temporal) {
231
- // Add temporal filtering for {active} marker
232
- // Assumes standard field names: valid_from and valid_to
233
- // This matches the interpreter's behavior in resolve_path_segment (002_functions.sql:316)
234
238
  conditions.push(`${targetTable}.valid_from <= NOW()`);
235
239
  conditions.push(`(${targetTable}.valid_to > NOW() OR ${targetTable}.valid_to IS NULL)`);
236
240
  }
package/src/server/db.js CHANGED
@@ -179,6 +179,20 @@ export async function setupListeners(callback) {
179
179
  }
180
180
  }
181
181
 
182
+ // Helper to detect if args contains a composite primary key (pk object or multiple PK fields, no 'id')
183
+ function isCompositePK(args) {
184
+ // If args has a 'pk' object, it's explicitly a composite PK call
185
+ if (args.pk && typeof args.pk === 'object') {
186
+ return true;
187
+ }
188
+ // If args has no 'id' field but has other fields, assume composite PK
189
+ // (the compiled function will validate the actual PK fields)
190
+ if (args.id === undefined && Object.keys(args).length > 0) {
191
+ return true;
192
+ }
193
+ return false;
194
+ }
195
+
182
196
  // DZQL Generic Operations - Try compiled functions first, fall back to generic_exec
183
197
  export async function callDZQLOperation(operation, entity, args, userId) {
184
198
  dbLogger.trace(`DZQL ${operation}.${entity} for user ${userId}`);
@@ -189,9 +203,9 @@ export async function callDZQLOperation(operation, entity, args, userId) {
189
203
  // Try compiled function first
190
204
  // Different operations have different signatures:
191
205
  // - search: search_entity(p_user_id, p_filters, p_search, p_sort, p_page, p_limit)
192
- // - get: get_entity(p_user_id, p_id, p_on_date)
206
+ // - get: get_entity(p_user_id, p_id, p_on_date) OR get_entity(p_user_id, p_pk, p_on_date) for composite PKs
193
207
  // - save: save_entity(p_user_id, p_data, p_on_date)
194
- // - delete: delete_entity(p_user_id, p_id)
208
+ // - delete: delete_entity(p_user_id, p_id) OR delete_entity(p_user_id, p_pk) for composite PKs
195
209
  // - lookup: lookup_entity(p_user_id, p_term, p_limit)
196
210
 
197
211
  if (operation === 'search') {
@@ -207,6 +221,15 @@ export async function callDZQLOperation(operation, entity, args, userId) {
207
221
  `, [userId, filters, search, sort, page, limit]);
208
222
  return result[0].result;
209
223
  } else if (operation === 'get') {
224
+ // Support composite primary keys: pass pk object or full args as JSONB
225
+ if (isCompositePK(args)) {
226
+ const pk = args.pk || args; // Use explicit pk object or treat all args as PK fields
227
+ const result = await sql.unsafe(`
228
+ SELECT ${compiledFunctionName}($1::int, $2::jsonb, NULL) as result
229
+ `, [userId, pk]);
230
+ return result[0].result;
231
+ }
232
+ // Simple PK (id)
210
233
  const result = await sql.unsafe(`
211
234
  SELECT ${compiledFunctionName}($1::int, $2::int, NULL) as result
212
235
  `, [userId, args.id]);
@@ -217,6 +240,15 @@ export async function callDZQLOperation(operation, entity, args, userId) {
217
240
  `, [userId, args]);
218
241
  return result[0].result;
219
242
  } else if (operation === 'delete') {
243
+ // Support composite primary keys: pass pk object or full args as JSONB
244
+ if (isCompositePK(args)) {
245
+ const pk = args.pk || args; // Use explicit pk object or treat all args as PK fields
246
+ const result = await sql.unsafe(`
247
+ SELECT ${compiledFunctionName}($1::int, $2::jsonb) as result
248
+ `, [userId, pk]);
249
+ return result[0].result;
250
+ }
251
+ // Simple PK (id)
220
252
  const result = await sql.unsafe(`
221
253
  SELECT ${compiledFunctionName}($1::int, $2::int) as result
222
254
  `, [userId, args.id]);