dzql 0.5.5 → 0.5.7
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 +7 -0
- package/docs/guides/atomic-updates.md +242 -0
- package/docs/guides/drop-semantics.md +554 -0
- package/docs/guides/subscriptions.md +3 -1
- package/package.json +1 -1
- package/src/client/ws.js +137 -7
- package/src/compiler/codegen/drop-semantics-codegen.js +553 -0
- package/src/compiler/codegen/subscribable-codegen.js +85 -0
- package/src/compiler/compiler.js +13 -3
- package/src/database/migrations/009_subscriptions.sql +10 -0
- package/src/database/migrations/010_atomic_updates.sql +150 -0
- package/src/server/index.js +25 -18
- package/src/server/subscriptions.js +125 -0
- package/src/server/ws.js +12 -2
|
@@ -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
|
+
}
|
|
@@ -433,6 +433,71 @@ $$ LANGUAGE plpgsql IMMUTABLE;`;
|
|
|
433
433
|
jsonb_build_object('${firstParam}', COALESCE((p_new->>'${relFK}')::int, (p_old->>'${relFK}')::int))
|
|
434
434
|
];`;
|
|
435
435
|
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Extract all tables in scope for this subscribable
|
|
439
|
+
* Used for efficient event filtering - only events from these tables need consideration
|
|
440
|
+
* @returns {string[]} Array of table names
|
|
441
|
+
*/
|
|
442
|
+
extractScopeTables() {
|
|
443
|
+
const tables = new Set([this.rootEntity]);
|
|
444
|
+
|
|
445
|
+
const extractFromRelations = (relations) => {
|
|
446
|
+
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
447
|
+
const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
448
|
+
if (entity) tables.add(entity);
|
|
449
|
+
|
|
450
|
+
// Handle nested relations (include or relations)
|
|
451
|
+
if (typeof relConfig === 'object') {
|
|
452
|
+
if (relConfig.include) {
|
|
453
|
+
extractFromRelations(relConfig.include);
|
|
454
|
+
}
|
|
455
|
+
if (relConfig.relations) {
|
|
456
|
+
extractFromRelations(relConfig.relations);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
extractFromRelations(this.relations);
|
|
463
|
+
return Array.from(tables);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Build path mapping for client-side patching
|
|
468
|
+
* Maps table names to their path in the document structure
|
|
469
|
+
* @returns {Object} Map of table name -> document path
|
|
470
|
+
*/
|
|
471
|
+
buildPathMapping() {
|
|
472
|
+
const paths = {};
|
|
473
|
+
|
|
474
|
+
// Root entity maps to top level
|
|
475
|
+
paths[this.rootEntity] = '.';
|
|
476
|
+
|
|
477
|
+
const buildPaths = (relations, parentPath = '') => {
|
|
478
|
+
for (const [relName, relConfig] of Object.entries(relations || {})) {
|
|
479
|
+
const entity = typeof relConfig === 'string' ? relConfig : relConfig.entity;
|
|
480
|
+
const currentPath = parentPath ? `${parentPath}.${relName}` : relName;
|
|
481
|
+
|
|
482
|
+
if (entity) {
|
|
483
|
+
paths[entity] = currentPath;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Handle nested relations
|
|
487
|
+
if (typeof relConfig === 'object') {
|
|
488
|
+
if (relConfig.include) {
|
|
489
|
+
buildPaths(relConfig.include, currentPath);
|
|
490
|
+
}
|
|
491
|
+
if (relConfig.relations) {
|
|
492
|
+
buildPaths(relConfig.relations, currentPath);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
buildPaths(this.relations);
|
|
499
|
+
return paths;
|
|
500
|
+
}
|
|
436
501
|
}
|
|
437
502
|
|
|
438
503
|
/**
|
|
@@ -444,3 +509,23 @@ export function generateSubscribable(subscribable) {
|
|
|
444
509
|
const codegen = new SubscribableCodegen(subscribable);
|
|
445
510
|
return codegen.generate();
|
|
446
511
|
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Extract scope tables from subscribable config
|
|
515
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
516
|
+
* @returns {string[]} Array of table names in scope
|
|
517
|
+
*/
|
|
518
|
+
export function extractScopeTables(subscribable) {
|
|
519
|
+
const codegen = new SubscribableCodegen(subscribable);
|
|
520
|
+
return codegen.extractScopeTables();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Build path mapping from subscribable config
|
|
525
|
+
* @param {Object} subscribable - Subscribable configuration
|
|
526
|
+
* @returns {Object} Map of table name -> document path
|
|
527
|
+
*/
|
|
528
|
+
export function buildPathMapping(subscribable) {
|
|
529
|
+
const codegen = new SubscribableCodegen(subscribable);
|
|
530
|
+
return codegen.buildPathMapping();
|
|
531
|
+
}
|
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
|
/**
|