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