@memberjunction/core 4.0.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/baseEntity.d.ts +166 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +549 -9
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +79 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +149 -2
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/interfaces.d.ts +53 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +13 -0
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/metadata.d.ts +2 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +4 -4
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/providerBase.d.ts +45 -4
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +125 -2
- package/dist/generic/providerBase.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +871 -1271
- package/dist/__tests__/mocks/TestMetadataProvider.d.ts +0 -45
- package/dist/__tests__/mocks/TestMetadataProvider.d.ts.map +0 -1
- package/dist/__tests__/mocks/TestMetadataProvider.js +0 -217
- package/dist/__tests__/mocks/TestMetadataProvider.js.map +0 -1
- package/dist/__tests__/providerBase.concurrency.test.d.ts +0 -10
- package/dist/__tests__/providerBase.concurrency.test.d.ts.map +0 -1
- package/dist/__tests__/providerBase.concurrency.test.js +0 -253
- package/dist/__tests__/providerBase.concurrency.test.js.map +0 -1
- package/dist/__tests__/providerBase.refresh.test.d.ts +0 -10
- package/dist/__tests__/providerBase.refresh.test.d.ts.map +0 -1
- package/dist/__tests__/providerBase.refresh.test.js +0 -161
- package/dist/__tests__/providerBase.refresh.test.js.map +0 -1
- package/dist/__tests__/setup.d.ts +0 -5
- package/dist/__tests__/setup.d.ts.map +0 -1
- package/dist/__tests__/setup.js +0 -17
- package/dist/__tests__/setup.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MJEventType, MJGlobal, uuidv4, WarningManager } from '@memberjunction/global';
|
|
2
2
|
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
3
|
-
import { EntitySaveOptions } from './interfaces.js';
|
|
3
|
+
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
4
4
|
import { Metadata } from './metadata.js';
|
|
5
5
|
import { RunView } from '../views/runView.js';
|
|
6
6
|
import { LogDebug, LogError } from './logging.js';
|
|
@@ -466,6 +466,22 @@ export class BaseEntityEvent {
|
|
|
466
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.
|
|
467
467
|
*/
|
|
468
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; }
|
|
469
485
|
constructor(Entity, Provider = null) {
|
|
470
486
|
this._Fields = [];
|
|
471
487
|
this._recordLoaded = false;
|
|
@@ -476,6 +492,33 @@ export class BaseEntity {
|
|
|
476
492
|
this._everSaved = false;
|
|
477
493
|
this._isLoading = false;
|
|
478
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;
|
|
479
522
|
this._compositeKey = null;
|
|
480
523
|
// Holds the current pending save observable (if any)
|
|
481
524
|
this._pendingSave$ = null;
|
|
@@ -491,6 +534,76 @@ export class BaseEntity {
|
|
|
491
534
|
this._provider = Provider;
|
|
492
535
|
this.init();
|
|
493
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
|
+
}
|
|
494
607
|
/**
|
|
495
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.
|
|
496
609
|
*/
|
|
@@ -750,7 +863,9 @@ export class BaseEntity {
|
|
|
750
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.
|
|
751
864
|
*/
|
|
752
865
|
get Dirty() {
|
|
753
|
-
return !this.IsSaved ||
|
|
866
|
+
return !this.IsSaved ||
|
|
867
|
+
this.Fields.some(f => f.Dirty) ||
|
|
868
|
+
(this._parentEntity?.Dirty ?? false);
|
|
754
869
|
}
|
|
755
870
|
/**
|
|
756
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.
|
|
@@ -785,10 +900,30 @@ export class BaseEntity {
|
|
|
785
900
|
/**
|
|
786
901
|
* Sets the value of a given field. If the field doesn't exist, nothing happens.
|
|
787
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
|
+
*
|
|
788
909
|
* @param FieldName
|
|
789
910
|
* @param Value
|
|
790
911
|
*/
|
|
791
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) {
|
|
792
927
|
const field = this.GetFieldByName(FieldName);
|
|
793
928
|
if (field != null) {
|
|
794
929
|
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
|
|
@@ -829,10 +964,18 @@ export class BaseEntity {
|
|
|
829
964
|
}
|
|
830
965
|
/**
|
|
831
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
|
+
*
|
|
832
971
|
* @param FieldName
|
|
833
972
|
* @returns
|
|
834
973
|
*/
|
|
835
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
|
+
}
|
|
836
979
|
const field = this.GetFieldByName(FieldName);
|
|
837
980
|
if (field != null) {
|
|
838
981
|
// if the field is a date and the value is a string, convert it to a date
|
|
@@ -847,6 +990,11 @@ export class BaseEntity {
|
|
|
847
990
|
* NOTE: Do not call this method directly. Use the {@link From} method instead
|
|
848
991
|
*
|
|
849
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
|
+
*
|
|
850
998
|
* @param object
|
|
851
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
|
|
852
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
|
|
@@ -863,7 +1011,8 @@ export class BaseEntity {
|
|
|
863
1011
|
if (ignoreActiveStatusAssertions) {
|
|
864
1012
|
field.ActiveStatusAssertions = false; // disable active status assertions for this field
|
|
865
1013
|
}
|
|
866
|
-
|
|
1014
|
+
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
1015
|
+
this.SetLocal(key, object[key]);
|
|
867
1016
|
if (replaceOldValues) {
|
|
868
1017
|
field.ResetOldValue();
|
|
869
1018
|
}
|
|
@@ -880,7 +1029,8 @@ export class BaseEntity {
|
|
|
880
1029
|
if (ignoreActiveStatusAssertions) {
|
|
881
1030
|
field.ActiveStatusAssertions = false; // disable active status assertions for this field
|
|
882
1031
|
}
|
|
883
|
-
|
|
1032
|
+
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
1033
|
+
this.SetLocal(field.Name, object[key]);
|
|
884
1034
|
if (replaceOldValues) {
|
|
885
1035
|
field.ResetOldValue();
|
|
886
1036
|
}
|
|
@@ -889,6 +1039,11 @@ export class BaseEntity {
|
|
|
889
1039
|
}
|
|
890
1040
|
}
|
|
891
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
|
+
}
|
|
892
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
|
|
893
1048
|
if (!ignoreNonExistentFields)
|
|
894
1049
|
throw new Error(`Field ${key} does not exist on ${this.EntityInfo.Name}`);
|
|
@@ -899,6 +1054,41 @@ export class BaseEntity {
|
|
|
899
1054
|
}
|
|
900
1055
|
}
|
|
901
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
|
+
}
|
|
902
1092
|
}
|
|
903
1093
|
/**
|
|
904
1094
|
* NOTE: Do not call this method directly. Use the {@link To} method instead
|
|
@@ -922,6 +1112,13 @@ export class BaseEntity {
|
|
|
922
1112
|
field.ActiveStatusAssertions = tempStatus; // restore the prior status for assertions
|
|
923
1113
|
}
|
|
924
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
|
+
}
|
|
925
1122
|
return obj;
|
|
926
1123
|
}
|
|
927
1124
|
/**
|
|
@@ -1096,6 +1293,18 @@ export class BaseEntity {
|
|
|
1096
1293
|
this.Set(kv.FieldName, kv.Value);
|
|
1097
1294
|
});
|
|
1098
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
|
+
}
|
|
1099
1308
|
this.RaiseEvent('new_record', null);
|
|
1100
1309
|
return true;
|
|
1101
1310
|
}
|
|
@@ -1136,9 +1345,44 @@ export class BaseEntity {
|
|
|
1136
1345
|
newResult.StartedAt = new Date();
|
|
1137
1346
|
try {
|
|
1138
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
|
+
}
|
|
1139
1377
|
const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
|
|
1140
1378
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
1141
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
|
+
}
|
|
1142
1386
|
if (_options.IgnoreDirtyState || this.Dirty || _options.ReplayOnly) {
|
|
1143
1387
|
// Raise save_started event only when we're actually going to save
|
|
1144
1388
|
this.RaiseEvent('save_started', null, saveSubType);
|
|
@@ -1175,12 +1419,18 @@ export class BaseEntity {
|
|
|
1175
1419
|
const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
|
|
1176
1420
|
if (!this.TransactionGroup) {
|
|
1177
1421
|
// no transaction group, so we have our results here
|
|
1178
|
-
|
|
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;
|
|
1179
1429
|
}
|
|
1180
1430
|
else {
|
|
1181
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
|
|
1182
1432
|
this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
|
|
1183
|
-
if (success) {
|
|
1433
|
+
if (success && results) {
|
|
1184
1434
|
const transItem = results.find(r => r.Transaction.BaseEntity === this);
|
|
1185
1435
|
if (transItem) {
|
|
1186
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
|
|
@@ -1206,6 +1456,9 @@ export class BaseEntity {
|
|
|
1206
1456
|
return true; // nothing to save since we're not dirty
|
|
1207
1457
|
}
|
|
1208
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);
|
|
1209
1462
|
if (currentResultCount === this.ResultHistory.length) {
|
|
1210
1463
|
// this means that NO new results were added to the history anywhere
|
|
1211
1464
|
// so we need to add a new result to the history here
|
|
@@ -1220,6 +1473,21 @@ export class BaseEntity {
|
|
|
1220
1473
|
return false;
|
|
1221
1474
|
}
|
|
1222
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
|
+
}
|
|
1223
1491
|
finalizeSave(data, saveSubType) {
|
|
1224
1492
|
if (data) {
|
|
1225
1493
|
this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
|
|
@@ -1228,6 +1496,8 @@ export class BaseEntity {
|
|
|
1228
1496
|
const result = this.LatestResult;
|
|
1229
1497
|
if (result)
|
|
1230
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();
|
|
1231
1501
|
this.RaiseEvent('save', null, saveSubType);
|
|
1232
1502
|
return true;
|
|
1233
1503
|
}
|
|
@@ -1241,6 +1511,23 @@ export class BaseEntity {
|
|
|
1241
1511
|
get ActiveUser() {
|
|
1242
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
|
|
1243
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);
|
|
1530
|
+
}
|
|
1244
1531
|
/**
|
|
1245
1532
|
* Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
|
|
1246
1533
|
* @param type
|
|
@@ -1251,6 +1538,17 @@ export class BaseEntity {
|
|
|
1251
1538
|
const u = this.ActiveUser;
|
|
1252
1539
|
if (!u)
|
|
1253
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
|
+
}
|
|
1254
1552
|
// first check if the AllowCreateAPI/AllowUpdateAPI/AllowDeleteAPI settings are flipped on for the entity in question
|
|
1255
1553
|
switch (type) {
|
|
1256
1554
|
case EntityPermissionType.Create:
|
|
@@ -1322,6 +1620,10 @@ export class BaseEntity {
|
|
|
1322
1620
|
for (let field of this.Fields) {
|
|
1323
1621
|
field.Value = field.OldValue;
|
|
1324
1622
|
}
|
|
1623
|
+
// IS-A composition: revert parent entity chain as well
|
|
1624
|
+
if (this._parentEntity) {
|
|
1625
|
+
this._parentEntity.Revert();
|
|
1626
|
+
}
|
|
1325
1627
|
}
|
|
1326
1628
|
return true;
|
|
1327
1629
|
}
|
|
@@ -1354,6 +1656,13 @@ export class BaseEntity {
|
|
|
1354
1656
|
LogError(`Error in BaseEntity.Load(${this.EntityInfo.Name}, Key: ${CompositeKey.ToString()}`);
|
|
1355
1657
|
return false; // no data loaded, return false
|
|
1356
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
|
+
}
|
|
1357
1666
|
this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
|
|
1358
1667
|
if (EntityRelationshipsToLoad) {
|
|
1359
1668
|
for (let relationship of EntityRelationshipsToLoad) {
|
|
@@ -1366,6 +1675,8 @@ export class BaseEntity {
|
|
|
1366
1675
|
this._recordLoaded = true;
|
|
1367
1676
|
this._everSaved = true; // Mark as saved since we loaded from database
|
|
1368
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();
|
|
1369
1680
|
// Raise load completion event
|
|
1370
1681
|
this.RaiseEvent('load_complete', { CompositeKey });
|
|
1371
1682
|
return true;
|
|
@@ -1429,11 +1740,16 @@ export class BaseEntity {
|
|
|
1429
1740
|
* @returns Promise<boolean> - Returns true if the load was successful
|
|
1430
1741
|
*/
|
|
1431
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
|
+
}
|
|
1432
1749
|
this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
|
|
1433
1750
|
// now, check to see if we have the primary key set, if so, we should consider ourselves
|
|
1434
1751
|
// loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
|
|
1435
1752
|
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
1436
|
-
// chck each pkey's value to make sur it is set
|
|
1437
1753
|
this._recordLoaded = true; // all primary keys are set, so we are loaded
|
|
1438
1754
|
this._everSaved = true; // Mark as saved since we loaded from data
|
|
1439
1755
|
for (let pkey of this.PrimaryKeys) {
|
|
@@ -1442,6 +1758,10 @@ export class BaseEntity {
|
|
|
1442
1758
|
this._everSaved = false; // if any primary key is not set, we cannot consider ourselves loaded
|
|
1443
1759
|
}
|
|
1444
1760
|
}
|
|
1761
|
+
// Cache the record name for faster lookups if successfully loaded
|
|
1762
|
+
if (this._recordLoaded) {
|
|
1763
|
+
this.CacheRecordName();
|
|
1764
|
+
}
|
|
1445
1765
|
}
|
|
1446
1766
|
else {
|
|
1447
1767
|
// this is an error state as every entity must have > 0 primary keys defined
|
|
@@ -1460,7 +1780,19 @@ export class BaseEntity {
|
|
|
1460
1780
|
Validate() {
|
|
1461
1781
|
const result = new ValidationResult();
|
|
1462
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
|
|
1463
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
|
|
1464
1796
|
const err = field.Validate();
|
|
1465
1797
|
err.Errors.forEach(element => {
|
|
1466
1798
|
result.Errors.push(element);
|
|
@@ -1541,6 +1873,42 @@ export class BaseEntity {
|
|
|
1541
1873
|
throw new Error('No provider set');
|
|
1542
1874
|
}
|
|
1543
1875
|
else {
|
|
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
|
+
}
|
|
1544
1912
|
this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
|
|
1545
1913
|
// Raise delete_started event before the actual delete operation begins
|
|
1546
1914
|
this.RaiseEvent('delete_started', null);
|
|
@@ -1553,7 +1921,27 @@ export class BaseEntity {
|
|
|
1553
1921
|
includeRelatedEntityData: false,
|
|
1554
1922
|
relatedEntityList: null
|
|
1555
1923
|
});
|
|
1556
|
-
|
|
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
|
+
}
|
|
1557
1945
|
if (!this.TransactionGroup) {
|
|
1558
1946
|
// NOT part of a transaction - raise event immediately
|
|
1559
1947
|
// record deleted correctly
|
|
@@ -1584,11 +1972,14 @@ export class BaseEntity {
|
|
|
1584
1972
|
}
|
|
1585
1973
|
return true;
|
|
1586
1974
|
}
|
|
1587
|
-
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
|
|
1588
1976
|
return false;
|
|
1589
1977
|
}
|
|
1590
1978
|
}
|
|
1591
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);
|
|
1592
1983
|
if (currentResultCount === this.ResultHistory.length) {
|
|
1593
1984
|
// this means that NO new results were added to the history anywhere
|
|
1594
1985
|
// so we need to add a new result to the history here
|
|
@@ -1603,6 +1994,155 @@ export class BaseEntity {
|
|
|
1603
1994
|
return false;
|
|
1604
1995
|
}
|
|
1605
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
|
+
}
|
|
1606
2146
|
/**
|
|
1607
2147
|
* Called before an Action is executed by the AI Engine
|
|
1608
2148
|
* This is intended to be overriden by subclass as needed, these methods called at the right time by the execution context
|