dzql 0.3.0 → 0.3.1
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.
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# DZQL Compiler Change Request: M2M Junction Table Sync in Compiled Save Functions
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
The DZQL compiler needs to generate **fully expanded, optimized M2M (many-to-many) junction table synchronization logic** in compiled `save_*` functions when an entity has `many_to_many` configuration in its `graph_rules`.
|
|
5
|
+
|
|
6
|
+
## Core Principle: Compilation, Not Interpretation
|
|
7
|
+
The entire purpose of the DZQL compiler is to **eliminate runtime interpretation** and generate static, optimized SQL that PostgreSQL can fully optimize. The M2M logic should be:
|
|
8
|
+
|
|
9
|
+
- ✅ **Fully compiled** - All M2M relationships expanded at compile time
|
|
10
|
+
- ✅ **Zero interpretation** - No runtime config lookups or dynamic SQL
|
|
11
|
+
- ✅ **Statically analyzable** - PostgreSQL query planner can fully optimize
|
|
12
|
+
- ✅ **Predictable execution** - Same code path every time
|
|
13
|
+
- ❌ **NOT dynamic** - No loops over M2M configs at runtime
|
|
14
|
+
- ❌ **NOT generic** - Each entity gets its specific M2M code
|
|
15
|
+
|
|
16
|
+
## Compiled vs. Generic Operations
|
|
17
|
+
|
|
18
|
+
### Generic Operations (Runtime Interpretation - SLOW)
|
|
19
|
+
```sql
|
|
20
|
+
-- generic_save() looks up M2M config at runtime
|
|
21
|
+
FOR relationship IN SELECT * FROM jsonb_each(entity_config->'many_to_many') LOOP
|
|
22
|
+
-- Dynamic junction table name
|
|
23
|
+
-- Dynamic column names
|
|
24
|
+
-- Cannot be optimized by query planner
|
|
25
|
+
END LOOP;
|
|
26
|
+
```
|
|
27
|
+
❌ Runtime config lookups
|
|
28
|
+
❌ Dynamic SQL generation
|
|
29
|
+
❌ Loops over relationships
|
|
30
|
+
❌ PostgreSQL can't optimize
|
|
31
|
+
|
|
32
|
+
### Compiled Operations (Static Code - FAST)
|
|
33
|
+
```sql
|
|
34
|
+
-- save_resources() has M2M code baked in at compile time
|
|
35
|
+
DELETE FROM resource_tags WHERE resource_id = v_id ...;
|
|
36
|
+
INSERT INTO resource_tags (resource_id, tag_id) SELECT v_id, unnest(v_tag_ids) ...;
|
|
37
|
+
```
|
|
38
|
+
✅ All table/column names known at compile time
|
|
39
|
+
✅ Direct SQL statements (no loops)
|
|
40
|
+
✅ PostgreSQL can fully optimize
|
|
41
|
+
✅ Execution plan cached
|
|
42
|
+
|
|
43
|
+
## Current Behavior
|
|
44
|
+
Currently, the compiler generates standard CRUD functions from entity definitions, but does **NOT** include M2M junction table sync logic in the `save_*` function. The M2M support only works when using the runtime `generic_save()` operation.
|
|
45
|
+
|
|
46
|
+
### Current Generated Code (save_resources)
|
|
47
|
+
```sql
|
|
48
|
+
CREATE OR REPLACE FUNCTION save_resources(
|
|
49
|
+
p_user_id INT,
|
|
50
|
+
p_data JSONB
|
|
51
|
+
) RETURNS JSONB AS $$
|
|
52
|
+
DECLARE
|
|
53
|
+
v_result resources%ROWTYPE;
|
|
54
|
+
-- ... other variables
|
|
55
|
+
BEGIN
|
|
56
|
+
-- Permission checks
|
|
57
|
+
-- Insert or update the main record
|
|
58
|
+
-- Apply field defaults on INSERT
|
|
59
|
+
-- Return the result
|
|
60
|
+
END;
|
|
61
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Problem**: When client sends `tag_ids: [1, 2, 3]`, it tries to insert into the `resources` table which fails because `tag_ids` is not a column.
|
|
65
|
+
|
|
66
|
+
## Desired Behavior
|
|
67
|
+
When an entity has M2M configuration, the compiler should:
|
|
68
|
+
|
|
69
|
+
1. **Detect M2M configuration** in `graph_rules.many_to_many`
|
|
70
|
+
2. **Extract the `id_field`** from configuration (e.g., `tag_ids`)
|
|
71
|
+
3. **Generate junction table sync logic** in the `save_*` function
|
|
72
|
+
4. **Handle the M2M field separately** from regular columns
|
|
73
|
+
|
|
74
|
+
### Entity Configuration Example
|
|
75
|
+
```sql
|
|
76
|
+
SELECT dzql.register_entity(
|
|
77
|
+
'resources',
|
|
78
|
+
'title',
|
|
79
|
+
ARRAY['title'],
|
|
80
|
+
'{}', -- fk_includes
|
|
81
|
+
false, -- soft_delete
|
|
82
|
+
'{}', -- temporal_fields
|
|
83
|
+
'{}', -- notification_paths
|
|
84
|
+
'{}', -- permission_paths
|
|
85
|
+
'{
|
|
86
|
+
"many_to_many": {
|
|
87
|
+
"tags": {
|
|
88
|
+
"junction_table": "resource_tags",
|
|
89
|
+
"local_key": "resource_id",
|
|
90
|
+
"foreign_key": "tag_id",
|
|
91
|
+
"target_entity": "tags",
|
|
92
|
+
"id_field": "tag_ids",
|
|
93
|
+
"expand": true
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}', -- graph_rules with M2M
|
|
97
|
+
'{
|
|
98
|
+
"owner_id": "@user_id"
|
|
99
|
+
}' -- field_defaults
|
|
100
|
+
);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Expected Generated Code (save_resources with M2M)
|
|
104
|
+
```sql
|
|
105
|
+
CREATE OR REPLACE FUNCTION save_resources(
|
|
106
|
+
p_user_id INT,
|
|
107
|
+
p_data JSONB
|
|
108
|
+
) RETURNS JSONB AS $$
|
|
109
|
+
DECLARE
|
|
110
|
+
v_result resources%ROWTYPE;
|
|
111
|
+
v_existing resources%ROWTYPE;
|
|
112
|
+
v_output JSONB;
|
|
113
|
+
v_is_insert BOOLEAN := false;
|
|
114
|
+
v_notify_users INT[];
|
|
115
|
+
v_id INT;
|
|
116
|
+
-- M2M variables
|
|
117
|
+
v_tag_ids INT[]; -- Extract from M2M config
|
|
118
|
+
BEGIN
|
|
119
|
+
-- Extract M2M fields from p_data (don't try to insert into main table)
|
|
120
|
+
IF p_data ? 'tag_ids' THEN
|
|
121
|
+
v_tag_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'tag_ids')::int);
|
|
122
|
+
p_data := p_data - 'tag_ids'; -- Remove from data to be inserted
|
|
123
|
+
END IF;
|
|
124
|
+
|
|
125
|
+
-- Check if insert or update
|
|
126
|
+
IF p_data ? 'id' THEN
|
|
127
|
+
v_id := (p_data->>'id')::int;
|
|
128
|
+
SELECT * INTO v_existing FROM resources WHERE id = v_id;
|
|
129
|
+
IF NOT FOUND THEN
|
|
130
|
+
RAISE EXCEPTION 'Record not found: resources with id=%', v_id;
|
|
131
|
+
END IF;
|
|
132
|
+
v_is_insert := false;
|
|
133
|
+
ELSE
|
|
134
|
+
v_is_insert := true;
|
|
135
|
+
END IF;
|
|
136
|
+
|
|
137
|
+
-- Apply field defaults on INSERT
|
|
138
|
+
IF v_is_insert THEN
|
|
139
|
+
-- Apply field defaults: owner_id = @user_id
|
|
140
|
+
IF NOT (p_data ? 'owner_id') THEN
|
|
141
|
+
p_data := p_data || jsonb_build_object('owner_id', p_user_id);
|
|
142
|
+
END IF;
|
|
143
|
+
END IF;
|
|
144
|
+
|
|
145
|
+
-- Permission checks
|
|
146
|
+
IF v_is_insert THEN
|
|
147
|
+
IF NOT can_create_resources(p_user_id, p_data) THEN
|
|
148
|
+
RAISE EXCEPTION 'Permission denied: create on resources';
|
|
149
|
+
END IF;
|
|
150
|
+
ELSE
|
|
151
|
+
IF NOT can_update_resources(p_user_id, to_jsonb(v_existing)) THEN
|
|
152
|
+
RAISE EXCEPTION 'Permission denied: update on resources';
|
|
153
|
+
END IF;
|
|
154
|
+
END IF;
|
|
155
|
+
|
|
156
|
+
-- Insert or update main record
|
|
157
|
+
IF v_is_insert THEN
|
|
158
|
+
INSERT INTO resources
|
|
159
|
+
SELECT * FROM jsonb_populate_record(NULL::resources, p_data)
|
|
160
|
+
RETURNING * INTO v_result;
|
|
161
|
+
v_id := v_result.id;
|
|
162
|
+
ELSE
|
|
163
|
+
UPDATE resources
|
|
164
|
+
SET
|
|
165
|
+
title = COALESCE((p_data->>'title'), title),
|
|
166
|
+
color = COALESCE((p_data->>'color'), color),
|
|
167
|
+
icon = COALESCE((p_data->>'icon'), icon),
|
|
168
|
+
parent_id = COALESCE((p_data->>'parent_id')::int, parent_id)
|
|
169
|
+
WHERE id = v_id
|
|
170
|
+
RETURNING * INTO v_result;
|
|
171
|
+
END IF;
|
|
172
|
+
|
|
173
|
+
-- ============================================================================
|
|
174
|
+
-- M2M: Sync junction table for "tags" relationship
|
|
175
|
+
-- ============================================================================
|
|
176
|
+
IF v_tag_ids IS NOT NULL THEN
|
|
177
|
+
-- Delete tags not in the new list
|
|
178
|
+
DELETE FROM resource_tags
|
|
179
|
+
WHERE resource_id = v_id
|
|
180
|
+
AND (tag_id <> ALL(v_tag_ids) OR v_tag_ids = '{}');
|
|
181
|
+
|
|
182
|
+
-- Insert new tags (ON CONFLICT DO NOTHING for idempotency)
|
|
183
|
+
IF array_length(v_tag_ids, 1) > 0 THEN
|
|
184
|
+
INSERT INTO resource_tags (resource_id, tag_id)
|
|
185
|
+
SELECT v_id, unnest(v_tag_ids)
|
|
186
|
+
ON CONFLICT (resource_id, tag_id) DO NOTHING;
|
|
187
|
+
END IF;
|
|
188
|
+
END IF;
|
|
189
|
+
|
|
190
|
+
-- Build output with M2M fields included
|
|
191
|
+
v_output := to_jsonb(v_result);
|
|
192
|
+
|
|
193
|
+
-- Add tag_ids to output
|
|
194
|
+
SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
195
|
+
INTO v_output
|
|
196
|
+
FROM resource_tags
|
|
197
|
+
WHERE resource_id = v_id;
|
|
198
|
+
|
|
199
|
+
v_output := v_output || jsonb_build_object('tag_ids',
|
|
200
|
+
(SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
201
|
+
FROM resource_tags WHERE resource_id = v_id)
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
-- Optionally expand full tag objects if expand=true
|
|
205
|
+
IF true THEN -- Read from M2M config
|
|
206
|
+
v_output := v_output || jsonb_build_object('tags',
|
|
207
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
208
|
+
FROM resource_tags rt
|
|
209
|
+
JOIN tags t ON t.id = rt.tag_id
|
|
210
|
+
WHERE rt.resource_id = v_id)
|
|
211
|
+
);
|
|
212
|
+
END IF;
|
|
213
|
+
|
|
214
|
+
-- Resolve notification paths and create event
|
|
215
|
+
v_notify_users := _resolve_notification_paths_resources(p_user_id, v_output);
|
|
216
|
+
|
|
217
|
+
INSERT INTO dzql.events (table_name, op, pk, before, after, user_id, notify_users)
|
|
218
|
+
VALUES (
|
|
219
|
+
'resources',
|
|
220
|
+
CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
|
|
221
|
+
jsonb_build_object('id', v_id),
|
|
222
|
+
CASE WHEN v_is_insert THEN NULL ELSE to_jsonb(v_existing) END,
|
|
223
|
+
v_output,
|
|
224
|
+
p_user_id,
|
|
225
|
+
v_notify_users
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
-- Remove sensitive fields
|
|
229
|
+
v_output := v_output - 'password_hash' - 'password' - 'secret' - 'token';
|
|
230
|
+
|
|
231
|
+
RETURN v_output;
|
|
232
|
+
END;
|
|
233
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Implementation Requirements
|
|
237
|
+
|
|
238
|
+
### 1. Compiler Detection & Expansion
|
|
239
|
+
The compiler needs to detect M2M configuration and **generate separate code blocks for each relationship**:
|
|
240
|
+
|
|
241
|
+
```javascript
|
|
242
|
+
const manyToMany = entity.graphRules?.many_to_many || {};
|
|
243
|
+
|
|
244
|
+
// Generate code for EACH M2M relationship (no runtime loops!)
|
|
245
|
+
for (const [relationshipName, config] of Object.entries(manyToMany)) {
|
|
246
|
+
// Generate static SQL for this specific relationship
|
|
247
|
+
// - Variable declarations: v_tag_ids INT[];
|
|
248
|
+
// - Extract logic: IF p_data ? 'tag_ids' THEN ...
|
|
249
|
+
// - Sync logic: DELETE FROM resource_tags WHERE ...
|
|
250
|
+
// - Output logic: Add tag_ids to result
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Key Point**: If an entity has 3 M2M relationships, generate 3 separate code blocks. No loops at runtime!
|
|
255
|
+
|
|
256
|
+
### 2. Code Generation Changes
|
|
257
|
+
File: `src/compiler/codegen/operation-codegen.js` (or similar)
|
|
258
|
+
|
|
259
|
+
For each M2M relationship, generate:
|
|
260
|
+
|
|
261
|
+
**A. Variable declarations** (in DECLARE section):
|
|
262
|
+
```sql
|
|
263
|
+
v_{id_field} INT[]; -- e.g., v_tag_ids INT[];
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**B. Extract M2M fields from input** (before INSERT/UPDATE):
|
|
267
|
+
```sql
|
|
268
|
+
IF p_data ? '{id_field}' THEN
|
|
269
|
+
v_{id_field} := ARRAY(SELECT jsonb_array_elements_text(p_data->'{id_field}')::int);
|
|
270
|
+
p_data := p_data - '{id_field}';
|
|
271
|
+
END IF;
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
**C. Junction table sync** (after main record save):
|
|
275
|
+
```sql
|
|
276
|
+
IF v_{id_field} IS NOT NULL THEN
|
|
277
|
+
DELETE FROM {junction_table}
|
|
278
|
+
WHERE {local_key} = v_id
|
|
279
|
+
AND ({foreign_key} <> ALL(v_{id_field}) OR v_{id_field} = '{}');
|
|
280
|
+
|
|
281
|
+
IF array_length(v_{id_field}, 1) > 0 THEN
|
|
282
|
+
INSERT INTO {junction_table} ({local_key}, {foreign_key})
|
|
283
|
+
SELECT v_id, unnest(v_{id_field})
|
|
284
|
+
ON CONFLICT ({local_key}, {foreign_key}) DO NOTHING;
|
|
285
|
+
END IF;
|
|
286
|
+
END IF;
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**D. Include M2M fields in output** (before RETURN):
|
|
290
|
+
```sql
|
|
291
|
+
-- Add ID array
|
|
292
|
+
v_output := v_output || jsonb_build_object('{id_field}',
|
|
293
|
+
(SELECT COALESCE(jsonb_agg({foreign_key} ORDER BY {foreign_key}), '[]'::jsonb)
|
|
294
|
+
FROM {junction_table} WHERE {local_key} = v_id)
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
-- Optionally expand full objects if expand=true
|
|
298
|
+
IF {expand} THEN
|
|
299
|
+
v_output := v_output || jsonb_build_object('{relationship_name}',
|
|
300
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
301
|
+
FROM {junction_table} jt
|
|
302
|
+
JOIN {target_entity} t ON t.id = jt.{foreign_key}
|
|
303
|
+
WHERE jt.{local_key} = v_id)
|
|
304
|
+
);
|
|
305
|
+
END IF;
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### 3. GET Operation Enhancement
|
|
309
|
+
The `get_*` function should also include M2M fields in its output:
|
|
310
|
+
```sql
|
|
311
|
+
-- After fetching the main record
|
|
312
|
+
v_result := to_jsonb(v_record);
|
|
313
|
+
|
|
314
|
+
-- Add M2M fields
|
|
315
|
+
v_result := v_result || jsonb_build_object('tag_ids',
|
|
316
|
+
(SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
317
|
+
FROM resource_tags WHERE resource_id = v_id)
|
|
318
|
+
);
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### 4. SEARCH Operation Enhancement
|
|
322
|
+
Include M2M fields for each record in search results.
|
|
323
|
+
|
|
324
|
+
## Testing Requirements
|
|
325
|
+
|
|
326
|
+
### Test Case 1: Create with Tags
|
|
327
|
+
```javascript
|
|
328
|
+
const resource = await api.save_resources({
|
|
329
|
+
title: "Room A",
|
|
330
|
+
color: "#3788d8",
|
|
331
|
+
tag_ids: [1, 2, 3]
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Expected result:
|
|
335
|
+
{
|
|
336
|
+
id: 1,
|
|
337
|
+
title: "Room A",
|
|
338
|
+
color: "#3788d8",
|
|
339
|
+
owner_id: 42, // from field defaults
|
|
340
|
+
tag_ids: [1, 2, 3],
|
|
341
|
+
tags: [ // if expand=true
|
|
342
|
+
{ id: 1, name: "Important", color: "#FF0000" },
|
|
343
|
+
{ id: 2, name: "Urgent", color: "#FFA500" },
|
|
344
|
+
{ id: 3, name: "Review", color: "#00FF00" }
|
|
345
|
+
]
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Test Case 2: Update Tags
|
|
350
|
+
```javascript
|
|
351
|
+
await api.save_resources({
|
|
352
|
+
id: 1,
|
|
353
|
+
tag_ids: [2, 3, 4] // Remove 1, keep 2&3, add 4
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Expected: Junction table updated atomically
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Test Case 3: Remove All Tags
|
|
360
|
+
```javascript
|
|
361
|
+
await api.save_resources({
|
|
362
|
+
id: 1,
|
|
363
|
+
tag_ids: [] // Clear all tags
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Expected: All junction table entries removed
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Test Case 4: Null/Undefined Handling
|
|
370
|
+
```javascript
|
|
371
|
+
await api.save_resources({
|
|
372
|
+
id: 1,
|
|
373
|
+
title: "Updated Title"
|
|
374
|
+
// tag_ids not included - should leave tags unchanged
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Expected: Tags remain the same
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
## Example: Entity with Multiple M2M Relationships
|
|
381
|
+
|
|
382
|
+
### Entity Configuration
|
|
383
|
+
```sql
|
|
384
|
+
SELECT dzql.register_entity(
|
|
385
|
+
'projects',
|
|
386
|
+
'name',
|
|
387
|
+
ARRAY['name'],
|
|
388
|
+
'{}', false, '{}', '{}', '{}',
|
|
389
|
+
'{
|
|
390
|
+
"many_to_many": {
|
|
391
|
+
"tags": {
|
|
392
|
+
"junction_table": "project_tags",
|
|
393
|
+
"local_key": "project_id",
|
|
394
|
+
"foreign_key": "tag_id",
|
|
395
|
+
"target_entity": "tags",
|
|
396
|
+
"id_field": "tag_ids",
|
|
397
|
+
"expand": false
|
|
398
|
+
},
|
|
399
|
+
"collaborators": {
|
|
400
|
+
"junction_table": "project_collaborators",
|
|
401
|
+
"local_key": "project_id",
|
|
402
|
+
"foreign_key": "user_id",
|
|
403
|
+
"target_entity": "users",
|
|
404
|
+
"id_field": "collaborator_ids",
|
|
405
|
+
"expand": true
|
|
406
|
+
},
|
|
407
|
+
"categories": {
|
|
408
|
+
"junction_table": "project_categories",
|
|
409
|
+
"local_key": "project_id",
|
|
410
|
+
"foreign_key": "category_id",
|
|
411
|
+
"target_entity": "categories",
|
|
412
|
+
"id_field": "category_ids",
|
|
413
|
+
"expand": false
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}'
|
|
417
|
+
);
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Generated Code (Static, No Loops!)
|
|
421
|
+
```sql
|
|
422
|
+
CREATE OR REPLACE FUNCTION save_projects(...) RETURNS JSONB AS $$
|
|
423
|
+
DECLARE
|
|
424
|
+
-- Separate variable for EACH M2M relationship (known at compile time)
|
|
425
|
+
v_tag_ids INT[];
|
|
426
|
+
v_collaborator_ids INT[];
|
|
427
|
+
v_category_ids INT[];
|
|
428
|
+
BEGIN
|
|
429
|
+
-- Extract tags
|
|
430
|
+
IF p_data ? 'tag_ids' THEN
|
|
431
|
+
v_tag_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'tag_ids')::int);
|
|
432
|
+
p_data := p_data - 'tag_ids';
|
|
433
|
+
END IF;
|
|
434
|
+
|
|
435
|
+
-- Extract collaborators
|
|
436
|
+
IF p_data ? 'collaborator_ids' THEN
|
|
437
|
+
v_collaborator_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'collaborator_ids')::int);
|
|
438
|
+
p_data := p_data - 'collaborator_ids';
|
|
439
|
+
END IF;
|
|
440
|
+
|
|
441
|
+
-- Extract categories
|
|
442
|
+
IF p_data ? 'category_ids' THEN
|
|
443
|
+
v_category_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'category_ids')::int);
|
|
444
|
+
p_data := p_data - 'category_ids';
|
|
445
|
+
END IF;
|
|
446
|
+
|
|
447
|
+
-- ... main record save ...
|
|
448
|
+
|
|
449
|
+
-- Sync tags (static code block #1)
|
|
450
|
+
IF v_tag_ids IS NOT NULL THEN
|
|
451
|
+
DELETE FROM project_tags WHERE project_id = v_id AND (tag_id <> ALL(v_tag_ids) OR v_tag_ids = '{}');
|
|
452
|
+
IF array_length(v_tag_ids, 1) > 0 THEN
|
|
453
|
+
INSERT INTO project_tags (project_id, tag_id) SELECT v_id, unnest(v_tag_ids) ON CONFLICT DO NOTHING;
|
|
454
|
+
END IF;
|
|
455
|
+
END IF;
|
|
456
|
+
|
|
457
|
+
-- Sync collaborators (static code block #2)
|
|
458
|
+
IF v_collaborator_ids IS NOT NULL THEN
|
|
459
|
+
DELETE FROM project_collaborators WHERE project_id = v_id AND (user_id <> ALL(v_collaborator_ids) OR v_collaborator_ids = '{}');
|
|
460
|
+
IF array_length(v_collaborator_ids, 1) > 0 THEN
|
|
461
|
+
INSERT INTO project_collaborators (project_id, user_id) SELECT v_id, unnest(v_collaborator_ids) ON CONFLICT DO NOTHING;
|
|
462
|
+
END IF;
|
|
463
|
+
END IF;
|
|
464
|
+
|
|
465
|
+
-- Sync categories (static code block #3)
|
|
466
|
+
IF v_category_ids IS NOT NULL THEN
|
|
467
|
+
DELETE FROM project_categories WHERE project_id = v_id AND (category_id <> ALL(v_category_ids) OR v_category_ids = '{}');
|
|
468
|
+
IF array_length(v_category_ids, 1) > 0 THEN
|
|
469
|
+
INSERT INTO project_categories (project_id, category_id) SELECT v_id, unnest(v_category_ids) ON CONFLICT DO NOTHING;
|
|
470
|
+
END IF;
|
|
471
|
+
END IF;
|
|
472
|
+
|
|
473
|
+
-- Add to output (3 separate blocks, not looped)
|
|
474
|
+
v_output := v_output
|
|
475
|
+
|| jsonb_build_object('tag_ids', (SELECT ...))
|
|
476
|
+
|| jsonb_build_object('collaborator_ids', (SELECT ...))
|
|
477
|
+
|| jsonb_build_object('collaborators', (SELECT ... JOIN users ...)) -- expand=true
|
|
478
|
+
|| jsonb_build_object('category_ids', (SELECT ...));
|
|
479
|
+
|
|
480
|
+
RETURN v_output;
|
|
481
|
+
END;
|
|
482
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
**Key Observation**: 3 M2M relationships = 3 code blocks, not a loop. PostgreSQL can optimize this!
|
|
486
|
+
|
|
487
|
+
## Edge Cases to Handle
|
|
488
|
+
|
|
489
|
+
1. **Empty array** (`tag_ids: []`) - Delete all relationships
|
|
490
|
+
2. **Null/undefined** - Don't touch relationships
|
|
491
|
+
3. **Invalid IDs** - Foreign key constraints should handle (or validate)
|
|
492
|
+
4. **Concurrent updates** - Use transaction isolation
|
|
493
|
+
5. **Multiple M2M relationships** - Process each independently (separate code blocks)
|
|
494
|
+
|
|
495
|
+
## Files to Modify
|
|
496
|
+
|
|
497
|
+
Based on DZQL compiler structure:
|
|
498
|
+
1. `src/compiler/codegen/operation-codegen.js` - Main save function generation
|
|
499
|
+
2. `src/compiler/compiler.js` - Entity processing and M2M detection
|
|
500
|
+
3. Possibly `src/compiler/parser/entity-parser.js` - If M2M parsing needs enhancement
|
|
501
|
+
|
|
502
|
+
## Success Criteria
|
|
503
|
+
|
|
504
|
+
- [ ] Compiled `save_*` functions accept M2M `id_field` arrays
|
|
505
|
+
- [ ] Junction tables sync atomically with main record
|
|
506
|
+
- [ ] GET operations return M2M fields
|
|
507
|
+
- [ ] SEARCH operations include M2M fields
|
|
508
|
+
- [ ] All test cases pass
|
|
509
|
+
- [ ] No breaking changes to existing compiled functions
|
|
510
|
+
- [ ] Field defaults still work correctly
|
|
511
|
+
- [ ] Backward compatible (entities without M2M work as before)
|
|
512
|
+
|
|
513
|
+
## Performance Benefits
|
|
514
|
+
|
|
515
|
+
### Generic vs. Compiled Performance
|
|
516
|
+
|
|
517
|
+
**Generic `generic_save()` - Interpreted:**
|
|
518
|
+
```
|
|
519
|
+
1. Look up entity config from dzql.entities table <- DB query
|
|
520
|
+
2. Parse many_to_many JSON config <- JSON parsing
|
|
521
|
+
3. Loop over relationships <- Interpreted loop
|
|
522
|
+
4. Build dynamic SQL for each relationship <- String concatenation
|
|
523
|
+
5. EXECUTE dynamic SQL <- No plan caching
|
|
524
|
+
6. Repeat for each M2M relationship
|
|
525
|
+
```
|
|
526
|
+
**Total overhead**: ~5-10ms per M2M relationship
|
|
527
|
+
|
|
528
|
+
**Compiled `save_resources()` - Optimized:**
|
|
529
|
+
```
|
|
530
|
+
1. Execute pre-generated static SQL for resource_tags <- Direct execution
|
|
531
|
+
2. Execute pre-generated static SQL for categories <- Direct execution
|
|
532
|
+
3. Execute pre-generated static SQL for assignments <- Direct execution
|
|
533
|
+
```
|
|
534
|
+
**Total overhead**: ~0.1ms (query plan cached, no interpretation)
|
|
535
|
+
|
|
536
|
+
**Performance Gain**: 50-100x faster for entities with multiple M2M relationships
|
|
537
|
+
|
|
538
|
+
### Why Compilation Matters
|
|
539
|
+
- **Startup cost**: Generic operations have ~5ms overhead per save
|
|
540
|
+
- **Scale**: On 1000 saves/sec, that's 5 seconds of pure overhead
|
|
541
|
+
- **Compiled**: Zero interpretation overhead, plan caching, vectorized execution
|
|
542
|
+
- **Query planner**: Can optimize the entire function as one execution plan
|
|
543
|
+
|
|
544
|
+
## Additional Notes
|
|
545
|
+
|
|
546
|
+
- This is similar to how **field defaults** were added - another enhancement to the save function generation
|
|
547
|
+
- The runtime `generic_save()` already has this logic - compiler should generate **equivalent but fully expanded** code
|
|
548
|
+
- M2M sync should happen **within the same transaction** as the main record save
|
|
549
|
+
- **Critical**: Each M2M relationship must be a separate, static code block (not looped)
|
|
550
|
+
- Table names, column names, and field names are all known at compile time - bake them in!
|
|
551
|
+
|
|
552
|
+
## Reference Implementation
|
|
553
|
+
|
|
554
|
+
The runtime implementation can be found in:
|
|
555
|
+
- `node_modules/dzql/src/database/migrations/003_operations.sql` - `generic_save()` function
|
|
556
|
+
- Look for the M2M sync logic in the `generic_save` function for the reference implementation
|
|
557
|
+
|
|
558
|
+
---
|
|
559
|
+
|
|
560
|
+
**Priority**: High
|
|
561
|
+
**Impact**: Enables full M2M support in compiled functions, making compiled entities feature-complete with generic operations
|
|
562
|
+
**Complexity**: Medium - Similar scope to field defaults feature
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# DZQL Compiler Change Request: M2M Junction Table Sync in Compiled Save Functions
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
The DZQL compiler needs to generate M2M (many-to-many) junction table synchronization logic in compiled `save_*` functions when an entity has `many_to_many` configuration in its `graph_rules`.
|
|
5
|
+
|
|
6
|
+
## Current Behavior
|
|
7
|
+
Currently, the compiler generates standard CRUD functions from entity definitions, but does **NOT** include M2M junction table sync logic in the `save_*` function. The M2M support only works when using the runtime `generic_save()` operation.
|
|
8
|
+
|
|
9
|
+
### Current Generated Code (save_resources)
|
|
10
|
+
```sql
|
|
11
|
+
CREATE OR REPLACE FUNCTION save_resources(
|
|
12
|
+
p_user_id INT,
|
|
13
|
+
p_data JSONB
|
|
14
|
+
) RETURNS JSONB AS $$
|
|
15
|
+
DECLARE
|
|
16
|
+
v_result resources%ROWTYPE;
|
|
17
|
+
-- ... other variables
|
|
18
|
+
BEGIN
|
|
19
|
+
-- Permission checks
|
|
20
|
+
-- Insert or update the main record
|
|
21
|
+
-- Apply field defaults on INSERT
|
|
22
|
+
-- Return the result
|
|
23
|
+
END;
|
|
24
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Problem**: When client sends `tag_ids: [1, 2, 3]`, it tries to insert into the `resources` table which fails because `tag_ids` is not a column.
|
|
28
|
+
|
|
29
|
+
## Desired Behavior
|
|
30
|
+
When an entity has M2M configuration, the compiler should:
|
|
31
|
+
|
|
32
|
+
1. **Detect M2M configuration** in `graph_rules.many_to_many`
|
|
33
|
+
2. **Extract the `id_field`** from configuration (e.g., `tag_ids`)
|
|
34
|
+
3. **Generate junction table sync logic** in the `save_*` function
|
|
35
|
+
4. **Handle the M2M field separately** from regular columns
|
|
36
|
+
|
|
37
|
+
### Entity Configuration Example
|
|
38
|
+
```sql
|
|
39
|
+
SELECT dzql.register_entity(
|
|
40
|
+
'resources',
|
|
41
|
+
'title',
|
|
42
|
+
ARRAY['title'],
|
|
43
|
+
'{}', -- fk_includes
|
|
44
|
+
false, -- soft_delete
|
|
45
|
+
'{}', -- temporal_fields
|
|
46
|
+
'{}', -- notification_paths
|
|
47
|
+
'{}', -- permission_paths
|
|
48
|
+
'{
|
|
49
|
+
"many_to_many": {
|
|
50
|
+
"tags": {
|
|
51
|
+
"junction_table": "resource_tags",
|
|
52
|
+
"local_key": "resource_id",
|
|
53
|
+
"foreign_key": "tag_id",
|
|
54
|
+
"target_entity": "tags",
|
|
55
|
+
"id_field": "tag_ids",
|
|
56
|
+
"expand": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}', -- graph_rules with M2M
|
|
60
|
+
'{
|
|
61
|
+
"owner_id": "@user_id"
|
|
62
|
+
}' -- field_defaults
|
|
63
|
+
);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Expected Generated Code (save_resources with M2M)
|
|
67
|
+
```sql
|
|
68
|
+
CREATE OR REPLACE FUNCTION save_resources(
|
|
69
|
+
p_user_id INT,
|
|
70
|
+
p_data JSONB
|
|
71
|
+
) RETURNS JSONB AS $$
|
|
72
|
+
DECLARE
|
|
73
|
+
v_result resources%ROWTYPE;
|
|
74
|
+
v_existing resources%ROWTYPE;
|
|
75
|
+
v_output JSONB;
|
|
76
|
+
v_is_insert BOOLEAN := false;
|
|
77
|
+
v_notify_users INT[];
|
|
78
|
+
v_id INT;
|
|
79
|
+
-- M2M variables
|
|
80
|
+
v_tag_ids INT[]; -- Extract from M2M config
|
|
81
|
+
BEGIN
|
|
82
|
+
-- Extract M2M fields from p_data (don't try to insert into main table)
|
|
83
|
+
IF p_data ? 'tag_ids' THEN
|
|
84
|
+
v_tag_ids := ARRAY(SELECT jsonb_array_elements_text(p_data->'tag_ids')::int);
|
|
85
|
+
p_data := p_data - 'tag_ids'; -- Remove from data to be inserted
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
-- Check if insert or update
|
|
89
|
+
IF p_data ? 'id' THEN
|
|
90
|
+
v_id := (p_data->>'id')::int;
|
|
91
|
+
SELECT * INTO v_existing FROM resources WHERE id = v_id;
|
|
92
|
+
IF NOT FOUND THEN
|
|
93
|
+
RAISE EXCEPTION 'Record not found: resources with id=%', v_id;
|
|
94
|
+
END IF;
|
|
95
|
+
v_is_insert := false;
|
|
96
|
+
ELSE
|
|
97
|
+
v_is_insert := true;
|
|
98
|
+
END IF;
|
|
99
|
+
|
|
100
|
+
-- Apply field defaults on INSERT
|
|
101
|
+
IF v_is_insert THEN
|
|
102
|
+
-- Apply field defaults: owner_id = @user_id
|
|
103
|
+
IF NOT (p_data ? 'owner_id') THEN
|
|
104
|
+
p_data := p_data || jsonb_build_object('owner_id', p_user_id);
|
|
105
|
+
END IF;
|
|
106
|
+
END IF;
|
|
107
|
+
|
|
108
|
+
-- Permission checks
|
|
109
|
+
IF v_is_insert THEN
|
|
110
|
+
IF NOT can_create_resources(p_user_id, p_data) THEN
|
|
111
|
+
RAISE EXCEPTION 'Permission denied: create on resources';
|
|
112
|
+
END IF;
|
|
113
|
+
ELSE
|
|
114
|
+
IF NOT can_update_resources(p_user_id, to_jsonb(v_existing)) THEN
|
|
115
|
+
RAISE EXCEPTION 'Permission denied: update on resources';
|
|
116
|
+
END IF;
|
|
117
|
+
END IF;
|
|
118
|
+
|
|
119
|
+
-- Insert or update main record
|
|
120
|
+
IF v_is_insert THEN
|
|
121
|
+
INSERT INTO resources
|
|
122
|
+
SELECT * FROM jsonb_populate_record(NULL::resources, p_data)
|
|
123
|
+
RETURNING * INTO v_result;
|
|
124
|
+
v_id := v_result.id;
|
|
125
|
+
ELSE
|
|
126
|
+
UPDATE resources
|
|
127
|
+
SET
|
|
128
|
+
title = COALESCE((p_data->>'title'), title),
|
|
129
|
+
color = COALESCE((p_data->>'color'), color),
|
|
130
|
+
icon = COALESCE((p_data->>'icon'), icon),
|
|
131
|
+
parent_id = COALESCE((p_data->>'parent_id')::int, parent_id)
|
|
132
|
+
WHERE id = v_id
|
|
133
|
+
RETURNING * INTO v_result;
|
|
134
|
+
END IF;
|
|
135
|
+
|
|
136
|
+
-- ============================================================================
|
|
137
|
+
-- M2M: Sync junction table for "tags" relationship
|
|
138
|
+
-- ============================================================================
|
|
139
|
+
IF v_tag_ids IS NOT NULL THEN
|
|
140
|
+
-- Delete tags not in the new list
|
|
141
|
+
DELETE FROM resource_tags
|
|
142
|
+
WHERE resource_id = v_id
|
|
143
|
+
AND (tag_id <> ALL(v_tag_ids) OR v_tag_ids = '{}');
|
|
144
|
+
|
|
145
|
+
-- Insert new tags (ON CONFLICT DO NOTHING for idempotency)
|
|
146
|
+
IF array_length(v_tag_ids, 1) > 0 THEN
|
|
147
|
+
INSERT INTO resource_tags (resource_id, tag_id)
|
|
148
|
+
SELECT v_id, unnest(v_tag_ids)
|
|
149
|
+
ON CONFLICT (resource_id, tag_id) DO NOTHING;
|
|
150
|
+
END IF;
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
-- Build output with M2M fields included
|
|
154
|
+
v_output := to_jsonb(v_result);
|
|
155
|
+
|
|
156
|
+
-- Add tag_ids to output
|
|
157
|
+
SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
158
|
+
INTO v_output
|
|
159
|
+
FROM resource_tags
|
|
160
|
+
WHERE resource_id = v_id;
|
|
161
|
+
|
|
162
|
+
v_output := v_output || jsonb_build_object('tag_ids',
|
|
163
|
+
(SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
164
|
+
FROM resource_tags WHERE resource_id = v_id)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
-- Optionally expand full tag objects if expand=true
|
|
168
|
+
IF true THEN -- Read from M2M config
|
|
169
|
+
v_output := v_output || jsonb_build_object('tags',
|
|
170
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
171
|
+
FROM resource_tags rt
|
|
172
|
+
JOIN tags t ON t.id = rt.tag_id
|
|
173
|
+
WHERE rt.resource_id = v_id)
|
|
174
|
+
);
|
|
175
|
+
END IF;
|
|
176
|
+
|
|
177
|
+
-- Resolve notification paths and create event
|
|
178
|
+
v_notify_users := _resolve_notification_paths_resources(p_user_id, v_output);
|
|
179
|
+
|
|
180
|
+
INSERT INTO dzql.events (table_name, op, pk, before, after, user_id, notify_users)
|
|
181
|
+
VALUES (
|
|
182
|
+
'resources',
|
|
183
|
+
CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
|
|
184
|
+
jsonb_build_object('id', v_id),
|
|
185
|
+
CASE WHEN v_is_insert THEN NULL ELSE to_jsonb(v_existing) END,
|
|
186
|
+
v_output,
|
|
187
|
+
p_user_id,
|
|
188
|
+
v_notify_users
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
-- Remove sensitive fields
|
|
192
|
+
v_output := v_output - 'password_hash' - 'password' - 'secret' - 'token';
|
|
193
|
+
|
|
194
|
+
RETURN v_output;
|
|
195
|
+
END;
|
|
196
|
+
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Implementation Requirements
|
|
200
|
+
|
|
201
|
+
### 1. Compiler Detection
|
|
202
|
+
The compiler needs to detect M2M configuration in the entity's `graph_rules`:
|
|
203
|
+
```javascript
|
|
204
|
+
const manyToMany = entity.graphRules?.many_to_many || {};
|
|
205
|
+
const hasManyToMany = Object.keys(manyToMany).length > 0;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 2. Code Generation Changes
|
|
209
|
+
File: `src/compiler/codegen/operation-codegen.js` (or similar)
|
|
210
|
+
|
|
211
|
+
For each M2M relationship, generate:
|
|
212
|
+
|
|
213
|
+
**A. Variable declarations** (in DECLARE section):
|
|
214
|
+
```sql
|
|
215
|
+
v_{id_field} INT[]; -- e.g., v_tag_ids INT[];
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**B. Extract M2M fields from input** (before INSERT/UPDATE):
|
|
219
|
+
```sql
|
|
220
|
+
IF p_data ? '{id_field}' THEN
|
|
221
|
+
v_{id_field} := ARRAY(SELECT jsonb_array_elements_text(p_data->'{id_field}')::int);
|
|
222
|
+
p_data := p_data - '{id_field}';
|
|
223
|
+
END IF;
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**C. Junction table sync** (after main record save):
|
|
227
|
+
```sql
|
|
228
|
+
IF v_{id_field} IS NOT NULL THEN
|
|
229
|
+
DELETE FROM {junction_table}
|
|
230
|
+
WHERE {local_key} = v_id
|
|
231
|
+
AND ({foreign_key} <> ALL(v_{id_field}) OR v_{id_field} = '{}');
|
|
232
|
+
|
|
233
|
+
IF array_length(v_{id_field}, 1) > 0 THEN
|
|
234
|
+
INSERT INTO {junction_table} ({local_key}, {foreign_key})
|
|
235
|
+
SELECT v_id, unnest(v_{id_field})
|
|
236
|
+
ON CONFLICT ({local_key}, {foreign_key}) DO NOTHING;
|
|
237
|
+
END IF;
|
|
238
|
+
END IF;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**D. Include M2M fields in output** (before RETURN):
|
|
242
|
+
```sql
|
|
243
|
+
-- Add ID array
|
|
244
|
+
v_output := v_output || jsonb_build_object('{id_field}',
|
|
245
|
+
(SELECT COALESCE(jsonb_agg({foreign_key} ORDER BY {foreign_key}), '[]'::jsonb)
|
|
246
|
+
FROM {junction_table} WHERE {local_key} = v_id)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
-- Optionally expand full objects if expand=true
|
|
250
|
+
IF {expand} THEN
|
|
251
|
+
v_output := v_output || jsonb_build_object('{relationship_name}',
|
|
252
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
253
|
+
FROM {junction_table} jt
|
|
254
|
+
JOIN {target_entity} t ON t.id = jt.{foreign_key}
|
|
255
|
+
WHERE jt.{local_key} = v_id)
|
|
256
|
+
);
|
|
257
|
+
END IF;
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### 3. GET Operation Enhancement
|
|
261
|
+
The `get_*` function should also include M2M fields in its output:
|
|
262
|
+
```sql
|
|
263
|
+
-- After fetching the main record
|
|
264
|
+
v_result := to_jsonb(v_record);
|
|
265
|
+
|
|
266
|
+
-- Add M2M fields
|
|
267
|
+
v_result := v_result || jsonb_build_object('tag_ids',
|
|
268
|
+
(SELECT COALESCE(jsonb_agg(tag_id ORDER BY tag_id), '[]'::jsonb)
|
|
269
|
+
FROM resource_tags WHERE resource_id = v_id)
|
|
270
|
+
);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### 4. SEARCH Operation Enhancement
|
|
274
|
+
Include M2M fields for each record in search results.
|
|
275
|
+
|
|
276
|
+
## Testing Requirements
|
|
277
|
+
|
|
278
|
+
### Test Case 1: Create with Tags
|
|
279
|
+
```javascript
|
|
280
|
+
const resource = await api.save_resources({
|
|
281
|
+
title: "Room A",
|
|
282
|
+
color: "#3788d8",
|
|
283
|
+
tag_ids: [1, 2, 3]
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Expected result:
|
|
287
|
+
{
|
|
288
|
+
id: 1,
|
|
289
|
+
title: "Room A",
|
|
290
|
+
color: "#3788d8",
|
|
291
|
+
owner_id: 42, // from field defaults
|
|
292
|
+
tag_ids: [1, 2, 3],
|
|
293
|
+
tags: [ // if expand=true
|
|
294
|
+
{ id: 1, name: "Important", color: "#FF0000" },
|
|
295
|
+
{ id: 2, name: "Urgent", color: "#FFA500" },
|
|
296
|
+
{ id: 3, name: "Review", color: "#00FF00" }
|
|
297
|
+
]
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Test Case 2: Update Tags
|
|
302
|
+
```javascript
|
|
303
|
+
await api.save_resources({
|
|
304
|
+
id: 1,
|
|
305
|
+
tag_ids: [2, 3, 4] // Remove 1, keep 2&3, add 4
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Expected: Junction table updated atomically
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Test Case 3: Remove All Tags
|
|
312
|
+
```javascript
|
|
313
|
+
await api.save_resources({
|
|
314
|
+
id: 1,
|
|
315
|
+
tag_ids: [] // Clear all tags
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Expected: All junction table entries removed
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Test Case 4: Null/Undefined Handling
|
|
322
|
+
```javascript
|
|
323
|
+
await api.save_resources({
|
|
324
|
+
id: 1,
|
|
325
|
+
title: "Updated Title"
|
|
326
|
+
// tag_ids not included - should leave tags unchanged
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Expected: Tags remain the same
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## Edge Cases to Handle
|
|
333
|
+
|
|
334
|
+
1. **Empty array** (`tag_ids: []`) - Delete all relationships
|
|
335
|
+
2. **Null/undefined** - Don't touch relationships
|
|
336
|
+
3. **Invalid IDs** - Foreign key constraints should handle (or validate)
|
|
337
|
+
4. **Concurrent updates** - Use transaction isolation
|
|
338
|
+
5. **Multiple M2M relationships** - Process each independently
|
|
339
|
+
|
|
340
|
+
## Files to Modify
|
|
341
|
+
|
|
342
|
+
Based on DZQL compiler structure:
|
|
343
|
+
1. `src/compiler/codegen/operation-codegen.js` - Main save function generation
|
|
344
|
+
2. `src/compiler/compiler.js` - Entity processing and M2M detection
|
|
345
|
+
3. Possibly `src/compiler/parser/entity-parser.js` - If M2M parsing needs enhancement
|
|
346
|
+
|
|
347
|
+
## Success Criteria
|
|
348
|
+
|
|
349
|
+
- [ ] Compiled `save_*` functions accept M2M `id_field` arrays
|
|
350
|
+
- [ ] Junction tables sync atomically with main record
|
|
351
|
+
- [ ] GET operations return M2M fields
|
|
352
|
+
- [ ] SEARCH operations include M2M fields
|
|
353
|
+
- [ ] All test cases pass
|
|
354
|
+
- [ ] No breaking changes to existing compiled functions
|
|
355
|
+
- [ ] Field defaults still work correctly
|
|
356
|
+
- [ ] Backward compatible (entities without M2M work as before)
|
|
357
|
+
|
|
358
|
+
## Additional Notes
|
|
359
|
+
|
|
360
|
+
- This is similar to how **field defaults** were added - another enhancement to the save function generation
|
|
361
|
+
- The runtime `generic_save()` already has this logic - compiler should generate equivalent code
|
|
362
|
+
- M2M sync should happen **within the same transaction** as the main record save
|
|
363
|
+
- Consider performance: for entities with many M2M relationships, minimize query count
|
|
364
|
+
|
|
365
|
+
## Reference Implementation
|
|
366
|
+
|
|
367
|
+
The runtime implementation can be found in:
|
|
368
|
+
- `node_modules/dzql/src/database/migrations/003_operations.sql` - `generic_save()` function
|
|
369
|
+
- Look for the M2M sync logic in the `generic_save` function for the reference implementation
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
**Priority**: High
|
|
374
|
+
**Impact**: Enables full M2M support in compiled functions, making compiled entities feature-complete with generic operations
|
|
375
|
+
**Complexity**: Medium - Similar scope to field defaults feature
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dzql",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "bun test",
|
|
25
|
-
"prepublishOnly": "echo '✅ Publishing DZQL v0.3.
|
|
25
|
+
"prepublishOnly": "echo '✅ Publishing DZQL v0.3.1...'"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"jose": "^6.1.0",
|
|
@@ -28,6 +28,7 @@ export class OperationCodegen {
|
|
|
28
28
|
*/
|
|
29
29
|
generateGetFunction() {
|
|
30
30
|
const fkExpansions = this._generateFKExpansions();
|
|
31
|
+
const m2mExpansionForGet = this._generateM2MExpansionForGet();
|
|
31
32
|
const filterSensitiveFields = this._generateSensitiveFieldFilter();
|
|
32
33
|
|
|
33
34
|
return `-- GET operation for ${this.tableName}
|
|
@@ -58,6 +59,7 @@ BEGIN
|
|
|
58
59
|
END IF;
|
|
59
60
|
|
|
60
61
|
${fkExpansions}
|
|
62
|
+
${m2mExpansionForGet}
|
|
61
63
|
${filterSensitiveFields}
|
|
62
64
|
|
|
63
65
|
RETURN v_result;
|
|
@@ -72,6 +74,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
72
74
|
const graphRulesCall = this._generateGraphRulesCall();
|
|
73
75
|
const notificationSQL = this._generateNotificationSQL();
|
|
74
76
|
const filterSensitiveFields = this._generateSensitiveFieldFilter('v_output');
|
|
77
|
+
const m2mVariables = this._generateM2MVariableDeclarations();
|
|
78
|
+
const m2mExtraction = this._generateM2MExtraction();
|
|
79
|
+
const m2mSync = this._generateM2MSync();
|
|
80
|
+
const m2mExpansion = this._generateM2MExpansion();
|
|
75
81
|
|
|
76
82
|
return `-- SAVE operation for ${this.tableName}
|
|
77
83
|
CREATE OR REPLACE FUNCTION save_${this.tableName}(
|
|
@@ -84,7 +90,9 @@ DECLARE
|
|
|
84
90
|
v_output JSONB;
|
|
85
91
|
v_is_insert BOOLEAN := false;
|
|
86
92
|
v_notify_users INT[];
|
|
93
|
+
${m2mVariables}
|
|
87
94
|
BEGIN
|
|
95
|
+
${m2mExtraction}
|
|
88
96
|
-- Determine if this is insert or update
|
|
89
97
|
IF p_data->>'id' IS NULL THEN
|
|
90
98
|
v_is_insert := true;
|
|
@@ -120,23 +128,32 @@ BEGIN
|
|
|
120
128
|
FROM jsonb_each_text(p_data) kv(key, value)
|
|
121
129
|
) INTO v_result;
|
|
122
130
|
ELSE
|
|
123
|
-
-- Dynamic UPDATE from JSONB
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
-- Dynamic UPDATE from JSONB (only if there are fields to update)
|
|
132
|
+
IF (SELECT COUNT(*) FROM jsonb_object_keys(p_data) WHERE jsonb_object_keys != 'id') > 0 THEN
|
|
133
|
+
EXECUTE (
|
|
134
|
+
SELECT format(
|
|
135
|
+
'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
|
|
136
|
+
string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
|
|
137
|
+
(p_data->>'id')::int
|
|
138
|
+
)
|
|
139
|
+
FROM jsonb_each_text(p_data) kv(key, value)
|
|
140
|
+
WHERE key != 'id'
|
|
141
|
+
) INTO v_result;
|
|
142
|
+
ELSE
|
|
143
|
+
-- No fields to update (only M2M fields were provided), just fetch existing
|
|
144
|
+
v_result := v_existing;
|
|
145
|
+
END IF;
|
|
133
146
|
END IF;
|
|
134
147
|
|
|
148
|
+
${m2mSync}
|
|
149
|
+
|
|
150
|
+
-- Prepare output with M2M fields (BEFORE event creation for real-time notifications!)
|
|
151
|
+
v_output := to_jsonb(v_result);
|
|
152
|
+
${m2mExpansion}
|
|
153
|
+
|
|
135
154
|
${graphRulesCall}
|
|
136
155
|
${notificationSQL}
|
|
137
156
|
|
|
138
|
-
-- Prepare output (removing sensitive fields)
|
|
139
|
-
v_output := to_jsonb(v_result);
|
|
140
157
|
${filterSensitiveFields}
|
|
141
158
|
|
|
142
159
|
RETURN v_output;
|
|
@@ -233,6 +250,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
233
250
|
`${field} ILIKE '%' || p_search || '%'`
|
|
234
251
|
).join(' OR ');
|
|
235
252
|
const filterSensitiveFieldsArray = this._generateSensitiveFieldFilterArray();
|
|
253
|
+
const m2mSearchExpansion = this._generateM2MExpansionForSearch();
|
|
236
254
|
|
|
237
255
|
return `-- SEARCH operation for ${this.tableName}
|
|
238
256
|
CREATE OR REPLACE FUNCTION search_${this.tableName}(
|
|
@@ -311,8 +329,8 @@ BEGIN
|
|
|
311
329
|
|
|
312
330
|
-- Get data
|
|
313
331
|
EXECUTE format('
|
|
314
|
-
SELECT COALESCE(jsonb_agg(
|
|
315
|
-
FROM ${this.tableName} t
|
|
332
|
+
SELECT COALESCE(jsonb_agg(${m2mSearchExpansion.selectExpression} ORDER BY %I %s), ''[]''::jsonb)
|
|
333
|
+
FROM ${this.tableName} t${m2mSearchExpansion.lateralJoins}
|
|
316
334
|
WHERE %s
|
|
317
335
|
LIMIT %L OFFSET %L
|
|
318
336
|
', v_sort_field, v_sort_order, v_where_clause, p_limit, v_offset) INTO v_data;
|
|
@@ -329,6 +347,251 @@ END;
|
|
|
329
347
|
$$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
330
348
|
}
|
|
331
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Generate M2M variable declarations
|
|
352
|
+
* COMPILE TIME: Loop to generate static variable declarations
|
|
353
|
+
* RUNTIME: No loops, just variables
|
|
354
|
+
* @private
|
|
355
|
+
*/
|
|
356
|
+
_generateM2MVariableDeclarations() {
|
|
357
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
358
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
359
|
+
|
|
360
|
+
const declarations = [];
|
|
361
|
+
|
|
362
|
+
// COMPILE TIME LOOP: Generate separate variable for each M2M relationship
|
|
363
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
364
|
+
const idField = config.id_field;
|
|
365
|
+
declarations.push(` v_${idField} INT[]; -- M2M: ${relationKey}`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return declarations.join('\n');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Generate M2M extraction logic
|
|
373
|
+
* COMPILE TIME: Loop to generate code
|
|
374
|
+
* RUNTIME: Separate IF blocks (NO loops!)
|
|
375
|
+
* @private
|
|
376
|
+
*/
|
|
377
|
+
_generateM2MExtraction() {
|
|
378
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
379
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
380
|
+
|
|
381
|
+
const extractions = [];
|
|
382
|
+
|
|
383
|
+
// COMPILE TIME LOOP: Generate separate extraction block for each M2M
|
|
384
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
385
|
+
const idField = config.id_field;
|
|
386
|
+
|
|
387
|
+
// Each M2M gets its own static IF block (no runtime loops!)
|
|
388
|
+
extractions.push(`
|
|
389
|
+
-- Extract M2M field: ${idField} (${relationKey})
|
|
390
|
+
IF p_data ? '${idField}' THEN
|
|
391
|
+
v_${idField} := ARRAY(SELECT jsonb_array_elements_text(p_data->'${idField}')::int);
|
|
392
|
+
p_data := p_data - '${idField}'; -- Remove from data (not a table column)
|
|
393
|
+
END IF;`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return extractions.join('');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Generate M2M junction table sync logic
|
|
401
|
+
* COMPILE TIME: Loop to generate code
|
|
402
|
+
* RUNTIME: Direct SQL execution (NO loops!)
|
|
403
|
+
* @private
|
|
404
|
+
*/
|
|
405
|
+
_generateM2MSync() {
|
|
406
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
407
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
408
|
+
|
|
409
|
+
const syncs = [];
|
|
410
|
+
|
|
411
|
+
// COMPILE TIME LOOP: Generate separate sync block for EACH relationship
|
|
412
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
413
|
+
const idField = config.id_field;
|
|
414
|
+
const junctionTable = config.junction_table;
|
|
415
|
+
const localKey = config.local_key;
|
|
416
|
+
const foreignKey = config.foreign_key;
|
|
417
|
+
|
|
418
|
+
// Static SQL - all names known at compile time!
|
|
419
|
+
syncs.push(`
|
|
420
|
+
-- ============================================================================
|
|
421
|
+
-- M2M Sync: ${relationKey} (junction: ${junctionTable})
|
|
422
|
+
-- ============================================================================
|
|
423
|
+
IF v_${idField} IS NOT NULL THEN
|
|
424
|
+
-- Delete relationships not in new list
|
|
425
|
+
DELETE FROM ${junctionTable}
|
|
426
|
+
WHERE ${localKey} = v_result.id
|
|
427
|
+
AND (${foreignKey} <> ALL(v_${idField}) OR v_${idField} = '{}');
|
|
428
|
+
|
|
429
|
+
-- Insert new relationships (idempotent)
|
|
430
|
+
IF array_length(v_${idField}, 1) > 0 THEN
|
|
431
|
+
INSERT INTO ${junctionTable} (${localKey}, ${foreignKey})
|
|
432
|
+
SELECT v_result.id, unnest(v_${idField})
|
|
433
|
+
ON CONFLICT (${localKey}, ${foreignKey}) DO NOTHING;
|
|
434
|
+
END IF;
|
|
435
|
+
END IF;`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return syncs.join('');
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Generate M2M expansion in output (for SAVE function)
|
|
443
|
+
* COMPILE TIME: Loop to generate code
|
|
444
|
+
* RUNTIME: Direct SQL queries (NO loops!)
|
|
445
|
+
* Expands M2M fields into v_output BEFORE event creation (for real-time notifications)
|
|
446
|
+
* @private
|
|
447
|
+
*/
|
|
448
|
+
_generateM2MExpansion() {
|
|
449
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
450
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
451
|
+
|
|
452
|
+
const expansions = [];
|
|
453
|
+
|
|
454
|
+
// COMPILE TIME LOOP: Generate code for each M2M relationship
|
|
455
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
456
|
+
const idField = config.id_field;
|
|
457
|
+
const junctionTable = config.junction_table;
|
|
458
|
+
const localKey = config.local_key;
|
|
459
|
+
const foreignKey = config.foreign_key;
|
|
460
|
+
const targetEntity = config.target_entity;
|
|
461
|
+
const expand = config.expand || false;
|
|
462
|
+
|
|
463
|
+
// Always add ID array (static SQL) - use v_result.id since v_output is v_result as jsonb
|
|
464
|
+
expansions.push(`
|
|
465
|
+
-- Add M2M IDs: ${idField}
|
|
466
|
+
v_output := v_output || jsonb_build_object('${idField}',
|
|
467
|
+
(SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
|
|
468
|
+
FROM ${junctionTable} WHERE ${localKey} = v_result.id)
|
|
469
|
+
);`);
|
|
470
|
+
|
|
471
|
+
// Conditionally expand full objects (known at compile time!)
|
|
472
|
+
if (expand) {
|
|
473
|
+
expansions.push(`
|
|
474
|
+
-- Expand M2M objects: ${relationKey} (expand=true)
|
|
475
|
+
v_output := v_output || jsonb_build_object('${relationKey}',
|
|
476
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
477
|
+
FROM ${junctionTable} jt
|
|
478
|
+
JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
|
|
479
|
+
WHERE jt.${localKey} = v_result.id)
|
|
480
|
+
);`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return expansions.join('');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Generate M2M expansion for SEARCH operation
|
|
489
|
+
* COMPILE TIME: Loop to generate LATERAL joins
|
|
490
|
+
* RUNTIME: Static joins (NO loops!)
|
|
491
|
+
* @private
|
|
492
|
+
*/
|
|
493
|
+
_generateM2MExpansionForSearch() {
|
|
494
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
495
|
+
|
|
496
|
+
if (Object.keys(manyToMany).length === 0) {
|
|
497
|
+
return {
|
|
498
|
+
lateralJoins: '',
|
|
499
|
+
selectExpression: 'to_jsonb(t.*)'
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const lateralJoins = [];
|
|
504
|
+
const mergeExpressions = [];
|
|
505
|
+
|
|
506
|
+
// COMPILE TIME LOOP: Generate LATERAL join for each M2M relationship
|
|
507
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
508
|
+
const idField = config.id_field;
|
|
509
|
+
const junctionTable = config.junction_table;
|
|
510
|
+
const localKey = config.local_key;
|
|
511
|
+
const foreignKey = config.foreign_key;
|
|
512
|
+
const targetEntity = config.target_entity;
|
|
513
|
+
const expand = config.expand || false;
|
|
514
|
+
|
|
515
|
+
// LATERAL join for ID array (static SQL)
|
|
516
|
+
lateralJoins.push(`
|
|
517
|
+
LEFT JOIN LATERAL (
|
|
518
|
+
SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), ''[]''::jsonb) as ${idField}
|
|
519
|
+
FROM ${junctionTable}
|
|
520
|
+
WHERE ${localKey} = t.id
|
|
521
|
+
) m2m_${idField} ON true`);
|
|
522
|
+
|
|
523
|
+
mergeExpressions.push(`jsonb_build_object(''${idField}'', m2m_${idField}.${idField})`);
|
|
524
|
+
|
|
525
|
+
// Optionally expand full objects
|
|
526
|
+
if (expand) {
|
|
527
|
+
lateralJoins.push(`
|
|
528
|
+
LEFT JOIN LATERAL (
|
|
529
|
+
SELECT COALESCE(jsonb_agg(to_jsonb(target.*) ORDER BY target.id), ''[]''::jsonb) as ${relationKey}
|
|
530
|
+
FROM ${junctionTable} jt
|
|
531
|
+
JOIN ${targetEntity} target ON target.id = jt.${foreignKey}
|
|
532
|
+
WHERE jt.${localKey} = t.id
|
|
533
|
+
) m2m_${relationKey} ON true`);
|
|
534
|
+
|
|
535
|
+
mergeExpressions.push(`jsonb_build_object(''${relationKey}'', m2m_${relationKey}.${relationKey})`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Build the select expression that merges M2M fields
|
|
540
|
+
const selectExpression = mergeExpressions.length > 0
|
|
541
|
+
? `to_jsonb(t.*) || ${mergeExpressions.join(' || ')}`
|
|
542
|
+
: 'to_jsonb(t.*)';
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
lateralJoins: lateralJoins.join(''),
|
|
546
|
+
selectExpression
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Generate M2M expansion for GET operation
|
|
552
|
+
* COMPILE TIME: Loop to generate code
|
|
553
|
+
* RUNTIME: Direct SQL queries (NO loops!)
|
|
554
|
+
* @private
|
|
555
|
+
*/
|
|
556
|
+
_generateM2MExpansionForGet() {
|
|
557
|
+
const manyToMany = this.entity.manyToMany || {};
|
|
558
|
+
if (Object.keys(manyToMany).length === 0) return '';
|
|
559
|
+
|
|
560
|
+
const expansions = [];
|
|
561
|
+
|
|
562
|
+
// COMPILE TIME LOOP: Generate code for each M2M relationship
|
|
563
|
+
for (const [relationKey, config] of Object.entries(manyToMany)) {
|
|
564
|
+
const idField = config.id_field;
|
|
565
|
+
const junctionTable = config.junction_table;
|
|
566
|
+
const localKey = config.local_key;
|
|
567
|
+
const foreignKey = config.foreign_key;
|
|
568
|
+
const targetEntity = config.target_entity;
|
|
569
|
+
const expand = config.expand || false;
|
|
570
|
+
|
|
571
|
+
// Always add ID array (static SQL)
|
|
572
|
+
expansions.push(`
|
|
573
|
+
-- Add M2M IDs: ${idField}
|
|
574
|
+
v_result := v_result || jsonb_build_object('${idField}',
|
|
575
|
+
(SELECT COALESCE(jsonb_agg(${foreignKey} ORDER BY ${foreignKey}), '[]'::jsonb)
|
|
576
|
+
FROM ${junctionTable} WHERE ${localKey} = v_record.id)
|
|
577
|
+
);`);
|
|
578
|
+
|
|
579
|
+
// Conditionally expand full objects (known at compile time!)
|
|
580
|
+
if (expand) {
|
|
581
|
+
expansions.push(`
|
|
582
|
+
-- Expand M2M objects: ${relationKey} (expand=true)
|
|
583
|
+
v_result := v_result || jsonb_build_object('${relationKey}',
|
|
584
|
+
(SELECT COALESCE(jsonb_agg(to_jsonb(t.*) ORDER BY t.id), '[]'::jsonb)
|
|
585
|
+
FROM ${junctionTable} jt
|
|
586
|
+
JOIN ${targetEntity} t ON t.id = jt.${foreignKey}
|
|
587
|
+
WHERE jt.${localKey} = v_record.id)
|
|
588
|
+
);`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return expansions.join('');
|
|
593
|
+
}
|
|
594
|
+
|
|
332
595
|
/**
|
|
333
596
|
* Generate FK expansions for GET
|
|
334
597
|
* @private
|
|
@@ -460,10 +723,10 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
460
723
|
|
|
461
724
|
if (operation === 'save') {
|
|
462
725
|
return `
|
|
463
|
-
-- Resolve notification recipients
|
|
464
|
-
${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id,
|
|
726
|
+
-- Resolve notification recipients (use v_output with M2M fields!)
|
|
727
|
+
${hasNotificationPaths ? `v_notify_users := _resolve_notification_paths_${this.tableName}(p_user_id, v_output);` : 'v_notify_users := ARRAY[]::INT[];'}
|
|
465
728
|
|
|
466
|
-
-- Create event for real-time notifications
|
|
729
|
+
-- Create event for real-time notifications (v_output includes M2M fields!)
|
|
467
730
|
INSERT INTO dzql.events (
|
|
468
731
|
table_name,
|
|
469
732
|
op,
|
|
@@ -477,7 +740,7 @@ $$ LANGUAGE plpgsql SECURITY DEFINER;`;
|
|
|
477
740
|
CASE WHEN v_is_insert THEN 'insert' ELSE 'update' END,
|
|
478
741
|
jsonb_build_object('id', v_result.id),
|
|
479
742
|
CASE WHEN NOT v_is_insert THEN to_jsonb(v_existing) ELSE NULL END,
|
|
480
|
-
|
|
743
|
+
v_output,
|
|
481
744
|
p_user_id,
|
|
482
745
|
v_notify_users
|
|
483
746
|
);`;
|