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.0",
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.0...'"
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
- EXECUTE (
125
- SELECT format(
126
- 'UPDATE ${this.tableName} SET %s WHERE id = %L RETURNING *',
127
- string_agg(quote_ident(key) || ' = ' || quote_nullable(value), ', '),
128
- (p_data->>'id')::int
129
- )
130
- FROM jsonb_each_text(p_data) kv(key, value)
131
- WHERE key != 'id'
132
- ) INTO v_result;
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(to_jsonb(t.*) ORDER BY %I %s), ''[]''::jsonb)
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, to_jsonb(v_result));` : 'v_notify_users := ARRAY[]::INT[];'}
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
- to_jsonb(v_result),
743
+ v_output,
481
744
  p_user_id,
482
745
  v_notify_users
483
746
  );`;