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.
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;`;
|
|
@@ -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
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
199
|
-
if (step.filter) {
|
|
200
|
-
filters = step.filter;
|
|
201
|
-
}
|
|
185
|
+
const sourceField = steps[0].field;
|
|
202
186
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
|
222
|
-
conditions.push(`${targetTable}.${filter.field} =
|
|
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]);
|