dzql 0.5.2 → 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.
@@ -87,6 +87,23 @@ SELECT dzql.register_entity(
87
87
 
88
88
  See [Many-to-Many Guide](../guides/many-to-many.md) for details.
89
89
 
90
+ ### Composite Primary Keys
91
+ ```sql
92
+ SELECT dzql.register_entity(
93
+ 'product_task_template_dependencies', 'template_id', ARRAY[]::text[],
94
+ '{}', false, '{}', '{}', '{}',
95
+ '{
96
+ "primary_key": ["template_id", "depends_on_template_id"]
97
+ }',
98
+ '{}'
99
+ );
100
+ ```
101
+
102
+ **Generated code:** Event records use all primary key columns
103
+ - Default assumes `id` column
104
+ - Composite keys generate `jsonb_build_object('col1', v_result.col1, 'col2', v_result.col2)`
105
+ - Required for junction tables and other composite PK scenarios
106
+
90
107
  ### Field Defaults
91
108
  ```sql
92
109
  '{
@@ -252,9 +252,14 @@ SELECT dzql.register_entity(
252
252
  | `p_temporal_fields` | JSONB | no | Temporal field config (valid_from/valid_to) |
253
253
  | `p_notification_paths` | JSONB | no | Who receives real-time updates |
254
254
  | `p_permission_paths` | JSONB | no | CRUD permission rules |
255
- | `p_graph_rules` | JSONB | no | Automatic relationship management + M2M |
255
+ | `p_graph_rules` | JSONB | no | Automatic relationship management, M2M, and primary_key |
256
256
  | `p_field_defaults` | JSONB | no | Auto-populate fields on INSERT |
257
257
 
258
+ **Note:** `p_graph_rules` can include:
259
+ - `primary_key` - Array of column names for composite primary keys (default: `["id"]`)
260
+ - `many_to_many` - M2M relationship configurations
261
+ - `on_create`, `on_update`, `on_delete` - Graph rule triggers
262
+
258
263
  ### FK Includes
259
264
 
260
265
  Configure which foreign keys to dereference in GET operations:
@@ -330,6 +335,64 @@ Auto-populate fields on INSERT with values or variables:
330
335
 
331
336
  See [Field Defaults Guide](../guides/field-defaults.md) for details.
332
337
 
338
+ ### Composite Primary Keys
339
+
340
+ For tables with composite primary keys (not just `id`), specify the primary key columns via `graph_rules.primary_key`:
341
+
342
+ ```sql
343
+ '{
344
+ "primary_key": ["template_id", "depends_on_template_id"]
345
+ }'
346
+ ```
347
+
348
+ **Or in JavaScript:**
349
+ ```javascript
350
+ {
351
+ tableName: "product_task_template_dependencies",
352
+ primaryKey: ["template_id", "depends_on_template_id"],
353
+ // ...
354
+ }
355
+ ```
356
+
357
+ **Default:** `["id"]` - assumes a single `id` column as primary key.
358
+
359
+ **Why this matters:** The compiler generates event records with a `pk` field containing the primary key values. Without this configuration, tables with composite primary keys would fail with "record has no field id" errors.
360
+
361
+ **Generated SQL (composite):**
362
+ ```sql
363
+ jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id', v_result.depends_on_template_id)
364
+ ```
365
+
366
+ **Generated SQL (simple, default):**
367
+ ```sql
368
+ jsonb_build_object('id', v_result.id)
369
+ ```
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
+
333
396
  ### Many-to-Many Relationships
334
397
 
335
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.2",
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",
@@ -4,9 +4,10 @@
4
4
  */
5
5
 
6
6
  export class GraphRulesCodegen {
7
- constructor(tableName, graphRules) {
7
+ constructor(tableName, graphRules, primaryKey = ['id']) {
8
8
  this.tableName = tableName;
9
9
  this.graphRules = graphRules;
10
+ this.primaryKey = Array.isArray(primaryKey) ? primaryKey : [primaryKey];
10
11
  }
11
12
 
12
13
  /**
@@ -233,6 +234,20 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
233
234
  PERFORM ${functionName}(${paramSQL});`;
234
235
  }
235
236
 
237
+ /**
238
+ * Generate jsonb_build_object for primary key columns from a JSONB record variable
239
+ * Supports composite primary keys
240
+ * @param {string} recordVar - The JSONB record variable name (e.g., 'p_record')
241
+ * @private
242
+ */
243
+ _generatePKBuildObject(recordVar = 'p_record') {
244
+ // Build jsonb_build_object with all primary key columns
245
+ // e.g., jsonb_build_object('id', (p_record->>'id')::int)
246
+ // or jsonb_build_object('template_id', (p_record->>'template_id')::int, 'depends_on_template_id', (p_record->>'depends_on_template_id')::int)
247
+ const pairs = this.primaryKey.map(col => `'${col}', (${recordVar}->>'${col}')::int`);
248
+ return `jsonb_build_object(${pairs.join(', ')})`;
249
+ }
250
+
236
251
  /**
237
252
  * Generate NOTIFY action
238
253
  * Creates an event that will be broadcast to specified users
@@ -298,6 +313,8 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
298
313
  ? `jsonb_build_object(${dataFields.join(', ')})`
299
314
  : "'{}'::jsonb";
300
315
 
316
+ const pkBuildObject = this._generatePKBuildObject('p_record');
317
+
301
318
  return `${comment}
302
319
  -- Create notification event
303
320
  INSERT INTO dzql.events (
@@ -310,7 +327,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
310
327
  ) VALUES (
311
328
  '${this.tableName}',
312
329
  'notify',
313
- jsonb_build_object('id', (p_record->>'id')::int),
330
+ ${pkBuildObject},
314
331
  ${dataSQL},
315
332
  p_user_id,
316
333
  ${userIdSQL}
@@ -407,9 +424,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
407
424
  * Generate graph rule functions for an entity
408
425
  * @param {string} tableName - Table name
409
426
  * @param {Object} graphRules - Graph rules object
427
+ * @param {Array<string>} primaryKey - Primary key column(s) (defaults to ['id'])
410
428
  * @returns {string} SQL for graph rule functions
411
429
  */
412
- export function generateGraphRuleFunctions(tableName, graphRules) {
413
- const codegen = new GraphRulesCodegen(tableName, graphRules);
430
+ export function generateGraphRuleFunctions(tableName, graphRules, primaryKey = ['id']) {
431
+ const codegen = new GraphRulesCodegen(tableName, graphRules, primaryKey);
414
432
  return codegen.generate();
415
433
  }
@@ -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;`;
@@ -855,12 +950,29 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
855
950
  return calls.join('');
856
951
  }
857
952
 
953
+ /**
954
+ * Generate jsonb_build_object for primary key columns
955
+ * Supports composite primary keys by building an object with all pk columns
956
+ * @param {string} recordVar - The record variable name (e.g., 'v_result', 'v_existing')
957
+ * @private
958
+ */
959
+ _generatePKBuildObject(recordVar = 'v_result') {
960
+ const primaryKey = this.entity.primaryKey || ['id'];
961
+
962
+ // Build jsonb_build_object with all primary key columns
963
+ // e.g., jsonb_build_object('id', v_result.id)
964
+ // or jsonb_build_object('template_id', v_result.template_id, 'depends_on_template_id', v_result.depends_on_template_id)
965
+ const pairs = primaryKey.map(col => `'${col}', ${recordVar}.${col}`);
966
+ return `jsonb_build_object(${pairs.join(', ')})`;
967
+ }
968
+
858
969
  /**
859
970
  * Generate notification SQL
860
971
  * @private
861
972
  */
862
973
  _generateNotificationSQL(operation = 'save') {
863
974
  const hasNotificationPaths = this.entity.notificationPaths && Object.keys(this.entity.notificationPaths).length > 0;
975
+ const pkBuildObject = this._generatePKBuildObject('v_result');
864
976
 
865
977
  if (operation === 'save') {
866
978
  return `
@@ -878,7 +990,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
878
990
  ) VALUES (
879
991
  '${this.tableName}',
880
992
  CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
881
- jsonb_build_object('id', v_result.id),
993
+ ${pkBuildObject},
882
994
  v_output,
883
995
  p_user_id,
884
996
  v_notify_users
@@ -899,7 +1011,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
899
1011
  ) VALUES (
900
1012
  '${this.tableName}',
901
1013
  'delete',
902
- jsonb_build_object('id', v_result.id),
1014
+ ${pkBuildObject},
903
1015
  NULL,
904
1016
  p_user_id,
905
1017
  v_notify_users
@@ -258,7 +258,8 @@ export class DZQLCompiler {
258
258
  _generateGraphRuleFunctions(entity) {
259
259
  return generateGraphRuleFunctions(
260
260
  entity.tableName,
261
- entity.graphRules
261
+ entity.graphRules,
262
+ entity.primaryKey
262
263
  );
263
264
  }
264
265
 
@@ -95,6 +95,9 @@ export class EntityParser {
95
95
  // Extract many_to_many from graph_rules if present
96
96
  const manyToMany = graphRules.many_to_many || {};
97
97
 
98
+ // Extract primary_key from graph_rules if present (defaults to ['id'])
99
+ const primaryKey = graphRules.primary_key || ['id'];
100
+
98
101
  const config = {
99
102
  tableName: this._cleanString(params[0]),
100
103
  labelField: this._cleanString(params[1]),
@@ -106,7 +109,8 @@ export class EntityParser {
106
109
  permissionPaths: params[7] ? this._parseJSON(params[7]) : {},
107
110
  graphRules: graphRules,
108
111
  fieldDefaults: params[9] ? this._parseJSON(params[9]) : {},
109
- manyToMany: manyToMany
112
+ manyToMany: manyToMany,
113
+ primaryKey: Array.isArray(primaryKey) ? primaryKey : [primaryKey]
110
114
  };
111
115
 
112
116
  return config;
@@ -328,6 +332,8 @@ export class EntityParser {
328
332
  parseFromObject(entity) {
329
333
  const graphRules = entity.graphRules || {};
330
334
  const manyToMany = entity.manyToMany || graphRules.many_to_many || {};
335
+ // Primary key can be specified directly on entity or in graphRules (defaults to ['id'])
336
+ const primaryKey = entity.primaryKey || graphRules.primary_key || ['id'];
331
337
 
332
338
  return {
333
339
  tableName: entity.tableName || entity.table,
@@ -341,6 +347,7 @@ export class EntityParser {
341
347
  graphRules: graphRules,
342
348
  fieldDefaults: entity.fieldDefaults || {},
343
349
  manyToMany: manyToMany,
350
+ primaryKey: Array.isArray(primaryKey) ? primaryKey : [primaryKey],
344
351
  customFunctions: entity.customFunctions || []
345
352
  };
346
353
  }
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]);