dzql 0.2.2 → 0.3.0

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,894 @@
1
+ # Many-to-Many Relationships
2
+
3
+ First-class support for many-to-many relationships with automatic junction table management.
4
+
5
+ ## Overview
6
+
7
+ DZQL now provides built-in support for many-to-many (M2M) relationships through junction tables. Define the relationship once in your entity configuration, and DZQL handles:
8
+
9
+ - Junction table synchronization
10
+ - Atomic updates in single API calls
11
+ - Automatic expansion in get/search operations
12
+ - Real-time broadcasts with complete state
13
+
14
+ ## Benefits
15
+
16
+ - **Single API Call** - No more N+1 calls for relationship management
17
+ - **Atomic Operations** - All changes in one transaction
18
+ - **Consistent API** - Works like regular fields
19
+ - **Less Boilerplate** - No custom toggle functions needed
20
+ - **Performance Control** - Optional expansion (off by default)
21
+
22
+ ## Quick Example
23
+
24
+ ### Setup
25
+
26
+ ```sql
27
+ -- Create tables
28
+ CREATE TABLE brands (
29
+ id serial PRIMARY KEY,
30
+ org_id integer REFERENCES organisations(id),
31
+ name text NOT NULL
32
+ );
33
+
34
+ CREATE TABLE tags (
35
+ id serial PRIMARY KEY,
36
+ name text NOT NULL UNIQUE,
37
+ color text
38
+ );
39
+
40
+ -- Junction table
41
+ CREATE TABLE brand_tags (
42
+ brand_id integer REFERENCES brands(id) ON DELETE CASCADE,
43
+ tag_id integer REFERENCES tags(id) ON DELETE CASCADE,
44
+ PRIMARY KEY (brand_id, tag_id)
45
+ );
46
+
47
+ -- Register with M2M support
48
+ SELECT dzql.register_entity(
49
+ 'brands',
50
+ 'name',
51
+ ARRAY['name'],
52
+ '{}', false, '{}', '{}', '{}',
53
+ '{
54
+ "many_to_many": {
55
+ "tags": {
56
+ "junction_table": "brand_tags",
57
+ "local_key": "brand_id",
58
+ "foreign_key": "tag_id",
59
+ "target_entity": "tags",
60
+ "id_field": "tag_ids",
61
+ "expand": false
62
+ }
63
+ }
64
+ }',
65
+ '{}'
66
+ );
67
+ ```
68
+
69
+ ### Client Usage
70
+
71
+ ```javascript
72
+ // Create brand with tags in one call
73
+ const brand = await api.save_brands({
74
+ data: {
75
+ name: "Premium Brand",
76
+ org_id: 1,
77
+ tag_ids: [1, 2, 3] // Junction table synced automatically!
78
+ }
79
+ })
80
+
81
+ // Response includes tag IDs
82
+ console.log(brand.tag_ids) // [1, 2, 3]
83
+
84
+ // Get brand - tag_ids always included
85
+ const retrieved = await api.get_brands({ id: brand.id })
86
+ console.log(retrieved.tag_ids) // [1, 2, 3]
87
+
88
+ // Update tags - single atomic operation
89
+ await api.save_brands({
90
+ data: {
91
+ id: brand.id,
92
+ tag_ids: [2, 3, 4] // Removes 1, keeps 2&3, adds 4
93
+ }
94
+ })
95
+
96
+ // Remove all tags
97
+ await api.save_brands({
98
+ data: {
99
+ id: brand.id,
100
+ tag_ids: [] // Clears all tags
101
+ }
102
+ })
103
+ ```
104
+
105
+ ## Configuration
106
+
107
+ M2M relationships are configured in the `graph_rules` parameter (9th parameter) of `register_entity()`:
108
+
109
+ ```sql
110
+ SELECT dzql.register_entity(
111
+ 'table_name',
112
+ 'label_field',
113
+ ARRAY['searchable_fields'],
114
+ '{}', -- fk_includes
115
+ false, -- soft_delete
116
+ '{}', -- temporal_fields
117
+ '{}', -- notification_paths
118
+ '{}', -- permission_paths
119
+ '{
120
+ "many_to_many": {
121
+ "relationship_name": {
122
+ "junction_table": "table_name",
123
+ "local_key": "local_fk_column",
124
+ "foreign_key": "foreign_fk_column",
125
+ "target_entity": "target_table",
126
+ "id_field": "field_name_for_ids",
127
+ "expand": false
128
+ }
129
+ }
130
+ }',
131
+ '{}' -- field_defaults
132
+ );
133
+ ```
134
+
135
+ ### Configuration Options
136
+
137
+ | Option | Required | Description | Example |
138
+ |--------|----------|-------------|---------|
139
+ | `junction_table` | Yes | Name of junction table | `"brand_tags"` |
140
+ | `local_key` | Yes | FK column pointing to this entity | `"brand_id"` |
141
+ | `foreign_key` | Yes | FK column pointing to target entity | `"tag_id"` |
142
+ | `target_entity` | Yes | Name of target entity table | `"tags"` |
143
+ | `id_field` | Yes | Field name for ID array in API | `"tag_ids"` |
144
+ | `expand` | No | Include full objects (default: false) | `false` or `true` |
145
+
146
+ ### The `expand` Flag
147
+
148
+ Controls whether full related objects are included in responses:
149
+
150
+ **`expand: false`** (default - recommended for performance):
151
+ ```javascript
152
+ {
153
+ id: 1,
154
+ name: "My Brand",
155
+ tag_ids: [1, 2, 3] // Just IDs
156
+ }
157
+ ```
158
+
159
+ **`expand: true`** (for detail views):
160
+ ```javascript
161
+ {
162
+ id: 1,
163
+ name: "My Brand",
164
+ tag_ids: [1, 2, 3], // IDs included
165
+ tags: [ // Full objects included
166
+ { id: 1, name: "Premium", color: "#FFD700" },
167
+ { id: 2, name: "Popular", color: "#3B82F6" },
168
+ { id: 3, name: "New", color: "#8B5CF6" }
169
+ ]
170
+ }
171
+ ```
172
+
173
+ **Performance Impact:**
174
+ - `expand: false` - Single query for ID array (fast)
175
+ - `expand: true` - Additional JOIN per relationship (slower)
176
+
177
+ **Recommendation:** Use `false` for list views, `true` for detail views (or fetch tags separately when needed).
178
+
179
+ ## Junction Table Requirements
180
+
181
+ Your junction table must have:
182
+
183
+ 1. **Two foreign key columns** pointing to the related tables
184
+ 2. **Composite primary key** or unique constraint on both columns
185
+ 3. **ON DELETE CASCADE** (recommended) for automatic cleanup
186
+
187
+ ```sql
188
+ CREATE TABLE brand_tags (
189
+ brand_id integer NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
190
+ tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
191
+ PRIMARY KEY (brand_id, tag_id) -- Composite PK prevents duplicates
192
+ );
193
+
194
+ -- Index for reverse lookups (optional but recommended)
195
+ CREATE INDEX idx_brand_tags_tag_id ON brand_tags(tag_id);
196
+ ```
197
+
198
+ ## API Operations
199
+
200
+ ### Create with Relationships
201
+
202
+ ```javascript
203
+ // Create brand with tags in single call
204
+ const brand = await api.save_brands({
205
+ data: {
206
+ name: "New Brand",
207
+ tag_ids: [1, 2, 3]
208
+ }
209
+ })
210
+
211
+ // Response
212
+ {
213
+ id: 5,
214
+ name: "New Brand",
215
+ tag_ids: [1, 2, 3]
216
+ }
217
+ ```
218
+
219
+ ### Update Relationships
220
+
221
+ ```javascript
222
+ // Add and remove tags atomically
223
+ await api.save_brands({
224
+ data: {
225
+ id: 5,
226
+ tag_ids: [2, 3, 4, 5] // Remove 1, keep 2&3, add 4&5
227
+ }
228
+ })
229
+ ```
230
+
231
+ ### Remove All Relationships
232
+
233
+ ```javascript
234
+ // Empty array removes all
235
+ await api.save_brands({
236
+ data: {
237
+ id: 5,
238
+ tag_ids: [] // Clears all tags
239
+ }
240
+ })
241
+ ```
242
+
243
+ ### Leave Relationships Unchanged
244
+
245
+ ```javascript
246
+ // Omit the field to not touch relationships
247
+ await api.save_brands({
248
+ data: {
249
+ id: 5,
250
+ name: "Updated Name"
251
+ // tag_ids not included - tags unchanged
252
+ }
253
+ })
254
+ ```
255
+
256
+ ### Get with Relationships
257
+
258
+ ```javascript
259
+ // Get always includes tag_ids
260
+ const brand = await api.get_brands({ id: 5 })
261
+
262
+ console.log(brand.tag_ids) // [2, 3, 4, 5]
263
+
264
+ // If expand: true in config
265
+ console.log(brand.tags) // [{id: 2, ...}, {id: 3, ...}, ...]
266
+ ```
267
+
268
+ ### Search with Relationships
269
+
270
+ ```javascript
271
+ // Search includes tag_ids for each result
272
+ const results = await api.search_brands({ limit: 10 })
273
+
274
+ results.data.forEach(brand => {
275
+ console.log(brand.tag_ids) // Array of IDs
276
+ })
277
+ ```
278
+
279
+ ## Multiple M2M Relationships
280
+
281
+ You can define multiple M2M relationships on a single entity:
282
+
283
+ ```sql
284
+ SELECT dzql.register_entity(
285
+ 'resources',
286
+ 'title',
287
+ ARRAY['title'],
288
+ '{}', false, '{}', '{}', '{}',
289
+ '{
290
+ "many_to_many": {
291
+ "tags": {
292
+ "junction_table": "resource_tags",
293
+ "local_key": "resource_id",
294
+ "foreign_key": "tag_id",
295
+ "target_entity": "tags",
296
+ "id_field": "tag_ids",
297
+ "expand": false
298
+ },
299
+ "collaborators": {
300
+ "junction_table": "resource_collaborators",
301
+ "local_key": "resource_id",
302
+ "foreign_key": "user_id",
303
+ "target_entity": "users",
304
+ "id_field": "collaborator_ids",
305
+ "expand": true
306
+ },
307
+ "categories": {
308
+ "junction_table": "resource_categories",
309
+ "local_key": "resource_id",
310
+ "foreign_key": "category_id",
311
+ "target_entity": "categories",
312
+ "id_field": "category_ids",
313
+ "expand": false
314
+ }
315
+ }
316
+ }',
317
+ '{}'
318
+ );
319
+ ```
320
+
321
+ Client usage:
322
+
323
+ ```javascript
324
+ await api.save_resources({
325
+ data: {
326
+ title: "My Resource",
327
+ tag_ids: [1, 2],
328
+ collaborator_ids: [10, 20],
329
+ category_ids: [5]
330
+ }
331
+ })
332
+
333
+ // All three relationships synced atomically!
334
+ ```
335
+
336
+ ## Implementation Details
337
+
338
+ ### How Junction Sync Works
339
+
340
+ When you call `save_entity()` with an M2M ID field:
341
+
342
+ 1. **INSERT/UPDATE** the main entity
343
+ 2. **DELETE** relationships not in new list
344
+ 3. **INSERT** new relationships (ON CONFLICT DO NOTHING)
345
+ 4. **QUERY** final state and return with ID arrays
346
+
347
+ All in a single transaction - atomic!
348
+
349
+ ### SQL Generated
350
+
351
+ For `save_brands()` with `tag_ids: [1, 2, 3]`:
352
+
353
+ ```sql
354
+ -- 1. Insert/update brand
355
+ INSERT INTO brands (...) VALUES (...);
356
+
357
+ -- 2. Delete tags not in [1,2,3]
358
+ DELETE FROM brand_tags
359
+ WHERE brand_id = 5
360
+ AND tag_id <> ALL(ARRAY[1,2,3]);
361
+
362
+ -- 3. Insert new tags
363
+ INSERT INTO brand_tags (brand_id, tag_id)
364
+ VALUES (5, 1), (5, 2), (5, 3)
365
+ ON CONFLICT DO NOTHING;
366
+
367
+ -- 4. Fetch final state
368
+ SELECT jsonb_agg(tag_id) FROM brand_tags WHERE brand_id = 5;
369
+ ```
370
+
371
+ ### Null Handling
372
+
373
+ | Input | Behavior |
374
+ |-------|----------|
375
+ | `tag_ids: [1, 2]` | Sync to exactly [1, 2] |
376
+ | `tag_ids: []` | Remove all relationships |
377
+ | `tag_ids: null` | Leave unchanged (same as omitted) |
378
+ | Field omitted | Leave unchanged |
379
+
380
+ ## Comparison to Manual Approach
381
+
382
+ ### Before DZQL M2M Support
383
+
384
+ **Database:**
385
+ ```sql
386
+ -- Custom toggle function (40+ lines)
387
+ CREATE FUNCTION toggle_resource_tag(
388
+ p_user_id INT,
389
+ p_resource_id INT,
390
+ p_tag_id INT
391
+ ) RETURNS JSONB AS $$ ... $$;
392
+ ```
393
+
394
+ **Client:**
395
+ ```javascript
396
+ // 1. Save resource
397
+ const resource = await api.save_resources({
398
+ data: { title: "Room A" }
399
+ })
400
+
401
+ // 2. Calculate delta
402
+ const toAdd = [1, 2, 3]
403
+ const toRemove = [4]
404
+
405
+ // 3. Make N API calls
406
+ for (const tagId of toAdd) {
407
+ await api.toggle_resource_tag({
408
+ p_resource_id: resource.id,
409
+ p_tag_id: tagId
410
+ })
411
+ }
412
+ ```
413
+
414
+ **Issues:**
415
+ - N+1 API calls
416
+ - Not atomic
417
+ - Custom function required
418
+ - Verbose client code
419
+
420
+ ### After DZQL M2M Support
421
+
422
+ **Database:**
423
+ ```sql
424
+ -- Just entity registration
425
+ SELECT dzql.register_entity(
426
+ 'resources',
427
+ ...,
428
+ '{"many_to_many": {"tags": {...}}}'
429
+ );
430
+ ```
431
+
432
+ **Client:**
433
+ ```javascript
434
+ // Single atomic call
435
+ const resource = await api.save_resources({
436
+ data: {
437
+ title: "Room A",
438
+ tag_ids: [1, 2, 3]
439
+ }
440
+ })
441
+ ```
442
+
443
+ **Benefits:**
444
+ - 1 API call
445
+ - Atomic
446
+ - No custom function
447
+ - Clean code
448
+
449
+ ## Common Patterns
450
+
451
+ ### Tags
452
+
453
+ ```sql
454
+ "tags": {
455
+ "junction_table": "resource_tags",
456
+ "local_key": "resource_id",
457
+ "foreign_key": "tag_id",
458
+ "target_entity": "tags",
459
+ "id_field": "tag_ids",
460
+ "expand": false
461
+ }
462
+ ```
463
+
464
+ ### Collaborators/Team Members
465
+
466
+ ```sql
467
+ "collaborators": {
468
+ "junction_table": "project_collaborators",
469
+ "local_key": "project_id",
470
+ "foreign_key": "user_id",
471
+ "target_entity": "users",
472
+ "id_field": "collaborator_ids",
473
+ "expand": true // Probably want full user objects
474
+ }
475
+ ```
476
+
477
+ ### Categories/Taxonomies
478
+
479
+ ```sql
480
+ "categories": {
481
+ "junction_table": "item_categories",
482
+ "local_key": "item_id",
483
+ "foreign_key": "category_id",
484
+ "target_entity": "categories",
485
+ "id_field": "category_ids",
486
+ "expand": false
487
+ }
488
+ ```
489
+
490
+ ### Permissions/Roles
491
+
492
+ ```sql
493
+ "roles": {
494
+ "junction_table": "user_roles",
495
+ "local_key": "user_id",
496
+ "foreign_key": "role_id",
497
+ "target_entity": "roles",
498
+ "id_field": "role_ids",
499
+ "expand": true
500
+ }
501
+ ```
502
+
503
+ ## Advanced Usage
504
+
505
+ ### Fetching Tags Separately
506
+
507
+ When using `expand: false`, fetch related entities when needed:
508
+
509
+ ```javascript
510
+ // Get brands (with tag IDs only)
511
+ const brands = await api.search_brands({ limit: 10 })
512
+
513
+ // For a specific brand, fetch full tag details
514
+ const brand = brands.data[0]
515
+ const tags = await api.search_tags({
516
+ p_filters: { id: { in: brand.tag_ids } }
517
+ })
518
+ ```
519
+
520
+ ### Filtering by M2M Relationships
521
+
522
+ To find all brands with a specific tag:
523
+
524
+ ```javascript
525
+ // Custom SQL or use raw query
526
+ const brandsWithTag = await sql`
527
+ SELECT b.*
528
+ FROM brands b
529
+ JOIN brand_tags bt ON bt.brand_id = b.id
530
+ WHERE bt.tag_id = 5
531
+ `
532
+
533
+ // Or create a custom function for this pattern
534
+ ```
535
+
536
+ ### Junction Tables with Extra Fields
537
+
538
+ DZQL's M2M currently supports simple junction tables. For junction tables with additional fields (e.g., `position`, `added_at`):
539
+
540
+ **Option 1:** Model junction as entity
541
+ ```sql
542
+ -- Register the junction table as its own entity
543
+ SELECT dzql.register_entity(
544
+ 'resource_tags',
545
+ 'id',
546
+ ARRAY[],
547
+ '{
548
+ "resource": "resources",
549
+ "tag": "tags"
550
+ }',
551
+ ...
552
+ );
553
+
554
+ -- Then use regular FK relationships
555
+ ```
556
+
557
+ **Option 2:** Use custom function
558
+ ```sql
559
+ CREATE FUNCTION add_tag_with_position(
560
+ p_user_id INT,
561
+ p_resource_id INT,
562
+ p_tag_id INT,
563
+ p_position INT
564
+ ) RETURNS JSONB AS $$ ... $$;
565
+ ```
566
+
567
+ ## Permissions
568
+
569
+ M2M operations respect the entity's update permissions:
570
+
571
+ ```sql
572
+ SELECT dzql.register_entity(
573
+ 'resources',
574
+ 'title',
575
+ ARRAY['title'],
576
+ '{}', false, '{}', '{}',
577
+ '{
578
+ "view": [],
579
+ "create": [],
580
+ "update": ["@owner_id"], -- User must own resource to change tags
581
+ "delete": ["@owner_id"]
582
+ }',
583
+ '{
584
+ "many_to_many": {
585
+ "tags": { ... }
586
+ }
587
+ }'
588
+ );
589
+ ```
590
+
591
+ If a user can update the resource, they can change its tags. No separate permission check for M2M.
592
+
593
+ ## Error Handling
594
+
595
+ ### Non-existent IDs
596
+
597
+ If you provide tag IDs that don't exist:
598
+
599
+ ```javascript
600
+ await api.save_brands({
601
+ data: {
602
+ id: 1,
603
+ tag_ids: [1, 999, 3] // 999 doesn't exist
604
+ }
605
+ })
606
+ ```
607
+
608
+ **Behavior:** `ON CONFLICT DO NOTHING` silently skips invalid IDs. Only valid relationships are created.
609
+
610
+ **Recommendation:** Validate IDs client-side or use lookup APIs.
611
+
612
+ ### Foreign Key Violations
613
+
614
+ Junction table foreign keys enforce referential integrity:
615
+
616
+ ```sql
617
+ CREATE TABLE brand_tags (
618
+ brand_id integer REFERENCES brands(id) ON DELETE CASCADE,
619
+ tag_id integer REFERENCES tags(id) ON DELETE CASCADE,
620
+ ...
621
+ );
622
+ ```
623
+
624
+ - ✅ Deleting a brand cascades to junction table
625
+ - ✅ Deleting a tag cascades to junction table
626
+ - ✅ Database ensures data integrity
627
+
628
+ ## Real-time Updates
629
+
630
+ M2M changes are broadcast via DZQL's event system:
631
+
632
+ ```javascript
633
+ // User A saves brand with new tags
634
+ await api.save_brands({
635
+ data: { id: 1, tag_ids: [1, 2, 3] }
636
+ })
637
+
638
+ // User B listening to brands entity receives:
639
+ {
640
+ op: "update",
641
+ table_name: "brands",
642
+ after: {
643
+ id: 1,
644
+ name: "Brand Name",
645
+ tag_ids: [1, 2, 3] // Complete state
646
+ }
647
+ }
648
+ ```
649
+
650
+ Broadcasts include complete state, so subscribers always have consistent data.
651
+
652
+ ## Performance Considerations
653
+
654
+ ### Search Performance
655
+
656
+ With `expand: false` (default):
657
+ - **Fast** - One additional query per record for ID array
658
+ - Recommended for list views
659
+
660
+ With `expand: true`:
661
+ - **Slower** - Additional JOIN per record per relationship
662
+ - Use sparingly, or only for detail views
663
+
664
+ ### Optimization Tips
665
+
666
+ 1. **Index junction tables:**
667
+ ```sql
668
+ CREATE INDEX idx_brand_tags_brand_id ON brand_tags(brand_id);
669
+ CREATE INDEX idx_brand_tags_tag_id ON brand_tags(tag_id);
670
+ ```
671
+
672
+ 2. **Limit array sizes** - Consider max tags per entity (e.g., 50)
673
+
674
+ 3. **Use expand: false** for listings, fetch full objects only when needed
675
+
676
+ 4. **Cache tag definitions** client-side if tags are static
677
+
678
+ ## Example: Complete Implementation
679
+
680
+ ```sql
681
+ -- ============================================================================
682
+ -- Tags & Resources with M2M
683
+ -- ============================================================================
684
+
685
+ -- Tags table
686
+ CREATE TABLE tags (
687
+ id serial PRIMARY KEY,
688
+ name text NOT NULL UNIQUE,
689
+ color text,
690
+ description text
691
+ );
692
+
693
+ -- Resources table
694
+ CREATE TABLE resources (
695
+ id serial PRIMARY KEY,
696
+ org_id integer REFERENCES organisations(id),
697
+ title text NOT NULL,
698
+ description text,
699
+ owner_id integer REFERENCES users(id)
700
+ );
701
+
702
+ -- Junction table
703
+ CREATE TABLE resource_tags (
704
+ resource_id integer NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
705
+ tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
706
+ PRIMARY KEY (resource_id, tag_id)
707
+ );
708
+
709
+ CREATE INDEX idx_resource_tags_tag_id ON resource_tags(tag_id);
710
+
711
+ -- Register tags entity (public, simple)
712
+ SELECT dzql.register_entity(
713
+ 'tags',
714
+ 'name',
715
+ ARRAY['name', 'description'],
716
+ '{}', false, '{}', '{}',
717
+ '{
718
+ "view": [],
719
+ "create": [],
720
+ "update": [],
721
+ "delete": []
722
+ }',
723
+ '{}',
724
+ '{}'
725
+ );
726
+
727
+ -- Register resources entity with M2M tags
728
+ SELECT dzql.register_entity(
729
+ 'resources',
730
+ 'title',
731
+ ARRAY['title', 'description'],
732
+ '{"org": "organisations"}',
733
+ false,
734
+ '{}',
735
+ '{}',
736
+ '{
737
+ "view": [],
738
+ "create": [],
739
+ "update": ["@owner_id"],
740
+ "delete": ["@owner_id"]
741
+ }',
742
+ '{
743
+ "many_to_many": {
744
+ "tags": {
745
+ "junction_table": "resource_tags",
746
+ "local_key": "resource_id",
747
+ "foreign_key": "tag_id",
748
+ "target_entity": "tags",
749
+ "id_field": "tag_ids",
750
+ "expand": false
751
+ }
752
+ }
753
+ }',
754
+ '{"owner_id": "@user_id", "created_at": "@now"}'
755
+ );
756
+
757
+ -- Sample tags
758
+ INSERT INTO tags (name, color, description) VALUES
759
+ ('Important', '#EF4444', 'High priority items'),
760
+ ('In Progress', '#F59E0B', 'Currently being worked on'),
761
+ ('Completed', '#10B981', 'Finished items')
762
+ ON CONFLICT (name) DO NOTHING;
763
+ ```
764
+
765
+ **Client usage:**
766
+
767
+ ```javascript
768
+ // Create resource with tags - single call!
769
+ const resource = await api.save_resources({
770
+ data: {
771
+ title: "Conference Room A",
772
+ description: "Main conference room",
773
+ tag_ids: [1, 2] // Important + In Progress
774
+ // owner_id auto-populated from field defaults
775
+ // created_at auto-populated from field defaults
776
+ }
777
+ })
778
+
779
+ // Response
780
+ {
781
+ id: 1,
782
+ title: "Conference Room A",
783
+ description: "Main conference room",
784
+ owner_id: 123,
785
+ created_at: "2025-11-20T15:00:00Z",
786
+ tag_ids: [1, 2]
787
+ }
788
+
789
+ // Update status by changing tags
790
+ await api.save_resources({
791
+ data: {
792
+ id: 1,
793
+ tag_ids: [3] // Change to Completed
794
+ }
795
+ })
796
+ ```
797
+
798
+ ## Migration Guide
799
+
800
+ ### From Manual M2M to DZQL M2M
801
+
802
+ If you're currently using custom toggle functions:
803
+
804
+ **Step 1:** Create junction table (if you haven't)
805
+ ```sql
806
+ CREATE TABLE resource_tags (
807
+ resource_id integer REFERENCES resources(id) ON DELETE CASCADE,
808
+ tag_id integer REFERENCES tags(id) ON DELETE CASCADE,
809
+ PRIMARY KEY (resource_id, tag_id)
810
+ );
811
+ ```
812
+
813
+ **Step 2:** Update entity registration to include M2M config
814
+
815
+ **Step 3:** Remove custom toggle functions (optional - both can coexist)
816
+
817
+ **Step 4:** Update client code to use `tag_ids` array instead of toggle calls
818
+
819
+ **Step 5:** Deploy updated functions
820
+
821
+ ## Comparison to Other ORMs
822
+
823
+ DZQL's M2M support is similar to:
824
+
825
+ ### Rails ActiveRecord
826
+ ```ruby
827
+ resource.tag_ids = [1, 2, 3]
828
+ resource.save # Junction table synced
829
+ ```
830
+
831
+ ### Django ORM
832
+ ```python
833
+ resource.tags.set([tag1, tag2, tag3])
834
+ ```
835
+
836
+ ### Prisma
837
+ ```javascript
838
+ await prisma.resource.update({
839
+ where: { id: 1 },
840
+ data: {
841
+ tags: { set: [{ id: 1 }, { id: 2 }] }
842
+ }
843
+ })
844
+ ```
845
+
846
+ DZQL provides similar ergonomics with the added benefit of real-time synchronization and row-level security.
847
+
848
+ ## Known Limitations
849
+
850
+ 1. **Composite Primary Keys** - Currently assumes single PK (uses first PK column)
851
+ 2. **Junction Table Fields** - No support for extra fields on junction table
852
+ 3. **Ordering** - No built-in support for position/order in relationships
853
+
854
+ For these advanced cases, model the junction table as its own entity or use custom functions.
855
+
856
+ ## Troubleshooting
857
+
858
+ ### IDs not appearing in response
859
+
860
+ **Check:** Is M2M configured in entity registration?
861
+ ```sql
862
+ SELECT many_to_many FROM dzql.entities WHERE table_name = 'brands';
863
+ ```
864
+
865
+ ### Junction table not syncing
866
+
867
+ **Check:** Is the `id_field` spelled correctly?
868
+ ```javascript
869
+ // Config says "tag_ids"
870
+ "id_field": "tag_ids"
871
+
872
+ // Client must use same name
873
+ tag_ids: [1, 2, 3] // ✅ Correct
874
+ tags: [1, 2, 3] // ❌ Wrong field name
875
+ ```
876
+
877
+ ### Foreign key violations
878
+
879
+ **Check:** Do the IDs exist in the target table?
880
+ ```sql
881
+ SELECT id FROM tags WHERE id IN (1, 2, 3);
882
+ ```
883
+
884
+ ### Performance issues in search
885
+
886
+ **Check:** Is `expand: true` on a heavily queried entity?
887
+
888
+ **Solution:** Change to `expand: false` and fetch full objects separately when needed.
889
+
890
+ ## See Also
891
+
892
+ - [Field Defaults](./field-defaults.md) - Auto-populate ownership and timestamps
893
+ - [Custom Functions](./custom-functions.md) - Advanced business logic
894
+ - [Graph Rules](../reference/api.md#graph-rules) - Automatic relationship management