dzql 0.5.3 → 0.5.4

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.4",
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;`;
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]);