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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dzql",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",
@@ -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
- // Extract components from the path
180
- let sourceField = null;
181
- let targetTable = null;
182
- let targetField = null;
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
- // Collect filter conditions
199
- if (step.filter) {
200
- filters = step.filter;
201
- }
185
+ const sourceField = steps[0].field;
202
186
 
203
- // Check for temporal marker
204
- if (step.temporal) {
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
- // Get target field if specified in table ref
209
- if (step.targetField) {
210
- targetField = step.targetField;
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
- // Build WHERE conditions
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 record's field value
222
- conditions.push(`${targetTable}.${filter.field} = (p_record->>'${sourceField}')::int`);
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
  }
@@ -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
- return this.compileAll(entities);
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
  /**