dzql 0.5.23 → 0.5.25
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/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Feature-specific guides and how-tos:
|
|
|
16
16
|
|
|
17
17
|
- **[Live Query Subscriptions](guides/subscriptions.md)** - Real-time denormalized documents
|
|
18
18
|
- **[Many-to-Many Relationships](guides/many-to-many.md)** - Junction table management
|
|
19
|
+
- **[Composite Primary Keys](guides/composite-primary-keys.md)** - Tables with compound keys
|
|
19
20
|
- **[Field Defaults](guides/field-defaults.md)** - Auto-populate fields on create
|
|
20
21
|
- **[Custom Functions](guides/custom-functions.md)** - Extend with PostgreSQL or Bun functions
|
|
21
22
|
- **[Client Stores](guides/client-stores.md)** - Pinia store patterns for Vue.js
|
|
@@ -23,10 +23,16 @@ Entity Registration:
|
|
|
23
23
|
temporal_fields, -- '{}'
|
|
24
24
|
notification_paths, -- '{"ownership": ["@org_id->acts_for..."]}'
|
|
25
25
|
permission_paths, -- '{"view": [], "create": [...]}'
|
|
26
|
-
graph_rules, -- '{"on_create": {...}, "many_to_many": {...}}'
|
|
26
|
+
graph_rules, -- '{"on_create": {...}, "many_to_many": {...}, "primary_key": [...]}'
|
|
27
27
|
field_defaults -- '{"owner_id": "@user_id"}'
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
+
Composite Primary Keys:
|
|
31
|
+
graph_rules: '{"primary_key": ["entity_type", "entity_id"]}'
|
|
32
|
+
- GET/DELETE accept JSONB: get_table(user_id, '{"col1": "val", "col2": 123}')
|
|
33
|
+
- SAVE detects insert/update by checking if all PK fields exist
|
|
34
|
+
- Columns ending with _id are cast to ::int, others stay text
|
|
35
|
+
|
|
30
36
|
M2M id_field naming: tag_ids (singular + _ids), NOT tags_ids
|
|
31
37
|
Permission [] = public, omitted = denied
|
|
32
38
|
Path syntax: @field->table[filter]{temporal}.target_field
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Composite Primary Keys
|
|
2
|
+
|
|
3
|
+
DZQL supports tables with composite (compound) primary keys. This guide explains how to register entities with composite keys and how the generated CRUD functions work.
|
|
4
|
+
|
|
5
|
+
## When to Use Composite Keys
|
|
6
|
+
|
|
7
|
+
Composite primary keys are useful for:
|
|
8
|
+
|
|
9
|
+
- **Junction tables** with additional data (beyond simple M2M)
|
|
10
|
+
- **Position/state tables** keyed by entity type and ID
|
|
11
|
+
- **Multi-tenant tables** keyed by tenant + entity
|
|
12
|
+
- **Versioned records** keyed by ID + version
|
|
13
|
+
|
|
14
|
+
## Registering an Entity with a Composite Key
|
|
15
|
+
|
|
16
|
+
To register an entity with a composite primary key, add `primary_key` to the `graph_rules` parameter:
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
-- Create a table with composite primary key
|
|
20
|
+
CREATE TABLE canvas_positions (
|
|
21
|
+
entity_type VARCHAR(50) NOT NULL,
|
|
22
|
+
entity_id INTEGER NOT NULL,
|
|
23
|
+
x INTEGER NOT NULL,
|
|
24
|
+
y INTEGER NOT NULL,
|
|
25
|
+
width INTEGER DEFAULT 100,
|
|
26
|
+
height INTEGER DEFAULT 100,
|
|
27
|
+
PRIMARY KEY (entity_type, entity_id)
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
-- Register with DZQL using composite key
|
|
31
|
+
SELECT dzql.register_entity(
|
|
32
|
+
'canvas_positions', -- table_name
|
|
33
|
+
'entity_type', -- label_field
|
|
34
|
+
array['x', 'y', 'width', 'height'], -- searchable_fields
|
|
35
|
+
'{}', -- fk_includes
|
|
36
|
+
false, -- soft_delete
|
|
37
|
+
'{}', -- temporal_fields
|
|
38
|
+
'{}', -- notification_paths
|
|
39
|
+
jsonb_build_object( -- permission_paths
|
|
40
|
+
'view', array[]::text[],
|
|
41
|
+
'create', array[]::text[],
|
|
42
|
+
'update', array[]::text[],
|
|
43
|
+
'delete', array[]::text[]
|
|
44
|
+
),
|
|
45
|
+
jsonb_build_object( -- graph_rules
|
|
46
|
+
'primary_key', array['entity_type', 'entity_id']
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `primary_key` array specifies the columns that form the composite key, in order.
|
|
52
|
+
|
|
53
|
+
## Generated Function Signatures
|
|
54
|
+
|
|
55
|
+
When you compile an entity with a composite primary key, the generated functions have different signatures:
|
|
56
|
+
|
|
57
|
+
### GET Function
|
|
58
|
+
|
|
59
|
+
```sql
|
|
60
|
+
-- Accepts JSONB with all PK fields
|
|
61
|
+
SELECT get_canvas_positions(
|
|
62
|
+
1, -- user_id
|
|
63
|
+
'{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
|
|
64
|
+
);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### SAVE Function
|
|
68
|
+
|
|
69
|
+
```sql
|
|
70
|
+
-- Insert: provide all PK fields plus data
|
|
71
|
+
SELECT save_canvas_positions(
|
|
72
|
+
1, -- user_id
|
|
73
|
+
'{"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}'
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- Update: same signature, existing record detected by PK
|
|
77
|
+
SELECT save_canvas_positions(
|
|
78
|
+
1,
|
|
79
|
+
'{"entity_type": "node", "entity_id": 42, "x": 150, "y": 250}'
|
|
80
|
+
);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The save function determines insert vs update by checking if a record with the composite key exists.
|
|
84
|
+
|
|
85
|
+
### DELETE Function
|
|
86
|
+
|
|
87
|
+
```sql
|
|
88
|
+
-- Accepts JSONB with all PK fields
|
|
89
|
+
SELECT delete_canvas_positions(
|
|
90
|
+
1, -- user_id
|
|
91
|
+
'{"entity_type": "node", "entity_id": 42}' -- composite PK as JSONB
|
|
92
|
+
);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### SEARCH Function
|
|
96
|
+
|
|
97
|
+
Search works the same as simple PK entities - it returns paginated results with all fields.
|
|
98
|
+
|
|
99
|
+
## Type Casting
|
|
100
|
+
|
|
101
|
+
DZQL automatically determines type casting for PK columns:
|
|
102
|
+
|
|
103
|
+
- Columns named `id` or ending with `_id` are cast to `::int`
|
|
104
|
+
- Other columns (like `entity_type`) are left as text
|
|
105
|
+
|
|
106
|
+
This means for a key like `(entity_type, entity_id)`:
|
|
107
|
+
- `entity_type` is compared as text
|
|
108
|
+
- `entity_id` is cast to integer
|
|
109
|
+
|
|
110
|
+
## Events and Notifications
|
|
111
|
+
|
|
112
|
+
Events for composite PK entities include the full composite key in the `pk` field:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"table_name": "canvas_positions",
|
|
117
|
+
"op": "insert",
|
|
118
|
+
"pk": {"entity_type": "node", "entity_id": 42},
|
|
119
|
+
"data": {"entity_type": "node", "entity_id": 42, "x": 100, "y": 200}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Limitations
|
|
124
|
+
|
|
125
|
+
- **M2M relationships**: Tables with composite PKs can have M2M relationships, but this is an advanced use case. The M2M sync uses the first PK column for junction table lookups.
|
|
126
|
+
- **Auto-increment**: Composite keys don't support auto-increment. All PK values must be provided on insert.
|
|
127
|
+
|
|
128
|
+
## Example: Template Dependencies
|
|
129
|
+
|
|
130
|
+
A practical example - tracking dependencies between templates:
|
|
131
|
+
|
|
132
|
+
```sql
|
|
133
|
+
CREATE TABLE template_dependencies (
|
|
134
|
+
template_id INTEGER NOT NULL REFERENCES templates(id),
|
|
135
|
+
depends_on_template_id INTEGER NOT NULL REFERENCES templates(id),
|
|
136
|
+
dependency_type VARCHAR(20) DEFAULT 'requires',
|
|
137
|
+
PRIMARY KEY (template_id, depends_on_template_id)
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
SELECT dzql.register_entity(
|
|
141
|
+
'template_dependencies',
|
|
142
|
+
'dependency_type',
|
|
143
|
+
array['dependency_type'],
|
|
144
|
+
'{"template": "templates", "depends_on": "templates"}',
|
|
145
|
+
false,
|
|
146
|
+
'{}',
|
|
147
|
+
'{}',
|
|
148
|
+
jsonb_build_object(
|
|
149
|
+
'view', array[]::text[],
|
|
150
|
+
'create', array[]::text[],
|
|
151
|
+
'update', array[]::text[],
|
|
152
|
+
'delete', array[]::text[]
|
|
153
|
+
),
|
|
154
|
+
jsonb_build_object(
|
|
155
|
+
'primary_key', array['template_id', 'depends_on_template_id']
|
|
156
|
+
)
|
|
157
|
+
);
|
|
158
|
+
```
|
package/package.json
CHANGED
|
@@ -11,6 +11,18 @@ export class OperationCodegen {
|
|
|
11
11
|
this.isCompositePK = this.primaryKey.length > 1 || this.primaryKey[0] !== 'id';
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Determine appropriate type cast for a column name
|
|
16
|
+
* Only casts to int if column is 'id' or ends with '_id'
|
|
17
|
+
* @private
|
|
18
|
+
*/
|
|
19
|
+
_getTypeCast(columnName) {
|
|
20
|
+
if (columnName === 'id' || columnName.endsWith('_id')) {
|
|
21
|
+
return '::int';
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Generate all operation functions
|
|
16
28
|
* @returns {string} SQL for all operations
|
|
@@ -36,7 +48,7 @@ export class OperationCodegen {
|
|
|
36
48
|
// For composite PKs, accept JSONB containing all PK fields
|
|
37
49
|
// For simple PKs, accept INT for backwards compatibility
|
|
38
50
|
if (this.isCompositePK) {
|
|
39
|
-
const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')
|
|
51
|
+
const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
|
|
40
52
|
const pkDescription = this.primaryKey.join(', ');
|
|
41
53
|
|
|
42
54
|
return `-- GET operation for ${this.tableName} (composite primary key: ${pkDescription})
|
|
@@ -125,6 +137,21 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
125
137
|
const m2mExpansion = this._generateM2MExpansion();
|
|
126
138
|
const fieldDefaults = this._generateFieldDefaults();
|
|
127
139
|
|
|
140
|
+
// For composite PKs, generate a different function signature and logic
|
|
141
|
+
if (this.isCompositePK) {
|
|
142
|
+
return this._generateCompositePKSaveFunction({
|
|
143
|
+
graphRulesCall,
|
|
144
|
+
notificationSQL,
|
|
145
|
+
filterSensitiveFields,
|
|
146
|
+
m2mVariables,
|
|
147
|
+
m2mExtraction,
|
|
148
|
+
m2mSync,
|
|
149
|
+
m2mExpansion,
|
|
150
|
+
fieldDefaults
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Simple PK (id column) - original implementation for backwards compatibility
|
|
128
155
|
return `-- SAVE operation for ${this.tableName}
|
|
129
156
|
CREATE OR REPLACE FUNCTION save_${this.tableName}(
|
|
130
157
|
p_user_id INT,
|
|
@@ -207,6 +234,141 @@ ${m2mExpansion}
|
|
|
207
234
|
${graphRulesCall}
|
|
208
235
|
${notificationSQL}
|
|
209
236
|
|
|
237
|
+
${filterSensitiveFields}
|
|
238
|
+
|
|
239
|
+
RETURN v_output;
|
|
240
|
+
END;
|
|
241
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Generate SAVE function for composite primary keys
|
|
246
|
+
* @private
|
|
247
|
+
*/
|
|
248
|
+
_generateCompositePKSaveFunction(helpers) {
|
|
249
|
+
const {
|
|
250
|
+
graphRulesCall,
|
|
251
|
+
notificationSQL,
|
|
252
|
+
filterSensitiveFields,
|
|
253
|
+
m2mVariables,
|
|
254
|
+
m2mExtraction,
|
|
255
|
+
m2mSync,
|
|
256
|
+
m2mExpansion,
|
|
257
|
+
fieldDefaults
|
|
258
|
+
} = helpers;
|
|
259
|
+
|
|
260
|
+
const pkDescription = this.primaryKey.join(', ');
|
|
261
|
+
|
|
262
|
+
// Generate WHERE clause for composite PK lookup
|
|
263
|
+
// e.g., "entity_type = (p_data->>'entity_type') AND entity_id = (p_data->>'entity_id')::int"
|
|
264
|
+
const whereClause = this.primaryKey.map(col => {
|
|
265
|
+
return `${col} = (p_data->>'${col}')${this._getTypeCast(col)}`;
|
|
266
|
+
}).join(' AND ');
|
|
267
|
+
|
|
268
|
+
// For use inside format() string literal with %L placeholders
|
|
269
|
+
// e.g., "entity_type = %L AND entity_id = %L"
|
|
270
|
+
const whereClauseForFormat = this.primaryKey.map(col => `${col} = %L`).join(' AND ');
|
|
271
|
+
|
|
272
|
+
// Format arguments for the WHERE clause (to be passed to format())
|
|
273
|
+
// e.g., "(p_data->>'entity_type'), (p_data->>'entity_id')::int"
|
|
274
|
+
const whereClauseFormatArgs = this.primaryKey.map(col => {
|
|
275
|
+
return `(p_data->>'${col}')${this._getTypeCast(col)}`;
|
|
276
|
+
}).join(', ');
|
|
277
|
+
|
|
278
|
+
// Check if all PK fields are present in p_data
|
|
279
|
+
const pkNullCheck = this.primaryKey.map(col => `p_data->>'${col}' IS NULL`).join(' OR ');
|
|
280
|
+
|
|
281
|
+
// Build the list of PK field names for exclusion from SET clause
|
|
282
|
+
const pkFieldsExclusion = this.primaryKey.map(col => `jsonb_object_keys != '${col}'`).join(' AND ');
|
|
283
|
+
|
|
284
|
+
// For M2M sync and expansion, we need to reference the PK fields from v_result
|
|
285
|
+
const m2mSyncCompositePK = this._generateM2MSyncCompositePK();
|
|
286
|
+
const m2mExpansionCompositePK = this._generateM2MExpansionCompositePK();
|
|
287
|
+
const m2mExpansionForBeforeCompositePK = this._generateM2MExpansionForBeforeCompositePK();
|
|
288
|
+
|
|
289
|
+
return `-- SAVE operation for ${this.tableName} (composite primary key: ${pkDescription})
|
|
290
|
+
CREATE OR REPLACE FUNCTION save_${this.tableName}(
|
|
291
|
+
p_user_id INT,
|
|
292
|
+
p_data JSONB
|
|
293
|
+
) RETURNS JSONB AS $$
|
|
294
|
+
DECLARE
|
|
295
|
+
v_result ${this.tableName}%ROWTYPE;
|
|
296
|
+
v_existing ${this.tableName}%ROWTYPE;
|
|
297
|
+
v_output JSONB;
|
|
298
|
+
v_before JSONB;
|
|
299
|
+
v_is_insert BOOLEAN := false;
|
|
300
|
+
v_notify_users INT[];
|
|
301
|
+
${m2mVariables}
|
|
302
|
+
BEGIN
|
|
303
|
+
${m2mExtraction}
|
|
304
|
+
-- Determine if this is insert or update (composite PK: ${pkDescription})
|
|
305
|
+
IF ${pkNullCheck} THEN
|
|
306
|
+
-- Missing one or more PK fields - this is an insert
|
|
307
|
+
v_is_insert := true;
|
|
308
|
+
ELSE
|
|
309
|
+
-- Try to fetch existing record by composite PK
|
|
310
|
+
SELECT * INTO v_existing
|
|
311
|
+
FROM ${this.tableName}
|
|
312
|
+
WHERE ${whereClause};
|
|
313
|
+
|
|
314
|
+
v_is_insert := NOT FOUND;
|
|
315
|
+
END IF;
|
|
316
|
+
|
|
317
|
+
-- Check permissions
|
|
318
|
+
IF v_is_insert THEN
|
|
319
|
+
IF NOT can_create_${this.tableName}(p_user_id, p_data) THEN
|
|
320
|
+
RAISE EXCEPTION 'Permission denied: create on ${this.tableName}';
|
|
321
|
+
END IF;
|
|
322
|
+
ELSE
|
|
323
|
+
IF NOT can_update_${this.tableName}(p_user_id, to_jsonb(v_existing)) THEN
|
|
324
|
+
RAISE EXCEPTION 'Permission denied: update on ${this.tableName}';
|
|
325
|
+
END IF;
|
|
326
|
+
END IF;
|
|
327
|
+
|
|
328
|
+
-- Expand M2M for existing record (for UPDATE events "before" field)
|
|
329
|
+
IF NOT v_is_insert THEN
|
|
330
|
+
v_before := to_jsonb(v_existing);
|
|
331
|
+
${m2mExpansionForBeforeCompositePK}
|
|
332
|
+
END IF;
|
|
333
|
+
${fieldDefaults}
|
|
334
|
+
-- Perform UPSERT
|
|
335
|
+
IF v_is_insert THEN
|
|
336
|
+
-- Dynamic INSERT from JSONB
|
|
337
|
+
EXECUTE (
|
|
338
|
+
SELECT format(
|
|
339
|
+
'INSERT INTO ${this.tableName} (%s) VALUES (%s) RETURNING *',
|
|
340
|
+
string_agg(quote_ident(key), ', '),
|
|
341
|
+
string_agg(quote_nullable(value), ', ')
|
|
342
|
+
)
|
|
343
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
344
|
+
) INTO v_result;
|
|
345
|
+
ELSE
|
|
346
|
+
-- Dynamic UPDATE from JSONB (only if there are non-PK fields to update)
|
|
347
|
+
IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE ${pkFieldsExclusion}) > 0 THEN
|
|
348
|
+
EXECUTE (
|
|
349
|
+
SELECT format(
|
|
350
|
+
'UPDATE ${this.tableName} SET %s WHERE ${whereClauseForFormat} RETURNING *',
|
|
351
|
+
string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
|
|
352
|
+
${whereClauseFormatArgs}
|
|
353
|
+
)
|
|
354
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
355
|
+
WHERE ${this.primaryKey.map(col => `key != '${col}'`).join(' AND ')}
|
|
356
|
+
) INTO v_result;
|
|
357
|
+
ELSE
|
|
358
|
+
-- No fields to update (only M2M fields were provided), just fetch existing
|
|
359
|
+
v_result := v_existing;
|
|
360
|
+
END IF;
|
|
361
|
+
END IF;
|
|
362
|
+
|
|
363
|
+
${m2mSyncCompositePK}
|
|
364
|
+
|
|
365
|
+
-- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
|
|
366
|
+
v_output := to_jsonb(v_result);
|
|
367
|
+
${m2mExpansionCompositePK}
|
|
368
|
+
|
|
369
|
+
${graphRulesCall}
|
|
370
|
+
${notificationSQL}
|
|
371
|
+
|
|
210
372
|
${filterSensitiveFields}
|
|
211
373
|
|
|
212
374
|
RETURN v_output;
|
|
@@ -224,7 +386,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
224
386
|
|
|
225
387
|
// For composite PKs, accept JSONB containing all PK fields
|
|
226
388
|
if (this.isCompositePK) {
|
|
227
|
-
const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')
|
|
389
|
+
const whereClause = this.primaryKey.map(col => `${col} = (p_pk->>'${col}')${this._getTypeCast(col)}`).join(' AND ');
|
|
228
390
|
const pkDescription = this.primaryKey.join(', ');
|
|
229
391
|
|
|
230
392
|
const deleteSQL = this.entity.softDelete
|
|
@@ -740,6 +902,129 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
740
902
|
return expansions.join('');
|
|
741
903
|
}
|
|
742
904
|
|
|
905
|
+
/**
|
|
906
|
+
* Generate M2M sync for composite primary keys
|
|
907
|
+
* For tables with composite PKs, M2M relationships are rare but possible
|
|
908
|
+
* @private
|
|
909
|
+
*/
|
|
910
|
+
_generateM2MSyncCompositePK() {
|
|
911
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
912
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
913
|
+
|
|
914
|
+
// For composite PKs, we need to build a composite key reference
|
|
915
|
+
// This is a rare case - most tables with composite PKs are junction tables themselves
|
|
916
|
+
const syncs = [];
|
|
917
|
+
|
|
918
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
919
|
+
const idField = config.id_field;
|
|
920
|
+
const junctionTable = config.junction_table;
|
|
921
|
+
const localKey = config.local_key;
|
|
922
|
+
const foreignKey = config.foreign_key;
|
|
923
|
+
|
|
924
|
+
// For composite PK tables with M2M, we'd need a different junction table structure
|
|
925
|
+
// This is an edge case - log a warning comment in the generated SQL
|
|
926
|
+
syncs.push(`
|
|
927
|
+
-- ============================================================================
|
|
928
|
+
-- M2M Sync: ${relationKey} (junction: ${junctionTable}) - COMPOSITE PK
|
|
929
|
+
-- Note: M2M on composite PK tables requires junction table to reference all PK columns
|
|
930
|
+
-- ============================================================================
|
|
931
|
+
IF v_${idField} IS NOT NULL THEN
|
|
932
|
+
-- Delete relationships not in new list (using first PK column as reference)
|
|
933
|
+
DELETE FROM ${junctionTable}
|
|
934
|
+
WHERE ${localKey} = v_result.${this.primaryKey[0]}
|
|
935
|
+
AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
|
|
936
|
+
|
|
937
|
+
-- Insert new relationships (idempotent)
|
|
938
|
+
IF array_length(v_${idField}, 1) > 0 THEN
|
|
939
|
+
INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
|
|
940
|
+
SELECT v_result.${this.primaryKey[0]}, unnest(v_${idField})
|
|
941
|
+
ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
|
|
942
|
+
END IF;
|
|
943
|
+
END IF;`);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return syncs.join('');
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Generate M2M expansion for composite primary keys (for SAVE output)
|
|
951
|
+
* @private
|
|
952
|
+
*/
|
|
953
|
+
_generateM2MExpansionCompositePK() {
|
|
954
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
955
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
956
|
+
|
|
957
|
+
const expansions = [];
|
|
958
|
+
|
|
959
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
960
|
+
const idField = config.id_field;
|
|
961
|
+
const junctionTable = config.junction_table;
|
|
962
|
+
const localKey = config.local_key;
|
|
963
|
+
const foreignKey = config.foreign_key;
|
|
964
|
+
const targetEntity = config.target_entity;
|
|
965
|
+
const expand = config.expand || false;
|
|
966
|
+
|
|
967
|
+
// Use first PK column for M2M lookups
|
|
968
|
+
expansions.push(`
|
|
969
|
+
-- Add M2M IDs: ${idField} (composite PK table)
|
|
970
|
+
v_output := v_output || jsonb_build_object('${idField}',
|
|
971
|
+
(SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
|
|
972
|
+
FROM ${junctionTable} WHERE ${localKey} = v_result.${this.primaryKey[0]})
|
|
973
|
+
);`);
|
|
974
|
+
|
|
975
|
+
if (expand) {
|
|
976
|
+
expansions.push(`
|
|
977
|
+
-- Expand M2M objects: ${relationKey} (expand=true, composite PK table)
|
|
978
|
+
v_output := v_output || jsonb_build_object('${relationKey}',
|
|
979
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
980
|
+
FROM ${junctionTable} jt
|
|
981
|
+
JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
|
|
982
|
+
WHERE jt.${localKey} = v_result.${this.primaryKey[0]})
|
|
983
|
+
);`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return expansions.join('');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Generate M2M expansion for "before" record with composite primary keys
|
|
992
|
+
* @private
|
|
993
|
+
*/
|
|
994
|
+
_generateM2MExpansionForBeforeCompositePK() {
|
|
995
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
996
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
997
|
+
|
|
998
|
+
const expansions = [];
|
|
999
|
+
|
|
1000
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
1001
|
+
const idField = config.id_field;
|
|
1002
|
+
const junctionTable = config.junction_table;
|
|
1003
|
+
const localKey = config.local_key;
|
|
1004
|
+
const foreignKey = config.foreign_key;
|
|
1005
|
+
const targetEntity = config.target_entity;
|
|
1006
|
+
const expand = config.expand || false;
|
|
1007
|
+
|
|
1008
|
+
expansions.push(`
|
|
1009
|
+
v_before := v_before || jsonb_build_object('${idField}',
|
|
1010
|
+
(SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
|
|
1011
|
+
FROM ${junctionTable} WHERE ${localKey} = v_existing.${this.primaryKey[0]})
|
|
1012
|
+
);`);
|
|
1013
|
+
|
|
1014
|
+
if (expand) {
|
|
1015
|
+
expansions.push(`
|
|
1016
|
+
v_before := v_before || jsonb_build_object('${relationKey}',
|
|
1017
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
1018
|
+
FROM ${junctionTable} jt
|
|
1019
|
+
JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
|
|
1020
|
+
WHERE jt.${localKey} = v_existing.${this.primaryKey[0]})
|
|
1021
|
+
);`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return expansions.join('');
|
|
1026
|
+
}
|
|
1027
|
+
|
|
743
1028
|
/**
|
|
744
1029
|
* Generate FK expansions for GET
|
|
745
1030
|
* @private
|
package/src/server/namespace.js
CHANGED
|
@@ -10,6 +10,7 @@ const DEFAULT_USER_ID = 1;
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Discover available entities from dzql.entities table or compiled functions
|
|
13
|
+
* @returns {Promise<Object>} Map of entity name to {label, searchable, description}
|
|
13
14
|
*/
|
|
14
15
|
async function discoverEntities() {
|
|
15
16
|
// First try dzql.entities table (runtime mode)
|
|
@@ -58,6 +59,9 @@ async function discoverEntities() {
|
|
|
58
59
|
/**
|
|
59
60
|
* DZQL operations namespace - provides CLI-style access to DZQL operations
|
|
60
61
|
*
|
|
62
|
+
* Each method outputs JSON to console and calls sql.end() before returning,
|
|
63
|
+
* making instances single-use. On error, methods call process.exit(1).
|
|
64
|
+
*
|
|
61
65
|
* Usage in tasks.js:
|
|
62
66
|
* ```js
|
|
63
67
|
* import { DzqlNamespace } from 'dzql/namespace';
|
|
@@ -70,11 +74,17 @@ async function discoverEntities() {
|
|
|
70
74
|
* ```
|
|
71
75
|
*/
|
|
72
76
|
export class DzqlNamespace {
|
|
77
|
+
/**
|
|
78
|
+
* @param {number} [userId=1] - User ID for permission checks
|
|
79
|
+
*/
|
|
73
80
|
constructor(userId = DEFAULT_USER_ID) {
|
|
74
81
|
this.userId = userId;
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* List all available entities
|
|
86
|
+
* @returns {Promise<void>} Outputs JSON to console
|
|
87
|
+
*/
|
|
78
88
|
async entities(c) {
|
|
79
89
|
try {
|
|
80
90
|
const entities = await discoverEntities();
|
|
@@ -89,7 +99,12 @@ export class DzqlNamespace {
|
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Search an entity
|
|
104
|
+
* @example invj dzql:search venues '{"query": "test"}'
|
|
105
|
+
* @param {string} entity - Entity/table name to search
|
|
106
|
+
* @param {string} [argsJson] - JSON string with search args (query, limit, offset, filters)
|
|
107
|
+
*/
|
|
93
108
|
async search(c, entity, argsJson = "{}") {
|
|
94
109
|
if (!entity) {
|
|
95
110
|
console.error("Error: entity name required");
|
|
@@ -123,7 +138,12 @@ export class DzqlNamespace {
|
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
|
|
126
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Get entity by ID
|
|
143
|
+
* @example invj dzql:get venues '{"id": 1}'
|
|
144
|
+
* @param {string} entity - Entity/table name
|
|
145
|
+
* @param {string} [argsJson] - JSON string with {id: number}
|
|
146
|
+
*/
|
|
127
147
|
async get(c, entity, argsJson = "{}") {
|
|
128
148
|
if (!entity) {
|
|
129
149
|
console.error("Error: entity name required");
|
|
@@ -155,7 +175,12 @@ export class DzqlNamespace {
|
|
|
155
175
|
}
|
|
156
176
|
}
|
|
157
177
|
|
|
158
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* Save (create or update) entity
|
|
180
|
+
* @example invj dzql:save venues '{"name": "New Venue", "org_id": 1}'
|
|
181
|
+
* @param {string} entity - Entity/table name
|
|
182
|
+
* @param {string} [argsJson] - JSON string with entity data (include id to update, omit to create)
|
|
183
|
+
*/
|
|
159
184
|
async save(c, entity, argsJson = "{}") {
|
|
160
185
|
if (!entity) {
|
|
161
186
|
console.error("Error: entity name required");
|
|
@@ -189,7 +214,12 @@ export class DzqlNamespace {
|
|
|
189
214
|
}
|
|
190
215
|
}
|
|
191
216
|
|
|
192
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Delete entity by ID
|
|
219
|
+
* @example invj dzql:delete venues '{"id": 1}'
|
|
220
|
+
* @param {string} entity - Entity/table name
|
|
221
|
+
* @param {string} [argsJson] - JSON string with {id: number}
|
|
222
|
+
*/
|
|
193
223
|
async delete(c, entity, argsJson = "{}") {
|
|
194
224
|
if (!entity) {
|
|
195
225
|
console.error("Error: entity name required");
|
|
@@ -221,7 +251,12 @@ export class DzqlNamespace {
|
|
|
221
251
|
}
|
|
222
252
|
}
|
|
223
253
|
|
|
224
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Lookup entity (for dropdowns/autocomplete)
|
|
256
|
+
* @example invj dzql:lookup organisations '{"query": "acme"}'
|
|
257
|
+
* @param {string} entity - Entity/table name
|
|
258
|
+
* @param {string} [argsJson] - JSON string with {query: string, limit?: number}
|
|
259
|
+
*/
|
|
225
260
|
async lookup(c, entity, argsJson = "{}") {
|
|
226
261
|
if (!entity) {
|
|
227
262
|
console.error("Error: entity name required");
|