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.
@@ -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
@@ -4,7 +4,7 @@ First-class support for many-to-many relationships with automatic junction table
4
4
 
5
5
  ## Overview
6
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:
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
8
 
9
9
  - Junction table synchronization
10
10
  - Atomic updates in single API calls
@@ -19,6 +19,24 @@ DZQL now provides built-in support for many-to-many (M2M) relationships through
19
19
  - **Less Boilerplate** - No custom toggle functions needed
20
20
  - **Performance Control** - Optional expansion (off by default)
21
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
+
22
40
  ## Quick Example
23
41
 
24
42
  ### Setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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.2...'"
26
26
  },
27
27
  "dependencies": {
28
28
  "jose": "^6.1.0",