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