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.
- package/docs/compiler/README.md +50 -11
- package/docs/compiler/dzql-compiler-m2m-change-request 2.md +562 -0
- package/docs/compiler/dzql-compiler-m2m-change-request.md +375 -0
- package/docs/guides/many-to-many.md +19 -1
- package/package.json +2 -2
- package/src/compiler/codegen/operation-codegen.js +281 -18
- package/src/compiler/parser/entity-parser.js +7 -2
|
@@ -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
|
|
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.
|
|
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.
|
|
25
|
+
"prepublishOnly": "echo '✅ Publishing DZQL v0.3.2...'"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"jose": "^6.1.0",
|