dzql 0.3.0 → 0.3.2

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.
@@ -46,17 +46,11 @@ SELECT dzql.register_entity(
46
46
  array['title', 'description'], -- Searchable fields
47
47
  '{}'::jsonb, -- FK includes
48
48
  false, -- Soft delete
49
- '{}'::jsonb, -- Graph rules
50
- jsonb_build_object( -- Notification paths
51
- 'owner', array['@user_id']
52
- ),
53
- jsonb_build_object( -- Permission paths
54
- 'view', array['@user_id'],
55
- 'create', array['@user_id'],
56
- 'update', array['@user_id'],
57
- 'delete', array['@user_id']
58
- ),
59
- '{}'::jsonb -- Temporal config
49
+ '{}'::jsonb, -- Temporal config
50
+ '{}'::jsonb, -- Notification paths
51
+ '{}'::jsonb, -- Permission paths
52
+ '{}'::jsonb, -- Graph rules (including M2M)
53
+ '{}'::jsonb -- Field defaults
60
54
  );
61
55
  ```
62
56
 
@@ -67,6 +61,51 @@ This generates 5 PostgreSQL functions:
67
61
  - `lookup_todos(params, user_id)` - Autocomplete
68
62
  - `search_todos(params, user_id)` - Search with filters
69
63
 
64
+ ## Compiler Features (v0.3.1+)
65
+
66
+ The compiler generates **static, optimized SQL** with zero runtime interpretation:
67
+
68
+ ### Many-to-Many Relationships
69
+ ```sql
70
+ SELECT dzql.register_entity(
71
+ 'brands', 'name', ARRAY['name'],
72
+ '{}', false, '{}', '{}', '{}',
73
+ '{
74
+ "many_to_many": {
75
+ "tags": {
76
+ "junction_table": "brand_tags",
77
+ "local_key": "brand_id",
78
+ "foreign_key": "tag_id",
79
+ "target_entity": "tags",
80
+ "id_field": "tag_ids",
81
+ "expand": false
82
+ }
83
+ }
84
+ }',
85
+ '{}'
86
+ );
87
+ ```
88
+
89
+ **Generated code:** Static M2M sync blocks (50-100x faster than generic operations)
90
+ - No runtime loops
91
+ - All table/column names are literals
92
+ - PostgreSQL can fully optimize and cache plans
93
+
94
+ See [Many-to-Many Guide](../guides/many-to-many.md) for details.
95
+
96
+ ### Field Defaults
97
+ ```sql
98
+ '{
99
+ "owner_id": "@user_id",
100
+ "created_at": "@now",
101
+ "status": "draft"
102
+ }'
103
+ ```
104
+
105
+ **Generated code:** Auto-populates fields on INSERT
106
+
107
+ See [Field Defaults Guide](../guides/field-defaults.md) for details.
108
+
70
109
  ## Architecture
71
110
 
72
111
  The compiler uses a three-phase approach:
@@ -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