@memberjunction/core 3.4.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +2 -6
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/QueryCache.d.ts +1 -1
- package/dist/generic/QueryCache.js +6 -10
- package/dist/generic/QueryCache.js.map +1 -1
- package/dist/generic/QueryCacheConfig.js +1 -2
- package/dist/generic/RegisterForStartup.d.ts +2 -2
- package/dist/generic/RegisterForStartup.js +7 -12
- package/dist/generic/RegisterForStartup.js.map +1 -1
- package/dist/generic/applicationInfo.d.ts +3 -3
- package/dist/generic/applicationInfo.js +4 -10
- package/dist/generic/applicationInfo.js.map +1 -1
- package/dist/generic/authEvaluator.d.ts +1 -1
- package/dist/generic/authEvaluator.js +4 -8
- package/dist/generic/authEvaluator.js.map +1 -1
- package/dist/generic/authTypes.js +1 -4
- package/dist/generic/authTypes.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +5 -5
- package/dist/generic/baseEngine.js +51 -56
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.js +13 -17
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +171 -5
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +651 -121
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.js +3 -7
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +2 -2
- package/dist/generic/compositeKey.js +5 -11
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +2 -2
- package/dist/generic/databaseProviderBase.js +2 -6
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +84 -5
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +235 -108
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/explorerNavigationItem.d.ts +1 -1
- package/dist/generic/explorerNavigationItem.js +2 -6
- package/dist/generic/explorerNavigationItem.js.map +1 -1
- package/dist/generic/graphqlTypeNames.d.ts +1 -1
- package/dist/generic/graphqlTypeNames.js +4 -9
- package/dist/generic/graphqlTypeNames.js.map +1 -1
- package/dist/generic/interfaces.d.ts +104 -14
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +28 -30
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/libraryInfo.d.ts +1 -1
- package/dist/generic/libraryInfo.js +2 -6
- package/dist/generic/libraryInfo.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +2 -2
- package/dist/generic/localCacheManager.js +44 -48
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +54 -67
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +12 -12
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +21 -25
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/metadataUtil.d.ts +1 -1
- package/dist/generic/metadataUtil.js +3 -7
- package/dist/generic/metadataUtil.js.map +1 -1
- package/dist/generic/providerBase.d.ts +63 -16
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +253 -130
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +5 -5
- package/dist/generic/queryInfo.js +21 -30
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.js +1 -2
- package/dist/generic/queryInfoInterfaces.js.map +1 -1
- package/dist/generic/querySQLFilters.js +5 -10
- package/dist/generic/querySQLFilters.js.map +1 -1
- package/dist/generic/runQuery.d.ts +2 -2
- package/dist/generic/runQuery.js +5 -9
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +4 -8
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/runReport.d.ts +2 -2
- package/dist/generic/runReport.js +5 -9
- package/dist/generic/runReport.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +2 -2
- package/dist/generic/securityInfo.js +10 -20
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.js +20 -32
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +1 -1
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +11 -19
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.js +15 -31
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +34 -34
- package/dist/index.js +45 -63
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +3 -3
- package/dist/views/runView.js +6 -11
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewInfo.d.ts +3 -3
- package/dist/views/viewInfo.js +10 -17
- package/dist/views/viewInfo.js.map +1 -1
- package/package.json +11 -10
- package/readme.md +871 -1271
|
@@ -1,19 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const logging_1 = require("./logging");
|
|
10
|
-
const compositeKey_1 = require("./compositeKey");
|
|
11
|
-
const rxjs_1 = require("rxjs");
|
|
1
|
+
import { MJEventType, MJGlobal, uuidv4, WarningManager } from '@memberjunction/global';
|
|
2
|
+
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
3
|
+
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
4
|
+
import { Metadata } from './metadata.js';
|
|
5
|
+
import { RunView } from '../views/runView.js';
|
|
6
|
+
import { LogDebug, LogError } from './logging.js';
|
|
7
|
+
import { CompositeKey } from './compositeKey.js';
|
|
8
|
+
import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
|
|
12
9
|
/**
|
|
13
10
|
* Represents a field in an instance of the BaseEntity class. This class is used to store the value of the field, dirty state, as well as other run-time information about the field. The class encapsulates the underlying field metadata and exposes some of the more commonly
|
|
14
11
|
* used properties from the entity field metadata.
|
|
15
12
|
*/
|
|
16
|
-
class EntityField {
|
|
13
|
+
export class EntityField {
|
|
14
|
+
/**
|
|
15
|
+
* Static object containing the value ranges for various SQL number types.
|
|
16
|
+
* This is used to validate the value of the field when it is set or validated.
|
|
17
|
+
*/
|
|
18
|
+
static { this.SQLTypeValueRanges = {
|
|
19
|
+
"int": { min: -2147483648, max: 2147483647 },
|
|
20
|
+
"bigint": { min: -9223372036854775808, max: 9223372036854775807 },
|
|
21
|
+
"smallint": { min: -32768, max: 32767 },
|
|
22
|
+
"tinyint": { min: 0, max: 255 },
|
|
23
|
+
"decimal": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
|
|
24
|
+
"numeric": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
|
|
25
|
+
"float": { min: -1.7976931348623157e+308, max: 1.7976931348623157e+308 },
|
|
26
|
+
"real": { min: -3.402823466e+38, max: 3.402823466e+38 },
|
|
27
|
+
"money": { min: -922337203685477.5808, max: 922337203685477.5807 },
|
|
28
|
+
}; }
|
|
17
29
|
get Name() {
|
|
18
30
|
return this._entityFieldInfo.Name;
|
|
19
31
|
}
|
|
@@ -45,7 +57,7 @@ class EntityField {
|
|
|
45
57
|
// Asserting status here for deprecated or disabled fields, not in constructor because
|
|
46
58
|
// we legacy fields will exist
|
|
47
59
|
if (this._assertActiveStatusRequired) {
|
|
48
|
-
|
|
60
|
+
EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
|
|
49
61
|
}
|
|
50
62
|
return this._Value;
|
|
51
63
|
}
|
|
@@ -75,7 +87,7 @@ class EntityField {
|
|
|
75
87
|
// asserting status here becuase the flag is on AND the values
|
|
76
88
|
// are different - this avoid assertions during sysops like SetMany that often aren't changing
|
|
77
89
|
// the value of the field
|
|
78
|
-
|
|
90
|
+
EntityFieldInfo.AssertEntityFieldActiveStatus(this._entityFieldInfo, 'EntityField.Value setter');
|
|
79
91
|
}
|
|
80
92
|
if (!this.ReadOnly ||
|
|
81
93
|
this._NeverSet /* Allow one time set of any field because BaseEntity Object passes in ReadOnly fields when we load,
|
|
@@ -121,7 +133,7 @@ class EntityField {
|
|
|
121
133
|
newCompare = this.Value.getTime();
|
|
122
134
|
}
|
|
123
135
|
// Special handling for bit/Boolean types - treat truthy values as equivalent
|
|
124
|
-
if (this._entityFieldInfo.TSType ===
|
|
136
|
+
if (this._entityFieldInfo.TSType === EntityFieldTSType.Boolean ||
|
|
125
137
|
this._entityFieldInfo.Type.toLowerCase() === 'bit') {
|
|
126
138
|
// Convert both values to boolean for comparison
|
|
127
139
|
const oldBool = this.convertToBoolean(oldCompare);
|
|
@@ -129,7 +141,7 @@ class EntityField {
|
|
|
129
141
|
return oldBool !== newBool;
|
|
130
142
|
}
|
|
131
143
|
// Special handling for numeric types - treat numeric strings that convert to same value as equivalent
|
|
132
|
-
if (this._entityFieldInfo.TSType ===
|
|
144
|
+
if (this._entityFieldInfo.TSType === EntityFieldTSType.Number || this.isNumericType(this._entityFieldInfo.Type)) {
|
|
133
145
|
const oldNum = this.convertToNumber(oldCompare);
|
|
134
146
|
const newNum = this.convertToNumber(newCompare);
|
|
135
147
|
// Handle NaN cases - if both are NaN, they're equivalent
|
|
@@ -143,7 +155,7 @@ class EntityField {
|
|
|
143
155
|
return oldNum !== newNum;
|
|
144
156
|
}
|
|
145
157
|
// for string types where the comparisons are not both strings
|
|
146
|
-
if (this._entityFieldInfo.TSType ===
|
|
158
|
+
if (this._entityFieldInfo.TSType === EntityFieldTSType.String) {
|
|
147
159
|
if (typeof oldCompare === 'object') {
|
|
148
160
|
// need to convert the object to a string for comparison
|
|
149
161
|
oldCompare = JSON.stringify(oldCompare);
|
|
@@ -228,7 +240,7 @@ class EntityField {
|
|
|
228
240
|
*/
|
|
229
241
|
Validate() {
|
|
230
242
|
const ef = this._entityFieldInfo;
|
|
231
|
-
const result = new
|
|
243
|
+
const result = new ValidationResult();
|
|
232
244
|
result.Success = true; // assume success
|
|
233
245
|
if (!ef.ReadOnly && !ef.SkipValidation) {
|
|
234
246
|
// only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
|
|
@@ -237,24 +249,24 @@ class EntityField {
|
|
|
237
249
|
if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
|
|
238
250
|
// we have no default value, so this is an error
|
|
239
251
|
result.Success = false;
|
|
240
|
-
result.Errors.push(new
|
|
252
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
|
|
241
253
|
}
|
|
242
254
|
else {
|
|
243
255
|
// we do have a default value, but our current value is null. If we are in an EXISTING record, this is an error, check the OldValue to determine this
|
|
244
256
|
if (this._OldValue !== null && this._OldValue !== undefined) {
|
|
245
257
|
result.Success = false;
|
|
246
|
-
result.Errors.push(new
|
|
258
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
|
|
247
259
|
}
|
|
248
260
|
}
|
|
249
261
|
}
|
|
250
|
-
if (ef.TSType ==
|
|
262
|
+
if (ef.TSType == EntityFieldTSType.String && ef.MaxLength > 0 && this.Value && this.Value.length > ef.MaxLength) {
|
|
251
263
|
result.Success = false;
|
|
252
|
-
result.Errors.push(new
|
|
264
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxLength} characters. Current value is ${this.Value.length} characters`, this.Value));
|
|
253
265
|
}
|
|
254
|
-
if (ef.TSType ==
|
|
266
|
+
if (ef.TSType == EntityFieldTSType.Date && (this.Value !== null && this.Value !== undefined && !(this.Value instanceof Date))) {
|
|
255
267
|
// invalid non-null date, but that is okay if we are a new record and we have a default value
|
|
256
268
|
result.Success = false;
|
|
257
|
-
result.Errors.push(new
|
|
269
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${this.Value} is not a valid date for ${ef.DisplayNameOrName}`, this.Value));
|
|
258
270
|
}
|
|
259
271
|
// add validation to ensure a number value is within range based on the
|
|
260
272
|
// underlying SQL type
|
|
@@ -263,7 +275,7 @@ class EntityField {
|
|
|
263
275
|
if (typeLookup) {
|
|
264
276
|
if (this.Value < typeLookup.min || this.Value > typeLookup.max) {
|
|
265
277
|
result.Success = false;
|
|
266
|
-
result.Errors.push(new
|
|
278
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} is ${ef.SQLFullType} in the database and must be a valid number between ${-typeLookup.min} and ${typeLookup.max}. Current value is ${this.Value}`, this.Value));
|
|
267
279
|
}
|
|
268
280
|
}
|
|
269
281
|
}
|
|
@@ -288,14 +300,14 @@ class EntityField {
|
|
|
288
300
|
this.Value = Value;
|
|
289
301
|
}
|
|
290
302
|
else if (fieldInfo.DefaultValue) {
|
|
291
|
-
if (fieldInfo.TSType ===
|
|
303
|
+
if (fieldInfo.TSType === EntityFieldTSType.Boolean) {
|
|
292
304
|
// special handling for booleans as we don't want a string passed into a boolean field, we want a true boolean
|
|
293
305
|
if (typeof fieldInfo.DefaultValue === "string" && fieldInfo.DefaultValue.trim() === "1" || fieldInfo.DefaultValue.trim().toLowerCase() === "true")
|
|
294
306
|
this.Value = true;
|
|
295
307
|
else
|
|
296
308
|
this.Value = false;
|
|
297
309
|
}
|
|
298
|
-
else if (fieldInfo.TSType ===
|
|
310
|
+
else if (fieldInfo.TSType === EntityFieldTSType.Number) {
|
|
299
311
|
// special handling for numbers as we don't want a string passed into a value for a numeric field
|
|
300
312
|
if (!isNaN(Number(fieldInfo.DefaultValue))) {
|
|
301
313
|
this.Value = Number(fieldInfo.DefaultValue);
|
|
@@ -308,7 +320,7 @@ class EntityField {
|
|
|
308
320
|
// special handling for GUIDs, we don't want to populate anything here because the server always sets the value, leave blank
|
|
309
321
|
this.Value = null;
|
|
310
322
|
}
|
|
311
|
-
else if (fieldInfo.TSType ===
|
|
323
|
+
else if (fieldInfo.TSType === EntityFieldTSType.Date) {
|
|
312
324
|
if (fieldInfo.DefaultValue.trim().length > 0) {
|
|
313
325
|
// special handling for dates as we don't want to use getdate() type defaults as is, we want to convert them to a JS Date object
|
|
314
326
|
try {
|
|
@@ -324,7 +336,7 @@ class EntityField {
|
|
|
324
336
|
}
|
|
325
337
|
catch (e) {
|
|
326
338
|
// if we get here, that means the default value is not a valid date, so we need to check to see if it's a SQL current date function
|
|
327
|
-
if (
|
|
339
|
+
if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
|
|
328
340
|
// we have a SQL current date function default, leave the field alone if its a special date field as the server (i.e. database) will handle
|
|
329
341
|
//setting the value, otherwise set the value to the current date
|
|
330
342
|
if (fieldInfo.IsSpecialDateField) {
|
|
@@ -379,26 +391,9 @@ class EntityField {
|
|
|
379
391
|
return this._OldValue;
|
|
380
392
|
}
|
|
381
393
|
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Static object containing the value ranges for various SQL number types.
|
|
385
|
-
* This is used to validate the value of the field when it is set or validated.
|
|
386
|
-
*/
|
|
387
|
-
EntityField.SQLTypeValueRanges = {
|
|
388
|
-
"int": { min: -2147483648, max: 2147483647 },
|
|
389
|
-
"bigint": { min: -9223372036854775808, max: 9223372036854775807 },
|
|
390
|
-
"smallint": { min: -32768, max: 32767 },
|
|
391
|
-
"tinyint": { min: 0, max: 255 },
|
|
392
|
-
"decimal": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
|
|
393
|
-
"numeric": { min: -7922816251426433759354395033, max: 79228162514264337593543950335 },
|
|
394
|
-
"float": { min: -1.7976931348623157e+308, max: 1.7976931348623157e+308 },
|
|
395
|
-
"real": { min: -3.402823466e+38, max: 3.402823466e+38 },
|
|
396
|
-
"money": { min: -922337203685477.5808, max: 922337203685477.5807 },
|
|
397
|
-
};
|
|
398
|
-
class DataObjectRelatedEntityParam {
|
|
394
|
+
export class DataObjectRelatedEntityParam {
|
|
399
395
|
}
|
|
400
|
-
|
|
401
|
-
class DataObjectParams {
|
|
396
|
+
export class DataObjectParams {
|
|
402
397
|
constructor(oldValues = false, omitNullValues = false, omitEmptyStrings = false, excludeFields = [], includeRelatedEntityData = false, relatedEntityList = []) {
|
|
403
398
|
this.oldValues = oldValues;
|
|
404
399
|
this.omitNullValues = omitNullValues;
|
|
@@ -408,14 +403,12 @@ class DataObjectParams {
|
|
|
408
403
|
this.relatedEntityList = relatedEntityList;
|
|
409
404
|
}
|
|
410
405
|
}
|
|
411
|
-
|
|
412
|
-
class BaseEntityAIActionParams {
|
|
406
|
+
export class BaseEntityAIActionParams {
|
|
413
407
|
}
|
|
414
|
-
exports.BaseEntityAIActionParams = BaseEntityAIActionParams;
|
|
415
408
|
/**
|
|
416
409
|
* Used for storing the result of a Save or Delete or other transactional operation within a BaseEntity
|
|
417
410
|
*/
|
|
418
|
-
class BaseEntityResult {
|
|
411
|
+
export class BaseEntityResult {
|
|
419
412
|
constructor(success, message, type) {
|
|
420
413
|
/**
|
|
421
414
|
* A copy of the values of the entity object BEFORE the operation was performed
|
|
@@ -463,18 +456,32 @@ class BaseEntityResult {
|
|
|
463
456
|
return msg;
|
|
464
457
|
}
|
|
465
458
|
}
|
|
466
|
-
exports.BaseEntityResult = BaseEntityResult;
|
|
467
459
|
/**
|
|
468
460
|
* Event type that is used to raise events and provided structured callbacks for any caller that is interested in registering for events.
|
|
469
461
|
* This type is also used for whenever a BaseEntity instance raises an event with MJGlobal.
|
|
470
462
|
*/
|
|
471
|
-
class BaseEntityEvent {
|
|
463
|
+
export class BaseEntityEvent {
|
|
472
464
|
}
|
|
473
|
-
exports.BaseEntityEvent = BaseEntityEvent;
|
|
474
465
|
/**
|
|
475
466
|
* Base class used for all entity objects. This class is abstract and is sub-classes for each particular entity using the CodeGen tool. This class provides the basic functionality for loading, saving, and validating entity objects.
|
|
476
467
|
*/
|
|
477
|
-
class BaseEntity {
|
|
468
|
+
export class BaseEntity {
|
|
469
|
+
/**
|
|
470
|
+
* Gets the provider transaction handle for IS-A chain orchestration.
|
|
471
|
+
*/
|
|
472
|
+
get ProviderTransaction() { return this._providerTransaction; }
|
|
473
|
+
/**
|
|
474
|
+
* Sets the provider transaction handle. Used during IS-A save/delete to share
|
|
475
|
+
* a single database transaction across the entire parent chain.
|
|
476
|
+
*/
|
|
477
|
+
set ProviderTransaction(value) { this._providerTransaction = value; }
|
|
478
|
+
/**
|
|
479
|
+
* Returns the parent entity in the IS-A composition chain, or null if this
|
|
480
|
+
* entity is not an IS-A child type. Read-only public access for inspection.
|
|
481
|
+
* Named ISAParentEntity to avoid collision with generated Entity.ParentEntity
|
|
482
|
+
* string column (which contains the parent entity's name from the view).
|
|
483
|
+
*/
|
|
484
|
+
get ISAParentEntity() { return this._parentEntity; }
|
|
478
485
|
constructor(Entity, Provider = null) {
|
|
479
486
|
this._Fields = [];
|
|
480
487
|
this._recordLoaded = false;
|
|
@@ -485,6 +492,33 @@ class BaseEntity {
|
|
|
485
492
|
this._everSaved = false;
|
|
486
493
|
this._isLoading = false;
|
|
487
494
|
this._pendingDelete$ = null;
|
|
495
|
+
/**************************************************************************
|
|
496
|
+
* IS-A Type Relationship — Parent Entity Composition
|
|
497
|
+
*
|
|
498
|
+
* For IS-A child entities, _parentEntity holds a persistent live reference
|
|
499
|
+
* to the parent entity instance. All data routing (Set/Get), dirty tracking,
|
|
500
|
+
* validation, and save/delete orchestration flow through this composition chain.
|
|
501
|
+
**************************************************************************/
|
|
502
|
+
/**
|
|
503
|
+
* Persistent reference to the parent entity in the IS-A chain.
|
|
504
|
+
* For example, if MeetingEntity IS-A ProductEntity, the MeetingEntity instance
|
|
505
|
+
* holds a reference to a ProductEntity instance here. This is null for entities
|
|
506
|
+
* that are not IS-A child types.
|
|
507
|
+
*/
|
|
508
|
+
this._parentEntity = null;
|
|
509
|
+
/**
|
|
510
|
+
* Cached set of field names that belong to parent entities — used for
|
|
511
|
+
* efficient O(1) lookup during Set/Get routing to determine if a field
|
|
512
|
+
* should be forwarded to _parentEntity.
|
|
513
|
+
*/
|
|
514
|
+
this._parentEntityFieldNames = null;
|
|
515
|
+
/**
|
|
516
|
+
* Opaque provider-level transaction handle. Used by IS-A save/delete orchestration
|
|
517
|
+
* to share a single SQL transaction across the parent chain.
|
|
518
|
+
* On client (GraphQLDataProvider), this remains null.
|
|
519
|
+
* On server (SQLServerDataProvider), this holds a sql.Transaction.
|
|
520
|
+
*/
|
|
521
|
+
this._providerTransaction = null;
|
|
488
522
|
this._compositeKey = null;
|
|
489
523
|
// Holds the current pending save observable (if any)
|
|
490
524
|
this._pendingSave$ = null;
|
|
@@ -494,12 +528,82 @@ class BaseEntity {
|
|
|
494
528
|
* that embed multiple fields if desired.
|
|
495
529
|
*/
|
|
496
530
|
this._vectors = new Map();
|
|
497
|
-
this._eventSubject = new
|
|
531
|
+
this._eventSubject = new Subject();
|
|
498
532
|
this._EntityInfo = Entity;
|
|
499
|
-
|
|
533
|
+
EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
|
|
500
534
|
this._provider = Provider;
|
|
501
535
|
this.init();
|
|
502
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Initializes the IS-A parent entity composition chain. For child type entities,
|
|
539
|
+
* this creates the parent entity instance (and recursively its parent, etc.) and
|
|
540
|
+
* caches the parent field name set for routing.
|
|
541
|
+
*
|
|
542
|
+
* Must be called AFTER EntityInfo is available but BEFORE any Load/NewRecord/Set/Get.
|
|
543
|
+
* This is called by Metadata.GetEntityObject() after constructing the entity.
|
|
544
|
+
*/
|
|
545
|
+
async InitializeParentEntity() {
|
|
546
|
+
if (!this.EntityInfo?.IsChildType)
|
|
547
|
+
return;
|
|
548
|
+
const md = new Metadata();
|
|
549
|
+
const parentEntityInfo = this.EntityInfo.ParentEntityInfo;
|
|
550
|
+
if (!parentEntityInfo)
|
|
551
|
+
return;
|
|
552
|
+
// Create the parent entity via Metadata to ensure proper class factory resolution
|
|
553
|
+
this._parentEntity = await md.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
|
|
554
|
+
// Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
|
|
555
|
+
// Cache the parent field names for O(1) routing lookups
|
|
556
|
+
this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Resets this entity to a pristine state and populates it from the provided data object.
|
|
560
|
+
*
|
|
561
|
+
* Unlike {@link SetMany}, which incrementally updates existing field values, `Hydrate()`
|
|
562
|
+
* first resets ALL internal state — fields, composite key cache, loaded/saved flags —
|
|
563
|
+
* then populates from the provided data as if loading a fresh record from the database.
|
|
564
|
+
*
|
|
565
|
+
* This is critical for IS-A (table-per-type) inheritance: when a child entity loads
|
|
566
|
+
* its record, parent entities in the chain must be fully reset and re-populated from
|
|
567
|
+
* the child's view data, including the shared primary key. After `init()`, each
|
|
568
|
+
* EntityField's `_NeverSet` flag is `true`, allowing even ReadOnly PK fields to be
|
|
569
|
+
* set exactly once via `SetMany`.
|
|
570
|
+
*
|
|
571
|
+
* After population, entities are automatically marked as saved/loaded when all PK
|
|
572
|
+
* values are present (via {@link UpdateSavedStateFromPrimaryKeys}).
|
|
573
|
+
*
|
|
574
|
+
* The parent chain is handled recursively: if this entity has an IS-A parent, the
|
|
575
|
+
* parent is hydrated first (deepest ancestor first) via SetMany's built-in routing.
|
|
576
|
+
*
|
|
577
|
+
* @param data - A plain object whose properties map to field names on this entity
|
|
578
|
+
* (and potentially parent entities in the IS-A chain).
|
|
579
|
+
*/
|
|
580
|
+
Hydrate(data) {
|
|
581
|
+
// Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
|
|
582
|
+
// _everSaved, and recreates all EntityField instances with _NeverSet = true
|
|
583
|
+
this.init();
|
|
584
|
+
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
585
|
+
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
586
|
+
if (this._parentEntity) {
|
|
587
|
+
this._parentEntity.Hydrate(data);
|
|
588
|
+
}
|
|
589
|
+
// Populate this entity's fields. SetMany also routes parent field values to
|
|
590
|
+
// _parentEntity via the IS-A routing block (which now includes PK fields).
|
|
591
|
+
// replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
|
|
592
|
+
// ignoreNonExistentFields=true because data may contain fields from other
|
|
593
|
+
// entities in the chain that don't exist on this entity.
|
|
594
|
+
this.SetMany(data, true, true, true);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Propagates the ProviderTransaction handle down the IS-A parent chain so all
|
|
598
|
+
* entities in the chain execute on the same database transaction.
|
|
599
|
+
*/
|
|
600
|
+
PropagateTransactionToParents() {
|
|
601
|
+
let current = this._parentEntity;
|
|
602
|
+
while (current) {
|
|
603
|
+
current.ProviderTransaction = this._providerTransaction;
|
|
604
|
+
current = current._parentEntity;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
503
607
|
/**
|
|
504
608
|
* Returns this provider to be used for a given instance of a BaseEntity derived subclass. If the provider is not set, the BaseEntity.Provider is returned.
|
|
505
609
|
*/
|
|
@@ -552,6 +656,7 @@ class BaseEntity {
|
|
|
552
656
|
RaiseReadyForTransaction() {
|
|
553
657
|
this.RaiseEvent('transaction_ready', null);
|
|
554
658
|
}
|
|
659
|
+
static { this._baseEventCode = 'BaseEntityEvent'; }
|
|
555
660
|
/**
|
|
556
661
|
* When a BaseEntity class raises an event with MJGlobal, the eventCode property is set to this value. This is used to identify events that are raised by BaseEntity objects.
|
|
557
662
|
* Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
|
|
@@ -564,7 +669,7 @@ class BaseEntity {
|
|
|
564
669
|
*/
|
|
565
670
|
RaiseEvent(type, payload, saveSubType = undefined) {
|
|
566
671
|
// this is the local event handler that is specific to THIS instance of the entity object
|
|
567
|
-
|
|
672
|
+
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
|
|
568
673
|
this._eventSubject.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this });
|
|
569
674
|
// this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
|
|
570
675
|
// we broadcast save/delete/load events and their _started counterparts
|
|
@@ -575,10 +680,10 @@ class BaseEntity {
|
|
|
575
680
|
event.payload = payload;
|
|
576
681
|
event.type = type;
|
|
577
682
|
event.saveSubType = saveSubType;
|
|
578
|
-
|
|
579
|
-
|
|
683
|
+
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
|
|
684
|
+
MJGlobal.Instance.RaiseEvent({
|
|
580
685
|
component: this,
|
|
581
|
-
event:
|
|
686
|
+
event: MJEventType.ComponentEvent,
|
|
582
687
|
eventCode: BaseEntity.BaseEventCode,
|
|
583
688
|
args: event
|
|
584
689
|
});
|
|
@@ -758,7 +863,9 @@ class BaseEntity {
|
|
|
758
863
|
* Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
|
|
759
864
|
*/
|
|
760
865
|
get Dirty() {
|
|
761
|
-
return !this.IsSaved ||
|
|
866
|
+
return !this.IsSaved ||
|
|
867
|
+
this.Fields.some(f => f.Dirty) ||
|
|
868
|
+
(this._parentEntity?.Dirty ?? false);
|
|
762
869
|
}
|
|
763
870
|
/**
|
|
764
871
|
* Returns an array of all primary key fields for the entity. If the entity has a composite primary key, this method will return an array of all primary key fields.
|
|
@@ -773,7 +880,7 @@ class BaseEntity {
|
|
|
773
880
|
*/
|
|
774
881
|
get PrimaryKey() {
|
|
775
882
|
if (this._compositeKey === null) {
|
|
776
|
-
this._compositeKey = new
|
|
883
|
+
this._compositeKey = new CompositeKey();
|
|
777
884
|
this._compositeKey.LoadFromEntityFields(this.PrimaryKeys);
|
|
778
885
|
}
|
|
779
886
|
return this._compositeKey;
|
|
@@ -793,16 +900,36 @@ class BaseEntity {
|
|
|
793
900
|
/**
|
|
794
901
|
* Sets the value of a given field. If the field doesn't exist, nothing happens.
|
|
795
902
|
* The field's type is used to convert the value to the appropriate type.
|
|
903
|
+
*
|
|
904
|
+
* For IS-A child entities, parent fields are routed to `_parentEntity.Set()` (recursive
|
|
905
|
+
* for N-level chains). The value is also mirrored on the child's own virtual EntityField
|
|
906
|
+
* so that code iterating `entity.Fields` still sees it. The authoritative state for parent
|
|
907
|
+
* fields lives on `_parentEntity`.
|
|
908
|
+
*
|
|
796
909
|
* @param FieldName
|
|
797
910
|
* @param Value
|
|
798
911
|
*/
|
|
799
912
|
Set(FieldName, Value) {
|
|
913
|
+
// IS-A routing: if this field belongs to a parent entity, route to parent
|
|
914
|
+
if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
|
|
915
|
+
this._parentEntity.Set(FieldName, Value); // recursive for N-level chains
|
|
916
|
+
// Also mirror the value on our own virtual EntityField for UI compatibility
|
|
917
|
+
this.SetLocal(FieldName, Value);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
this.SetLocal(FieldName, Value);
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Internal helper that sets a field value directly on THIS entity's own Fields array
|
|
924
|
+
* without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
|
|
925
|
+
*/
|
|
926
|
+
SetLocal(FieldName, Value) {
|
|
800
927
|
const field = this.GetFieldByName(FieldName);
|
|
801
928
|
if (field != null) {
|
|
802
|
-
if (field.EntityFieldInfo.TSType ===
|
|
929
|
+
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
|
|
803
930
|
field.Value = new Date(Value);
|
|
804
931
|
}
|
|
805
|
-
else if (field.EntityFieldInfo.TSType ===
|
|
932
|
+
else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
|
|
806
933
|
const numericValue = Number(Value);
|
|
807
934
|
if (!isNaN(numericValue)) {
|
|
808
935
|
field.Value = numericValue;
|
|
@@ -811,7 +938,7 @@ class BaseEntity {
|
|
|
811
938
|
field.Value = Value;
|
|
812
939
|
}
|
|
813
940
|
}
|
|
814
|
-
else if (field.EntityFieldInfo.TSType ===
|
|
941
|
+
else if (field.EntityFieldInfo.TSType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
|
|
815
942
|
if (typeof Value === 'string') {
|
|
816
943
|
if (Value.trim() === '1' || Value.trim().toLowerCase() === 'true') {
|
|
817
944
|
field.Value = true;
|
|
@@ -837,14 +964,22 @@ class BaseEntity {
|
|
|
837
964
|
}
|
|
838
965
|
/**
|
|
839
966
|
* Returns the value of the field with the given name. If the field is a date, and the value is a string, it will be converted to a date object.
|
|
967
|
+
*
|
|
968
|
+
* For IS-A child entities, parent fields return the authoritative value from `_parentEntity.Get()`
|
|
969
|
+
* (recursive for N-level chains), NOT the mirrored value on the child's own Fields array.
|
|
970
|
+
*
|
|
840
971
|
* @param FieldName
|
|
841
972
|
* @returns
|
|
842
973
|
*/
|
|
843
974
|
Get(FieldName) {
|
|
975
|
+
// IS-A routing: return the authoritative value from the parent entity
|
|
976
|
+
if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
|
|
977
|
+
return this._parentEntity.Get(FieldName); // recursive for N-level chains
|
|
978
|
+
}
|
|
844
979
|
const field = this.GetFieldByName(FieldName);
|
|
845
980
|
if (field != null) {
|
|
846
981
|
// if the field is a date and the value is a string, convert it to a date
|
|
847
|
-
if (field.EntityFieldInfo.TSType ===
|
|
982
|
+
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof field.Value === 'string' || typeof field.Value === 'number')) {
|
|
848
983
|
field.Value = new Date(field.Value);
|
|
849
984
|
}
|
|
850
985
|
return field.Value;
|
|
@@ -855,6 +990,11 @@ class BaseEntity {
|
|
|
855
990
|
* NOTE: Do not call this method directly. Use the {@link From} method instead
|
|
856
991
|
*
|
|
857
992
|
* Sets any number of values on the entity object from the object passed in. The properties of the object being passed in must either match the field name (in most cases) or the CodeName (which is only different from field name if field name has spaces in it)
|
|
993
|
+
*
|
|
994
|
+
* For IS-A child entities, all fields are first set on self (including parent fields as mirrors),
|
|
995
|
+
* then parent fields are extracted and forwarded to `_parentEntity.SetMany()` for authoritative
|
|
996
|
+
* state, including proper OldValue tracking via the replaceOldValues parameter.
|
|
997
|
+
*
|
|
858
998
|
* @param object
|
|
859
999
|
* @param ignoreNonExistentFields - if set to true, fields that don't exist on the entity object will be ignored, if false, an error will be thrown if a field doesn't exist
|
|
860
1000
|
* @param replaceOldValues - if set to true, the old values of the fields will be reset to the values provided in the object parameter, if false, they will be left alone
|
|
@@ -871,7 +1011,8 @@ class BaseEntity {
|
|
|
871
1011
|
if (ignoreActiveStatusAssertions) {
|
|
872
1012
|
field.ActiveStatusAssertions = false; // disable active status assertions for this field
|
|
873
1013
|
}
|
|
874
|
-
|
|
1014
|
+
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
1015
|
+
this.SetLocal(key, object[key]);
|
|
875
1016
|
if (replaceOldValues) {
|
|
876
1017
|
field.ResetOldValue();
|
|
877
1018
|
}
|
|
@@ -888,7 +1029,8 @@ class BaseEntity {
|
|
|
888
1029
|
if (ignoreActiveStatusAssertions) {
|
|
889
1030
|
field.ActiveStatusAssertions = false; // disable active status assertions for this field
|
|
890
1031
|
}
|
|
891
|
-
|
|
1032
|
+
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
1033
|
+
this.SetLocal(field.Name, object[key]);
|
|
892
1034
|
if (replaceOldValues) {
|
|
893
1035
|
field.ResetOldValue();
|
|
894
1036
|
}
|
|
@@ -897,16 +1039,56 @@ class BaseEntity {
|
|
|
897
1039
|
}
|
|
898
1040
|
}
|
|
899
1041
|
else {
|
|
1042
|
+
// IS-A routing: parent fields may not have local mirrors (virtual EntityField records)
|
|
1043
|
+
// yet — they'll be handled by the IS-A routing block below, so skip the error/warning
|
|
1044
|
+
if (this._parentEntityFieldNames?.has(key)) {
|
|
1045
|
+
continue; // parent field — will be forwarded to _parentEntity below
|
|
1046
|
+
}
|
|
900
1047
|
// if we get here, we have a field that doesn't match either the field name or the code name, so throw an error
|
|
901
1048
|
if (!ignoreNonExistentFields)
|
|
902
1049
|
throw new Error(`Field ${key} does not exist on ${this.EntityInfo.Name}`);
|
|
903
1050
|
else {
|
|
904
1051
|
// Record field-not-found warning - will be batched and displayed after debounce period
|
|
905
|
-
|
|
1052
|
+
WarningManager.Instance.RecordFieldNotFoundWarning(this.EntityInfo.Name, key, 'BaseEntity::SetMany');
|
|
906
1053
|
}
|
|
907
1054
|
}
|
|
908
1055
|
}
|
|
909
1056
|
}
|
|
1057
|
+
// IS-A routing: forward parent fields to _parentEntity for authoritative state
|
|
1058
|
+
// This ensures proper OldValue tracking and dirty flags on the parent entity
|
|
1059
|
+
if (this._parentEntity && this._parentEntityFieldNames) {
|
|
1060
|
+
const parentData = {};
|
|
1061
|
+
for (const key of Object.keys(object)) {
|
|
1062
|
+
if (this._parentEntityFieldNames.has(key)) {
|
|
1063
|
+
parentData[key] = object[key];
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (Object.keys(parentData).length > 0) {
|
|
1067
|
+
this._parentEntity.SetMany(parentData, true, replaceOldValues, ignoreActiveStatusAssertions);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// When replaceOldValues is true, this is a load operation (not a user edit).
|
|
1071
|
+
// If all primary keys are set, mark the entity as a saved record. This is critical
|
|
1072
|
+
// for IS-A parent entities whose data is populated via SetMany routing from the child's
|
|
1073
|
+
// load — without this, the parent would think it's a new record and attempt CREATE
|
|
1074
|
+
// instead of UPDATE on save.
|
|
1075
|
+
if (replaceOldValues) {
|
|
1076
|
+
this.UpdateSavedStateFromPrimaryKeys();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Checks if all primary key fields have values set, and if so, marks the entity as
|
|
1081
|
+
* a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
|
|
1082
|
+
* entities) to correctly recognize themselves as existing records.
|
|
1083
|
+
*/
|
|
1084
|
+
UpdateSavedStateFromPrimaryKeys() {
|
|
1085
|
+
if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
|
|
1086
|
+
return;
|
|
1087
|
+
const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
|
|
1088
|
+
if (allPKsSet) {
|
|
1089
|
+
this._recordLoaded = true;
|
|
1090
|
+
this._everSaved = true;
|
|
1091
|
+
}
|
|
910
1092
|
}
|
|
911
1093
|
/**
|
|
912
1094
|
* NOTE: Do not call this method directly. Use the {@link To} method instead
|
|
@@ -924,12 +1106,19 @@ class BaseEntity {
|
|
|
924
1106
|
const tempStatus = field.ActiveStatusAssertions; // save the current active status assertions
|
|
925
1107
|
field.ActiveStatusAssertions = false; // disable active status assertions for this field
|
|
926
1108
|
obj[field.Name] = oldValues ? field.OldValue : field.Value;
|
|
927
|
-
if (field.EntityFieldInfo.TSType ==
|
|
1109
|
+
if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
|
|
928
1110
|
obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
|
|
929
1111
|
}
|
|
930
1112
|
field.ActiveStatusAssertions = tempStatus; // restore the prior status for assertions
|
|
931
1113
|
}
|
|
932
1114
|
}
|
|
1115
|
+
// IS-A composition: merge parent entity data with own data
|
|
1116
|
+
// Parent data goes first, own fields override (for shared PK 'ID')
|
|
1117
|
+
// Parent's GetAll() recursively collects from its own parent for N-level chains
|
|
1118
|
+
if (this._parentEntity) {
|
|
1119
|
+
const parentData = this._parentEntity.GetAll(oldValues, onlyDirtyFields);
|
|
1120
|
+
return { ...parentData, ...obj };
|
|
1121
|
+
}
|
|
933
1122
|
return obj;
|
|
934
1123
|
}
|
|
935
1124
|
/**
|
|
@@ -1009,8 +1198,8 @@ class BaseEntity {
|
|
|
1009
1198
|
}
|
|
1010
1199
|
async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
|
|
1011
1200
|
// we need to query the database to get related entity info
|
|
1012
|
-
const params =
|
|
1013
|
-
const rv = new
|
|
1201
|
+
const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
|
|
1202
|
+
const rv = new RunView();
|
|
1014
1203
|
const result = await rv.RunView(params, this._contextCurrentUser);
|
|
1015
1204
|
if (result && result.Success) {
|
|
1016
1205
|
return {
|
|
@@ -1030,7 +1219,7 @@ class BaseEntity {
|
|
|
1030
1219
|
for (const rawField of this.EntityInfo.Fields) {
|
|
1031
1220
|
const key = this.EntityInfo.Name + '.' + rawField.Name;
|
|
1032
1221
|
// support for sub-classes of the EntityField class
|
|
1033
|
-
const newField =
|
|
1222
|
+
const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
|
|
1034
1223
|
this.Fields.push(newField);
|
|
1035
1224
|
}
|
|
1036
1225
|
}
|
|
@@ -1059,7 +1248,7 @@ class BaseEntity {
|
|
|
1059
1248
|
return true;
|
|
1060
1249
|
}
|
|
1061
1250
|
catch (e) {
|
|
1062
|
-
|
|
1251
|
+
LogError(`Error in BaseEntity.CopyFrom: ${e}`);
|
|
1063
1252
|
return false;
|
|
1064
1253
|
}
|
|
1065
1254
|
}
|
|
@@ -1090,7 +1279,7 @@ class BaseEntity {
|
|
|
1090
1279
|
pk.Type.toLowerCase().trim() === 'uniqueidentifier' &&
|
|
1091
1280
|
!this.Get(pk.Name)) {
|
|
1092
1281
|
// Generate and set UUID for this primary key
|
|
1093
|
-
const uuid =
|
|
1282
|
+
const uuid = uuidv4();
|
|
1094
1283
|
this.Set(pk.Name, uuid);
|
|
1095
1284
|
const field = this.GetFieldByName(pk.Name);
|
|
1096
1285
|
if (field) {
|
|
@@ -1104,6 +1293,18 @@ class BaseEntity {
|
|
|
1104
1293
|
this.Set(kv.FieldName, kv.Value);
|
|
1105
1294
|
});
|
|
1106
1295
|
}
|
|
1296
|
+
// IS-A composition: propagate PK value to parent entity chain
|
|
1297
|
+
// Parent needs NewRecord() called first, then share the same PK value
|
|
1298
|
+
if (this._parentEntity) {
|
|
1299
|
+
this._parentEntity.NewRecord();
|
|
1300
|
+
// Propagate PK — child and parent must share the same UUID
|
|
1301
|
+
for (const pk of this.EntityInfo.PrimaryKeys) {
|
|
1302
|
+
const pkValue = this.Get(pk.Name);
|
|
1303
|
+
if (pkValue != null) {
|
|
1304
|
+
this._parentEntity.Set(pk.Name, pkValue);
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1107
1308
|
this.RaiseEvent('new_record', null);
|
|
1108
1309
|
return true;
|
|
1109
1310
|
}
|
|
@@ -1120,17 +1321,17 @@ class BaseEntity {
|
|
|
1120
1321
|
async Save(options) {
|
|
1121
1322
|
// If a save is already in progress, return its promise.
|
|
1122
1323
|
if (this._pendingSave$) {
|
|
1123
|
-
return
|
|
1324
|
+
return firstValueFrom(this._pendingSave$);
|
|
1124
1325
|
}
|
|
1125
1326
|
// Create a new observable that debounces duplicative calls, and executes the save.
|
|
1126
|
-
this._pendingSave$ =
|
|
1327
|
+
this._pendingSave$ = of(options).pipe(
|
|
1127
1328
|
// Execute the actual save logic.
|
|
1128
|
-
|
|
1329
|
+
switchMap(opts => from(this._InnerSave(opts))),
|
|
1129
1330
|
// When the save completes (whether successfully or not), clear the pending save observable.
|
|
1130
|
-
|
|
1331
|
+
finalize(() => { this._pendingSave$ = null; }),
|
|
1131
1332
|
// Ensure that all subscribers get the same result.
|
|
1132
|
-
|
|
1133
|
-
return
|
|
1333
|
+
shareReplay(1));
|
|
1334
|
+
return firstValueFrom(this._pendingSave$);
|
|
1134
1335
|
}
|
|
1135
1336
|
/**
|
|
1136
1337
|
* Private, internal method to handle saving the current state of the object to the database. This method is called by the public facing Save() method
|
|
@@ -1143,10 +1344,45 @@ class BaseEntity {
|
|
|
1143
1344
|
const newResult = new BaseEntityResult();
|
|
1144
1345
|
newResult.StartedAt = new Date();
|
|
1145
1346
|
try {
|
|
1146
|
-
const _options = options ? options : new
|
|
1147
|
-
|
|
1347
|
+
const _options = options ? options : new EntitySaveOptions();
|
|
1348
|
+
// IS-A orchestration: determine if this is the initiating save in a parent chain
|
|
1349
|
+
const isISAInitiator = (!!this._parentEntity) && !_options.IsParentEntitySave;
|
|
1350
|
+
// Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
|
|
1351
|
+
// TransactionGroup manages its own atomicity; IS-A just orchestrates save order within it
|
|
1352
|
+
if (isISAInitiator && !this.TransactionGroup) {
|
|
1353
|
+
const txn = await this.ProviderToUse?.BeginISATransaction?.();
|
|
1354
|
+
if (txn) {
|
|
1355
|
+
this.ProviderTransaction = txn;
|
|
1356
|
+
this.PropagateTransactionToParents();
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
// Save parent chain first (root → branch → immediate parent)
|
|
1360
|
+
// Parent calls Save() recursively which handles its own parents, permissions, validation
|
|
1361
|
+
if (this._parentEntity) {
|
|
1362
|
+
const parentSaveOptions = new EntitySaveOptions();
|
|
1363
|
+
parentSaveOptions.IgnoreDirtyState = _options.IgnoreDirtyState;
|
|
1364
|
+
parentSaveOptions.SkipEntityAIActions = _options.SkipEntityAIActions;
|
|
1365
|
+
parentSaveOptions.SkipEntityActions = _options.SkipEntityActions;
|
|
1366
|
+
parentSaveOptions.ReplayOnly = _options.ReplayOnly;
|
|
1367
|
+
parentSaveOptions.SkipOldValuesCheck = _options.SkipOldValuesCheck;
|
|
1368
|
+
parentSaveOptions.SkipAsyncValidation = _options.SkipAsyncValidation;
|
|
1369
|
+
parentSaveOptions.IsParentEntitySave = true;
|
|
1370
|
+
const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
|
|
1371
|
+
if (!parentResult) {
|
|
1372
|
+
// Parent save failed — rollback if we started the transaction
|
|
1373
|
+
await this.RollbackISATransaction(isISAInitiator);
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
|
|
1148
1378
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
1149
1379
|
this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
|
|
1380
|
+
// IS-A disjoint subtype enforcement: on CREATE, ensure parent record
|
|
1381
|
+
// isn't already claimed by another child type (e.g., can't create Meeting
|
|
1382
|
+
// if a Publication already exists with the same Product ID)
|
|
1383
|
+
if (!this.IsSaved && this.EntityInfo.IsChildType && !_options.ReplayOnly) {
|
|
1384
|
+
await this.EnforceDisjointSubtype();
|
|
1385
|
+
}
|
|
1150
1386
|
if (_options.IgnoreDirtyState || this.Dirty || _options.ReplayOnly) {
|
|
1151
1387
|
// Raise save_started event only when we're actually going to save
|
|
1152
1388
|
this.RaiseEvent('save_started', null, saveSubType);
|
|
@@ -1154,7 +1390,7 @@ class BaseEntity {
|
|
|
1154
1390
|
throw new Error('No provider set');
|
|
1155
1391
|
}
|
|
1156
1392
|
else {
|
|
1157
|
-
let valResult = new
|
|
1393
|
+
let valResult = new ValidationResult();
|
|
1158
1394
|
if (_options.ReplayOnly) {
|
|
1159
1395
|
valResult.Success = true; // bypassing validation since we are in replay only....
|
|
1160
1396
|
}
|
|
@@ -1183,12 +1419,18 @@ class BaseEntity {
|
|
|
1183
1419
|
const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
|
|
1184
1420
|
if (!this.TransactionGroup) {
|
|
1185
1421
|
// no transaction group, so we have our results here
|
|
1186
|
-
|
|
1422
|
+
const result = this.finalizeSave(data, saveSubType);
|
|
1423
|
+
// IS-A: commit transaction after successful save (only the initiator commits)
|
|
1424
|
+
if (isISAInitiator && this.ProviderTransaction) {
|
|
1425
|
+
await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
|
|
1426
|
+
this.ProviderTransaction = null;
|
|
1427
|
+
}
|
|
1428
|
+
return result;
|
|
1187
1429
|
}
|
|
1188
1430
|
else {
|
|
1189
1431
|
// we are part of a transaction group, so we return true and subscribe to the transaction groups' events and do the finalization work then
|
|
1190
1432
|
this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
|
|
1191
|
-
if (success) {
|
|
1433
|
+
if (success && results) {
|
|
1192
1434
|
const transItem = results.find(r => r.Transaction.BaseEntity === this);
|
|
1193
1435
|
if (transItem) {
|
|
1194
1436
|
this.finalizeSave(transItem.Result, saveSubType); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
|
|
@@ -1214,6 +1456,9 @@ class BaseEntity {
|
|
|
1214
1456
|
return true; // nothing to save since we're not dirty
|
|
1215
1457
|
}
|
|
1216
1458
|
catch (e) {
|
|
1459
|
+
// IS-A: rollback transaction on failure (only the initiator rolls back)
|
|
1460
|
+
const isISAInitiator = this._parentEntity != null && !options?.IsParentEntitySave;
|
|
1461
|
+
await this.RollbackISATransaction(isISAInitiator);
|
|
1217
1462
|
if (currentResultCount === this.ResultHistory.length) {
|
|
1218
1463
|
// this means that NO new results were added to the history anywhere
|
|
1219
1464
|
// so we need to add a new result to the history here
|
|
@@ -1228,6 +1473,21 @@ class BaseEntity {
|
|
|
1228
1473
|
return false;
|
|
1229
1474
|
}
|
|
1230
1475
|
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Helper to rollback an IS-A provider transaction if one is active.
|
|
1478
|
+
* Only called by the IS-A initiator (the leaf entity that started the chain).
|
|
1479
|
+
*/
|
|
1480
|
+
async RollbackISATransaction(isInitiator) {
|
|
1481
|
+
if (isInitiator && this.ProviderTransaction) {
|
|
1482
|
+
try {
|
|
1483
|
+
await this.ProviderToUse?.RollbackISATransaction?.(this.ProviderTransaction);
|
|
1484
|
+
}
|
|
1485
|
+
catch (rollbackError) {
|
|
1486
|
+
LogError(`Error rolling back IS-A transaction: ${rollbackError}`);
|
|
1487
|
+
}
|
|
1488
|
+
this.ProviderTransaction = null;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1231
1491
|
finalizeSave(data, saveSubType) {
|
|
1232
1492
|
if (data) {
|
|
1233
1493
|
this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
|
|
@@ -1236,6 +1496,8 @@ class BaseEntity {
|
|
|
1236
1496
|
const result = this.LatestResult;
|
|
1237
1497
|
if (result)
|
|
1238
1498
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
1499
|
+
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
1500
|
+
this.CacheRecordName();
|
|
1239
1501
|
this.RaiseEvent('save', null, saveSubType);
|
|
1240
1502
|
return true;
|
|
1241
1503
|
}
|
|
@@ -1247,7 +1509,24 @@ class BaseEntity {
|
|
|
1247
1509
|
* Internal helper method for the class and sub-classes - used to easily get the Active User which is either the ContextCurrentUser, if defined, or the Metadata.Provider.CurrentUser if not.
|
|
1248
1510
|
*/
|
|
1249
1511
|
get ActiveUser() {
|
|
1250
|
-
return this.ContextCurrentUser ||
|
|
1512
|
+
return this.ContextCurrentUser || Metadata.Provider.CurrentUser; // use the context user ahead of the Provider.Current User - this is for SERVER side ops where the user changes per request
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
|
|
1516
|
+
* Called automatically after successful Load(), LoadFromData(), and Save() operations.
|
|
1517
|
+
*/
|
|
1518
|
+
CacheRecordName() {
|
|
1519
|
+
// Only cache if we have a valid primary key
|
|
1520
|
+
if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// Get the record name
|
|
1524
|
+
const recordName = this.GetRecordName();
|
|
1525
|
+
if (!recordName) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// Cache it via the provider's public API
|
|
1529
|
+
Metadata.Provider.SetCachedRecordName(this.EntityInfo.Name, this.PrimaryKey, recordName);
|
|
1251
1530
|
}
|
|
1252
1531
|
/**
|
|
1253
1532
|
* Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
|
|
@@ -1259,9 +1538,20 @@ class BaseEntity {
|
|
|
1259
1538
|
const u = this.ActiveUser;
|
|
1260
1539
|
if (!u)
|
|
1261
1540
|
throw new Error('No user set - either the context user for the entity object must be set, or the Metadata.Provider.CurrentUser must be set');
|
|
1541
|
+
// Virtual entities are read-only — block Create, Update, Delete at the ORM level
|
|
1542
|
+
// This catches server-side code calling .Save()/.Delete() directly, which bypasses the API layer flags
|
|
1543
|
+
if (this.EntityInfo.VirtualEntity &&
|
|
1544
|
+
(type === EntityPermissionType.Create ||
|
|
1545
|
+
type === EntityPermissionType.Update ||
|
|
1546
|
+
type === EntityPermissionType.Delete)) {
|
|
1547
|
+
const msg = `Cannot ${type} on virtual entity '${this.EntityInfo.Name}' — virtual entities are read-only`;
|
|
1548
|
+
if (throwError)
|
|
1549
|
+
throw new Error(msg);
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1262
1552
|
// first check if the AllowCreateAPI/AllowUpdateAPI/AllowDeleteAPI settings are flipped on for the entity in question
|
|
1263
1553
|
switch (type) {
|
|
1264
|
-
case
|
|
1554
|
+
case EntityPermissionType.Create:
|
|
1265
1555
|
if (!this.EntityInfo.AllowCreateAPI) {
|
|
1266
1556
|
if (throwError)
|
|
1267
1557
|
throw new Error(`Create API is disabled for ${this.EntityInfo.Name}`);
|
|
@@ -1269,7 +1559,7 @@ class BaseEntity {
|
|
|
1269
1559
|
return false;
|
|
1270
1560
|
}
|
|
1271
1561
|
break;
|
|
1272
|
-
case
|
|
1562
|
+
case EntityPermissionType.Update:
|
|
1273
1563
|
if (!this.EntityInfo.AllowUpdateAPI) {
|
|
1274
1564
|
if (throwError)
|
|
1275
1565
|
throw new Error(`Update API is disabled for ${this.EntityInfo.Name}`);
|
|
@@ -1277,7 +1567,7 @@ class BaseEntity {
|
|
|
1277
1567
|
return false;
|
|
1278
1568
|
}
|
|
1279
1569
|
break;
|
|
1280
|
-
case
|
|
1570
|
+
case EntityPermissionType.Delete:
|
|
1281
1571
|
if (!this.EntityInfo.AllowDeleteAPI) {
|
|
1282
1572
|
if (throwError)
|
|
1283
1573
|
throw new Error(`Delete API is disabled for ${this.EntityInfo.Name}`);
|
|
@@ -1285,7 +1575,7 @@ class BaseEntity {
|
|
|
1285
1575
|
return false;
|
|
1286
1576
|
}
|
|
1287
1577
|
break;
|
|
1288
|
-
case
|
|
1578
|
+
case EntityPermissionType.Read:
|
|
1289
1579
|
if (!this.EntityInfo.IncludeInAPI) {
|
|
1290
1580
|
if (throwError)
|
|
1291
1581
|
throw new Error(`API is disabled for ${this.EntityInfo.Name}`);
|
|
@@ -1297,16 +1587,16 @@ class BaseEntity {
|
|
|
1297
1587
|
const permissions = this.EntityInfo.GetUserPermisions(u);
|
|
1298
1588
|
let bAllowed = false;
|
|
1299
1589
|
switch (type) {
|
|
1300
|
-
case
|
|
1590
|
+
case EntityPermissionType.Create:
|
|
1301
1591
|
bAllowed = permissions.CanCreate;
|
|
1302
1592
|
break;
|
|
1303
|
-
case
|
|
1593
|
+
case EntityPermissionType.Read:
|
|
1304
1594
|
bAllowed = permissions.CanRead;
|
|
1305
1595
|
break;
|
|
1306
|
-
case
|
|
1596
|
+
case EntityPermissionType.Update:
|
|
1307
1597
|
bAllowed = permissions.CanUpdate;
|
|
1308
1598
|
break;
|
|
1309
|
-
case
|
|
1599
|
+
case EntityPermissionType.Delete:
|
|
1310
1600
|
bAllowed = permissions.CanDelete;
|
|
1311
1601
|
break;
|
|
1312
1602
|
}
|
|
@@ -1318,7 +1608,7 @@ class BaseEntity {
|
|
|
1318
1608
|
}
|
|
1319
1609
|
ThrowPermissionError(u, type, additionalInfoMessage) {
|
|
1320
1610
|
throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
|
|
1321
|
-
Does NOT have permission to ${
|
|
1611
|
+
Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
|
|
1322
1612
|
If you believe this is an error, please contact your system administrator.${additionalInfoMessage ? '\nAdditional Information: ' + additionalInfoMessage : ''}}`);
|
|
1323
1613
|
}
|
|
1324
1614
|
/**
|
|
@@ -1330,6 +1620,10 @@ class BaseEntity {
|
|
|
1330
1620
|
for (let field of this.Fields) {
|
|
1331
1621
|
field.Value = field.OldValue;
|
|
1332
1622
|
}
|
|
1623
|
+
// IS-A composition: revert parent entity chain as well
|
|
1624
|
+
if (this._parentEntity) {
|
|
1625
|
+
this._parentEntity.Revert();
|
|
1626
|
+
}
|
|
1333
1627
|
}
|
|
1334
1628
|
return true;
|
|
1335
1629
|
}
|
|
@@ -1349,7 +1643,7 @@ class BaseEntity {
|
|
|
1349
1643
|
const valResult = CompositeKey.Validate();
|
|
1350
1644
|
if (!valResult || !valResult.IsValid)
|
|
1351
1645
|
throw new Error(`Invalid CompositeKey passed to BaseEntity.Load(${this.EntityInfo.Name}): ${valResult.ErrorMessage}`);
|
|
1352
|
-
this.CheckPermissions(
|
|
1646
|
+
this.CheckPermissions(EntityPermissionType.Read, true); // this will throw an error and exit out if we don't have permission
|
|
1353
1647
|
// Set loading state and raise load_started event
|
|
1354
1648
|
this._isLoading = true;
|
|
1355
1649
|
this.RaiseEvent('load_started', { CompositeKey });
|
|
@@ -1359,9 +1653,16 @@ class BaseEntity {
|
|
|
1359
1653
|
}
|
|
1360
1654
|
const data = await this.ProviderToUse.Load(this, CompositeKey, EntityRelationshipsToLoad, this.ActiveUser);
|
|
1361
1655
|
if (!data) {
|
|
1362
|
-
|
|
1656
|
+
LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
|
|
1363
1657
|
return false; // no data loaded, return false
|
|
1364
1658
|
}
|
|
1659
|
+
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
1660
|
+
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
1661
|
+
// then populates from data. This must happen before self's SetMany so parents
|
|
1662
|
+
// are in clean state when they receive routed field values.
|
|
1663
|
+
if (this._parentEntity) {
|
|
1664
|
+
this._parentEntity.Hydrate(data);
|
|
1665
|
+
}
|
|
1365
1666
|
this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
|
|
1366
1667
|
if (EntityRelationshipsToLoad) {
|
|
1367
1668
|
for (let relationship of EntityRelationshipsToLoad) {
|
|
@@ -1374,6 +1675,8 @@ class BaseEntity {
|
|
|
1374
1675
|
this._recordLoaded = true;
|
|
1375
1676
|
this._everSaved = true; // Mark as saved since we loaded from database
|
|
1376
1677
|
this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
|
|
1678
|
+
// Cache the record name for faster lookups
|
|
1679
|
+
this.CacheRecordName();
|
|
1377
1680
|
// Raise load completion event
|
|
1378
1681
|
this.RaiseEvent('load_complete', { CompositeKey });
|
|
1379
1682
|
return true;
|
|
@@ -1437,11 +1740,16 @@ class BaseEntity {
|
|
|
1437
1740
|
* @returns Promise<boolean> - Returns true if the load was successful
|
|
1438
1741
|
*/
|
|
1439
1742
|
async LoadFromData(data, _replaceOldValues = false) {
|
|
1743
|
+
// IS-A: hydrate parent chain from data before populating self.
|
|
1744
|
+
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
1745
|
+
// then populates from data, ensuring correct PK and saved state on parents.
|
|
1746
|
+
if (this._parentEntity) {
|
|
1747
|
+
this._parentEntity.Hydrate(data);
|
|
1748
|
+
}
|
|
1440
1749
|
this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
|
|
1441
1750
|
// now, check to see if we have the primary key set, if so, we should consider ourselves
|
|
1442
1751
|
// loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
|
|
1443
1752
|
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
1444
|
-
// chck each pkey's value to make sur it is set
|
|
1445
1753
|
this._recordLoaded = true; // all primary keys are set, so we are loaded
|
|
1446
1754
|
this._everSaved = true; // Mark as saved since we loaded from data
|
|
1447
1755
|
for (let pkey of this.PrimaryKeys) {
|
|
@@ -1450,10 +1758,14 @@ class BaseEntity {
|
|
|
1450
1758
|
this._everSaved = false; // if any primary key is not set, we cannot consider ourselves loaded
|
|
1451
1759
|
}
|
|
1452
1760
|
}
|
|
1761
|
+
// Cache the record name for faster lookups if successfully loaded
|
|
1762
|
+
if (this._recordLoaded) {
|
|
1763
|
+
this.CacheRecordName();
|
|
1764
|
+
}
|
|
1453
1765
|
}
|
|
1454
1766
|
else {
|
|
1455
1767
|
// this is an error state as every entity must have > 0 primary keys defined
|
|
1456
|
-
|
|
1768
|
+
LogError(`BaseEntity.LoadFromData() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
|
|
1457
1769
|
this._recordLoaded = false;
|
|
1458
1770
|
this._everSaved = false; // Mark as NOT saved since we loaded from data without primary keys
|
|
1459
1771
|
}
|
|
@@ -1466,9 +1778,21 @@ class BaseEntity {
|
|
|
1466
1778
|
* @returns ValidationResult The validation result
|
|
1467
1779
|
*/
|
|
1468
1780
|
Validate() {
|
|
1469
|
-
const result = new
|
|
1781
|
+
const result = new ValidationResult();
|
|
1470
1782
|
result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
|
|
1783
|
+
// IS-A composition: validate parent entity first to collect all chain errors
|
|
1784
|
+
if (this._parentEntity) {
|
|
1785
|
+
const parentResult = this._parentEntity.Validate();
|
|
1786
|
+
if (!parentResult.Success) {
|
|
1787
|
+
result.Success = false;
|
|
1788
|
+
parentResult.Errors.forEach(err => result.Errors.push(err));
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
// Validate own fields — for IS-A entities, skip parent field mirrors since
|
|
1792
|
+
// those are validated via _parentEntity above
|
|
1471
1793
|
for (let field of this.Fields) {
|
|
1794
|
+
if (this._parentEntityFieldNames?.has(field.Name))
|
|
1795
|
+
continue; // skip parent field mirrors — authoritative validation is on _parentEntity
|
|
1472
1796
|
const err = field.Validate();
|
|
1473
1797
|
err.Errors.forEach(element => {
|
|
1474
1798
|
result.Errors.push(element);
|
|
@@ -1507,7 +1831,7 @@ class BaseEntity {
|
|
|
1507
1831
|
async ValidateAsync() {
|
|
1508
1832
|
// Default implementation just returns success
|
|
1509
1833
|
// Subclasses should override this to perform actual async validation
|
|
1510
|
-
const result = new
|
|
1834
|
+
const result = new ValidationResult();
|
|
1511
1835
|
result.Success = true;
|
|
1512
1836
|
return result;
|
|
1513
1837
|
}
|
|
@@ -1522,17 +1846,17 @@ class BaseEntity {
|
|
|
1522
1846
|
async Delete(options) {
|
|
1523
1847
|
// If a delete is already in progress, return its promise.
|
|
1524
1848
|
if (this._pendingDelete$) {
|
|
1525
|
-
return
|
|
1849
|
+
return firstValueFrom(this._pendingDelete$);
|
|
1526
1850
|
}
|
|
1527
1851
|
// Create a new observable that debounces duplicative calls, and executes the delete.
|
|
1528
|
-
this._pendingDelete$ =
|
|
1852
|
+
this._pendingDelete$ = of(options).pipe(
|
|
1529
1853
|
// Execute the actual delete logic.
|
|
1530
|
-
|
|
1854
|
+
switchMap(opts => from(this._InnerDelete(opts))),
|
|
1531
1855
|
// When the delete completes (whether successfully or not), clear the pending delete observable.
|
|
1532
|
-
|
|
1856
|
+
finalize(() => { this._pendingDelete$ = null; }),
|
|
1533
1857
|
// Ensure that all subscribers get the same result.
|
|
1534
|
-
|
|
1535
|
-
return
|
|
1858
|
+
shareReplay(1));
|
|
1859
|
+
return firstValueFrom(this._pendingDelete$);
|
|
1536
1860
|
}
|
|
1537
1861
|
/**
|
|
1538
1862
|
* Private, internal method to handle deleting a record from the database. This method is called by the public facing Delete() method
|
|
@@ -1549,7 +1873,43 @@ class BaseEntity {
|
|
|
1549
1873
|
throw new Error('No provider set');
|
|
1550
1874
|
}
|
|
1551
1875
|
else {
|
|
1552
|
-
|
|
1876
|
+
const _options = options ? options : new EntityDeleteOptions();
|
|
1877
|
+
// IS-A orchestration: determine if this is the initiating delete
|
|
1878
|
+
const hasParentChain = this._parentEntity != null;
|
|
1879
|
+
const isISAInitiator = hasParentChain && !_options.IsParentEntityDelete;
|
|
1880
|
+
// IS-A parent delete protection: prevent deleting a parent record
|
|
1881
|
+
// that has child records — FK constraints require child deletion first.
|
|
1882
|
+
// If CascadeDeletes is enabled, auto-delete child records through the chain.
|
|
1883
|
+
if (this.EntityInfo.IsParentType && !_options.IsParentEntityDelete) {
|
|
1884
|
+
const childCheck = await this.CheckForChildRecords();
|
|
1885
|
+
if (childCheck.HasChildren) {
|
|
1886
|
+
if (this.EntityInfo.CascadeDeletes) {
|
|
1887
|
+
// CascadeDeletes enabled: auto-delete the child record
|
|
1888
|
+
// This will recursively cascade through the IS-A chain
|
|
1889
|
+
const cascadeResult = await this.CascadeDeleteChildRecord(childCheck, _options);
|
|
1890
|
+
if (!cascadeResult) {
|
|
1891
|
+
throw new Error(`Cascade delete failed for ${childCheck.ChildEntityName} child record ` +
|
|
1892
|
+
`of ${this.EntityInfo.Name} '${this.PrimaryKey.Values()}'.`);
|
|
1893
|
+
}
|
|
1894
|
+
// After cascade, proceed with our own delete (parent chain will be handled)
|
|
1895
|
+
}
|
|
1896
|
+
else {
|
|
1897
|
+
throw new Error(`Cannot delete ${this.EntityInfo.Name} record '${this.PrimaryKey.Values()}': ` +
|
|
1898
|
+
`it is referenced as a ${childCheck.ChildEntityName} record. ` +
|
|
1899
|
+
`Delete the ${childCheck.ChildEntityName} record first, ` +
|
|
1900
|
+
`or enable CascadeDeletes on the ${this.EntityInfo.Name} entity.`);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
// Begin provider transaction if IS-A initiator and NOT in a TransactionGroup
|
|
1905
|
+
if (isISAInitiator && !this.TransactionGroup) {
|
|
1906
|
+
const txn = await this.ProviderToUse?.BeginISATransaction?.();
|
|
1907
|
+
if (txn) {
|
|
1908
|
+
this.ProviderTransaction = txn;
|
|
1909
|
+
this.PropagateTransactionToParents();
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
|
|
1553
1913
|
// Raise delete_started event before the actual delete operation begins
|
|
1554
1914
|
this.RaiseEvent('delete_started', null);
|
|
1555
1915
|
// stash the old values for the event
|
|
@@ -1561,7 +1921,27 @@ class BaseEntity {
|
|
|
1561
1921
|
includeRelatedEntityData: false,
|
|
1562
1922
|
relatedEntityList: null
|
|
1563
1923
|
});
|
|
1564
|
-
|
|
1924
|
+
// Delete OWN row first (FK constraint: child must be deleted before parent)
|
|
1925
|
+
if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
|
|
1926
|
+
// IS-A: after own delete succeeds, cascade to parent chain
|
|
1927
|
+
if (hasParentChain) {
|
|
1928
|
+
const parentDeleteOptions = new EntityDeleteOptions();
|
|
1929
|
+
parentDeleteOptions.SkipEntityAIActions = _options.SkipEntityAIActions;
|
|
1930
|
+
parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
|
|
1931
|
+
parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
|
|
1932
|
+
parentDeleteOptions.IsParentEntityDelete = true;
|
|
1933
|
+
const parentResult = await this._parentEntity.Delete(parentDeleteOptions);
|
|
1934
|
+
if (!parentResult) {
|
|
1935
|
+
// Parent delete failed — rollback if we started the transaction
|
|
1936
|
+
await this.RollbackISATransaction(isISAInitiator);
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
// IS-A: commit transaction after successful chain delete
|
|
1941
|
+
if (isISAInitiator && this.ProviderTransaction) {
|
|
1942
|
+
await this.ProviderToUse.CommitISATransaction?.(this.ProviderTransaction);
|
|
1943
|
+
this.ProviderTransaction = null;
|
|
1944
|
+
}
|
|
1565
1945
|
if (!this.TransactionGroup) {
|
|
1566
1946
|
// NOT part of a transaction - raise event immediately
|
|
1567
1947
|
// record deleted correctly
|
|
@@ -1592,11 +1972,14 @@ class BaseEntity {
|
|
|
1592
1972
|
}
|
|
1593
1973
|
return true;
|
|
1594
1974
|
}
|
|
1595
|
-
else // record didn't
|
|
1975
|
+
else // record didn't delete, return false, but also don't wipe out the entity like we do if the Delete() worked
|
|
1596
1976
|
return false;
|
|
1597
1977
|
}
|
|
1598
1978
|
}
|
|
1599
1979
|
catch (e) {
|
|
1980
|
+
// IS-A: rollback transaction on failure (only the initiator rolls back)
|
|
1981
|
+
const isISAInitiator = this._parentEntity != null && !options?.IsParentEntityDelete;
|
|
1982
|
+
await this.RollbackISATransaction(isISAInitiator);
|
|
1600
1983
|
if (currentResultCount === this.ResultHistory.length) {
|
|
1601
1984
|
// this means that NO new results were added to the history anywhere
|
|
1602
1985
|
// so we need to add a new result to the history here
|
|
@@ -1611,6 +1994,155 @@ class BaseEntity {
|
|
|
1611
1994
|
return false;
|
|
1612
1995
|
}
|
|
1613
1996
|
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Checks if this entity has any child records in IS-A child entity tables.
|
|
1999
|
+
* Used for parent delete protection — a parent record cannot be deleted
|
|
2000
|
+
* while child type records referencing it still exist.
|
|
2001
|
+
* @returns Object with HasChildren flag and the name of the child entity found
|
|
2002
|
+
*/
|
|
2003
|
+
async CheckForChildRecords() {
|
|
2004
|
+
const childEntities = this.EntityInfo.ChildEntities;
|
|
2005
|
+
if (childEntities.length === 0) {
|
|
2006
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
2007
|
+
}
|
|
2008
|
+
// Use RunView to check each child entity for records with our PK
|
|
2009
|
+
const rv = new RunView();
|
|
2010
|
+
const pkValue = this.PrimaryKey.Values();
|
|
2011
|
+
for (const childEntity of childEntities) {
|
|
2012
|
+
const pkField = childEntity.PrimaryKeys[0];
|
|
2013
|
+
if (!pkField)
|
|
2014
|
+
continue;
|
|
2015
|
+
const result = await rv.RunView({
|
|
2016
|
+
EntityName: childEntity.Name,
|
|
2017
|
+
ExtraFilter: `${pkField.Name} = '${pkValue}'`,
|
|
2018
|
+
ResultType: 'simple',
|
|
2019
|
+
Fields: [pkField.Name],
|
|
2020
|
+
MaxRows: 1
|
|
2021
|
+
}, this._contextCurrentUser);
|
|
2022
|
+
if (result && result.Success && result.Results && result.Results.length > 0) {
|
|
2023
|
+
return { HasChildren: true, ChildEntityName: childEntity.Name };
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Cascade-deletes an IS-A child record when the parent entity has CascadeDeletes enabled.
|
|
2030
|
+
* Loads the child entity, then deletes it through the normal IS-A chain. The child's
|
|
2031
|
+
* delete will cascade further down if it also has children and CascadeDeletes.
|
|
2032
|
+
*/
|
|
2033
|
+
async CascadeDeleteChildRecord(childCheck, parentOptions) {
|
|
2034
|
+
const md = new Metadata();
|
|
2035
|
+
const childEntity = await md.GetEntityObject(childCheck.ChildEntityName, this._contextCurrentUser);
|
|
2036
|
+
if (!childEntity)
|
|
2037
|
+
return false;
|
|
2038
|
+
// Load the child record using the same PK as this parent
|
|
2039
|
+
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
2040
|
+
if (!loaded)
|
|
2041
|
+
return false;
|
|
2042
|
+
// If the child entity itself is a parent with cascade, its delete will
|
|
2043
|
+
// handle its own children recursively through _InnerDelete
|
|
2044
|
+
const deleteOptions = new EntityDeleteOptions();
|
|
2045
|
+
deleteOptions.SkipEntityAIActions = parentOptions.SkipEntityAIActions;
|
|
2046
|
+
deleteOptions.SkipEntityActions = parentOptions.SkipEntityActions;
|
|
2047
|
+
deleteOptions.ReplayOnly = parentOptions.ReplayOnly;
|
|
2048
|
+
// Note: do NOT set IsParentEntityDelete — the child is the chain initiator
|
|
2049
|
+
return childEntity.Delete(deleteOptions);
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Resolves the leaf (most-derived) entity type for a given parent entity record.
|
|
2053
|
+
* Walks down the IS-A child hierarchy to find which child type a record belongs to.
|
|
2054
|
+
* Returns the child entity name, or the parent's own name if no children exist.
|
|
2055
|
+
* Useful for polymorphic operations where you have a parent record and need
|
|
2056
|
+
* to know its actual leaf type.
|
|
2057
|
+
*
|
|
2058
|
+
* @param entityName The parent entity name
|
|
2059
|
+
* @param primaryKey The primary key to look up
|
|
2060
|
+
* @param contextUser Optional context user for server-side operations
|
|
2061
|
+
* @returns The leaf entity name and whether it was resolved to a child type
|
|
2062
|
+
*/
|
|
2063
|
+
static async ResolveLeafEntity(entityName, primaryKey, contextUser) {
|
|
2064
|
+
const md = new Metadata();
|
|
2065
|
+
const entityInfo = md.Entities.find(e => e.Name === entityName);
|
|
2066
|
+
if (!entityInfo) {
|
|
2067
|
+
return { LeafEntityName: entityName, IsLeaf: true };
|
|
2068
|
+
}
|
|
2069
|
+
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Internal recursive helper for leaf entity resolution.
|
|
2073
|
+
* Walks down the child hierarchy until no more children are found.
|
|
2074
|
+
*/
|
|
2075
|
+
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
|
|
2076
|
+
const childEntities = entityInfo.ChildEntities;
|
|
2077
|
+
if (childEntities.length === 0) {
|
|
2078
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
2079
|
+
}
|
|
2080
|
+
const rv = new RunView();
|
|
2081
|
+
const pkValue = primaryKey.Values();
|
|
2082
|
+
for (const child of childEntities) {
|
|
2083
|
+
const childPK = child.PrimaryKeys[0];
|
|
2084
|
+
if (!childPK)
|
|
2085
|
+
continue;
|
|
2086
|
+
const result = await rv.RunView({
|
|
2087
|
+
EntityName: child.Name,
|
|
2088
|
+
ExtraFilter: `${childPK.Name} = '${pkValue}'`,
|
|
2089
|
+
ResultType: 'simple',
|
|
2090
|
+
Fields: [childPK.Name],
|
|
2091
|
+
MaxRows: 1
|
|
2092
|
+
}, contextUser);
|
|
2093
|
+
if (result?.Success && result.Results?.length > 0) {
|
|
2094
|
+
// Found a child — recurse to see if there's an even more specific leaf
|
|
2095
|
+
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
// No child found — this entity IS the leaf
|
|
2099
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* Enforces disjoint subtype constraint during IS-A child entity creation.
|
|
2103
|
+
* A parent record can only be ONE child type at a time. Checks all sibling
|
|
2104
|
+
* child types (excluding self) for records with the same PK value.
|
|
2105
|
+
* Throws if a sibling child record is found.
|
|
2106
|
+
*
|
|
2107
|
+
* Only runs on Database providers — client-side (Network/GraphQL) skips this
|
|
2108
|
+
* because the server-side save will perform the check authoritatively.
|
|
2109
|
+
*/
|
|
2110
|
+
async EnforceDisjointSubtype() {
|
|
2111
|
+
// Skip on client-side providers — the server will enforce this during its save
|
|
2112
|
+
const md = this.ProviderToUse;
|
|
2113
|
+
if (md.ProviderType !== ProviderType.Database)
|
|
2114
|
+
return;
|
|
2115
|
+
const parentEntityInfo = this.EntityInfo.ParentEntityInfo;
|
|
2116
|
+
if (!parentEntityInfo)
|
|
2117
|
+
return;
|
|
2118
|
+
const siblingChildEntities = parentEntityInfo.ChildEntities.filter(e => e.ID !== this.EntityInfo.ID);
|
|
2119
|
+
if (siblingChildEntities.length === 0)
|
|
2120
|
+
return;
|
|
2121
|
+
const pkValue = this.PrimaryKey.Values();
|
|
2122
|
+
if (!pkValue)
|
|
2123
|
+
return;
|
|
2124
|
+
// Build all sibling queries and execute them in a single batch
|
|
2125
|
+
const rv = new RunView();
|
|
2126
|
+
const validSiblings = siblingChildEntities.filter(s => s.PrimaryKeys[0]);
|
|
2127
|
+
if (validSiblings.length === 0)
|
|
2128
|
+
return;
|
|
2129
|
+
const viewParams = validSiblings.map(sibling => ({
|
|
2130
|
+
EntityName: sibling.Name,
|
|
2131
|
+
ExtraFilter: `${sibling.PrimaryKeys[0].Name} = '${pkValue}'`,
|
|
2132
|
+
ResultType: 'simple',
|
|
2133
|
+
Fields: [sibling.PrimaryKeys[0].Name],
|
|
2134
|
+
MaxRows: 1
|
|
2135
|
+
}));
|
|
2136
|
+
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
2137
|
+
for (let i = 0; i < results.length; i++) {
|
|
2138
|
+
const result = results[i];
|
|
2139
|
+
if (result?.Success && result.Results?.length > 0) {
|
|
2140
|
+
const sibling = validSiblings[i];
|
|
2141
|
+
throw new Error(`Cannot create ${this.EntityInfo.Name} record: ID '${pkValue}' already exists as ${sibling.Name}. ` +
|
|
2142
|
+
`A ${parentEntityInfo.Name} record can only be one child type at a time.`);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1614
2146
|
/**
|
|
1615
2147
|
* Called before an Action is executed by the AI Engine
|
|
1616
2148
|
* This is intended to be overriden by subclass as needed, these methods called at the right time by the execution context
|
|
@@ -1624,20 +2156,21 @@ class BaseEntity {
|
|
|
1624
2156
|
async AfterEntityAIAction(params) {
|
|
1625
2157
|
return true; // default implementation does nothing
|
|
1626
2158
|
}
|
|
2159
|
+
static { this._globalProviderKey = 'MJ_BaseEntityProvider'; }
|
|
1627
2160
|
/**
|
|
1628
2161
|
* Static property to get/set the IEntityDataProvider that is used by all BaseEntity objects. This is a global setting that is used by all BaseEntity objects. It can be overriden for a given BaseEntity object instance by passing in a provider to the
|
|
1629
2162
|
* constructor of the BaseEntity object. Typically, a provider will pass itself into BaseEntity objects it creates to create a tight coupling between the provider and the BaseEntity objects it creates. This allows multiple concurrent
|
|
1630
2163
|
* connections to exist in the same process space without interfering with each other.
|
|
1631
2164
|
*/
|
|
1632
2165
|
static get Provider() {
|
|
1633
|
-
const g =
|
|
2166
|
+
const g = MJGlobal.Instance.GetGlobalObjectStore();
|
|
1634
2167
|
if (g)
|
|
1635
2168
|
return g[BaseEntity._globalProviderKey];
|
|
1636
2169
|
else
|
|
1637
2170
|
throw new Error('No global object store, so we cant get the static provider');
|
|
1638
2171
|
}
|
|
1639
2172
|
static set Provider(value) {
|
|
1640
|
-
const g =
|
|
2173
|
+
const g = MJGlobal.Instance.GetGlobalObjectStore();
|
|
1641
2174
|
if (g)
|
|
1642
2175
|
g[BaseEntity._globalProviderKey] = value;
|
|
1643
2176
|
else
|
|
@@ -1683,7 +2216,7 @@ class BaseEntity {
|
|
|
1683
2216
|
if (results) {
|
|
1684
2217
|
const changes = [];
|
|
1685
2218
|
for (let result of results)
|
|
1686
|
-
changes.push(new
|
|
2219
|
+
changes.push(new RecordChange(result));
|
|
1687
2220
|
return changes;
|
|
1688
2221
|
}
|
|
1689
2222
|
else
|
|
@@ -1704,7 +2237,7 @@ class BaseEntity {
|
|
|
1704
2237
|
return true;
|
|
1705
2238
|
}
|
|
1706
2239
|
else {
|
|
1707
|
-
|
|
2240
|
+
LogError(parseResult.error.flatten());
|
|
1708
2241
|
return false;
|
|
1709
2242
|
}
|
|
1710
2243
|
}
|
|
@@ -1725,7 +2258,7 @@ class BaseEntity {
|
|
|
1725
2258
|
parseResult.data;
|
|
1726
2259
|
}
|
|
1727
2260
|
else {
|
|
1728
|
-
|
|
2261
|
+
LogError(parseResult.error.flatten());
|
|
1729
2262
|
return null;
|
|
1730
2263
|
}
|
|
1731
2264
|
}
|
|
@@ -1846,7 +2379,4 @@ class BaseEntity {
|
|
|
1846
2379
|
this._vectors.clear();
|
|
1847
2380
|
}
|
|
1848
2381
|
}
|
|
1849
|
-
exports.BaseEntity = BaseEntity;
|
|
1850
|
-
BaseEntity._baseEventCode = 'BaseEntityEvent';
|
|
1851
|
-
BaseEntity._globalProviderKey = 'MJ_BaseEntityProvider';
|
|
1852
2382
|
//# sourceMappingURL=baseEntity.js.map
|