dzql 0.5.4 → 0.5.6
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/bin/cli.js
CHANGED
|
@@ -346,6 +346,13 @@ $$;
|
|
|
346
346
|
writeFileSync(checksumsFile, JSON.stringify(checksums, null, 2), 'utf-8');
|
|
347
347
|
|
|
348
348
|
console.log(` ✓ checksums.json`);
|
|
349
|
+
|
|
350
|
+
// Write drop-semantics.json (drag-and-drop manifest for canvas UI)
|
|
351
|
+
if (result.dropSemantics) {
|
|
352
|
+
const semanticsFile = resolve(options.output, 'drop-semantics.json');
|
|
353
|
+
writeFileSync(semanticsFile, JSON.stringify(result.dropSemantics, null, 2), 'utf-8');
|
|
354
|
+
console.log(` ✓ drop-semantics.json`);
|
|
355
|
+
}
|
|
349
356
|
}
|
|
350
357
|
|
|
351
358
|
console.log(`\n✅ Compilation complete!\n`);
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# Drop Semantics
|
|
2
|
+
|
|
3
|
+
Compile-time manifest describing valid drag-and-drop interactions for canvas UIs.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
When you compile entity definitions, DZQL generates a `drop-semantics.json` file that describes all valid drag-and-drop relationships between entities. This allows canvas UIs to:
|
|
8
|
+
|
|
9
|
+
- Know which entities can be dropped onto which targets
|
|
10
|
+
- Display appropriate visual feedback (containment, frames, edges, badges)
|
|
11
|
+
- Execute the correct database operation for each drop
|
|
12
|
+
- Provide unlink/remove functionality
|
|
13
|
+
|
|
14
|
+
**Key benefit:** The canvas never interprets SQL - it reads a static manifest and knows exactly what connections are valid.
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
dzql compile entities/domain.sql -o compiled/
|
|
20
|
+
# Outputs:
|
|
21
|
+
# compiled/entities.sql
|
|
22
|
+
# compiled/drop-semantics.json ← Canvas consumes this
|
|
23
|
+
# compiled/checksums.json
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Output Format
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"entities": {
|
|
31
|
+
"tasks": {
|
|
32
|
+
"droppable_on": {
|
|
33
|
+
"task_groups": [{
|
|
34
|
+
"relation": "group_id",
|
|
35
|
+
"type": "fk",
|
|
36
|
+
"action": "move",
|
|
37
|
+
"visual": "containment",
|
|
38
|
+
"label": "Move to group",
|
|
39
|
+
"operation": {
|
|
40
|
+
"method": "save",
|
|
41
|
+
"entity": "tasks",
|
|
42
|
+
"params": { "id": "@source.id", "group_id": "@target.id" }
|
|
43
|
+
},
|
|
44
|
+
"removable": true,
|
|
45
|
+
"remove_operation": {
|
|
46
|
+
"method": "save",
|
|
47
|
+
"entity": "tasks",
|
|
48
|
+
"params": { "id": "@source.id", "group_id": null }
|
|
49
|
+
}
|
|
50
|
+
}],
|
|
51
|
+
"users": [{
|
|
52
|
+
"relation": "assigned_to_user_id",
|
|
53
|
+
"type": "fk",
|
|
54
|
+
"action": "move",
|
|
55
|
+
"visual": "badge",
|
|
56
|
+
"label": "Move to user",
|
|
57
|
+
"primary_direction": "accepts",
|
|
58
|
+
"operation": { ... }
|
|
59
|
+
}]
|
|
60
|
+
},
|
|
61
|
+
"accepts": {
|
|
62
|
+
"users": [{
|
|
63
|
+
"relation": "assigned_to_user_id",
|
|
64
|
+
"type": "fk",
|
|
65
|
+
"action": "assign",
|
|
66
|
+
"visual": "badge",
|
|
67
|
+
"label": "Assign user",
|
|
68
|
+
"operation": {
|
|
69
|
+
"method": "save",
|
|
70
|
+
"entity": "tasks",
|
|
71
|
+
"params": { "id": "@target.id", "assigned_to_user_id": "@source.id" }
|
|
72
|
+
},
|
|
73
|
+
"removable": true,
|
|
74
|
+
"remove_operation": {
|
|
75
|
+
"method": "save",
|
|
76
|
+
"entity": "tasks",
|
|
77
|
+
"params": { "id": "@target.id", "assigned_to_user_id": null }
|
|
78
|
+
}
|
|
79
|
+
}]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Primary Direction Hint
|
|
87
|
+
|
|
88
|
+
Some relationships have a natural gesture direction. For example, you typically drop a *user* onto a *task* to assign them, not the other way around. When `primary_direction: "accepts"` is present, the canvas should prioritize the `accepts` entry for UI affordances (drop zones, visual hints).
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"relation": "assigned_to_user_id",
|
|
93
|
+
"primary_direction": "accepts"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The compiler infers this from naming patterns like `assigned_to_*`, `created_by_*`, `author`, `owner`, etc.
|
|
98
|
+
|
|
99
|
+
## Terminology
|
|
100
|
+
|
|
101
|
+
- **source** - The entity being dragged
|
|
102
|
+
- **target** - The entity being dropped onto
|
|
103
|
+
- **droppable_on** - What THIS entity can be dropped onto
|
|
104
|
+
- **accepts** - What can be dropped onto THIS entity
|
|
105
|
+
|
|
106
|
+
## Derivation Rules
|
|
107
|
+
|
|
108
|
+
The compiler derives drop semantics from your schema relationships:
|
|
109
|
+
|
|
110
|
+
### 1. Foreign Key Relationships
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
-- tasks.group_id REFERENCES task_groups
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Generates:
|
|
117
|
+
|
|
118
|
+
| Perspective | Entry | Meaning |
|
|
119
|
+
|-------------|-------|---------|
|
|
120
|
+
| `tasks.droppable_on.task_groups` | action: "move" | Drag task onto group → update task.group_id |
|
|
121
|
+
| `tasks.accepts.task_groups` | action: "assign" | Drag group onto task → update task.group_id |
|
|
122
|
+
|
|
123
|
+
### 2. Many-to-Many (Junction Tables)
|
|
124
|
+
|
|
125
|
+
```sql
|
|
126
|
+
-- post_tags(post_id, tag_id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Generates:
|
|
130
|
+
|
|
131
|
+
| Perspective | Entry | Meaning |
|
|
132
|
+
|-------------|-------|---------|
|
|
133
|
+
| `posts.droppable_on.tags` | action: "link" | Drag post onto tag → insert junction |
|
|
134
|
+
| `posts.accepts.tags` | action: "link" | Drag tag onto post → insert junction |
|
|
135
|
+
|
|
136
|
+
### 3. Self-Referential FK
|
|
137
|
+
|
|
138
|
+
```sql
|
|
139
|
+
-- categories.parent_id REFERENCES categories
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Generates:
|
|
143
|
+
|
|
144
|
+
| Perspective | Entry | Meaning |
|
|
145
|
+
|-------------|-------|---------|
|
|
146
|
+
| `categories.droppable_on.categories` | action: "reparent" | Drag category onto another → set parent |
|
|
147
|
+
|
|
148
|
+
### 4. Self-Referential Junction (Dependencies)
|
|
149
|
+
|
|
150
|
+
```sql
|
|
151
|
+
-- task_dependencies(task_id, depends_on_task_id)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Generates:
|
|
155
|
+
|
|
156
|
+
| Perspective | Entry | Meaning |
|
|
157
|
+
|-------------|-------|---------|
|
|
158
|
+
| `tasks.droppable_on.tasks` | action: "link", visual: "edge" | Drag task onto task → create dependency edge |
|
|
159
|
+
|
|
160
|
+
## Visual Types
|
|
161
|
+
|
|
162
|
+
The `visual` field tells the canvas how to render each relationship:
|
|
163
|
+
|
|
164
|
+
| Visual | Meaning | When Used |
|
|
165
|
+
|--------|---------|-----------|
|
|
166
|
+
| `containment` | Node moves inside container | Tree structures (folders, groups) |
|
|
167
|
+
| `frame` | Visual bounding box around members | Sets, collections |
|
|
168
|
+
| `edge` | Arrow drawn between nodes | Dependencies, relationships |
|
|
169
|
+
| `badge` | Tag/chip displayed on node | Assignments, references |
|
|
170
|
+
|
|
171
|
+
### Automatic Visual Inference
|
|
172
|
+
|
|
173
|
+
The compiler infers visual type using these rules (in order):
|
|
174
|
+
|
|
175
|
+
1. **Self-referential junction** → `edge`
|
|
176
|
+
2. **Self-referential FK** → `containment`
|
|
177
|
+
3. **Target has self-referential FK** (is a tree) → `containment`
|
|
178
|
+
4. **Name ends with `_groups`, `_folders`, `_categories`** → `containment`
|
|
179
|
+
5. **Name ends with `_sets`, `_collections`, `_lists`** → `frame`
|
|
180
|
+
6. **Default** → `badge`
|
|
181
|
+
|
|
182
|
+
### Edge Direction
|
|
183
|
+
|
|
184
|
+
For `edge` visuals (self-referential junctions), the output includes direction:
|
|
185
|
+
|
|
186
|
+
```json
|
|
187
|
+
{
|
|
188
|
+
"visual": "edge",
|
|
189
|
+
"direction": "source_to_target",
|
|
190
|
+
"self_referential": true
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The canvas can use this to draw arrows in the correct direction.
|
|
195
|
+
|
|
196
|
+
## Remove Operations
|
|
197
|
+
|
|
198
|
+
Every relationship includes remove semantics:
|
|
199
|
+
|
|
200
|
+
### FK Relationships
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"removable": true,
|
|
205
|
+
"remove_operation": {
|
|
206
|
+
"method": "save",
|
|
207
|
+
"entity": "tasks",
|
|
208
|
+
"params": { "id": "@source.id", "group_id": null }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Setting the FK to `null` unlinks the relationship.
|
|
214
|
+
|
|
215
|
+
### Junction Relationships
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"removable": true,
|
|
220
|
+
"remove_operation": {
|
|
221
|
+
"method": "delete",
|
|
222
|
+
"entity": "post_tags",
|
|
223
|
+
"params": { "post_id": "@source.id", "tag_id": "@target.id" }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Deleting the junction record removes the link.
|
|
229
|
+
|
|
230
|
+
## Composite Primary Keys
|
|
231
|
+
|
|
232
|
+
Entities with composite primary keys include all key fields in params:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"operation": {
|
|
237
|
+
"method": "save",
|
|
238
|
+
"entity": "org_items",
|
|
239
|
+
"params": {
|
|
240
|
+
"org_id": "@source.org_id",
|
|
241
|
+
"item_code": "@source.item_code",
|
|
242
|
+
"category_id": "@target.id"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Canvas Integration
|
|
249
|
+
|
|
250
|
+
### Checking Valid Drops
|
|
251
|
+
|
|
252
|
+
```javascript
|
|
253
|
+
function canDrop(sourceEntity, sourceId, targetEntity, targetId) {
|
|
254
|
+
const semantics = dropSemantics.entities[sourceEntity];
|
|
255
|
+
if (!semantics) return false;
|
|
256
|
+
|
|
257
|
+
return semantics.droppable_on[targetEntity]?.length > 0;
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Executing Drop
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
async function executeDrop(ws, sourceEntity, sourceData, targetEntity, targetData, relationIndex = 0) {
|
|
265
|
+
const action = dropSemantics.entities[sourceEntity].droppable_on[targetEntity][relationIndex];
|
|
266
|
+
|
|
267
|
+
const params = resolveParams(action.operation.params, sourceData, targetData);
|
|
268
|
+
|
|
269
|
+
if (action.operation.method === 'save') {
|
|
270
|
+
await ws.api.save[action.operation.entity](params);
|
|
271
|
+
} else if (action.operation.method === 'delete') {
|
|
272
|
+
await ws.api.delete[action.operation.entity](params);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function resolveParams(template, sourceData, targetData) {
|
|
277
|
+
const params = {};
|
|
278
|
+
for (const [key, value] of Object.entries(template)) {
|
|
279
|
+
if (typeof value === 'string' && value.startsWith('@source.')) {
|
|
280
|
+
params[key] = sourceData[value.replace('@source.', '')];
|
|
281
|
+
} else if (typeof value === 'string' && value.startsWith('@target.')) {
|
|
282
|
+
params[key] = targetData[value.replace('@target.', '')];
|
|
283
|
+
} else {
|
|
284
|
+
params[key] = value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return params;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Getting Visual Hint
|
|
292
|
+
|
|
293
|
+
```javascript
|
|
294
|
+
function getDropVisual(sourceEntity, targetEntity) {
|
|
295
|
+
const action = dropSemantics.entities[sourceEntity]?.droppable_on[targetEntity]?.[0];
|
|
296
|
+
return action?.visual || null;
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Multiple Relations Picker
|
|
301
|
+
|
|
302
|
+
When multiple relations exist between the same entities (e.g., task→task could be "depends on" or "blocks"), show a picker:
|
|
303
|
+
|
|
304
|
+
```javascript
|
|
305
|
+
function getDropOptions(sourceEntity, targetEntity) {
|
|
306
|
+
const actions = dropSemantics.entities[sourceEntity]?.droppable_on[targetEntity] || [];
|
|
307
|
+
return actions.map((action, index) => ({
|
|
308
|
+
index,
|
|
309
|
+
label: action.label,
|
|
310
|
+
visual: action.visual,
|
|
311
|
+
relation: action.relation
|
|
312
|
+
}));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// In Vue component
|
|
316
|
+
<template>
|
|
317
|
+
<div v-if="dropOptions.length > 1" class="relation-picker">
|
|
318
|
+
<button
|
|
319
|
+
v-for="option in dropOptions"
|
|
320
|
+
:key="option.index"
|
|
321
|
+
@click="executeDrop(option.index)"
|
|
322
|
+
>
|
|
323
|
+
{{ option.label }}
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
</template>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Example: Complete Task Management
|
|
330
|
+
|
|
331
|
+
### Schema
|
|
332
|
+
|
|
333
|
+
```sql
|
|
334
|
+
-- Groups with hierarchy
|
|
335
|
+
CREATE TABLE task_groups (
|
|
336
|
+
id SERIAL PRIMARY KEY,
|
|
337
|
+
name TEXT NOT NULL,
|
|
338
|
+
parent_id INT REFERENCES task_groups(id)
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
-- Tasks
|
|
342
|
+
CREATE TABLE tasks (
|
|
343
|
+
id SERIAL PRIMARY KEY,
|
|
344
|
+
title TEXT NOT NULL,
|
|
345
|
+
group_id INT REFERENCES task_groups(id),
|
|
346
|
+
assigned_to_user_id INT REFERENCES users(id)
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
-- Task sets (for batch operations)
|
|
350
|
+
CREATE TABLE task_sets (
|
|
351
|
+
id SERIAL PRIMARY KEY,
|
|
352
|
+
name TEXT NOT NULL
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
CREATE TABLE task_set_members (
|
|
356
|
+
task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
357
|
+
set_id INT REFERENCES task_sets(id) ON DELETE CASCADE,
|
|
358
|
+
PRIMARY KEY (task_id, set_id)
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
-- Task dependencies
|
|
362
|
+
CREATE TABLE task_dependencies (
|
|
363
|
+
task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
364
|
+
depends_on_task_id INT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
365
|
+
PRIMARY KEY (task_id, depends_on_task_id)
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
-- Entity registrations
|
|
369
|
+
SELECT dzql.register_entity('task_groups', 'name', ARRAY['name'],
|
|
370
|
+
jsonb_build_object('parent', 'task_groups'),
|
|
371
|
+
false, '{}', '{}',
|
|
372
|
+
jsonb_build_object('view', ARRAY[]::text[], 'create', ARRAY[]::text[],
|
|
373
|
+
'update', ARRAY[]::text[], 'delete', ARRAY[]::text[])
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
SELECT dzql.register_entity('tasks', 'title', ARRAY['title'],
|
|
377
|
+
jsonb_build_object('group', 'task_groups', 'assigned_to_user', 'users'),
|
|
378
|
+
false, '{}', '{}',
|
|
379
|
+
jsonb_build_object('view', ARRAY[]::text[], 'create', ARRAY[]::text[],
|
|
380
|
+
'update', ARRAY[]::text[], 'delete', ARRAY[]::text[]),
|
|
381
|
+
jsonb_build_object(
|
|
382
|
+
'many_to_many', jsonb_build_object(
|
|
383
|
+
'sets', jsonb_build_object(
|
|
384
|
+
'junction_table', 'task_set_members',
|
|
385
|
+
'local_key', 'task_id',
|
|
386
|
+
'foreign_key', 'set_id',
|
|
387
|
+
'target_entity', 'task_sets',
|
|
388
|
+
'id_field', 'set_ids'
|
|
389
|
+
),
|
|
390
|
+
'dependencies', jsonb_build_object(
|
|
391
|
+
'junction_table', 'task_dependencies',
|
|
392
|
+
'local_key', 'task_id',
|
|
393
|
+
'foreign_key', 'depends_on_task_id',
|
|
394
|
+
'target_entity', 'tasks',
|
|
395
|
+
'id_field', 'dependency_ids'
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Generated Drop Semantics
|
|
403
|
+
|
|
404
|
+
```json
|
|
405
|
+
{
|
|
406
|
+
"entities": {
|
|
407
|
+
"task_groups": {
|
|
408
|
+
"droppable_on": {
|
|
409
|
+
"task_groups": [{
|
|
410
|
+
"relation": "parent_id",
|
|
411
|
+
"type": "fk",
|
|
412
|
+
"action": "reparent",
|
|
413
|
+
"visual": "containment",
|
|
414
|
+
"label": "Set parent",
|
|
415
|
+
"operation": { ... },
|
|
416
|
+
"removable": true,
|
|
417
|
+
"remove_operation": { ... }
|
|
418
|
+
}]
|
|
419
|
+
},
|
|
420
|
+
"accepts": {
|
|
421
|
+
"tasks": [{ ... }]
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
"tasks": {
|
|
425
|
+
"droppable_on": {
|
|
426
|
+
"task_groups": [{
|
|
427
|
+
"relation": "group_id",
|
|
428
|
+
"type": "fk",
|
|
429
|
+
"action": "move",
|
|
430
|
+
"visual": "containment",
|
|
431
|
+
"label": "Move to group",
|
|
432
|
+
"operation": { ... }
|
|
433
|
+
}],
|
|
434
|
+
"users": [{
|
|
435
|
+
"relation": "assigned_to_user_id",
|
|
436
|
+
"type": "fk",
|
|
437
|
+
"action": "move",
|
|
438
|
+
"visual": "badge",
|
|
439
|
+
"label": "Move to assigned to user",
|
|
440
|
+
"operation": { ... }
|
|
441
|
+
}],
|
|
442
|
+
"task_sets": [{
|
|
443
|
+
"relation": "task_set_members",
|
|
444
|
+
"type": "junction",
|
|
445
|
+
"action": "link",
|
|
446
|
+
"visual": "frame",
|
|
447
|
+
"label": "Add task set member",
|
|
448
|
+
"operation": { ... }
|
|
449
|
+
}],
|
|
450
|
+
"tasks": [{
|
|
451
|
+
"relation": "task_dependencies",
|
|
452
|
+
"type": "junction",
|
|
453
|
+
"action": "link",
|
|
454
|
+
"visual": "edge",
|
|
455
|
+
"direction": "source_to_target",
|
|
456
|
+
"label": "Add task dependency",
|
|
457
|
+
"operation": { ... },
|
|
458
|
+
"self_referential": true
|
|
459
|
+
}]
|
|
460
|
+
},
|
|
461
|
+
"accepts": {
|
|
462
|
+
"users": [{
|
|
463
|
+
"relation": "assigned_to_user_id",
|
|
464
|
+
"type": "fk",
|
|
465
|
+
"action": "assign",
|
|
466
|
+
"visual": "badge",
|
|
467
|
+
"label": "Assign assigned to user",
|
|
468
|
+
"operation": { ... }
|
|
469
|
+
}],
|
|
470
|
+
"task_sets": [{ ... }],
|
|
471
|
+
"tasks": [{
|
|
472
|
+
"visual": "edge",
|
|
473
|
+
"direction": "target_to_source"
|
|
474
|
+
}]
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
### Canvas Interpretation
|
|
482
|
+
|
|
483
|
+
| Drop | Visual | Result |
|
|
484
|
+
|------|--------|--------|
|
|
485
|
+
| Task → Group | containment | Task node moves inside group container |
|
|
486
|
+
| Task → Task | edge | Arrow drawn from source to target |
|
|
487
|
+
| User → Task | badge | User chip appears on task node |
|
|
488
|
+
| Task → Set | frame | Task included in set's visual boundary |
|
|
489
|
+
| Group → Group | containment | Group nests inside another group |
|
|
490
|
+
|
|
491
|
+
## Relationship Types Summary
|
|
492
|
+
|
|
493
|
+
| Schema Pattern | Type | Action | Default Visual |
|
|
494
|
+
|----------------|------|--------|----------------|
|
|
495
|
+
| `A.fk_id REFERENCES B` | `fk` | `move` | `badge` or `containment`* |
|
|
496
|
+
| `A.fk_id REFERENCES A` | `fk` | `reparent` | `containment` |
|
|
497
|
+
| Junction(A, B) | `junction` | `link` | `badge` or `frame`* |
|
|
498
|
+
| Junction(A, A) | `junction` | `link` | `edge` |
|
|
499
|
+
|
|
500
|
+
*Visual depends on target entity name and structure
|
|
501
|
+
|
|
502
|
+
## Canvas Position Storage
|
|
503
|
+
|
|
504
|
+
The drop-semantics manifest covers relationships but not node positions. For canvas x/y coordinates, consider:
|
|
505
|
+
|
|
506
|
+
### Option A: JSON Column on Entity
|
|
507
|
+
|
|
508
|
+
Simple approach for single-user or shared layouts:
|
|
509
|
+
|
|
510
|
+
```sql
|
|
511
|
+
ALTER TABLE tasks ADD COLUMN canvas JSONB DEFAULT '{}';
|
|
512
|
+
|
|
513
|
+
-- Store position
|
|
514
|
+
UPDATE tasks SET canvas = jsonb_build_object('x', 100, 'y', 200) WHERE id = 1;
|
|
515
|
+
|
|
516
|
+
-- Or include in entity definition for automatic handling
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Option B: Separate Positions Table
|
|
520
|
+
|
|
521
|
+
For multi-user layouts or per-project views:
|
|
522
|
+
|
|
523
|
+
```sql
|
|
524
|
+
CREATE TABLE canvas_positions (
|
|
525
|
+
entity TEXT NOT NULL,
|
|
526
|
+
record_id INT NOT NULL,
|
|
527
|
+
user_id INT REFERENCES users(id),
|
|
528
|
+
project_id INT, -- Optional: per-project layouts
|
|
529
|
+
x FLOAT NOT NULL,
|
|
530
|
+
y FLOAT NOT NULL,
|
|
531
|
+
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
532
|
+
PRIMARY KEY (entity, record_id, COALESCE(user_id, 0), COALESCE(project_id, 0))
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
CREATE INDEX idx_canvas_positions_lookup
|
|
536
|
+
ON canvas_positions(entity, user_id, project_id);
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Option C: Client-Side Storage
|
|
540
|
+
|
|
541
|
+
For personal layouts that don't need server persistence:
|
|
542
|
+
|
|
543
|
+
```javascript
|
|
544
|
+
// localStorage per user
|
|
545
|
+
const positions = JSON.parse(localStorage.getItem('canvas_positions') || '{}');
|
|
546
|
+
positions[`${entity}:${id}`] = { x, y };
|
|
547
|
+
localStorage.setItem('canvas_positions', JSON.stringify(positions));
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
## See Also
|
|
551
|
+
|
|
552
|
+
- [Many-to-Many](./many-to-many.md) - Junction table configuration
|
|
553
|
+
- [Compiler Guide](../compiler/README.md) - Full compilation workflow
|
|
554
|
+
- [Custom Functions](./custom-functions.md) - Extending with business logic
|
package/package.json
CHANGED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drop Semantics Code Generator
|
|
3
|
+
* Generates a JSON manifest describing valid drag-and-drop interactions for a canvas UI
|
|
4
|
+
*
|
|
5
|
+
* Terminology clarification:
|
|
6
|
+
* - "source" = the entity being dragged
|
|
7
|
+
* - "target" = the entity being dropped onto
|
|
8
|
+
*
|
|
9
|
+
* Derivation rules:
|
|
10
|
+
* 1. FK on entity A pointing to entity B (e.g., tasks.group_id REFERENCES task_groups):
|
|
11
|
+
* - A.droppable_on.B: drag A onto B → update A.group_id = B.id
|
|
12
|
+
* - A.accepts.B: drag B onto A → update A.group_id = B.id (same operation, different drag direction)
|
|
13
|
+
*
|
|
14
|
+
* 2. Junction table (M2M):
|
|
15
|
+
* - Both entities are droppable_on each other via junction insert
|
|
16
|
+
* - Both entities accept each other
|
|
17
|
+
*
|
|
18
|
+
* 3. Self-referential FK:
|
|
19
|
+
* - Entity is droppable on itself
|
|
20
|
+
*
|
|
21
|
+
* Visual semantics:
|
|
22
|
+
* - "containment": node moves inside container (tree structures, folders)
|
|
23
|
+
* - "frame": visual bounding box around members (sets, collections)
|
|
24
|
+
* - "edge": arrow drawn between nodes (dependencies, relationships)
|
|
25
|
+
* - "badge": tag/chip displayed on node (assignments, references)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export class DropSemanticsCodegen {
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} entities - Map of tableName -> entityConfig
|
|
31
|
+
*/
|
|
32
|
+
constructor(entities) {
|
|
33
|
+
this.entities = entities;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate the complete drop semantics manifest
|
|
38
|
+
* @returns {Object} Drop semantics JSON structure
|
|
39
|
+
*/
|
|
40
|
+
generate() {
|
|
41
|
+
// Initialize result structure for all entities
|
|
42
|
+
const semantics = {};
|
|
43
|
+
for (const tableName of Object.keys(this.entities)) {
|
|
44
|
+
semantics[tableName] = {
|
|
45
|
+
droppable_on: {},
|
|
46
|
+
accepts: {}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Process all FK relationships (adds to source's droppable_on and accepts)
|
|
51
|
+
for (const [tableName, config] of Object.entries(this.entities)) {
|
|
52
|
+
this._processFKRelationships(tableName, config, semantics);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Process all M2M relationships (adds to source's droppable_on and accepts)
|
|
56
|
+
for (const [tableName, config] of Object.entries(this.entities)) {
|
|
57
|
+
this._processM2MRelationships(tableName, config, semantics);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Second pass: populate target's accepts from source's droppable_on
|
|
61
|
+
// This ensures that if posts.droppable_on.tags exists, tags.accepts.posts also exists
|
|
62
|
+
this._populateTargetAccepts(semantics);
|
|
63
|
+
|
|
64
|
+
// Filter out entities with no semantics
|
|
65
|
+
const result = { entities: {} };
|
|
66
|
+
for (const [tableName, sem] of Object.entries(semantics)) {
|
|
67
|
+
if (Object.keys(sem.droppable_on).length > 0 || Object.keys(sem.accepts).length > 0) {
|
|
68
|
+
result.entities[tableName] = sem;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Populate target's accepts from source's droppable_on
|
|
77
|
+
* If A.droppable_on.B exists, then B.accepts.A should also exist
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
80
|
+
_populateTargetAccepts(semantics) {
|
|
81
|
+
for (const [sourceTable, sem] of Object.entries(semantics)) {
|
|
82
|
+
for (const [targetTable, actions] of Object.entries(sem.droppable_on)) {
|
|
83
|
+
// Skip if target doesn't exist in our entities
|
|
84
|
+
if (!semantics[targetTable]) continue;
|
|
85
|
+
|
|
86
|
+
// For each droppable_on action, create a corresponding accepts entry
|
|
87
|
+
for (const action of actions) {
|
|
88
|
+
if (!semantics[targetTable].accepts[sourceTable]) {
|
|
89
|
+
semantics[targetTable].accepts[sourceTable] = [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if this exact relation already exists (avoid duplicates)
|
|
93
|
+
const exists = semantics[targetTable].accepts[sourceTable].some(
|
|
94
|
+
a => a.relation === action.relation && a.type === action.type
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (!exists) {
|
|
98
|
+
// Create the inverse action - swap source and target in params
|
|
99
|
+
const inverseAction = this._createInverseAction(action, sourceTable, targetTable);
|
|
100
|
+
semantics[targetTable].accepts[sourceTable].push(inverseAction);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create an inverse action for accepts (swap source/target perspective)
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
_createInverseAction(action, sourceTable, targetTable) {
|
|
112
|
+
// For the inverse, @source becomes what was @target and vice versa
|
|
113
|
+
const swapRefs = (params) => {
|
|
114
|
+
const swapped = {};
|
|
115
|
+
for (const [key, value] of Object.entries(params)) {
|
|
116
|
+
if (typeof value === 'string') {
|
|
117
|
+
swapped[key] = value
|
|
118
|
+
.replace('@source.', '@__tmp__.')
|
|
119
|
+
.replace('@target.', '@source.')
|
|
120
|
+
.replace('@__tmp__.', '@target.');
|
|
121
|
+
} else {
|
|
122
|
+
swapped[key] = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return swapped;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// For inverse (accepts), visual is typically badge unless it's an edge
|
|
129
|
+
let inverseVisual = 'badge';
|
|
130
|
+
if (action.visual === 'edge') {
|
|
131
|
+
inverseVisual = 'edge'; // Edges are bidirectional visually
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const result = {
|
|
135
|
+
...action,
|
|
136
|
+
action: action.type === 'fk' ? 'assign' : action.action,
|
|
137
|
+
visual: inverseVisual,
|
|
138
|
+
label: action.type === 'fk'
|
|
139
|
+
? this._generateLabel(action.relation, 'assign')
|
|
140
|
+
: action.label,
|
|
141
|
+
operation: {
|
|
142
|
+
...action.operation,
|
|
143
|
+
params: swapRefs(action.operation.params)
|
|
144
|
+
},
|
|
145
|
+
remove_operation: action.remove_operation ? {
|
|
146
|
+
...action.remove_operation,
|
|
147
|
+
params: swapRefs(action.remove_operation.params)
|
|
148
|
+
} : undefined
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// For edge visual, swap direction
|
|
152
|
+
if (action.direction === 'source_to_target') {
|
|
153
|
+
result.direction = 'target_to_source';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Process FK relationships for an entity
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
_processFKRelationships(tableName, config, semantics) {
|
|
164
|
+
const fkIncludes = config.fkIncludes || {};
|
|
165
|
+
const primaryKey = config.primaryKey || ['id'];
|
|
166
|
+
|
|
167
|
+
for (const [alias, targetTable] of Object.entries(fkIncludes)) {
|
|
168
|
+
// Skip reverse FKs (child arrays) - indicated when alias === targetTable
|
|
169
|
+
if (alias === targetTable) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const fkColumn = alias.endsWith('_id') ? alias : `${alias}_id`;
|
|
174
|
+
const isSelfReferential = targetTable === tableName;
|
|
175
|
+
|
|
176
|
+
// 1. Source (tableName) can be dropped onto target
|
|
177
|
+
// e.g., tasks.droppable_on.task_groups - drag task onto group
|
|
178
|
+
if (!semantics[tableName].droppable_on[targetTable]) {
|
|
179
|
+
semantics[tableName].droppable_on[targetTable] = [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const action = isSelfReferential ? this._getSelfReferentialAction(fkColumn) : 'move';
|
|
183
|
+
const visual = this._inferVisual('fk', targetTable, isSelfReferential);
|
|
184
|
+
|
|
185
|
+
// Determine if this is primarily an "accepts" relationship (assign pattern)
|
|
186
|
+
// e.g., tasks.assigned_to_user_id - natural gesture is to drop user onto task
|
|
187
|
+
const isAssignPattern = this._isAssignPattern(alias);
|
|
188
|
+
|
|
189
|
+
semantics[tableName].droppable_on[targetTable].push({
|
|
190
|
+
relation: fkColumn,
|
|
191
|
+
type: 'fk',
|
|
192
|
+
action: action,
|
|
193
|
+
visual: visual,
|
|
194
|
+
label: this._generateLabel(fkColumn, action),
|
|
195
|
+
...(isAssignPattern && { primary_direction: 'accepts' }),
|
|
196
|
+
operation: {
|
|
197
|
+
method: 'save',
|
|
198
|
+
entity: tableName,
|
|
199
|
+
params: this._buildPKParams(primaryKey, '@source', { [fkColumn]: '@target.id' })
|
|
200
|
+
},
|
|
201
|
+
removable: true,
|
|
202
|
+
remove_operation: {
|
|
203
|
+
method: 'save',
|
|
204
|
+
entity: tableName,
|
|
205
|
+
params: this._buildPKParams(primaryKey, '@source', { [fkColumn]: null })
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// 2. Source (tableName) accepts target being dropped on it
|
|
210
|
+
// e.g., tasks.accepts.users - drag user onto task to assign
|
|
211
|
+
// This only makes sense for non-self-referential FKs
|
|
212
|
+
if (!isSelfReferential && this.entities[targetTable]) {
|
|
213
|
+
if (!semantics[tableName].accepts[targetTable]) {
|
|
214
|
+
semantics[tableName].accepts[targetTable] = [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// For accepts, visual is always badge (something is being attached to this entity)
|
|
218
|
+
semantics[tableName].accepts[targetTable].push({
|
|
219
|
+
relation: fkColumn,
|
|
220
|
+
type: 'fk',
|
|
221
|
+
action: 'assign',
|
|
222
|
+
visual: 'badge',
|
|
223
|
+
label: this._generateLabel(alias, 'assign'),
|
|
224
|
+
operation: {
|
|
225
|
+
method: 'save',
|
|
226
|
+
entity: tableName, // Update the entity with the FK
|
|
227
|
+
params: this._buildPKParams(primaryKey, '@target', { [fkColumn]: '@source.id' })
|
|
228
|
+
},
|
|
229
|
+
removable: true,
|
|
230
|
+
remove_operation: {
|
|
231
|
+
method: 'save',
|
|
232
|
+
entity: tableName,
|
|
233
|
+
params: this._buildPKParams(primaryKey, '@target', { [fkColumn]: null })
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Process M2M relationships for an entity
|
|
242
|
+
* @private
|
|
243
|
+
*/
|
|
244
|
+
_processM2MRelationships(tableName, config, semantics) {
|
|
245
|
+
const manyToMany = config.manyToMany || {};
|
|
246
|
+
|
|
247
|
+
for (const [relationKey, m2mConfig] of Object.entries(manyToMany)) {
|
|
248
|
+
const { junction_table, local_key, foreign_key, target_entity } = m2mConfig;
|
|
249
|
+
|
|
250
|
+
if (!junction_table || !local_key || !foreign_key || !target_entity) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const isSelfReferential = target_entity === tableName;
|
|
255
|
+
const visual = this._inferVisual('junction', target_entity, isSelfReferential);
|
|
256
|
+
|
|
257
|
+
// 1. Source (tableName) can be dropped onto target
|
|
258
|
+
if (!semantics[tableName].droppable_on[target_entity]) {
|
|
259
|
+
semantics[tableName].droppable_on[target_entity] = [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const baseEntry = {
|
|
263
|
+
relation: junction_table,
|
|
264
|
+
type: 'junction',
|
|
265
|
+
action: 'link',
|
|
266
|
+
visual: visual,
|
|
267
|
+
label: this._generateLabel(junction_table, 'link'),
|
|
268
|
+
operation: {
|
|
269
|
+
method: 'save',
|
|
270
|
+
entity: junction_table,
|
|
271
|
+
params: {
|
|
272
|
+
[local_key]: '@source.id',
|
|
273
|
+
[foreign_key]: '@target.id'
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
removable: true,
|
|
277
|
+
remove_operation: {
|
|
278
|
+
method: 'delete',
|
|
279
|
+
entity: junction_table,
|
|
280
|
+
params: {
|
|
281
|
+
[local_key]: '@source.id',
|
|
282
|
+
[foreign_key]: '@target.id'
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
...(isSelfReferential && { self_referential: true })
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// For edge visual (self-referential), add direction hint
|
|
289
|
+
if (visual === 'edge') {
|
|
290
|
+
baseEntry.direction = 'source_to_target';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
semantics[tableName].droppable_on[target_entity].push(baseEntry);
|
|
294
|
+
|
|
295
|
+
// 2. Source (tableName) accepts target being dropped on it
|
|
296
|
+
// For M2M, the operation is symmetric but params swap
|
|
297
|
+
if (!isSelfReferential && this.entities[target_entity]) {
|
|
298
|
+
if (!semantics[tableName].accepts[target_entity]) {
|
|
299
|
+
semantics[tableName].accepts[target_entity] = [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// For accepts on M2M, use frame if target is a set, otherwise badge
|
|
303
|
+
const acceptVisual = this._matchesSetPattern(target_entity) ? 'frame' : 'badge';
|
|
304
|
+
|
|
305
|
+
semantics[tableName].accepts[target_entity].push({
|
|
306
|
+
relation: junction_table,
|
|
307
|
+
type: 'junction',
|
|
308
|
+
action: 'link',
|
|
309
|
+
visual: acceptVisual,
|
|
310
|
+
label: this._generateLabel(junction_table, 'link'),
|
|
311
|
+
operation: {
|
|
312
|
+
method: 'save',
|
|
313
|
+
entity: junction_table,
|
|
314
|
+
params: {
|
|
315
|
+
[local_key]: '@target.id', // target is the entity with the M2M config
|
|
316
|
+
[foreign_key]: '@source.id' // source is what's being dropped
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
removable: true,
|
|
320
|
+
remove_operation: {
|
|
321
|
+
method: 'delete',
|
|
322
|
+
entity: junction_table,
|
|
323
|
+
params: {
|
|
324
|
+
[local_key]: '@target.id',
|
|
325
|
+
[foreign_key]: '@source.id'
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Build params object with primary key fields
|
|
335
|
+
* @private
|
|
336
|
+
*/
|
|
337
|
+
_buildPKParams(primaryKey, refPrefix, additionalParams) {
|
|
338
|
+
const params = {};
|
|
339
|
+
|
|
340
|
+
for (const pkField of primaryKey) {
|
|
341
|
+
params[pkField] = `${refPrefix}.${pkField}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
Object.assign(params, additionalParams);
|
|
345
|
+
return params;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Infer the visual representation type for a relationship
|
|
350
|
+
* @private
|
|
351
|
+
* @param {string} type - 'fk' or 'junction'
|
|
352
|
+
* @param {string} targetTable - The target entity name
|
|
353
|
+
* @param {boolean} isSelfReferential - Whether this is a self-referential relation
|
|
354
|
+
* @returns {string} Visual type: 'containment', 'frame', 'edge', or 'badge'
|
|
355
|
+
*/
|
|
356
|
+
_inferVisual(type, targetTable, isSelfReferential) {
|
|
357
|
+
// Rule 1: Self-referential junction → edge (arrows between same entity type)
|
|
358
|
+
if (type === 'junction' && isSelfReferential) {
|
|
359
|
+
return 'edge';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Rule 2: Self-referential FK → containment (tree/hierarchy)
|
|
363
|
+
if (type === 'fk' && isSelfReferential) {
|
|
364
|
+
return 'containment';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Rule 3: Target entity has self-referential FK → it's a tree/container
|
|
368
|
+
if (this._isTreeEntity(targetTable)) {
|
|
369
|
+
return 'containment';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Rule 4: Naming convention fallback
|
|
373
|
+
if (this._matchesContainerPattern(targetTable)) {
|
|
374
|
+
return 'containment';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (this._matchesSetPattern(targetTable)) {
|
|
378
|
+
return 'frame';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Default: badge (tag/chip on node)
|
|
382
|
+
return 'badge';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Check if an entity has a self-referential FK (making it a tree structure)
|
|
387
|
+
* @private
|
|
388
|
+
*/
|
|
389
|
+
_isTreeEntity(tableName) {
|
|
390
|
+
const config = this.entities[tableName];
|
|
391
|
+
if (!config) return false;
|
|
392
|
+
|
|
393
|
+
const fkIncludes = config.fkIncludes || {};
|
|
394
|
+
for (const [alias, target] of Object.entries(fkIncludes)) {
|
|
395
|
+
if (target === tableName) {
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Check if entity name matches container patterns
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
406
|
+
_matchesContainerPattern(tableName) {
|
|
407
|
+
const patterns = ['_groups', '_folders', '_categories', '_containers', '_parents'];
|
|
408
|
+
return patterns.some(p => tableName.endsWith(p));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Check if entity name matches set/collection patterns
|
|
413
|
+
* @private
|
|
414
|
+
*/
|
|
415
|
+
_matchesSetPattern(tableName) {
|
|
416
|
+
const patterns = ['_sets', '_collections', '_lists', '_pools', '_batches'];
|
|
417
|
+
return patterns.some(p => tableName.endsWith(p));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Determine action type for self-referential FK
|
|
422
|
+
* @private
|
|
423
|
+
*/
|
|
424
|
+
_getSelfReferentialAction(fkColumn) {
|
|
425
|
+
if (fkColumn.includes('parent')) {
|
|
426
|
+
return 'reparent';
|
|
427
|
+
}
|
|
428
|
+
if (fkColumn.includes('depends') || fkColumn.includes('dependency')) {
|
|
429
|
+
return 'link';
|
|
430
|
+
}
|
|
431
|
+
return 'nest';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Check if an FK alias represents an "assign" pattern
|
|
436
|
+
* where the natural gesture is to drop the target onto the source
|
|
437
|
+
* e.g., "assigned_to_user" - you drop user onto task, not task onto user
|
|
438
|
+
* @private
|
|
439
|
+
*/
|
|
440
|
+
_isAssignPattern(alias) {
|
|
441
|
+
const assignPatterns = [
|
|
442
|
+
/^assigned_to_/,
|
|
443
|
+
/^created_by_/,
|
|
444
|
+
/^updated_by_/,
|
|
445
|
+
/^owned_by_/,
|
|
446
|
+
/^approved_by_/,
|
|
447
|
+
/^reviewed_by_/,
|
|
448
|
+
/^managed_by_/,
|
|
449
|
+
/^author$/,
|
|
450
|
+
/^owner$/,
|
|
451
|
+
/^assignee$/,
|
|
452
|
+
/^reviewer$/,
|
|
453
|
+
/^approver$/
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
return assignPatterns.some(pattern => pattern.test(alias));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Generate human-readable label from relation name
|
|
461
|
+
* @private
|
|
462
|
+
*/
|
|
463
|
+
_generateLabel(relationName, action) {
|
|
464
|
+
// Remove common suffixes and extract the core noun
|
|
465
|
+
let name = relationName
|
|
466
|
+
.replace(/_id$/, '')
|
|
467
|
+
.replace(/^fk_/, '');
|
|
468
|
+
|
|
469
|
+
// Strip preposition patterns to get the core entity name
|
|
470
|
+
// "assigned_to_user" → "user"
|
|
471
|
+
// "depends_on_task" → "task" (but keep "depends on" for special handling)
|
|
472
|
+
// "created_by_user" → "user"
|
|
473
|
+
// "owner_org" → "org"
|
|
474
|
+
const prepositionPatterns = [
|
|
475
|
+
/^assigned_to_/,
|
|
476
|
+
/^created_by_/,
|
|
477
|
+
/^updated_by_/,
|
|
478
|
+
/^owned_by_/,
|
|
479
|
+
/^belongs_to_/,
|
|
480
|
+
/^managed_by_/,
|
|
481
|
+
/^approved_by_/,
|
|
482
|
+
/^reviewed_by_/
|
|
483
|
+
];
|
|
484
|
+
|
|
485
|
+
let strippedName = name;
|
|
486
|
+
for (const pattern of prepositionPatterns) {
|
|
487
|
+
if (pattern.test(name)) {
|
|
488
|
+
strippedName = name.replace(pattern, '');
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Convert snake_case to Title Case
|
|
494
|
+
const words = strippedName.split('_').map(word =>
|
|
495
|
+
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
// Map action to verb
|
|
499
|
+
const verbs = {
|
|
500
|
+
'move': 'Move to',
|
|
501
|
+
'assign': 'Assign',
|
|
502
|
+
'link': 'Add',
|
|
503
|
+
'nest': 'Set as child of',
|
|
504
|
+
'reparent': 'Set parent'
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const verb = verbs[action] || '';
|
|
508
|
+
|
|
509
|
+
// Special handling for junction tables (link action)
|
|
510
|
+
if (action === 'link') {
|
|
511
|
+
const singularName = this._singularize(words.join(' '));
|
|
512
|
+
return `Add ${singularName.toLowerCase()}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return `${verb} ${words.join(' ').toLowerCase()}`.trim();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Simple singularization
|
|
520
|
+
* @private
|
|
521
|
+
*/
|
|
522
|
+
_singularize(word) {
|
|
523
|
+
if (word.endsWith('ies')) {
|
|
524
|
+
return word.slice(0, -3) + 'y';
|
|
525
|
+
}
|
|
526
|
+
if (word.endsWith('es') && !word.endsWith('ses')) {
|
|
527
|
+
return word.slice(0, -2);
|
|
528
|
+
}
|
|
529
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
530
|
+
return word.slice(0, -1);
|
|
531
|
+
}
|
|
532
|
+
return word;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Generate drop semantics from parsed entities
|
|
538
|
+
* @param {Array|Object} entities - Array of entity configs or map of tableName -> config
|
|
539
|
+
* @returns {Object} Drop semantics manifest
|
|
540
|
+
*/
|
|
541
|
+
export function generateDropSemantics(entities) {
|
|
542
|
+
// Convert array to map if needed
|
|
543
|
+
let entityMap = entities;
|
|
544
|
+
if (Array.isArray(entities)) {
|
|
545
|
+
entityMap = {};
|
|
546
|
+
for (const entity of entities) {
|
|
547
|
+
entityMap[entity.tableName] = entity;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const gen = new DropSemanticsCodegen(entityMap);
|
|
552
|
+
return gen.generate();
|
|
553
|
+
}
|
|
@@ -171,55 +171,62 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
173
|
* Generate traversal check: @org_id->acts_for[org_id=$]{active}.user_id
|
|
174
|
+
* Supports multi-hop paths like: @product_id->products.organisation_id->acts_for[organisation_id=$].user_id
|
|
174
175
|
* @private
|
|
175
176
|
*/
|
|
176
177
|
_generateTraversalCheck(ast) {
|
|
177
178
|
const steps = ast.steps;
|
|
178
179
|
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
let filters = [];
|
|
184
|
-
let temporal = false;
|
|
185
|
-
|
|
186
|
-
for (const step of steps) {
|
|
187
|
-
if (step.type === 'field_ref') {
|
|
188
|
-
if (!sourceField) {
|
|
189
|
-
// First field reference is the source
|
|
190
|
-
sourceField = step.field;
|
|
191
|
-
} else {
|
|
192
|
-
// Last field reference is the target
|
|
193
|
-
targetField = step.field;
|
|
194
|
-
}
|
|
195
|
-
} else if (step.type === 'table_ref') {
|
|
196
|
-
targetTable = step.table;
|
|
180
|
+
// First step should be the source field reference
|
|
181
|
+
if (steps.length === 0 || steps[0].type !== 'field_ref') {
|
|
182
|
+
return 'false';
|
|
183
|
+
}
|
|
197
184
|
|
|
198
|
-
|
|
199
|
-
if (step.filter) {
|
|
200
|
-
filters = step.filter;
|
|
201
|
-
}
|
|
185
|
+
const sourceField = steps[0].field;
|
|
202
186
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
temporal = true;
|
|
206
|
-
}
|
|
187
|
+
// Collect all table_ref steps (these are the hops)
|
|
188
|
+
const tableSteps = steps.filter(s => s.type === 'table_ref');
|
|
207
189
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
190
|
+
if (tableSteps.length === 0) {
|
|
191
|
+
return 'false';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build the value expression that resolves through intermediate tables
|
|
195
|
+
// Start with the record's source field
|
|
196
|
+
let valueExpr = `(p_record->>'${sourceField}')::int`;
|
|
197
|
+
|
|
198
|
+
// Process intermediate hops (all but the last table_ref)
|
|
199
|
+
// Each intermediate hop needs a subquery to resolve to the next field
|
|
200
|
+
for (let i = 0; i < tableSteps.length - 1; i++) {
|
|
201
|
+
const hop = tableSteps[i];
|
|
202
|
+
const table = hop.table;
|
|
203
|
+
const targetField = hop.targetField;
|
|
204
|
+
|
|
205
|
+
if (!targetField) {
|
|
206
|
+
// If no target field specified, assume 'id' for the lookup
|
|
207
|
+
// This shouldn't normally happen in well-formed paths
|
|
208
|
+
continue;
|
|
212
209
|
}
|
|
210
|
+
|
|
211
|
+
// Build subquery: (SELECT targetField FROM table WHERE id = previousValue)
|
|
212
|
+
valueExpr = `(SELECT ${targetField} FROM ${table} WHERE id = ${valueExpr})`;
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
//
|
|
215
|
+
// The last table_ref is where we do the EXISTS check
|
|
216
|
+
const finalStep = tableSteps[tableSteps.length - 1];
|
|
217
|
+
const targetTable = finalStep.table;
|
|
218
|
+
const targetField = finalStep.targetField;
|
|
219
|
+
const filters = finalStep.filter || [];
|
|
220
|
+
const temporal = finalStep.temporal || false;
|
|
221
|
+
|
|
222
|
+
// Build WHERE conditions for the final EXISTS query
|
|
216
223
|
const conditions = [];
|
|
217
224
|
|
|
218
225
|
// Add filter conditions
|
|
219
226
|
for (const filter of filters) {
|
|
220
227
|
if (filter.operator === '=' && filter.value.type === 'param') {
|
|
221
|
-
// field=$ means match the
|
|
222
|
-
conditions.push(`${targetTable}.${filter.field} =
|
|
228
|
+
// field=$ means match the resolved value from the path
|
|
229
|
+
conditions.push(`${targetTable}.${filter.field} = ${valueExpr}`);
|
|
223
230
|
} else if (filter.operator === '=') {
|
|
224
231
|
const value = this._formatValue(filter.value);
|
|
225
232
|
conditions.push(`${targetTable}.${filter.field} = ${value}`);
|
|
@@ -228,9 +235,6 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;`;
|
|
|
228
235
|
|
|
229
236
|
// Add temporal condition
|
|
230
237
|
if (temporal) {
|
|
231
|
-
// Add temporal filtering for {active} marker
|
|
232
|
-
// Assumes standard field names: valid_from and valid_to
|
|
233
|
-
// This matches the interpreter's behavior in resolve_path_segment (002_functions.sql:316)
|
|
234
238
|
conditions.push(`${targetTable}.valid_from <= NOW()`);
|
|
235
239
|
conditions.push(`(${targetTable}.valid_to > NOW() OR ${targetTable}.valid_to IS NULL)`);
|
|
236
240
|
}
|
package/src/compiler/compiler.js
CHANGED
|
@@ -11,6 +11,7 @@ import { generateNotificationFunction } from './codegen/notification-codegen.js'
|
|
|
11
11
|
import { generateGraphRuleFunctions } from './codegen/graph-rules-codegen.js';
|
|
12
12
|
import { generateSubscribable } from './codegen/subscribable-codegen.js';
|
|
13
13
|
import { generateAuthFunctions } from './codegen/auth-codegen.js';
|
|
14
|
+
import { generateDropSemantics } from './codegen/drop-semantics-codegen.js';
|
|
14
15
|
import crypto from 'crypto';
|
|
15
16
|
|
|
16
17
|
export class DZQLCompiler {
|
|
@@ -190,7 +191,7 @@ export class DZQLCompiler {
|
|
|
190
191
|
/**
|
|
191
192
|
* Compile from SQL file
|
|
192
193
|
* @param {string} sqlContent - SQL file content
|
|
193
|
-
* @returns {Object} Compilation results
|
|
194
|
+
* @returns {Object} Compilation results with dropSemantics
|
|
194
195
|
*/
|
|
195
196
|
compileFromSQL(sqlContent) {
|
|
196
197
|
// Use parseEntitiesFromSQL to properly extract custom functions
|
|
@@ -200,11 +201,20 @@ export class DZQLCompiler {
|
|
|
200
201
|
return {
|
|
201
202
|
results: [],
|
|
202
203
|
errors: [],
|
|
203
|
-
summary: { total: 0, successful: 0, failed: 0 }
|
|
204
|
+
summary: { total: 0, successful: 0, failed: 0 },
|
|
205
|
+
dropSemantics: { entities: {} }
|
|
204
206
|
};
|
|
205
207
|
}
|
|
206
208
|
|
|
207
|
-
|
|
209
|
+
const compilationResult = this.compileAll(entities);
|
|
210
|
+
|
|
211
|
+
// Generate drop semantics from all parsed entities
|
|
212
|
+
const dropSemantics = generateDropSemantics(entities);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...compilationResult,
|
|
216
|
+
dropSemantics
|
|
217
|
+
};
|
|
208
218
|
}
|
|
209
219
|
|
|
210
220
|
/**
|