@vidyano-labs/virtual-service 0.1.0
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/README.md +1116 -0
- package/index.d.ts +559 -0
- package/index.js +2008 -0
- package/package.json +24 -0
package/index.js
ADDED
|
@@ -0,0 +1,2008 @@
|
|
|
1
|
+
import { DataType, ServiceHooks, Service } from '@vidyano/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Value conversion utilities for the virtual service.
|
|
5
|
+
* Wraps core DataType conversions to return JavaScript primitives instead of BigNumber.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Convert from service string to primitive
|
|
9
|
+
* fromServiceValue("100.50", "Decimal") // => 100.5 (number)
|
|
10
|
+
* fromServiceValue("True", "Boolean") // => true (boolean)
|
|
11
|
+
* fromServiceValue("15-01-2024 10:30:00", "DateTime") // => Date object
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // Convert from primitive to service string
|
|
15
|
+
* toServiceValue(100.5, "Decimal") // => "100.5"
|
|
16
|
+
* toServiceValue(true, "Boolean") // => "True"
|
|
17
|
+
* toServiceValue(new Date(2024, 0, 15), "Date") // => "15-01-2024 00:00:00"
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Converts a service string value to a primitive JavaScript type.
|
|
21
|
+
* Unlike DataType.fromServiceString, this returns number instead of BigNumber
|
|
22
|
+
* for numeric types (Decimal, Double, Int64, etc.).
|
|
23
|
+
*/
|
|
24
|
+
function fromServiceValue(value, type) {
|
|
25
|
+
const result = DataType.fromServiceString(value, type);
|
|
26
|
+
// Check for BigNumber (has toNumber method) and convert to number primitive
|
|
27
|
+
if (result && typeof result.toNumber === "function")
|
|
28
|
+
return result.toNumber();
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Converts a primitive JavaScript value to a service string.
|
|
33
|
+
*/
|
|
34
|
+
function toServiceValue(value, type) {
|
|
35
|
+
return DataType.toServiceString(value, type);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Registry for managing PersistentObject configurations and instances
|
|
40
|
+
*/
|
|
41
|
+
class VirtualPersistentObjectRegistry {
|
|
42
|
+
#configs = new Map();
|
|
43
|
+
#actionHandlers;
|
|
44
|
+
#validator;
|
|
45
|
+
#queryRegistry;
|
|
46
|
+
#actionsRegistry;
|
|
47
|
+
constructor(validator, actionHandlers, queryRegistry, actionsRegistry) {
|
|
48
|
+
this.#validator = validator;
|
|
49
|
+
this.#actionHandlers = actionHandlers;
|
|
50
|
+
this.#queryRegistry = queryRegistry;
|
|
51
|
+
this.#actionsRegistry = actionsRegistry;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Registers a PersistentObject configuration
|
|
55
|
+
*/
|
|
56
|
+
register(config) {
|
|
57
|
+
this.#configs.set(config.type, config);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Gets a registered PersistentObject configuration
|
|
61
|
+
*/
|
|
62
|
+
getConfig(type) {
|
|
63
|
+
return this.#configs.get(type);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Gets or creates a PersistentObject DTO
|
|
67
|
+
*/
|
|
68
|
+
async getPersistentObject(type, objectId, isNew, parent = null) {
|
|
69
|
+
const config = this.#configs.get(type);
|
|
70
|
+
if (!config)
|
|
71
|
+
throw new Error(`PersistentObject type "${type}" is not registered`);
|
|
72
|
+
// Create new instance
|
|
73
|
+
let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, isNew);
|
|
74
|
+
// Auto-add actions based on overridden lifecycle methods
|
|
75
|
+
if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
|
|
76
|
+
if (!po.actions.includes("Save"))
|
|
77
|
+
po.actions.push("Save");
|
|
78
|
+
}
|
|
79
|
+
// Call lifecycle hooks
|
|
80
|
+
const conversionContext = this.#createConversionContext();
|
|
81
|
+
// Always call onConstruct
|
|
82
|
+
this.#actionsRegistry.executeConstruct(po, conversionContext);
|
|
83
|
+
// Call onLoad only for existing objects
|
|
84
|
+
if (!isNew)
|
|
85
|
+
po = await this.#actionsRegistry.executeLoad(po, parent, conversionContext);
|
|
86
|
+
return po;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Creates a new PersistentObject with the New action lifecycle
|
|
90
|
+
* @param type - The PersistentObject type
|
|
91
|
+
* @param parent - The parent PersistentObject (or null)
|
|
92
|
+
* @param query - The Query from which New was invoked (or null)
|
|
93
|
+
* @param parameters - Additional parameters (or null)
|
|
94
|
+
* @returns The new PersistentObject DTO
|
|
95
|
+
*/
|
|
96
|
+
async createNewPersistentObject(type, parent, query, parameters) {
|
|
97
|
+
const config = this.#configs.get(type);
|
|
98
|
+
if (!config)
|
|
99
|
+
throw new Error(`PersistentObject type "${type}" is not registered`);
|
|
100
|
+
// Create new instance with a generated ID
|
|
101
|
+
const objectId = crypto.randomUUID();
|
|
102
|
+
let po = await buildPersistentObjectDto(config, this.#queryRegistry, objectId, true);
|
|
103
|
+
// Auto-add actions based on overridden lifecycle methods
|
|
104
|
+
if (this.#actionsRegistry.isMethodOverridden(config.type, "onSave")) {
|
|
105
|
+
if (!po.actions.includes("Save"))
|
|
106
|
+
po.actions.push("Save");
|
|
107
|
+
}
|
|
108
|
+
// Call lifecycle hooks
|
|
109
|
+
const conversionContext = this.#createConversionContext();
|
|
110
|
+
// Always call onConstruct first
|
|
111
|
+
this.#actionsRegistry.executeConstruct(po, conversionContext);
|
|
112
|
+
// Then call onNew for new objects
|
|
113
|
+
po = await this.#actionsRegistry.executeNew(po, parent, query, parameters, conversionContext);
|
|
114
|
+
return po;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Executes an action on a PersistentObject
|
|
118
|
+
*/
|
|
119
|
+
async executeAction(request) {
|
|
120
|
+
let parent = request.parent;
|
|
121
|
+
if (!parent)
|
|
122
|
+
throw new Error("ExecuteAction requires a parent PersistentObject");
|
|
123
|
+
const type = parent.type;
|
|
124
|
+
const actionName = request.action.split(".").pop();
|
|
125
|
+
// Handle refresh action
|
|
126
|
+
if (actionName === "Refresh")
|
|
127
|
+
return this.#handleRefresh(request);
|
|
128
|
+
// Validate before Save action
|
|
129
|
+
if (actionName === "Save") {
|
|
130
|
+
const config = this.#configs.get(type);
|
|
131
|
+
try {
|
|
132
|
+
const validationFailed = this.#validateAttributes(parent, config);
|
|
133
|
+
if (validationFailed) {
|
|
134
|
+
return {
|
|
135
|
+
result: parent
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
parent.notification = error instanceof Error ? error.message : String(error);
|
|
141
|
+
parent.notificationType = "Error";
|
|
142
|
+
return {
|
|
143
|
+
result: parent
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Call onSave from actions registry
|
|
147
|
+
const conversionContext = this.#createConversionContext();
|
|
148
|
+
parent = await this.#actionsRegistry.executeSave(parent, conversionContext);
|
|
149
|
+
return {
|
|
150
|
+
result: parent
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Get custom action handler
|
|
154
|
+
const handler = this.#actionHandlers.get(actionName);
|
|
155
|
+
if (!handler)
|
|
156
|
+
throw new Error(`Action "${actionName}" is not registered`);
|
|
157
|
+
// Create action context
|
|
158
|
+
const context = this.#createActionContext(parent);
|
|
159
|
+
// Build unified action args for PersistentObject actions
|
|
160
|
+
const args = {
|
|
161
|
+
parent: parent,
|
|
162
|
+
query: undefined,
|
|
163
|
+
selectedItems: undefined,
|
|
164
|
+
parameters: request.parameters,
|
|
165
|
+
context: context
|
|
166
|
+
};
|
|
167
|
+
// Execute handler and get result
|
|
168
|
+
const result = await handler(args);
|
|
169
|
+
// Handle both old and new API formats for backwards compatibility
|
|
170
|
+
// Old API: handler returns { result: PersistentObjectDto }
|
|
171
|
+
// New API: handler returns PersistentObjectDto | null
|
|
172
|
+
let finalResult;
|
|
173
|
+
if (result && typeof result === "object" && "result" in result) {
|
|
174
|
+
// Old API format - extract the result property
|
|
175
|
+
finalResult = result.result || parent;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// New API format - use directly
|
|
179
|
+
finalResult = result || parent;
|
|
180
|
+
}
|
|
181
|
+
// Return response with result
|
|
182
|
+
return {
|
|
183
|
+
result: finalResult
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Handles the PersistentObject.Refresh action
|
|
188
|
+
*/
|
|
189
|
+
async #handleRefresh(request) {
|
|
190
|
+
let parent = request.parent;
|
|
191
|
+
const type = parent.type;
|
|
192
|
+
const config = this.#configs.get(type);
|
|
193
|
+
if (!config)
|
|
194
|
+
throw new Error(`PersistentObject type "${type}" is not registered`);
|
|
195
|
+
// Get the attribute that triggered the refresh
|
|
196
|
+
const refreshedAttributeId = request.parameters?.RefreshedPersistentObjectAttributeId;
|
|
197
|
+
const triggeredAttribute = refreshedAttributeId
|
|
198
|
+
? parent.attributes?.find(a => a.id === refreshedAttributeId)
|
|
199
|
+
: undefined;
|
|
200
|
+
// Call onRefresh from actions registry
|
|
201
|
+
const conversionContext = this.#createConversionContext();
|
|
202
|
+
parent = await this.#actionsRegistry.executeRefresh(parent, triggeredAttribute, conversionContext);
|
|
203
|
+
return {
|
|
204
|
+
result: parent
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Validates all attributes on a PersistentObject
|
|
209
|
+
* @returns true if validation failed, false if all valid
|
|
210
|
+
*/
|
|
211
|
+
#validateAttributes(po, config) {
|
|
212
|
+
if (!po.attributes || !config)
|
|
213
|
+
return false;
|
|
214
|
+
let hasErrors = false;
|
|
215
|
+
for (const attr of po.attributes) {
|
|
216
|
+
// Clear previous validation errors
|
|
217
|
+
attr.validationError = undefined;
|
|
218
|
+
// Find the attribute config to get rules and isRequired
|
|
219
|
+
const attrConfig = config.attributes.find(a => a.name === attr.name);
|
|
220
|
+
if (!attrConfig)
|
|
221
|
+
continue;
|
|
222
|
+
// Create a DTO with the rules and isRequired from config
|
|
223
|
+
const attrWithRules = {
|
|
224
|
+
...attr,
|
|
225
|
+
rules: attrConfig.rules,
|
|
226
|
+
isRequired: attrConfig.isRequired || false
|
|
227
|
+
};
|
|
228
|
+
// Validate the attribute
|
|
229
|
+
const error = this.#validator.validateAttribute(attrWithRules);
|
|
230
|
+
if (error) {
|
|
231
|
+
attr.validationError = error;
|
|
232
|
+
hasErrors = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return hasErrors;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Creates an action context for custom action handlers
|
|
239
|
+
*/
|
|
240
|
+
#createActionContext(po) {
|
|
241
|
+
return {
|
|
242
|
+
getAttribute: (name) => {
|
|
243
|
+
return po.attributes?.find(a => a.name === name);
|
|
244
|
+
},
|
|
245
|
+
getAttributeValue: (name) => {
|
|
246
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
247
|
+
if (!attr)
|
|
248
|
+
return undefined;
|
|
249
|
+
return fromServiceValue(attr.value, attr.type);
|
|
250
|
+
},
|
|
251
|
+
setAttributeValue: (name, value) => {
|
|
252
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
253
|
+
if (attr) {
|
|
254
|
+
attr.value = toServiceValue(value, attr.type);
|
|
255
|
+
attr.isValueChanged = true;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
getConvertedValue: (attr) => {
|
|
259
|
+
return fromServiceValue(attr.value, attr.type);
|
|
260
|
+
},
|
|
261
|
+
setConvertedValue: (attr, value) => {
|
|
262
|
+
attr.value = toServiceValue(value, attr.type);
|
|
263
|
+
attr.isValueChanged = true;
|
|
264
|
+
},
|
|
265
|
+
setValidationError: (name, error) => {
|
|
266
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
267
|
+
if (attr)
|
|
268
|
+
attr.validationError = error;
|
|
269
|
+
},
|
|
270
|
+
clearValidationError: (name) => {
|
|
271
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
272
|
+
if (attr)
|
|
273
|
+
attr.validationError = undefined;
|
|
274
|
+
},
|
|
275
|
+
setNotification: (message, type, duration) => {
|
|
276
|
+
po.notification = message;
|
|
277
|
+
po.notificationType = type;
|
|
278
|
+
po.notificationDuration = duration;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Creates a conversion context for type conversions
|
|
284
|
+
*/
|
|
285
|
+
#createConversionContext() {
|
|
286
|
+
return {
|
|
287
|
+
getConvertedValue: (attr) => {
|
|
288
|
+
return fromServiceValue(attr.value, attr.type);
|
|
289
|
+
},
|
|
290
|
+
setConvertedValue: (attr, value) => {
|
|
291
|
+
attr.value = toServiceValue(value, attr.type);
|
|
292
|
+
attr.isValueChanged = true;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Builds a PersistentObjectDto from configuration
|
|
299
|
+
*/
|
|
300
|
+
async function buildPersistentObjectDto(config, queryRegistry, objectId, isNew) {
|
|
301
|
+
const id = crypto.randomUUID();
|
|
302
|
+
const fullTypeName = `MockNamespace.${config.type}`;
|
|
303
|
+
// Build attributes using existing DTO type
|
|
304
|
+
const attributes = await Promise.all(config.attributes
|
|
305
|
+
.map((attr, index) => buildAttributeDto(attr, index, queryRegistry)));
|
|
306
|
+
// Build tabs using existing DTO type
|
|
307
|
+
const tabs = {};
|
|
308
|
+
if (config.tabs) {
|
|
309
|
+
Object.entries(config.tabs).forEach(([key, tab]) => {
|
|
310
|
+
tabs[key] = {
|
|
311
|
+
name: tab.name || key,
|
|
312
|
+
columnCount: tab.columnCount || 0,
|
|
313
|
+
id: tab.id,
|
|
314
|
+
layout: tab.layout
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
// Default tab
|
|
320
|
+
tabs[""] = { name: "", columnCount: 0 };
|
|
321
|
+
}
|
|
322
|
+
// Build action names (reference by name)
|
|
323
|
+
const actions = config.actions || [];
|
|
324
|
+
// Build queries (detail queries)
|
|
325
|
+
const queries = [];
|
|
326
|
+
if (config.queries) {
|
|
327
|
+
for (const queryName of config.queries) {
|
|
328
|
+
const queryDto = await queryRegistry.getQuery(queryName);
|
|
329
|
+
queries.push(queryDto);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Return proper PersistentObjectDto
|
|
333
|
+
return {
|
|
334
|
+
id,
|
|
335
|
+
objectId,
|
|
336
|
+
type: config.type,
|
|
337
|
+
fullTypeName,
|
|
338
|
+
label: config.label || config.type,
|
|
339
|
+
isNew,
|
|
340
|
+
isReadOnly: false,
|
|
341
|
+
stateBehavior: config.stateBehavior || "StayInEdit",
|
|
342
|
+
attributes,
|
|
343
|
+
actions,
|
|
344
|
+
tabs,
|
|
345
|
+
queries
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Builds a PersistentObjectAttributeDto from configuration
|
|
350
|
+
*/
|
|
351
|
+
async function buildAttributeDto(config, index, queryRegistry) {
|
|
352
|
+
const baseDto = {
|
|
353
|
+
id: config.id || crypto.randomUUID(),
|
|
354
|
+
name: config.name,
|
|
355
|
+
type: config.type || "String",
|
|
356
|
+
label: config.label || config.name,
|
|
357
|
+
value: config.value,
|
|
358
|
+
isRequired: config.isRequired || false,
|
|
359
|
+
isReadOnly: config.isReadOnly || false,
|
|
360
|
+
rules: config.rules,
|
|
361
|
+
visibility: config.visibility || "Always",
|
|
362
|
+
group: config.group || "",
|
|
363
|
+
tab: config.tab || "",
|
|
364
|
+
triggersRefresh: config.triggersRefresh || false,
|
|
365
|
+
options: config.options,
|
|
366
|
+
typeHints: config.typeHints,
|
|
367
|
+
column: config.column,
|
|
368
|
+
columnSpan: config.columnSpan || 4,
|
|
369
|
+
offset: config.offset ?? index
|
|
370
|
+
};
|
|
371
|
+
// Handle Reference attributes with lookup
|
|
372
|
+
if (config.lookup) {
|
|
373
|
+
if (!queryRegistry)
|
|
374
|
+
throw new Error(`Cannot build reference attribute "${config.name}" without queryRegistry`);
|
|
375
|
+
const lookupQuery = await queryRegistry.getQuery(config.lookup);
|
|
376
|
+
// Determine displayAttribute - use provided or first visible column from lookup query
|
|
377
|
+
let displayAttribute = config.displayAttribute;
|
|
378
|
+
if (!displayAttribute && lookupQuery.columns && lookupQuery.columns.length > 0) {
|
|
379
|
+
// Find first non-Id visible column, or use first column
|
|
380
|
+
const firstVisibleColumn = lookupQuery.columns.find(c => !c.isHidden && c.name !== "Id");
|
|
381
|
+
displayAttribute = firstVisibleColumn?.name || lookupQuery.columns[0].name;
|
|
382
|
+
}
|
|
383
|
+
if (!displayAttribute)
|
|
384
|
+
displayAttribute = "Id";
|
|
385
|
+
// Build reference attribute DTO
|
|
386
|
+
const refDto = {
|
|
387
|
+
...baseDto,
|
|
388
|
+
type: "Reference",
|
|
389
|
+
lookup: lookupQuery,
|
|
390
|
+
objectId: config.value || null,
|
|
391
|
+
displayAttribute,
|
|
392
|
+
canAddNewReference: config.canAddNewReference ?? false,
|
|
393
|
+
selectInPlace: config.selectInPlace ?? false
|
|
394
|
+
};
|
|
395
|
+
return refDto;
|
|
396
|
+
}
|
|
397
|
+
return baseDto;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Registry for managing Query configurations
|
|
402
|
+
*/
|
|
403
|
+
class VirtualQueryRegistry {
|
|
404
|
+
#configs = new Map();
|
|
405
|
+
#actionsRegistry;
|
|
406
|
+
constructor(actionsRegistry) {
|
|
407
|
+
this.#actionsRegistry = actionsRegistry || null;
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Checks if a query is registered
|
|
411
|
+
* @param name - The query name
|
|
412
|
+
*/
|
|
413
|
+
hasQuery(name) {
|
|
414
|
+
return this.#configs.has(name);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Registers a Query configuration
|
|
418
|
+
*/
|
|
419
|
+
register(config, persistentObjectConfig) {
|
|
420
|
+
// Validate required fields
|
|
421
|
+
if (!config.name || config.name.trim() === "")
|
|
422
|
+
throw new Error("Query configuration must have a 'name' property");
|
|
423
|
+
if (!config.persistentObject || config.persistentObject.trim() === "")
|
|
424
|
+
throw new Error("Query configuration must have a 'persistentObject' property");
|
|
425
|
+
// Derive columns from PersistentObject configuration
|
|
426
|
+
const columns = VirtualQueryRegistry.#deriveColumnsFromPersistentObject(persistentObjectConfig);
|
|
427
|
+
this.#configs.set(config.name, {
|
|
428
|
+
config,
|
|
429
|
+
columns,
|
|
430
|
+
persistentObjectConfig,
|
|
431
|
+
data: config.data || []
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Derives query columns from PersistentObject attributes
|
|
436
|
+
*/
|
|
437
|
+
static #deriveColumnsFromPersistentObject(poConfig) {
|
|
438
|
+
return poConfig.attributes.map((attr, index) => {
|
|
439
|
+
const offset = attr.offset ?? index;
|
|
440
|
+
return {
|
|
441
|
+
id: attr.name,
|
|
442
|
+
name: attr.name,
|
|
443
|
+
label: attr.label || this.#humanize(attr.name),
|
|
444
|
+
type: attr.type || "String",
|
|
445
|
+
offset,
|
|
446
|
+
isHidden: this.#mapVisibilityToIsHidden(attr.visibility),
|
|
447
|
+
isSensitive: false,
|
|
448
|
+
// Query capabilities - following plan specifications
|
|
449
|
+
canFilter: false, // Not supported in mock implementation
|
|
450
|
+
canSort: attr.canSort ?? true, // Defaults to true
|
|
451
|
+
canGroupBy: false, // Not supported in mock implementation
|
|
452
|
+
canListDistincts: false, // Not supported (no filtering)
|
|
453
|
+
// Filter arrays (empty for mock)
|
|
454
|
+
includes: [],
|
|
455
|
+
excludes: []
|
|
456
|
+
// Note: typeHints are not stored on columns, only on QueryResultItemValueDto
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Maps attribute visibility to column isHidden property
|
|
462
|
+
*/
|
|
463
|
+
static #mapVisibilityToIsHidden(visibility) {
|
|
464
|
+
if (!visibility || visibility === "Always" || visibility === "Read" || visibility === "Query")
|
|
465
|
+
return false;
|
|
466
|
+
if (visibility === "New" || visibility === "Never")
|
|
467
|
+
return true;
|
|
468
|
+
// Handle compound visibility values
|
|
469
|
+
if (visibility === "Read, Query" || visibility === "Query, New")
|
|
470
|
+
return false;
|
|
471
|
+
if (visibility === "Read, New")
|
|
472
|
+
return true;
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Converts camelCase or PascalCase string to human-readable format
|
|
477
|
+
*/
|
|
478
|
+
static #humanize(name) {
|
|
479
|
+
if (!name)
|
|
480
|
+
return name;
|
|
481
|
+
// Insert space before capital letters (except first) and between number/letter transitions
|
|
482
|
+
return name
|
|
483
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
484
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
|
|
485
|
+
.replace(/([a-zA-Z])(\d)/g, '$1 $2')
|
|
486
|
+
.replace(/(\d)([a-zA-Z])/g, '$1 $2')
|
|
487
|
+
.trim();
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Gets a QueryDto by name
|
|
491
|
+
* @param name - The query name
|
|
492
|
+
* @param parent - The parent PersistentObject (for detail queries) or null
|
|
493
|
+
*/
|
|
494
|
+
async getQuery(name, parent) {
|
|
495
|
+
const entry = this.#configs.get(name);
|
|
496
|
+
if (!entry)
|
|
497
|
+
throw new Error(`Query '${name}' is not registered`);
|
|
498
|
+
const { config, columns, persistentObjectConfig } = entry;
|
|
499
|
+
// Build default actions
|
|
500
|
+
const actions = ["RefreshQuery"];
|
|
501
|
+
// Auto-add New if onNew is overridden for the PersistentObject type
|
|
502
|
+
if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onNew")) {
|
|
503
|
+
if (!actions.includes("New"))
|
|
504
|
+
actions.push("New");
|
|
505
|
+
}
|
|
506
|
+
// Auto-add Delete if onDelete is overridden for the PersistentObject type
|
|
507
|
+
if (this.#actionsRegistry?.isMethodOverridden(persistentObjectConfig.type, "onDelete")) {
|
|
508
|
+
if (!actions.includes("Delete"))
|
|
509
|
+
actions.push("Delete");
|
|
510
|
+
}
|
|
511
|
+
// Add custom query-level actions
|
|
512
|
+
if (config.actions) {
|
|
513
|
+
for (const actionName of config.actions)
|
|
514
|
+
if (!actions.includes(actionName))
|
|
515
|
+
actions.push(actionName);
|
|
516
|
+
}
|
|
517
|
+
// Add custom item-level actions
|
|
518
|
+
if (config.itemActions) {
|
|
519
|
+
for (const actionName of config.itemActions)
|
|
520
|
+
if (!actions.includes(actionName))
|
|
521
|
+
actions.push(actionName);
|
|
522
|
+
}
|
|
523
|
+
// Build the query DTO first (needed for onConstructQuery and executeQuery)
|
|
524
|
+
const queryDto = buildQueryDto(config, columns, actions, persistentObjectConfig);
|
|
525
|
+
// Call onConstructQuery lifecycle hook
|
|
526
|
+
if (this.#actionsRegistry)
|
|
527
|
+
this.#actionsRegistry.executeConstructQuery(queryDto, parent || null);
|
|
528
|
+
// Handle autoQuery flag - execute query to get initial results
|
|
529
|
+
if (config.autoQuery !== false && this.#actionsRegistry) {
|
|
530
|
+
const result = await this.executeQuery(queryDto, parent || null);
|
|
531
|
+
queryDto.result = result;
|
|
532
|
+
}
|
|
533
|
+
return queryDto;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Executes a query by delegating to the actions registry's onExecuteQuery
|
|
537
|
+
* @param query - The query DTO with textSearch, sortOptions, skip, top set
|
|
538
|
+
* @param parent - The parent PersistentObject (for detail queries) or null
|
|
539
|
+
*/
|
|
540
|
+
async executeQuery(query, parent) {
|
|
541
|
+
if (!this.#actionsRegistry)
|
|
542
|
+
throw new Error("Actions registry is required for query execution");
|
|
543
|
+
const entry = this.#configs.get(query.name);
|
|
544
|
+
if (!entry)
|
|
545
|
+
throw new Error(`Query '${query.name}' is not registered`);
|
|
546
|
+
const { columns, data } = entry;
|
|
547
|
+
// Use registered columns (with isHidden) instead of request columns
|
|
548
|
+
const queryWithColumns = { ...query, columns };
|
|
549
|
+
// Delegate to actions registry to get items, passing default data from config
|
|
550
|
+
const result = await this.#actionsRegistry.executeQuery(queryWithColumns, parent, data);
|
|
551
|
+
// Convert data rows to QueryResultItemDto
|
|
552
|
+
const items = result.items.map(row => buildQueryResultItemDto(row, columns));
|
|
553
|
+
// Build and return QueryResultDto
|
|
554
|
+
return buildQueryResultDto(items, columns, result.totalItems, undefined, // No continuation - use skip/top instead
|
|
555
|
+
query.pageSize, query.skip, query.sortOptions);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Builds a QueryDto from configuration
|
|
560
|
+
*/
|
|
561
|
+
function buildQueryDto(config, columns, actions, persistentObjectConfig) {
|
|
562
|
+
// Build minimal PersistentObjectDto for the query
|
|
563
|
+
const persistentObject = {
|
|
564
|
+
type: persistentObjectConfig.type,
|
|
565
|
+
id: crypto.randomUUID(),
|
|
566
|
+
objectId: null,
|
|
567
|
+
label: persistentObjectConfig.type,
|
|
568
|
+
fullTypeName: `Mock.${persistentObjectConfig.type}`,
|
|
569
|
+
isNew: false,
|
|
570
|
+
isReadOnly: true,
|
|
571
|
+
attributes: [],
|
|
572
|
+
actions: [],
|
|
573
|
+
tabs: {},
|
|
574
|
+
queries: []
|
|
575
|
+
};
|
|
576
|
+
return {
|
|
577
|
+
id: crypto.randomUUID(),
|
|
578
|
+
name: config.name,
|
|
579
|
+
label: config.label || config.name,
|
|
580
|
+
columns,
|
|
581
|
+
actions,
|
|
582
|
+
allowTextSearch: config.allowTextSearch ?? true,
|
|
583
|
+
disableBulkEdit: config.disableBulkEdit ?? false,
|
|
584
|
+
autoQuery: config.autoQuery ?? true,
|
|
585
|
+
pageSize: config.pageSize || 20,
|
|
586
|
+
persistentObject,
|
|
587
|
+
sortOptions: "",
|
|
588
|
+
textSearch: "",
|
|
589
|
+
skip: 0,
|
|
590
|
+
top: config.pageSize || 20,
|
|
591
|
+
totalItems: -1,
|
|
592
|
+
canRead: true,
|
|
593
|
+
canReorder: false,
|
|
594
|
+
enableSelectAll: true
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Builds a QueryResultDto from result data
|
|
599
|
+
*/
|
|
600
|
+
function buildQueryResultDto(items, columns, totalItems, continuation, pageSize, skip, sortOptions) {
|
|
601
|
+
return {
|
|
602
|
+
items,
|
|
603
|
+
columns,
|
|
604
|
+
totalItems,
|
|
605
|
+
continuation,
|
|
606
|
+
pageSize,
|
|
607
|
+
skip,
|
|
608
|
+
sortOptions: sortOptions || "",
|
|
609
|
+
charts: []
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Builds a QueryResultItemDto from a data row
|
|
614
|
+
*/
|
|
615
|
+
function buildQueryResultItemDto(data, columns) {
|
|
616
|
+
// Extract or generate ID (check both lowercase "id" and capitalized "Id")
|
|
617
|
+
let id;
|
|
618
|
+
if (data["id"] != null && (typeof data["id"] === "string" || typeof data["id"] === "number"))
|
|
619
|
+
id = String(data["id"]);
|
|
620
|
+
else if (data["Id"] != null && (typeof data["Id"] === "string" || typeof data["Id"] === "number"))
|
|
621
|
+
id = String(data["Id"]);
|
|
622
|
+
else
|
|
623
|
+
id = crypto.randomUUID();
|
|
624
|
+
// Build values array
|
|
625
|
+
const values = columns.map(column => {
|
|
626
|
+
return buildQueryResultItemValueDto(column.name, data[column.name], column.type, data[column.name + "Id"], data[column.name + "$typeHints"]);
|
|
627
|
+
});
|
|
628
|
+
// Extract typeHints and tag from row if present
|
|
629
|
+
const typeHints = data.$typeHints;
|
|
630
|
+
const tag = data.$tag;
|
|
631
|
+
return {
|
|
632
|
+
id,
|
|
633
|
+
values,
|
|
634
|
+
typeHints,
|
|
635
|
+
tag
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Builds a QueryResultItemValueDto from a single value
|
|
640
|
+
* Converts JavaScript primitives to service string format
|
|
641
|
+
*/
|
|
642
|
+
function buildQueryResultItemValueDto(key, rawValue, type, objectId, typeHints) {
|
|
643
|
+
// Convert to service string format using DataType.toServiceString
|
|
644
|
+
const value = rawValue == null
|
|
645
|
+
? null
|
|
646
|
+
: DataType.toServiceString(rawValue, type);
|
|
647
|
+
return {
|
|
648
|
+
key,
|
|
649
|
+
value,
|
|
650
|
+
objectId,
|
|
651
|
+
typeHints
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Base class for PersistentObject lifecycle methods
|
|
657
|
+
* Extend this class to override lifecycle hooks like onLoad, onSave, onRefresh, etc.
|
|
658
|
+
* All methods have default implementations, so you only need to override what you need.
|
|
659
|
+
*/
|
|
660
|
+
class VirtualPersistentObjectActions {
|
|
661
|
+
/**
|
|
662
|
+
* Called every time a PersistentObject DTO is created (both new and existing objects)
|
|
663
|
+
* Use this to set metadata on attributes that can only be known at runtime
|
|
664
|
+
* @param obj - The PersistentObject DTO being constructed
|
|
665
|
+
*/
|
|
666
|
+
onConstruct(obj) {
|
|
667
|
+
// Default implementation: do nothing
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Called when loading an existing PersistentObject by ID
|
|
671
|
+
* Use this to load entity data and populate attribute values
|
|
672
|
+
* @param obj - The constructed PersistentObject DTO (after onConstruct)
|
|
673
|
+
* @param parent - The parent PersistentObject if loaded in a master-detail context, null otherwise
|
|
674
|
+
* @returns The PersistentObject with loaded data
|
|
675
|
+
*/
|
|
676
|
+
async onLoad(obj, parent) {
|
|
677
|
+
// Default implementation: return obj as-is
|
|
678
|
+
return obj;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Called when creating a new PersistentObject via the "New" action
|
|
682
|
+
* Use this to create a fresh entity instance with default values
|
|
683
|
+
* @param obj - The constructed PersistentObject DTO (after onConstruct)
|
|
684
|
+
* @param parent - The parent PersistentObject if creating from a detail query, null otherwise
|
|
685
|
+
* @param query - The Query from which the New action was invoked, null if not from a query
|
|
686
|
+
* @param parameters - Optional parameters including "MenuOption" if NewOptions exist
|
|
687
|
+
* @returns The PersistentObject with default values set
|
|
688
|
+
*/
|
|
689
|
+
async onNew(obj, parent, query, parameters) {
|
|
690
|
+
// Default implementation: return obj as-is
|
|
691
|
+
return obj;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Called when an attribute with triggersRefresh: true is changed
|
|
695
|
+
* Use this to update other attributes based on the changed attribute
|
|
696
|
+
* @param obj - The PersistentObject DTO in edit mode
|
|
697
|
+
* @param attribute - The attribute that triggered the refresh (the one with triggersRefresh=true that was changed), wrapped with getValue/setValue
|
|
698
|
+
* @returns The PersistentObject with refreshed attributes
|
|
699
|
+
*/
|
|
700
|
+
async onRefresh(obj, attribute) {
|
|
701
|
+
// Default implementation: return obj as-is
|
|
702
|
+
return obj;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Called when the Save action is executed
|
|
706
|
+
* Orchestrates the save process by calling saveNew or saveExisting based on obj.isNew
|
|
707
|
+
* @param obj - The PersistentObject DTO to save
|
|
708
|
+
* @returns The saved PersistentObject
|
|
709
|
+
*/
|
|
710
|
+
async onSave(obj) {
|
|
711
|
+
// Default implementation: delegate to saveNew or saveExisting based on isNew flag
|
|
712
|
+
if (obj.isNew)
|
|
713
|
+
return await this.saveNew(obj);
|
|
714
|
+
else
|
|
715
|
+
return await this.saveExisting(obj);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Called by onSave when obj.isNew === true
|
|
719
|
+
* Use this to save a new entity to the data store
|
|
720
|
+
* @param obj - The new PersistentObject DTO to save
|
|
721
|
+
* @returns The saved PersistentObject
|
|
722
|
+
*/
|
|
723
|
+
async saveNew(obj) {
|
|
724
|
+
// Default implementation: return obj as-is (no persistence in mock)
|
|
725
|
+
return obj;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Called by onSave when obj.isNew === false
|
|
729
|
+
* Use this to update an existing entity in the data store
|
|
730
|
+
* @param obj - The existing PersistentObject DTO to save
|
|
731
|
+
* @returns The saved PersistentObject
|
|
732
|
+
*/
|
|
733
|
+
async saveExisting(obj) {
|
|
734
|
+
// Default implementation: return obj as-is (no persistence in mock)
|
|
735
|
+
return obj;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Called when a reference attribute is changed (via changeReference() on a PersistentObjectAttributeWithReference)
|
|
739
|
+
* The base implementation sets objectId, value, and isValueChanged on the reference attribute.
|
|
740
|
+
* Override this to add custom logic after the reference is set.
|
|
741
|
+
* @param parent - The PersistentObject that contains the reference attribute
|
|
742
|
+
* @param referenceAttribute - The reference attribute that was changed
|
|
743
|
+
* @param query - The query that was used to select the reference
|
|
744
|
+
* @param selectedItem - The QueryResultItem that was selected, or null if reference was cleared
|
|
745
|
+
*/
|
|
746
|
+
async onSelectReference(_parent, referenceAttribute, _query, selectedItem) {
|
|
747
|
+
const refAttr = referenceAttribute;
|
|
748
|
+
if (selectedItem == null) {
|
|
749
|
+
// Clear the reference
|
|
750
|
+
refAttr.objectId = null;
|
|
751
|
+
refAttr.value = null;
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
// Set the reference to the selected item
|
|
755
|
+
refAttr.objectId = selectedItem.id;
|
|
756
|
+
// Get the display value from the item using displayAttribute
|
|
757
|
+
const displayAttribute = refAttr.displayAttribute;
|
|
758
|
+
const displayValue = selectedItem.values?.find((v) => v.key === displayAttribute);
|
|
759
|
+
refAttr.value = displayValue?.value || selectedItem.id;
|
|
760
|
+
}
|
|
761
|
+
refAttr.isValueChanged = true;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Called when the Delete action is executed on selected items in a Query
|
|
765
|
+
* Use this to handle deletion of entities
|
|
766
|
+
* @param parent - The parent PersistentObject if deleting from a detail query, null for top-level queries
|
|
767
|
+
* @param query - The query from which items are being deleted
|
|
768
|
+
* @param selectedItems - The QueryResultItems that are selected for deletion
|
|
769
|
+
*/
|
|
770
|
+
async onDelete(parent, query, selectedItems) {
|
|
771
|
+
// Default implementation: do nothing (no persistence in mock)
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Called when a Query of this PersistentObject type is constructed
|
|
775
|
+
* Use this to set metadata on query columns that can only be known at runtime
|
|
776
|
+
* @param query - The Query DTO being constructed
|
|
777
|
+
* @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
|
|
778
|
+
*/
|
|
779
|
+
onConstructQuery(query, parent) {
|
|
780
|
+
// Default implementation: do nothing
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Called when a Query is executed.
|
|
784
|
+
* The base implementation calls getEntities() and applies text search, sorting, and pagination.
|
|
785
|
+
* @param query - The Query DTO with textSearch, sortOptions, skip, top properties set
|
|
786
|
+
* @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
|
|
787
|
+
* @param data - Default data from the query config (used if getEntities is not overridden)
|
|
788
|
+
* @returns The items matching the query criteria and the total count before pagination
|
|
789
|
+
*/
|
|
790
|
+
async onExecuteQuery(query, parent, data) {
|
|
791
|
+
// Get all items from subclass or use provided data
|
|
792
|
+
let result = await this.getEntities(query, parent, data);
|
|
793
|
+
// Apply text search if provided
|
|
794
|
+
if (query.textSearch && query.textSearch.trim() !== "") {
|
|
795
|
+
const searchLower = query.textSearch.toLowerCase();
|
|
796
|
+
const stringColumns = query.columns?.filter(c => c.type === "String" && !c.isHidden) || [];
|
|
797
|
+
result = result.filter(row => {
|
|
798
|
+
for (const column of stringColumns) {
|
|
799
|
+
const value = String(row[column.name] || "").toLowerCase();
|
|
800
|
+
if (value.includes(searchLower))
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
return false;
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Apply sorting if provided
|
|
807
|
+
if (query.sortOptions && query.sortOptions.trim() !== "") {
|
|
808
|
+
const sortParts = query.sortOptions.split(";").map(s => s.trim()).filter(Boolean);
|
|
809
|
+
const sortOptions = sortParts.map(part => {
|
|
810
|
+
const [name, dir] = part.split(/\s+/);
|
|
811
|
+
return { name, direction: dir?.toUpperCase() === "DESC" ? "DESC" : "ASC" };
|
|
812
|
+
});
|
|
813
|
+
result = [...result].sort((a, b) => {
|
|
814
|
+
for (const { name, direction } of sortOptions) {
|
|
815
|
+
const aVal = a[name];
|
|
816
|
+
const bVal = b[name];
|
|
817
|
+
// Null handling
|
|
818
|
+
if (aVal == null && bVal == null)
|
|
819
|
+
continue;
|
|
820
|
+
if (aVal == null)
|
|
821
|
+
return direction === "ASC" ? -1 : 1;
|
|
822
|
+
if (bVal == null)
|
|
823
|
+
return direction === "ASC" ? 1 : -1;
|
|
824
|
+
// Type-aware comparison
|
|
825
|
+
let cmp = 0;
|
|
826
|
+
if (typeof aVal === "boolean")
|
|
827
|
+
cmp = aVal === bVal ? 0 : (aVal ? 1 : -1);
|
|
828
|
+
else if (aVal instanceof Date && bVal instanceof Date)
|
|
829
|
+
cmp = aVal.getTime() - bVal.getTime();
|
|
830
|
+
else if (typeof aVal === "number" && typeof bVal === "number")
|
|
831
|
+
cmp = aVal - bVal;
|
|
832
|
+
else
|
|
833
|
+
cmp = String(aVal).toLowerCase().localeCompare(String(bVal).toLowerCase());
|
|
834
|
+
if (cmp !== 0)
|
|
835
|
+
return direction === "ASC" ? cmp : -cmp;
|
|
836
|
+
}
|
|
837
|
+
return 0;
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
// Apply pagination
|
|
841
|
+
const skip = query.skip || 0;
|
|
842
|
+
const top = query.top || query.pageSize || 20;
|
|
843
|
+
const items = result.slice(skip, skip + top);
|
|
844
|
+
return {
|
|
845
|
+
items,
|
|
846
|
+
totalItems: result.length
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Override this to provide the full list of items for a query.
|
|
851
|
+
* The base onExecuteQuery will handle text search, sorting, and pagination automatically.
|
|
852
|
+
* @param query - The Query DTO with textSearch, sortOptions properties set
|
|
853
|
+
* @param parent - The parent PersistentObject if this is a detail query, null for top-level queries
|
|
854
|
+
* @param data - Default data from the query config
|
|
855
|
+
* @returns All items matching the query criteria (before pagination)
|
|
856
|
+
*/
|
|
857
|
+
async getEntities(_query, _parent, data) {
|
|
858
|
+
// Default implementation: return provided data (from query config)
|
|
859
|
+
return [...data];
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Creates a VirtualPersistentObjectAttribute by wrapping an attribute DTO with a Proxy
|
|
865
|
+
* The Proxy intercepts property access to provide getValue/setValue methods
|
|
866
|
+
* @param attr - The PersistentObjectAttributeDto to wrap
|
|
867
|
+
* @param conversionContext - The conversion context for type conversions
|
|
868
|
+
* @returns A VirtualPersistentObjectAttribute that combines DTO properties with helper methods
|
|
869
|
+
*/
|
|
870
|
+
function createVirtualPersistentObjectAttribute(attr, conversionContext) {
|
|
871
|
+
const helpers = {
|
|
872
|
+
getValue() {
|
|
873
|
+
return conversionContext.getConvertedValue(attr);
|
|
874
|
+
},
|
|
875
|
+
setValue(value) {
|
|
876
|
+
conversionContext.setConvertedValue(attr, value);
|
|
877
|
+
},
|
|
878
|
+
setValidationError(error) {
|
|
879
|
+
attr.validationError = error;
|
|
880
|
+
},
|
|
881
|
+
clearValidationError() {
|
|
882
|
+
attr.validationError = undefined;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
return new Proxy(attr, {
|
|
886
|
+
get(target, prop) {
|
|
887
|
+
if (prop in helpers)
|
|
888
|
+
return helpers[prop];
|
|
889
|
+
return target[prop];
|
|
890
|
+
},
|
|
891
|
+
set(target, prop, value) {
|
|
892
|
+
target[prop] = value;
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Creates a VirtualPersistentObject by wrapping a DTO with a Proxy
|
|
899
|
+
* The Proxy intercepts property access to provide helper methods while keeping the underlying DTO unchanged
|
|
900
|
+
* @param dto - The PersistentObjectDto to wrap
|
|
901
|
+
* @param conversionContext - The conversion context for type conversions
|
|
902
|
+
* @returns A VirtualPersistentObject that combines DTO properties with helper methods
|
|
903
|
+
*/
|
|
904
|
+
function createVirtualPersistentObject(dto, conversionContext) {
|
|
905
|
+
// Helper methods - logic is inlined here, only using conversionContext for type conversion
|
|
906
|
+
const helpers = {
|
|
907
|
+
getAttribute(name) {
|
|
908
|
+
const attr = dto.attributes?.find(a => a.name === name);
|
|
909
|
+
if (!attr)
|
|
910
|
+
return undefined;
|
|
911
|
+
return createVirtualPersistentObjectAttribute(attr, conversionContext);
|
|
912
|
+
},
|
|
913
|
+
getAttributeValue(name) {
|
|
914
|
+
const attr = dto.attributes?.find(a => a.name === name);
|
|
915
|
+
if (!attr)
|
|
916
|
+
return undefined;
|
|
917
|
+
return conversionContext.getConvertedValue(attr);
|
|
918
|
+
},
|
|
919
|
+
setAttributeValue(name, value) {
|
|
920
|
+
const attr = dto.attributes?.find(a => a.name === name);
|
|
921
|
+
if (attr)
|
|
922
|
+
conversionContext.setConvertedValue(attr, value);
|
|
923
|
+
},
|
|
924
|
+
setValidationError(name, error) {
|
|
925
|
+
const attr = dto.attributes?.find(a => a.name === name);
|
|
926
|
+
if (attr)
|
|
927
|
+
attr.validationError = error;
|
|
928
|
+
},
|
|
929
|
+
clearValidationError(name) {
|
|
930
|
+
const attr = dto.attributes?.find(a => a.name === name);
|
|
931
|
+
if (attr)
|
|
932
|
+
attr.validationError = undefined;
|
|
933
|
+
},
|
|
934
|
+
setNotification(message, type, duration) {
|
|
935
|
+
dto.notification = message;
|
|
936
|
+
dto.notificationType = type;
|
|
937
|
+
dto.notificationDuration = duration;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
// Create a Proxy that intercepts property access
|
|
941
|
+
return new Proxy(dto, {
|
|
942
|
+
get(target, prop) {
|
|
943
|
+
// If accessing a helper method, return it
|
|
944
|
+
if (prop in helpers)
|
|
945
|
+
return helpers[prop];
|
|
946
|
+
// Otherwise, return the DTO property
|
|
947
|
+
return target[prop];
|
|
948
|
+
},
|
|
949
|
+
set(target, prop, value) {
|
|
950
|
+
// Allow setting DTO properties directly
|
|
951
|
+
target[prop] = value;
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Unwraps a VirtualPersistentObject to get the underlying DTO
|
|
958
|
+
* Since the Proxy wraps the DTO, we can safely cast it back
|
|
959
|
+
* @param wrapped - The VirtualPersistentObject to unwrap
|
|
960
|
+
* @returns The underlying PersistentObjectDto
|
|
961
|
+
*/
|
|
962
|
+
function unwrapVirtualPersistentObject(wrapped) {
|
|
963
|
+
// The wrapped object is a Proxy around the DTO
|
|
964
|
+
// We can return it as-is since the DTO is the target of the Proxy
|
|
965
|
+
return wrapped;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Registry for managing VirtualPersistentObjectActions classes
|
|
970
|
+
* Provides methods to register actions classes and execute lifecycle hooks
|
|
971
|
+
*/
|
|
972
|
+
class VirtualPersistentObjectActionsRegistry {
|
|
973
|
+
#actionsClasses = new Map();
|
|
974
|
+
#overrideInfo = new Map();
|
|
975
|
+
/**
|
|
976
|
+
* Registers a VirtualPersistentObjectActions class for a specific type
|
|
977
|
+
* @param type - The PersistentObject type name
|
|
978
|
+
* @param ActionsClass - The VirtualPersistentObjectActions class constructor
|
|
979
|
+
*/
|
|
980
|
+
register(type, ActionsClass) {
|
|
981
|
+
this.#actionsClasses.set(type, ActionsClass);
|
|
982
|
+
// Detect overridden methods
|
|
983
|
+
const overriddenMethods = this.#detectOverriddenMethods(ActionsClass);
|
|
984
|
+
this.#overrideInfo.set(type, { overriddenMethods });
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Detects which lifecycle methods are overridden in the given class
|
|
988
|
+
* @param ActionsClass - The VirtualPersistentObjectActions class constructor
|
|
989
|
+
* @returns Set of overridden method names
|
|
990
|
+
*/
|
|
991
|
+
#detectOverriddenMethods(ActionsClass) {
|
|
992
|
+
const overridden = new Set();
|
|
993
|
+
const baseProto = VirtualPersistentObjectActions.prototype;
|
|
994
|
+
const classProto = ActionsClass.prototype;
|
|
995
|
+
const methodsToCheck = [
|
|
996
|
+
"onSave", "onNew", "onDelete", "onRefresh",
|
|
997
|
+
"onLoad", "onConstruct", "onConstructQuery", "onSelectReference", "onExecuteQuery", "getEntities"
|
|
998
|
+
];
|
|
999
|
+
for (const methodName of methodsToCheck) {
|
|
1000
|
+
if (classProto[methodName] !== baseProto[methodName])
|
|
1001
|
+
overridden.add(methodName);
|
|
1002
|
+
}
|
|
1003
|
+
return overridden;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Checks if a specific lifecycle method is overridden for a type
|
|
1007
|
+
* @param type - The PersistentObject type name
|
|
1008
|
+
* @param methodName - The lifecycle method name
|
|
1009
|
+
* @returns true if the method is overridden, false otherwise
|
|
1010
|
+
*/
|
|
1011
|
+
isMethodOverridden(type, methodName) {
|
|
1012
|
+
const info = this.#overrideInfo.get(type);
|
|
1013
|
+
if (!info)
|
|
1014
|
+
return false;
|
|
1015
|
+
return info.overriddenMethods.has(methodName);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Checks if a type has registered actions
|
|
1019
|
+
* @param type - The PersistentObject type name
|
|
1020
|
+
* @returns true if the type has registered actions, false otherwise
|
|
1021
|
+
*/
|
|
1022
|
+
has(type) {
|
|
1023
|
+
return this.#actionsClasses.has(type);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Creates a new instance of the actions class for a type
|
|
1027
|
+
* Returns a default VirtualPersistentObjectActions if no custom actions are registered
|
|
1028
|
+
* @param type - The PersistentObject type name
|
|
1029
|
+
* @returns A new instance of the VirtualPersistentObjectActions class
|
|
1030
|
+
*/
|
|
1031
|
+
createInstance(type) {
|
|
1032
|
+
const ActionsClass = this.#actionsClasses.get(type);
|
|
1033
|
+
if (!ActionsClass)
|
|
1034
|
+
return new VirtualPersistentObjectActions();
|
|
1035
|
+
return new ActionsClass();
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Executes onConstruct lifecycle hook
|
|
1039
|
+
* @param dto - The PersistentObject DTO
|
|
1040
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1041
|
+
*/
|
|
1042
|
+
executeConstruct(dto, conversionContext) {
|
|
1043
|
+
const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
|
|
1044
|
+
const instance = this.createInstance(dto.type);
|
|
1045
|
+
instance.onConstruct(wrappedObj);
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Executes onLoad lifecycle hook
|
|
1049
|
+
* @param dto - The PersistentObject DTO
|
|
1050
|
+
* @param parent - The parent DTO (or null)
|
|
1051
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1052
|
+
* @returns The updated DTO
|
|
1053
|
+
*/
|
|
1054
|
+
async executeLoad(dto, parent, conversionContext) {
|
|
1055
|
+
const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
|
|
1056
|
+
// Wrap parent if provided
|
|
1057
|
+
let wrappedParent = null;
|
|
1058
|
+
if (parent) {
|
|
1059
|
+
const parentContext = this.#createConversionContext();
|
|
1060
|
+
wrappedParent = createVirtualPersistentObject(parent, parentContext);
|
|
1061
|
+
}
|
|
1062
|
+
const instance = this.createInstance(dto.type);
|
|
1063
|
+
const result = await instance.onLoad(wrappedObj, wrappedParent);
|
|
1064
|
+
return unwrapVirtualPersistentObject(result);
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Executes onNew lifecycle hook
|
|
1068
|
+
* @param dto - The PersistentObject DTO
|
|
1069
|
+
* @param parent - The parent DTO (or null)
|
|
1070
|
+
* @param query - The query DTO (or null)
|
|
1071
|
+
* @param parameters - The parameters (or null)
|
|
1072
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1073
|
+
* @returns The updated DTO
|
|
1074
|
+
*/
|
|
1075
|
+
async executeNew(dto, parent, query, parameters, conversionContext) {
|
|
1076
|
+
const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
|
|
1077
|
+
// Wrap parent if provided
|
|
1078
|
+
let wrappedParent = null;
|
|
1079
|
+
if (parent) {
|
|
1080
|
+
const parentContext = this.#createConversionContext();
|
|
1081
|
+
wrappedParent = createVirtualPersistentObject(parent, parentContext);
|
|
1082
|
+
}
|
|
1083
|
+
const instance = this.createInstance(dto.type);
|
|
1084
|
+
const result = await instance.onNew(wrappedObj, wrappedParent, query, parameters);
|
|
1085
|
+
return unwrapVirtualPersistentObject(result);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Executes onRefresh lifecycle hook
|
|
1089
|
+
* @param dto - The PersistentObject DTO
|
|
1090
|
+
* @param attribute - The attribute that triggered the refresh (or undefined)
|
|
1091
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1092
|
+
* @returns The updated DTO
|
|
1093
|
+
*/
|
|
1094
|
+
async executeRefresh(dto, attribute, conversionContext) {
|
|
1095
|
+
const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
|
|
1096
|
+
// Wrap attribute if provided
|
|
1097
|
+
let wrappedAttribute;
|
|
1098
|
+
if (attribute)
|
|
1099
|
+
wrappedAttribute = createVirtualPersistentObjectAttribute(attribute, conversionContext);
|
|
1100
|
+
const instance = this.createInstance(dto.type);
|
|
1101
|
+
const result = await instance.onRefresh(wrappedObj, wrappedAttribute);
|
|
1102
|
+
return unwrapVirtualPersistentObject(result);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Executes onSave lifecycle hook
|
|
1106
|
+
* @param dto - The PersistentObject DTO
|
|
1107
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1108
|
+
* @returns The updated DTO
|
|
1109
|
+
*/
|
|
1110
|
+
async executeSave(dto, conversionContext) {
|
|
1111
|
+
const wrappedObj = createVirtualPersistentObject(dto, conversionContext);
|
|
1112
|
+
const instance = this.createInstance(dto.type);
|
|
1113
|
+
const result = await instance.onSave(wrappedObj);
|
|
1114
|
+
return unwrapVirtualPersistentObject(result);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Executes onSelectReference lifecycle hook
|
|
1118
|
+
* @param parent - The parent DTO
|
|
1119
|
+
* @param referenceAttribute - The reference attribute DTO
|
|
1120
|
+
* @param query - The query DTO
|
|
1121
|
+
* @param selectedItem - The selected item (or null)
|
|
1122
|
+
* @param conversionContext - The conversion context for type conversions
|
|
1123
|
+
*/
|
|
1124
|
+
async executeSelectReference(parent, referenceAttribute, query, selectedItem, conversionContext) {
|
|
1125
|
+
const wrappedParent = createVirtualPersistentObject(parent, conversionContext);
|
|
1126
|
+
const instance = this.createInstance(parent.type);
|
|
1127
|
+
await instance.onSelectReference(wrappedParent, referenceAttribute, query, selectedItem);
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Executes onDelete lifecycle hook
|
|
1131
|
+
* @param parent - The parent DTO (or null)
|
|
1132
|
+
* @param query - The query DTO
|
|
1133
|
+
* @param selectedItems - The selected items
|
|
1134
|
+
*/
|
|
1135
|
+
async executeDelete(parent, query, selectedItems) {
|
|
1136
|
+
// Wrap parent if provided
|
|
1137
|
+
let wrappedParent = null;
|
|
1138
|
+
if (parent) {
|
|
1139
|
+
const conversionContext = this.#createConversionContext();
|
|
1140
|
+
wrappedParent = createVirtualPersistentObject(parent, conversionContext);
|
|
1141
|
+
}
|
|
1142
|
+
// Get type from query's persistentObject
|
|
1143
|
+
const type = query.persistentObject?.type;
|
|
1144
|
+
if (!type)
|
|
1145
|
+
throw new Error("Query does not have a persistentObject type");
|
|
1146
|
+
const instance = this.createInstance(type);
|
|
1147
|
+
await instance.onDelete(wrappedParent, query, selectedItems);
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Executes onConstructQuery lifecycle hook
|
|
1151
|
+
* @param query - The query DTO
|
|
1152
|
+
* @param parent - The parent DTO (or null)
|
|
1153
|
+
*/
|
|
1154
|
+
executeConstructQuery(query, parent) {
|
|
1155
|
+
// Wrap parent if provided
|
|
1156
|
+
let wrappedParent = null;
|
|
1157
|
+
if (parent) {
|
|
1158
|
+
const conversionContext = this.#createConversionContext();
|
|
1159
|
+
wrappedParent = createVirtualPersistentObject(parent, conversionContext);
|
|
1160
|
+
}
|
|
1161
|
+
// Get type from query's persistentObject
|
|
1162
|
+
const type = query.persistentObject?.type;
|
|
1163
|
+
if (!type)
|
|
1164
|
+
throw new Error("Query does not have a persistentObject type");
|
|
1165
|
+
const instance = this.createInstance(type);
|
|
1166
|
+
instance.onConstructQuery(query, wrappedParent);
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Executes onExecuteQuery lifecycle hook
|
|
1170
|
+
* @param query - The query DTO with textSearch, sortOptions, skip, top set
|
|
1171
|
+
* @param parent - The parent DTO (or null)
|
|
1172
|
+
* @param data - Default data from the query config (used if getEntities is not overridden)
|
|
1173
|
+
* @returns The query execution result with items and totalItems
|
|
1174
|
+
*/
|
|
1175
|
+
async executeQuery(query, parent, data) {
|
|
1176
|
+
// Wrap parent if provided
|
|
1177
|
+
let wrappedParent = null;
|
|
1178
|
+
if (parent) {
|
|
1179
|
+
const conversionContext = this.#createConversionContext();
|
|
1180
|
+
wrappedParent = createVirtualPersistentObject(parent, conversionContext);
|
|
1181
|
+
}
|
|
1182
|
+
// Get type from query's persistentObject
|
|
1183
|
+
const type = query.persistentObject?.type;
|
|
1184
|
+
if (!type)
|
|
1185
|
+
throw new Error("Query does not have a persistentObject type");
|
|
1186
|
+
const instance = this.createInstance(type);
|
|
1187
|
+
return await instance.onExecuteQuery(query, wrappedParent, data);
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Creates a conversion context for type conversions
|
|
1191
|
+
* @returns A ConversionContext with type conversion methods
|
|
1192
|
+
*/
|
|
1193
|
+
#createConversionContext() {
|
|
1194
|
+
return {
|
|
1195
|
+
getConvertedValue: (attr) => {
|
|
1196
|
+
return fromServiceValue(attr.value, attr.type);
|
|
1197
|
+
},
|
|
1198
|
+
setConvertedValue: (attr, value) => {
|
|
1199
|
+
attr.value = toServiceValue(value, attr.type);
|
|
1200
|
+
attr.isValueChanged = true;
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Business rule validator that supports built-in and custom rules
|
|
1208
|
+
*/
|
|
1209
|
+
class BusinessRuleValidator {
|
|
1210
|
+
#builtInRules = new Map();
|
|
1211
|
+
#customRules = new Map();
|
|
1212
|
+
constructor() {
|
|
1213
|
+
// Register all built-in rules
|
|
1214
|
+
this.#builtInRules.set("IsBase64", this.#validateIsBase64.bind(this));
|
|
1215
|
+
this.#builtInRules.set("IsEmail", this.#validateIsEmail.bind(this));
|
|
1216
|
+
this.#builtInRules.set("IsRegex", this.#validateIsRegex.bind(this));
|
|
1217
|
+
this.#builtInRules.set("IsUrl", this.#validateIsUrl.bind(this));
|
|
1218
|
+
this.#builtInRules.set("IsWord", this.#validateIsWord.bind(this));
|
|
1219
|
+
this.#builtInRules.set("MaxLength", this.#validateMaxLength.bind(this));
|
|
1220
|
+
this.#builtInRules.set("MaxValue", this.#validateMaxValue.bind(this));
|
|
1221
|
+
this.#builtInRules.set("MinLength", this.#validateMinLength.bind(this));
|
|
1222
|
+
this.#builtInRules.set("MinValue", this.#validateMinValue.bind(this));
|
|
1223
|
+
this.#builtInRules.set("NotEmpty", this.#validateNotEmpty.bind(this));
|
|
1224
|
+
this.#builtInRules.set("Required", this.#validateNotEmpty.bind(this)); // Required is same as NotEmpty
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Register a custom business rule
|
|
1228
|
+
* @throws Error if attempting to override a built-in rule
|
|
1229
|
+
*/
|
|
1230
|
+
registerCustomRule(name, validator) {
|
|
1231
|
+
if (this.#builtInRules.has(name))
|
|
1232
|
+
throw new Error(`Cannot override built-in rule: ${name}`);
|
|
1233
|
+
this.#customRules.set(name, validator);
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1236
|
+
* Validate an attribute against its rules
|
|
1237
|
+
* @returns Error message if validation fails, null if valid
|
|
1238
|
+
*/
|
|
1239
|
+
validateAttribute(attr) {
|
|
1240
|
+
// Check isRequired first
|
|
1241
|
+
if (attr.isRequired) {
|
|
1242
|
+
try {
|
|
1243
|
+
this.#validateNotEmpty(attr.value);
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
return error instanceof Error ? error.message : String(error);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// Parse and validate rules string
|
|
1250
|
+
if (!attr.rules)
|
|
1251
|
+
return null;
|
|
1252
|
+
const rules = this.parseRules(attr.rules);
|
|
1253
|
+
for (const rule of rules) {
|
|
1254
|
+
const validator = this.#builtInRules.get(rule.name) || this.#customRules.get(rule.name);
|
|
1255
|
+
if (!validator)
|
|
1256
|
+
throw new Error(`Unknown business rule: ${rule.name}`);
|
|
1257
|
+
try {
|
|
1258
|
+
validator(attr.value, ...rule.params);
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
return error instanceof Error ? error.message : String(error);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Parse a rules string into individual rules with parameters
|
|
1268
|
+
* Example: "NotEmpty; MaxLength(40); IsEmail" -> [
|
|
1269
|
+
* { name: "NotEmpty", params: [] },
|
|
1270
|
+
* { name: "MaxLength", params: [40] },
|
|
1271
|
+
* { name: "IsEmail", params: [] }
|
|
1272
|
+
* ]
|
|
1273
|
+
*/
|
|
1274
|
+
parseRules(rulesString) {
|
|
1275
|
+
if (!rulesString?.trim())
|
|
1276
|
+
return [];
|
|
1277
|
+
return rulesString
|
|
1278
|
+
.split(";")
|
|
1279
|
+
.map(rule => rule.trim())
|
|
1280
|
+
.filter(rule => rule.length > 0)
|
|
1281
|
+
.map(rule => this.#parseRule(rule));
|
|
1282
|
+
}
|
|
1283
|
+
#parseRule(ruleString) {
|
|
1284
|
+
const match = ruleString.match(/^(\w+)(?:\(([^)]*)\))?$/);
|
|
1285
|
+
if (!match)
|
|
1286
|
+
throw new Error(`Invalid rule format: ${ruleString}`);
|
|
1287
|
+
const name = match[1];
|
|
1288
|
+
const paramsString = match[2];
|
|
1289
|
+
if (!paramsString) {
|
|
1290
|
+
return { name, params: [] };
|
|
1291
|
+
}
|
|
1292
|
+
// Parse parameters (can be comma-separated)
|
|
1293
|
+
const params = paramsString
|
|
1294
|
+
.split(",")
|
|
1295
|
+
.map(p => p.trim())
|
|
1296
|
+
.map(p => {
|
|
1297
|
+
// Try to parse as number
|
|
1298
|
+
const num = Number(p);
|
|
1299
|
+
if (p !== "" && !isNaN(num))
|
|
1300
|
+
return num;
|
|
1301
|
+
// Try to parse as boolean
|
|
1302
|
+
if (p === "true")
|
|
1303
|
+
return true;
|
|
1304
|
+
if (p === "false")
|
|
1305
|
+
return false;
|
|
1306
|
+
// Otherwise return as string (remove quotes if present)
|
|
1307
|
+
return p.replace(/^["']|["']$/g, "");
|
|
1308
|
+
});
|
|
1309
|
+
return { name, params };
|
|
1310
|
+
}
|
|
1311
|
+
// Built-in validators - throw errors instead of returning strings
|
|
1312
|
+
#validateIsBase64(value) {
|
|
1313
|
+
if (value == null || value === "")
|
|
1314
|
+
return;
|
|
1315
|
+
const base64Regex = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1316
|
+
if (!base64Regex.test(String(value)))
|
|
1317
|
+
throw new Error("Value must be a valid base64 string");
|
|
1318
|
+
}
|
|
1319
|
+
#validateIsEmail(value) {
|
|
1320
|
+
if (value == null || value === "")
|
|
1321
|
+
return;
|
|
1322
|
+
// Only allow ASCII characters in email addresses
|
|
1323
|
+
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
1324
|
+
if (!emailRegex.test(String(value)))
|
|
1325
|
+
throw new Error("Email format is invalid");
|
|
1326
|
+
}
|
|
1327
|
+
#validateIsRegex(value) {
|
|
1328
|
+
if (value == null || value === "")
|
|
1329
|
+
return;
|
|
1330
|
+
try {
|
|
1331
|
+
new RegExp(String(value));
|
|
1332
|
+
}
|
|
1333
|
+
catch {
|
|
1334
|
+
throw new Error("Value must be a valid regular expression");
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
#validateIsUrl(value) {
|
|
1338
|
+
if (value == null || value === "")
|
|
1339
|
+
return;
|
|
1340
|
+
try {
|
|
1341
|
+
new URL(String(value));
|
|
1342
|
+
}
|
|
1343
|
+
catch {
|
|
1344
|
+
throw new Error("Value must be a valid URL");
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
#validateIsWord(value) {
|
|
1348
|
+
if (value == null || value === "")
|
|
1349
|
+
return;
|
|
1350
|
+
const wordRegex = /^\w+$/;
|
|
1351
|
+
if (!wordRegex.test(String(value)))
|
|
1352
|
+
throw new Error("Value must contain only word characters");
|
|
1353
|
+
}
|
|
1354
|
+
#validateMaxLength(value, maxLength) {
|
|
1355
|
+
if (value == null || value === "")
|
|
1356
|
+
return;
|
|
1357
|
+
const length = String(value).length;
|
|
1358
|
+
if (length > maxLength)
|
|
1359
|
+
throw new Error(`Maximum length is ${maxLength} characters`);
|
|
1360
|
+
}
|
|
1361
|
+
#validateMaxValue(value, maximum) {
|
|
1362
|
+
if (value == null || value === "")
|
|
1363
|
+
return;
|
|
1364
|
+
const num = Number(value);
|
|
1365
|
+
if (isNaN(num))
|
|
1366
|
+
throw new Error("Value must be a number");
|
|
1367
|
+
if (num > maximum)
|
|
1368
|
+
throw new Error(`Maximum value is ${maximum}`);
|
|
1369
|
+
}
|
|
1370
|
+
#validateMinLength(value, minLength) {
|
|
1371
|
+
if (value == null || value === "")
|
|
1372
|
+
return;
|
|
1373
|
+
const length = String(value).length;
|
|
1374
|
+
if (length < minLength)
|
|
1375
|
+
throw new Error(`Minimum length is ${minLength} characters`);
|
|
1376
|
+
}
|
|
1377
|
+
#validateMinValue(value, minimum) {
|
|
1378
|
+
if (value == null || value === "")
|
|
1379
|
+
return;
|
|
1380
|
+
const num = Number(value);
|
|
1381
|
+
if (isNaN(num))
|
|
1382
|
+
throw new Error("Value must be a number");
|
|
1383
|
+
if (num < minimum)
|
|
1384
|
+
throw new Error(`Minimum value is ${minimum}`);
|
|
1385
|
+
}
|
|
1386
|
+
#validateNotEmpty(value) {
|
|
1387
|
+
if (value == null || value === "" || (typeof value === "string" && value.trim() === ""))
|
|
1388
|
+
throw new Error("This field is required");
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Virtual implementation of ServiceHooks for testing without a backend
|
|
1394
|
+
*/
|
|
1395
|
+
class VirtualServiceHooks extends ServiceHooks {
|
|
1396
|
+
#persistentObjectRegistry;
|
|
1397
|
+
#queryRegistry;
|
|
1398
|
+
#actionDefinitions = new Map();
|
|
1399
|
+
#actionHandlers = new Map();
|
|
1400
|
+
#validator;
|
|
1401
|
+
#persistentObjectActionsRegistry;
|
|
1402
|
+
#builtInActions = new Set(["New", "Delete", "SelectReference", "RefreshQuery", "Edit", "CancelEdit", "Save", "EndEdit"]);
|
|
1403
|
+
constructor() {
|
|
1404
|
+
super();
|
|
1405
|
+
this.#validator = new BusinessRuleValidator();
|
|
1406
|
+
this.#persistentObjectActionsRegistry = new VirtualPersistentObjectActionsRegistry();
|
|
1407
|
+
this.#queryRegistry = new VirtualQueryRegistry(this.#persistentObjectActionsRegistry);
|
|
1408
|
+
this.#persistentObjectRegistry = new VirtualPersistentObjectRegistry(this.#validator, this.#actionHandlers, this.#queryRegistry, this.#persistentObjectActionsRegistry);
|
|
1409
|
+
// Register default action definitions (these are built-in actions without custom handlers)
|
|
1410
|
+
this.#actionDefinitions.set("AddReference", { name: "AddReference", displayName: "Add", isPinned: false });
|
|
1411
|
+
this.#actionDefinitions.set("BulkEdit", { name: "BulkEdit", displayName: "Edit", isPinned: false });
|
|
1412
|
+
this.#actionDefinitions.set("CancelEdit", { name: "CancelEdit", displayName: "Cancel", isPinned: false });
|
|
1413
|
+
this.#actionDefinitions.set("CancelSave", { name: "CancelSave", displayName: "Cancel", isPinned: false });
|
|
1414
|
+
this.#actionDefinitions.set("Delete", { name: "Delete", displayName: "Delete", isPinned: false });
|
|
1415
|
+
this.#actionDefinitions.set("Edit", { name: "Edit", displayName: "Edit", isPinned: false });
|
|
1416
|
+
this.#actionDefinitions.set("EndEdit", { name: "EndEdit", displayName: "Save", isPinned: false });
|
|
1417
|
+
this.#actionDefinitions.set("Filter", { name: "Filter", displayName: "", isPinned: false });
|
|
1418
|
+
this.#actionDefinitions.set("New", { name: "New", displayName: "New", isPinned: false });
|
|
1419
|
+
this.#actionDefinitions.set("RefreshQuery", { name: "RefreshQuery", displayName: "", isPinned: false });
|
|
1420
|
+
this.#actionDefinitions.set("Remove", { name: "Remove", displayName: "Remove", isPinned: false });
|
|
1421
|
+
this.#actionDefinitions.set("Save", { name: "Save", displayName: "Save", isPinned: false });
|
|
1422
|
+
this.#actionDefinitions.set("SelectReference", { name: "SelectReference", displayName: "Select", isPinned: false });
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Intercepts fetch requests and routes them to virtual handlers
|
|
1426
|
+
*/
|
|
1427
|
+
async onFetch(request) {
|
|
1428
|
+
const url = new URL(request.url);
|
|
1429
|
+
const pathname = url.pathname;
|
|
1430
|
+
const method = pathname.includes("GetClientData") ? "GetClientData" : pathname.split('/').pop();
|
|
1431
|
+
const body = request.method === "POST" ? await request.clone().json() : null;
|
|
1432
|
+
let result;
|
|
1433
|
+
try {
|
|
1434
|
+
// Route to appropriate handler based on service method
|
|
1435
|
+
switch (method) {
|
|
1436
|
+
case "GetClientData":
|
|
1437
|
+
result = this.#handleGetClientData();
|
|
1438
|
+
break;
|
|
1439
|
+
case "GetApplication":
|
|
1440
|
+
result = this.#handleGetApplication();
|
|
1441
|
+
break;
|
|
1442
|
+
case "GetQuery":
|
|
1443
|
+
result = await this.#handleGetQuery(body);
|
|
1444
|
+
break;
|
|
1445
|
+
case "ExecuteQuery":
|
|
1446
|
+
result = await this.#handleExecuteQuery(body);
|
|
1447
|
+
break;
|
|
1448
|
+
case "GetPersistentObject":
|
|
1449
|
+
result = await this.#handleGetPersistentObject(body);
|
|
1450
|
+
break;
|
|
1451
|
+
case "ExecuteAction":
|
|
1452
|
+
result = await this.#handleExecuteAction(body);
|
|
1453
|
+
break;
|
|
1454
|
+
default:
|
|
1455
|
+
throw new Error(`Virtual handler not implemented for method: ${method}`);
|
|
1456
|
+
}
|
|
1457
|
+
return new Response(JSON.stringify(result), {
|
|
1458
|
+
headers: { "content-type": "application/json" }
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
catch (error) {
|
|
1462
|
+
const errorResponse = {
|
|
1463
|
+
exception: error instanceof Error ? error.message : String(error)
|
|
1464
|
+
};
|
|
1465
|
+
// Return 200 OK with exception in JSON body (like real service does)
|
|
1466
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
1467
|
+
status: 200,
|
|
1468
|
+
headers: { "content-type": "application/json" }
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Registers a PersistentObject configuration
|
|
1474
|
+
*/
|
|
1475
|
+
registerPersistentObject(config) {
|
|
1476
|
+
// Validate configuration
|
|
1477
|
+
if (!config.type)
|
|
1478
|
+
throw new Error("VirtualPersistentObjectConfig.type is required");
|
|
1479
|
+
if (!config.attributes || config.attributes.length === 0)
|
|
1480
|
+
throw new Error("VirtualPersistentObjectConfig.attributes must have at least one attribute");
|
|
1481
|
+
// Validate that referenced actions are registered (skip built-in actions)
|
|
1482
|
+
if (config.actions) {
|
|
1483
|
+
config.actions.forEach(actionName => {
|
|
1484
|
+
if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
|
|
1485
|
+
throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
// Validate that referenced queries are registered
|
|
1489
|
+
if (config.queries) {
|
|
1490
|
+
config.queries.forEach(queryName => {
|
|
1491
|
+
if (!this.#queryRegistry.hasQuery(queryName))
|
|
1492
|
+
throw new Error(`Query "${queryName}" is not registered. Call registerQuery first.`);
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
// Validate that lookup queries for reference attributes are registered
|
|
1496
|
+
if (config.attributes) {
|
|
1497
|
+
config.attributes.forEach(attr => {
|
|
1498
|
+
if (attr.lookup) {
|
|
1499
|
+
if (!this.#queryRegistry.hasQuery(attr.lookup))
|
|
1500
|
+
throw new Error(`Lookup query "${attr.lookup}" for attribute "${attr.name}" is not registered. Call registerQuery first.`);
|
|
1501
|
+
}
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
// Register with registry
|
|
1505
|
+
this.#persistentObjectRegistry.register(config);
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Registers a Query configuration
|
|
1509
|
+
*/
|
|
1510
|
+
registerQuery(config) {
|
|
1511
|
+
// Validate configuration
|
|
1512
|
+
if (!config.name)
|
|
1513
|
+
throw new Error("VirtualQueryConfig.name is required");
|
|
1514
|
+
if (!config.persistentObject)
|
|
1515
|
+
throw new Error("VirtualQueryConfig.persistentObject is required");
|
|
1516
|
+
// Verify PersistentObject is already registered
|
|
1517
|
+
const persistentObjectConfig = this.#persistentObjectRegistry.getConfig(config.persistentObject);
|
|
1518
|
+
if (!persistentObjectConfig)
|
|
1519
|
+
throw new Error(`PersistentObject type '${config.persistentObject}' must be registered before creating a query. Call registerPersistentObject first.`);
|
|
1520
|
+
// Validate that referenced actions are registered (skip built-in actions)
|
|
1521
|
+
if (config.actions) {
|
|
1522
|
+
config.actions.forEach(actionName => {
|
|
1523
|
+
if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
|
|
1524
|
+
throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
if (config.itemActions) {
|
|
1528
|
+
config.itemActions.forEach(actionName => {
|
|
1529
|
+
if (!this.#builtInActions.has(actionName) && !this.#actionHandlers.has(actionName))
|
|
1530
|
+
throw new Error(`Action "${actionName}" is not registered. Call registerAction first.`);
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
// Register with query registry
|
|
1534
|
+
this.#queryRegistry.register(config, persistentObjectConfig);
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Registers a custom action that can be used on PersistentObjects and Queries
|
|
1538
|
+
* @param config - The action configuration with handler
|
|
1539
|
+
*/
|
|
1540
|
+
registerAction(config) {
|
|
1541
|
+
// Validate configuration
|
|
1542
|
+
if (!config.name)
|
|
1543
|
+
throw new Error("ActionConfig.name is required");
|
|
1544
|
+
if (!config.handler)
|
|
1545
|
+
throw new Error("ActionConfig.handler is required");
|
|
1546
|
+
// Register action definition
|
|
1547
|
+
this.#actionDefinitions.set(config.name, {
|
|
1548
|
+
name: config.name,
|
|
1549
|
+
displayName: config.displayName || config.name,
|
|
1550
|
+
isPinned: config.isPinned || false
|
|
1551
|
+
});
|
|
1552
|
+
// Register action handler
|
|
1553
|
+
this.#actionHandlers.set(config.name, config.handler);
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Registers a custom business rule for validation
|
|
1557
|
+
* @param name - The rule name (cannot override built-in rules)
|
|
1558
|
+
* @param validator - The validation function
|
|
1559
|
+
*/
|
|
1560
|
+
registerBusinessRule(name, validator) {
|
|
1561
|
+
this.#validator.registerCustomRule(name, validator);
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Registers a VirtualPersistentObjectActions class for a specific type
|
|
1565
|
+
* @param type - The PersistentObject type name
|
|
1566
|
+
* @param ActionsClass - The VirtualPersistentObjectActions class constructor
|
|
1567
|
+
*/
|
|
1568
|
+
registerPersistentObjectActions(type, ActionsClass) {
|
|
1569
|
+
this.#persistentObjectActionsRegistry.register(type, ActionsClass);
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* Handles GetClientData requests
|
|
1573
|
+
*/
|
|
1574
|
+
#handleGetClientData() {
|
|
1575
|
+
return {
|
|
1576
|
+
defaultUser: "VirtualUser",
|
|
1577
|
+
exception: null,
|
|
1578
|
+
languages: {
|
|
1579
|
+
"en": {
|
|
1580
|
+
name: "English",
|
|
1581
|
+
isDefault: true,
|
|
1582
|
+
messages: {
|
|
1583
|
+
"OK": "OK",
|
|
1584
|
+
"Cancel": "Cancel",
|
|
1585
|
+
"Save": "Save"
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
},
|
|
1589
|
+
providers: {
|
|
1590
|
+
"Vidyano": {
|
|
1591
|
+
parameters: {
|
|
1592
|
+
label: "Vidyano",
|
|
1593
|
+
description: "Vidyano Provider",
|
|
1594
|
+
requestUri: null,
|
|
1595
|
+
signOutUri: null
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
windowsAuthentication: false
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Handles GetApplication requests
|
|
1604
|
+
*/
|
|
1605
|
+
#handleGetApplication() {
|
|
1606
|
+
// Build action definition items
|
|
1607
|
+
const actionItems = Array.from(this.#actionDefinitions.values()).map(action => ({
|
|
1608
|
+
id: crypto.randomUUID(),
|
|
1609
|
+
values: [
|
|
1610
|
+
{ key: "Name", value: action.name },
|
|
1611
|
+
{ key: "DisplayName", value: action.displayName },
|
|
1612
|
+
{ key: "IsPinned", value: String(action.isPinned) }
|
|
1613
|
+
]
|
|
1614
|
+
}));
|
|
1615
|
+
// Create a minimal application PersistentObject with required attributes
|
|
1616
|
+
const appPo = {
|
|
1617
|
+
id: crypto.randomUUID(),
|
|
1618
|
+
objectId: "Application",
|
|
1619
|
+
type: "Application",
|
|
1620
|
+
fullTypeName: "Vidyano.Application",
|
|
1621
|
+
label: "Virtual Application",
|
|
1622
|
+
isNew: false,
|
|
1623
|
+
isReadOnly: true,
|
|
1624
|
+
stateBehavior: "StayInEdit",
|
|
1625
|
+
attributes: [
|
|
1626
|
+
{ name: "UserId", type: "String", value: "VirtualUserId", id: crypto.randomUUID(), offset: 0, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1627
|
+
{ name: "FriendlyUserName", type: "String", value: "Virtual User", id: crypto.randomUUID(), offset: 1, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1628
|
+
{ name: "FeedbackId", type: "String", value: null, id: crypto.randomUUID(), offset: 2, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1629
|
+
{ name: "UserSettingsId", type: "String", value: null, id: crypto.randomUUID(), offset: 3, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1630
|
+
{ name: "GlobalSearchId", type: "String", value: null, id: crypto.randomUUID(), offset: 4, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1631
|
+
{ name: "AnalyticsKey", type: "String", value: null, id: crypto.randomUUID(), offset: 5, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1632
|
+
{ name: "Routes", type: "String", value: JSON.stringify({ programUnits: {}, persistentObjects: {}, queries: {}, persistentObjectKeys: [], queryKeys: [] }), id: crypto.randomUUID(), offset: 6, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1633
|
+
{ name: "UserSettings", type: "String", value: "{}", id: crypto.randomUUID(), offset: 7, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1634
|
+
{ name: "CanProfile", type: "Boolean", value: "False", id: crypto.randomUUID(), offset: 8, label: "", visibility: "Always", toolTip: "", group: "", tab: "" },
|
|
1635
|
+
{ name: "ProgramUnits", type: "String", value: JSON.stringify({ hasManagement: false, units: [] }), id: crypto.randomUUID(), offset: 9, label: "", visibility: "Always", toolTip: "", group: "", tab: "" }
|
|
1636
|
+
],
|
|
1637
|
+
actions: [],
|
|
1638
|
+
tabs: {},
|
|
1639
|
+
queries: [
|
|
1640
|
+
// Empty Resources query
|
|
1641
|
+
{
|
|
1642
|
+
id: crypto.randomUUID(),
|
|
1643
|
+
name: "Resources",
|
|
1644
|
+
label: "Resources",
|
|
1645
|
+
columns: [],
|
|
1646
|
+
persistentObject: {
|
|
1647
|
+
type: "Resource",
|
|
1648
|
+
id: crypto.randomUUID(),
|
|
1649
|
+
objectId: null,
|
|
1650
|
+
label: "Resource",
|
|
1651
|
+
isReadOnly: true,
|
|
1652
|
+
attributes: [],
|
|
1653
|
+
actions: [],
|
|
1654
|
+
tabs: {},
|
|
1655
|
+
queries: [],
|
|
1656
|
+
fullTypeName: "Vidyano.Resource"
|
|
1657
|
+
},
|
|
1658
|
+
result: {
|
|
1659
|
+
charts: [],
|
|
1660
|
+
columns: [],
|
|
1661
|
+
items: [],
|
|
1662
|
+
sortOptions: ""
|
|
1663
|
+
}
|
|
1664
|
+
},
|
|
1665
|
+
// Actions query with registered actions
|
|
1666
|
+
{
|
|
1667
|
+
id: crypto.randomUUID(),
|
|
1668
|
+
name: "Actions",
|
|
1669
|
+
label: "Actions",
|
|
1670
|
+
columns: [
|
|
1671
|
+
{ id: "Name", name: "Name", label: "Name", type: "String", offset: 0, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
|
|
1672
|
+
{ id: "DisplayName", name: "DisplayName", label: "Display Name", type: "String", offset: 1, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
|
|
1673
|
+
{ id: "IsPinned", name: "IsPinned", label: "Is Pinned", type: "Boolean", offset: 2, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] }
|
|
1674
|
+
],
|
|
1675
|
+
persistentObject: {
|
|
1676
|
+
type: "ActionDefinition",
|
|
1677
|
+
id: crypto.randomUUID(),
|
|
1678
|
+
objectId: null,
|
|
1679
|
+
label: "Action Definition",
|
|
1680
|
+
isReadOnly: true,
|
|
1681
|
+
attributes: [],
|
|
1682
|
+
actions: [],
|
|
1683
|
+
tabs: {},
|
|
1684
|
+
queries: [],
|
|
1685
|
+
fullTypeName: "Vidyano.ActionDefinition"
|
|
1686
|
+
},
|
|
1687
|
+
result: {
|
|
1688
|
+
charts: [],
|
|
1689
|
+
columns: [
|
|
1690
|
+
{ id: "Name", name: "Name", label: "Name", type: "String", offset: 0, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
|
|
1691
|
+
{ id: "DisplayName", name: "DisplayName", label: "Display Name", type: "String", offset: 1, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] },
|
|
1692
|
+
{ id: "IsPinned", name: "IsPinned", label: "Is Pinned", type: "Boolean", offset: 2, canFilter: false, canGroupBy: false, canListDistincts: false, canSort: false, isHidden: false, includes: [], excludes: [] }
|
|
1693
|
+
],
|
|
1694
|
+
items: actionItems,
|
|
1695
|
+
sortOptions: ""
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
]
|
|
1699
|
+
};
|
|
1700
|
+
return {
|
|
1701
|
+
application: appPo,
|
|
1702
|
+
userCultureInfo: "en-US",
|
|
1703
|
+
userLanguage: "en",
|
|
1704
|
+
userName: "VirtualUser",
|
|
1705
|
+
hasSensitive: false
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Handles GetQuery requests
|
|
1710
|
+
*/
|
|
1711
|
+
async #handleGetQuery(request) {
|
|
1712
|
+
const queryName = request.id;
|
|
1713
|
+
const queryDto = await this.#queryRegistry.getQuery(queryName);
|
|
1714
|
+
return {
|
|
1715
|
+
query: queryDto
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Handles ExecuteQuery requests
|
|
1720
|
+
*/
|
|
1721
|
+
async #handleExecuteQuery(request) {
|
|
1722
|
+
const query = request.query;
|
|
1723
|
+
const result = await this.#queryRegistry.executeQuery(query, request.parent || null);
|
|
1724
|
+
return {
|
|
1725
|
+
result
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Handles GetPersistentObject requests
|
|
1730
|
+
*/
|
|
1731
|
+
async #handleGetPersistentObject(request) {
|
|
1732
|
+
const type = request.persistentObjectTypeId;
|
|
1733
|
+
const objectId = request.objectId || crypto.randomUUID();
|
|
1734
|
+
const isNew = request.isNew || false;
|
|
1735
|
+
const parent = request.parent || null;
|
|
1736
|
+
const po = await this.#persistentObjectRegistry.getPersistentObject(type, objectId, isNew, parent);
|
|
1737
|
+
return {
|
|
1738
|
+
result: po
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Handles ExecuteAction requests
|
|
1743
|
+
*/
|
|
1744
|
+
async #handleExecuteAction(request) {
|
|
1745
|
+
const actionName = request.action.split(".").pop();
|
|
1746
|
+
// Check if this is a query action
|
|
1747
|
+
const queryActionRequest = request;
|
|
1748
|
+
if (queryActionRequest.query) {
|
|
1749
|
+
// Query action - can have parent or not
|
|
1750
|
+
const queryDto = await this.#queryRegistry.getQuery(queryActionRequest.query.name);
|
|
1751
|
+
return await this.#executeQueryAction(request, queryDto, actionName);
|
|
1752
|
+
}
|
|
1753
|
+
// PersistentObject action - must have parent
|
|
1754
|
+
return await this.#persistentObjectRegistry.executeAction(request);
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Executes an action from a query context
|
|
1758
|
+
*/
|
|
1759
|
+
async #executeQueryAction(request, query, actionName) {
|
|
1760
|
+
// Note: query.persistentObject is the TEMPLATE/SCHEMA, not the parent!
|
|
1761
|
+
const parent = request.parent;
|
|
1762
|
+
// Handle built-in New action
|
|
1763
|
+
if (actionName === "New") {
|
|
1764
|
+
const type = query.persistentObject?.type;
|
|
1765
|
+
if (!type)
|
|
1766
|
+
throw new Error("Query does not have a persistentObject type");
|
|
1767
|
+
// Create a new PersistentObject with the full lifecycle
|
|
1768
|
+
const newPo = await this.#persistentObjectRegistry.createNewPersistentObject(type, parent, query, request.parameters || null);
|
|
1769
|
+
return {
|
|
1770
|
+
result: newPo
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
// Handle built-in SelectReference action
|
|
1774
|
+
if (actionName === "SelectReference") {
|
|
1775
|
+
if (!parent)
|
|
1776
|
+
throw new Error("SelectReference requires a parent PersistentObject");
|
|
1777
|
+
const attributeId = request.parameters?.PersistentObjectAttributeId;
|
|
1778
|
+
if (!attributeId)
|
|
1779
|
+
throw new Error("SelectReference requires PersistentObjectAttributeId parameter");
|
|
1780
|
+
// Find the reference attribute
|
|
1781
|
+
const refAttr = parent.attributes?.find(a => a.id === attributeId);
|
|
1782
|
+
if (!refAttr)
|
|
1783
|
+
throw new Error(`Attribute with id "${attributeId}" not found`);
|
|
1784
|
+
// Get selected items from the request
|
|
1785
|
+
const queryActionRequest = request;
|
|
1786
|
+
const selectedItems = queryActionRequest.selectedItems || [];
|
|
1787
|
+
const selectedItem = selectedItems.length > 0 ? selectedItems[0] : null;
|
|
1788
|
+
// Call onSelectReference - base implementation sets objectId/value
|
|
1789
|
+
const conversionContext = this.#createConversionContext();
|
|
1790
|
+
await this.#persistentObjectActionsRegistry.executeSelectReference(parent, refAttr, query, selectedItem, conversionContext);
|
|
1791
|
+
return {
|
|
1792
|
+
result: parent
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
// Handle built-in Delete action
|
|
1796
|
+
if (actionName === "Delete") {
|
|
1797
|
+
const type = query.persistentObject?.type;
|
|
1798
|
+
if (!type)
|
|
1799
|
+
throw new Error("Query does not have a persistentObject type");
|
|
1800
|
+
// Get selected items from the request
|
|
1801
|
+
const queryActionRequest = request;
|
|
1802
|
+
const selectedItems = queryActionRequest.selectedItems || [];
|
|
1803
|
+
if (selectedItems.length === 0)
|
|
1804
|
+
throw new Error("Delete requires at least one selected item");
|
|
1805
|
+
// Call onDelete lifecycle hook
|
|
1806
|
+
await this.#persistentObjectActionsRegistry.executeDelete(parent || null, query, selectedItems);
|
|
1807
|
+
// Return parent or empty result
|
|
1808
|
+
return {
|
|
1809
|
+
result: parent || null
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
// Get action handler
|
|
1813
|
+
const handler = this.#actionHandlers.get(actionName);
|
|
1814
|
+
if (!handler)
|
|
1815
|
+
throw new Error(`Action "${actionName}" is not registered`);
|
|
1816
|
+
// Create action context - use parent if provided, otherwise fall back to query's template PO
|
|
1817
|
+
const contextPo = parent || query.persistentObject;
|
|
1818
|
+
const context = this.#createActionContext(contextPo);
|
|
1819
|
+
// Build unified action args
|
|
1820
|
+
const queryActionRequest = request;
|
|
1821
|
+
const args = {
|
|
1822
|
+
parent: parent ?? null,
|
|
1823
|
+
query: query,
|
|
1824
|
+
selectedItems: queryActionRequest.selectedItems,
|
|
1825
|
+
parameters: request.parameters,
|
|
1826
|
+
context: context
|
|
1827
|
+
};
|
|
1828
|
+
// Execute handler
|
|
1829
|
+
const result = await handler(args);
|
|
1830
|
+
// Handle result - use parent if available, otherwise use template
|
|
1831
|
+
const finalResult = this.#normalizeActionResult(result, contextPo);
|
|
1832
|
+
return {
|
|
1833
|
+
result: finalResult
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Creates an action context for attribute manipulation
|
|
1838
|
+
*/
|
|
1839
|
+
#createActionContext(po) {
|
|
1840
|
+
return {
|
|
1841
|
+
getAttribute: (name) => {
|
|
1842
|
+
return po.attributes?.find(a => a.name === name);
|
|
1843
|
+
},
|
|
1844
|
+
getAttributeValue: (name) => {
|
|
1845
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
1846
|
+
if (!attr)
|
|
1847
|
+
return undefined;
|
|
1848
|
+
return fromServiceValue(attr.value, attr.type);
|
|
1849
|
+
},
|
|
1850
|
+
setAttributeValue: (name, value) => {
|
|
1851
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
1852
|
+
if (attr) {
|
|
1853
|
+
attr.value = toServiceValue(value, attr.type);
|
|
1854
|
+
attr.isValueChanged = true;
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
getConvertedValue: (attr) => {
|
|
1858
|
+
return fromServiceValue(attr.value, attr.type);
|
|
1859
|
+
},
|
|
1860
|
+
setConvertedValue: (attr, value) => {
|
|
1861
|
+
attr.value = toServiceValue(value, attr.type);
|
|
1862
|
+
attr.isValueChanged = true;
|
|
1863
|
+
},
|
|
1864
|
+
setValidationError: (name, error) => {
|
|
1865
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
1866
|
+
if (attr)
|
|
1867
|
+
attr.validationError = error;
|
|
1868
|
+
},
|
|
1869
|
+
clearValidationError: (name) => {
|
|
1870
|
+
const attr = po.attributes?.find(a => a.name === name);
|
|
1871
|
+
if (attr)
|
|
1872
|
+
attr.validationError = undefined;
|
|
1873
|
+
},
|
|
1874
|
+
setNotification: (message, type, duration) => {
|
|
1875
|
+
po.notification = message;
|
|
1876
|
+
po.notificationType = type;
|
|
1877
|
+
po.notificationDuration = duration;
|
|
1878
|
+
}
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Creates a conversion context for type conversions
|
|
1883
|
+
*/
|
|
1884
|
+
#createConversionContext() {
|
|
1885
|
+
return {
|
|
1886
|
+
getConvertedValue: (attr) => {
|
|
1887
|
+
return fromServiceValue(attr.value, attr.type);
|
|
1888
|
+
},
|
|
1889
|
+
setConvertedValue: (attr, value) => {
|
|
1890
|
+
attr.value = toServiceValue(value, attr.type);
|
|
1891
|
+
attr.isValueChanged = true;
|
|
1892
|
+
}
|
|
1893
|
+
};
|
|
1894
|
+
}
|
|
1895
|
+
/**
|
|
1896
|
+
* Normalizes action result (handles both old and new API formats)
|
|
1897
|
+
*/
|
|
1898
|
+
#normalizeActionResult(result, defaultParent) {
|
|
1899
|
+
// Handle both old and new API formats for backwards compatibility
|
|
1900
|
+
// Old API: handler returns { result: PersistentObjectDto }
|
|
1901
|
+
// New API: handler returns PersistentObjectDto | null
|
|
1902
|
+
if (result && typeof result === "object" && "result" in result) {
|
|
1903
|
+
// Old API format - extract the result property
|
|
1904
|
+
return result.result || defaultParent;
|
|
1905
|
+
}
|
|
1906
|
+
// New API format - use directly
|
|
1907
|
+
return result || defaultParent;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* A virtual service for testing without a backend.
|
|
1913
|
+
*
|
|
1914
|
+
* Register PersistentObjects, Queries, Actions, and BusinessRules before calling initialize().
|
|
1915
|
+
* Once initialize() is called, no more registrations are allowed.
|
|
1916
|
+
*
|
|
1917
|
+
* @example
|
|
1918
|
+
* ```typescript
|
|
1919
|
+
* const service = new VirtualService();
|
|
1920
|
+
* service.registerPersistentObject({ type: "Person", attributes: [...] });
|
|
1921
|
+
* service.registerQuery({ name: "AllPersons", persistentObject: "Person" });
|
|
1922
|
+
* await service.initialize();
|
|
1923
|
+
*
|
|
1924
|
+
* // Now use the service
|
|
1925
|
+
* const query = await service.getQuery("AllPersons");
|
|
1926
|
+
* ```
|
|
1927
|
+
*/
|
|
1928
|
+
class VirtualService extends Service {
|
|
1929
|
+
#isInitialized = false;
|
|
1930
|
+
/**
|
|
1931
|
+
* Creates a new VirtualService instance.
|
|
1932
|
+
* @param hooks - Optional custom VirtualServiceHooks. If not provided, a default instance is created.
|
|
1933
|
+
*/
|
|
1934
|
+
constructor(hooks) {
|
|
1935
|
+
super("http://virtual.local", hooks ?? new VirtualServiceHooks(), true);
|
|
1936
|
+
}
|
|
1937
|
+
/**
|
|
1938
|
+
* Gets the VirtualServiceHooks instance.
|
|
1939
|
+
*/
|
|
1940
|
+
get virtualHooks() {
|
|
1941
|
+
return this.hooks;
|
|
1942
|
+
}
|
|
1943
|
+
async initialize(arg) {
|
|
1944
|
+
this.#isInitialized = true;
|
|
1945
|
+
return super.initialize(arg);
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Registers a PersistentObject configuration.
|
|
1949
|
+
* Must be called before initialize().
|
|
1950
|
+
* @param config - The PersistentObject configuration.
|
|
1951
|
+
* @throws Error if called after initialize().
|
|
1952
|
+
*/
|
|
1953
|
+
registerPersistentObject(config) {
|
|
1954
|
+
this.#ensureNotInitialized();
|
|
1955
|
+
this.virtualHooks.registerPersistentObject(config);
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Registers a Query configuration.
|
|
1959
|
+
* Must be called before initialize().
|
|
1960
|
+
* @param config - The Query configuration.
|
|
1961
|
+
* @throws Error if called after initialize().
|
|
1962
|
+
*/
|
|
1963
|
+
registerQuery(config) {
|
|
1964
|
+
this.#ensureNotInitialized();
|
|
1965
|
+
this.virtualHooks.registerQuery(config);
|
|
1966
|
+
}
|
|
1967
|
+
/**
|
|
1968
|
+
* Registers a custom action that can be used on PersistentObjects and Queries.
|
|
1969
|
+
* Must be called before initialize().
|
|
1970
|
+
* @param config - The action configuration with handler.
|
|
1971
|
+
* @throws Error if called after initialize().
|
|
1972
|
+
*/
|
|
1973
|
+
registerAction(config) {
|
|
1974
|
+
this.#ensureNotInitialized();
|
|
1975
|
+
this.virtualHooks.registerAction(config);
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* Registers a custom business rule for validation.
|
|
1979
|
+
* Must be called before initialize().
|
|
1980
|
+
* @param name - The rule name (cannot override built-in rules).
|
|
1981
|
+
* @param validator - The validation function.
|
|
1982
|
+
* @throws Error if called after initialize().
|
|
1983
|
+
*/
|
|
1984
|
+
registerBusinessRule(name, validator) {
|
|
1985
|
+
this.#ensureNotInitialized();
|
|
1986
|
+
this.virtualHooks.registerBusinessRule(name, validator);
|
|
1987
|
+
}
|
|
1988
|
+
/**
|
|
1989
|
+
* Registers a VirtualPersistentObjectActions class for a specific type.
|
|
1990
|
+
* Must be called before initialize().
|
|
1991
|
+
* @param type - The PersistentObject type name.
|
|
1992
|
+
* @param ActionsClass - The VirtualPersistentObjectActions class constructor.
|
|
1993
|
+
* @throws Error if called after initialize().
|
|
1994
|
+
*/
|
|
1995
|
+
registerPersistentObjectActions(type, ActionsClass) {
|
|
1996
|
+
this.#ensureNotInitialized();
|
|
1997
|
+
this.virtualHooks.registerPersistentObjectActions(type, ActionsClass);
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Throws an error if the service has already been initialized.
|
|
2001
|
+
*/
|
|
2002
|
+
#ensureNotInitialized() {
|
|
2003
|
+
if (this.#isInitialized)
|
|
2004
|
+
throw new Error("Cannot register after initialize() has been called");
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
export { VirtualPersistentObjectActions, VirtualService, VirtualServiceHooks };
|