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.
package/docs/reference/api.md
CHANGED
|
@@ -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
|
@@ -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]);
|