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
package/docs/compiler/README.md
CHANGED
|
@@ -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, --
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
53
|
-
|
|
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
|