@memberjunction/core 6.1.0-edge.1 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/generic/InMemoryLocalStorageProvider.d.ts +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.d.ts.map +1 -1
- package/dist/generic/InMemoryLocalStorageProvider.js +6 -0
- package/dist/generic/InMemoryLocalStorageProvider.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +120 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +31 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +75 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.js.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts +19 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IFileStorageProviderConfiguration.js.map +1 -0
- package/dist/generic/authTypes.d.ts +69 -0
- package/dist/generic/authTypes.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +14 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +53 -13
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +150 -23
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +435 -66
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +16 -4
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +30 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +97 -0
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/dataHooks.d.ts +5 -0
- package/dist/generic/dataHooks.d.ts.map +1 -1
- package/dist/generic/dataHooks.js +27 -3
- package/dist/generic/dataHooks.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +9 -5
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +123 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -0
- package/dist/generic/embeddedRecord.js +356 -0
- package/dist/generic/embeddedRecord.js.map +1 -0
- package/dist/generic/entityCompanion.d.ts +20 -2
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +17 -1
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +131 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -0
- package/dist/generic/entityConfiguration.js +346 -0
- package/dist/generic/entityConfiguration.js.map +1 -0
- package/dist/generic/entityInfo.d.ts +274 -4
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +502 -14
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySavePlan.d.ts +40 -11
- package/dist/generic/entitySavePlan.d.ts.map +1 -1
- package/dist/generic/entitySavePlan.js +70 -2
- package/dist/generic/entitySavePlan.js.map +1 -1
- package/dist/generic/interfaces.d.ts +35 -24
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +6 -24
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +123 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +264 -9
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +118 -0
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +311 -11
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +36 -4
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +70 -8
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +14 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +1 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -5
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +58 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +20 -0
- package/dist/views/runView.js.map +1 -1
- package/package.json +5 -4
- package/readme.md +64 -1
|
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
var EntityField_1;
|
|
11
|
-
import { MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
11
|
+
import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
13
|
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
14
14
|
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
@@ -18,6 +18,7 @@ import { LogDebug, LogError } from './logging.js';
|
|
|
18
18
|
import { CompositeKey } from './compositeKey.js';
|
|
19
19
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
20
20
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
21
|
+
import { EmbeddedRecord } from './embeddedRecord.js';
|
|
21
22
|
import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
|
|
22
23
|
import { BaseRemotableOperation } from './baseRemotableOperation.js';
|
|
23
24
|
import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
|
|
@@ -312,6 +313,20 @@ let EntityField = class EntityField {
|
|
|
312
313
|
}
|
|
313
314
|
}
|
|
314
315
|
}
|
|
316
|
+
// Value-list validation (MJ issue #3969): a field whose ValueListType is `List` carries an
|
|
317
|
+
// EXHAUSTIVE set of legal values in metadata, and for an `IN (...)` CHECK constraint that
|
|
318
|
+
// list is the only runtime representation CodeGen produces — so without this rung an
|
|
319
|
+
// out-of-list value reached the database as a raw constraint violation attributed to no
|
|
320
|
+
// field, on every path that is not a form. Which modes and value types the rule applies
|
|
321
|
+
// to, how values are compared, and why, all live in
|
|
322
|
+
// EntityFieldInfo.ValueIsPermittedByValueList: the normalized set of legal values is
|
|
323
|
+
// derived from immutable metadata SHARED by every EntityField instance of this field, so
|
|
324
|
+
// it is built once per field rather than rebuilt on every record's validation.
|
|
325
|
+
if (!ef.ValueIsPermittedByValueList(this.Value)) {
|
|
326
|
+
result.Success = false;
|
|
327
|
+
const nullNote = ef.AllowsNull ? ' (or null)' : '';
|
|
328
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
|
|
329
|
+
}
|
|
315
330
|
}
|
|
316
331
|
return result;
|
|
317
332
|
}
|
|
@@ -422,6 +437,13 @@ let EntityField = class EntityField {
|
|
|
422
437
|
ResetOldValue() {
|
|
423
438
|
this._OldValue = this.Value;
|
|
424
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* Restores the dirty-tracking baseline to a previously captured value.
|
|
442
|
+
* Used by graph rollback so a retried save still sees the pre-attempt dirty set.
|
|
443
|
+
*/
|
|
444
|
+
RestoreOldValue(value) {
|
|
445
|
+
this._OldValue = value;
|
|
446
|
+
}
|
|
425
447
|
/**
|
|
426
448
|
* @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
|
|
427
449
|
* assertions are now enforced at BaseEntity.Get/Set/SetMany — the entry points genuine code uses —
|
|
@@ -519,9 +541,6 @@ export class BaseEntityResult {
|
|
|
519
541
|
*/
|
|
520
542
|
export class BaseEntityEvent {
|
|
521
543
|
}
|
|
522
|
-
/**
|
|
523
|
-
* 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.
|
|
524
|
-
*/
|
|
525
544
|
export class BaseEntity {
|
|
526
545
|
/**
|
|
527
546
|
* Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
|
|
@@ -614,6 +633,21 @@ export class BaseEntity {
|
|
|
614
633
|
* authoritative source.
|
|
615
634
|
*/
|
|
616
635
|
this._raw = null;
|
|
636
|
+
/**
|
|
637
|
+
* Per-instance memo for values `Get()` derives from `_raw` — a parsed `Date`, an rtrimmed
|
|
638
|
+
* fixed-width string. Lazily created; only converted fields ever get an entry.
|
|
639
|
+
*
|
|
640
|
+
* This deliberately does NOT write back into `_raw`. `LoadFromData`'s fast path keeps the
|
|
641
|
+
* caller's row BY REFERENCE, and that row is frequently a LocalCacheManager entry, which the
|
|
642
|
+
* cache deep-freezes on reference-sharing providers. Memoizing into the row therefore made a
|
|
643
|
+
* plain field READ throw — and gating that write on a once-sampled `Object.isFrozen` could
|
|
644
|
+
* not be made correct, because the freeze is asynchronous relative to the consumer (cache
|
|
645
|
+
* writes are not always awaited), so the sample can be stale by the first read.
|
|
646
|
+
*
|
|
647
|
+
* Keeping the memo here makes freeze timing irrelevant AND restores the optimization on
|
|
648
|
+
* frozen rows, which the isFrozen-guard version had to give up.
|
|
649
|
+
*/
|
|
650
|
+
this._rawConverted = null;
|
|
617
651
|
/**
|
|
618
652
|
* Whether a database record has been loaded into this instance (via `Load`, `NewRecord`,
|
|
619
653
|
* `LoadFromData`, etc.). Used to gate operations that require loaded state and to distinguish
|
|
@@ -913,6 +947,8 @@ export class BaseEntity {
|
|
|
913
947
|
// We need to replace the child's auto-initialized parent with our existing instance
|
|
914
948
|
this.replaceChildParentChain(childEntity);
|
|
915
949
|
this._childEntity = childEntity;
|
|
950
|
+
// Capture any pending modifications on this entity or its ancestors before child InnerLoad
|
|
951
|
+
const dirtySnapshots = this.captureChainDirtyState();
|
|
916
952
|
// Load the child's record using our shared PK
|
|
917
953
|
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
918
954
|
if (!loaded) {
|
|
@@ -920,10 +956,34 @@ export class BaseEntity {
|
|
|
920
956
|
this._childEntity = null;
|
|
921
957
|
return;
|
|
922
958
|
}
|
|
959
|
+
// Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
|
|
960
|
+
this.restoreChainDirtyState(dirtySnapshots);
|
|
923
961
|
// Recursively discover grandchildren (child may also be a parent type)
|
|
924
962
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
925
963
|
await childEntity.InitializeChildEntity();
|
|
926
964
|
}
|
|
965
|
+
captureChainDirtyState() {
|
|
966
|
+
const snapshots = [];
|
|
967
|
+
let curr = this;
|
|
968
|
+
while (curr) {
|
|
969
|
+
const dirtyFields = curr.Fields.filter(f => f.Dirty).map(f => ({
|
|
970
|
+
name: f.Name,
|
|
971
|
+
value: f.Value,
|
|
972
|
+
}));
|
|
973
|
+
if (dirtyFields.length > 0) {
|
|
974
|
+
snapshots.push({ entity: curr, dirtyFields });
|
|
975
|
+
}
|
|
976
|
+
curr = curr._parentEntity;
|
|
977
|
+
}
|
|
978
|
+
return snapshots;
|
|
979
|
+
}
|
|
980
|
+
restoreChainDirtyState(snapshots) {
|
|
981
|
+
for (const snap of snapshots) {
|
|
982
|
+
for (const df of snap.dirtyFields) {
|
|
983
|
+
snap.entity.Set(df.name, df.value);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
927
987
|
/**
|
|
928
988
|
* Replaces the child entity's auto-initialized parent chain with references
|
|
929
989
|
* to our existing chain. This ensures all entities in the IS-A hierarchy
|
|
@@ -1008,7 +1068,12 @@ export class BaseEntity {
|
|
|
1008
1068
|
* values are present (via {@link UpdateSavedStateFromPrimaryKeys}).
|
|
1009
1069
|
*
|
|
1010
1070
|
* The parent chain is handled recursively: if this entity has an IS-A parent, the
|
|
1011
|
-
* parent is hydrated first (deepest ancestor first)
|
|
1071
|
+
* parent is hydrated first (deepest ancestor first). Each level only receives the
|
|
1072
|
+
* fields it owns — a child's view row is the union of every ancestor plus its own
|
|
1073
|
+
* columns, and passing that whole row to the parent used to trip
|
|
1074
|
+
* `WarningManager` ("fields were not found in entity definitions") for every
|
|
1075
|
+
* child-only column. That is how loading `Accounting Company Profiles` as
|
|
1076
|
+
* entity objects produced a `MJ: Companies` missing-field dump at MJAPI boot.
|
|
1012
1077
|
*
|
|
1013
1078
|
* @param data - A plain object whose properties map to field names on this entity
|
|
1014
1079
|
* (and potentially parent entities in the IS-A chain).
|
|
@@ -1025,9 +1090,23 @@ export class BaseEntity {
|
|
|
1025
1090
|
// Populate this entity's fields. SetMany also routes parent field values to
|
|
1026
1091
|
// _parentEntity via the IS-A routing block (which now includes PK fields).
|
|
1027
1092
|
// replaceOldValues=true ensures OldValue matches Value (no false dirty flags).
|
|
1028
|
-
// ignoreNonExistentFields=true
|
|
1029
|
-
//
|
|
1030
|
-
this.SetMany(data, true, true, true);
|
|
1093
|
+
// ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
|
|
1094
|
+
// dropped columns that belong to another level of the IS-A chain.
|
|
1095
|
+
this.SetMany(this.ownedFieldsFrom(data), true, true, true);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
1099
|
+
* Used by {@link Hydrate} so an IS-A ancestor is not asked to SetMany its child's
|
|
1100
|
+
* extra view columns.
|
|
1101
|
+
*/
|
|
1102
|
+
ownedFieldsFrom(data) {
|
|
1103
|
+
const owned = {};
|
|
1104
|
+
for (const key of Object.keys(data)) {
|
|
1105
|
+
if (this.GetFieldByName(key) || this.GetFieldByCodeName(key)) {
|
|
1106
|
+
owned[key] = data[key];
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return owned;
|
|
1031
1110
|
}
|
|
1032
1111
|
// ─── Entity Companions ──────────────────────────────────────────────────────
|
|
1033
1112
|
//
|
|
@@ -1108,21 +1187,91 @@ export class BaseEntity {
|
|
|
1108
1187
|
DeclareRelatedRecords(options) {
|
|
1109
1188
|
return this.RegisterCompanion(new RelatedRecordCollection(this, options));
|
|
1110
1189
|
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
|
|
1192
|
+
* and registers it as a companion.
|
|
1193
|
+
*
|
|
1194
|
+
* Call from a field initialiser on a **shared** (client + server) subclass — or let
|
|
1195
|
+
* CodeGen emit it from `EntityField.EmbeddedRecord`. The public surface is the
|
|
1196
|
+
* generated `{Field}_Object` getter, not this companion.
|
|
1197
|
+
*
|
|
1198
|
+
* @typeParam TEmbedded - The peer entity type.
|
|
1199
|
+
* @param options - The declaration.
|
|
1200
|
+
* @returns The registered companion.
|
|
1201
|
+
*/
|
|
1202
|
+
DeclareEmbeddedRecord(options) {
|
|
1203
|
+
return this.RegisterCompanion(new EmbeddedRecord(this, options));
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Constructs every declared embedded peer without `NewRecord` or `Load`.
|
|
1207
|
+
* Called from `GetEntityObject` after {@link InitializeParentEntity}.
|
|
1208
|
+
*
|
|
1209
|
+
* @param visited - Entity names already being constructed (cycle guard).
|
|
1210
|
+
*/
|
|
1211
|
+
async InitializeEmbeddedRecords(visited = new Set()) {
|
|
1212
|
+
if (!this.HasCompanions) {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const embeddeds = this.Companions.filter((c) => c instanceof EmbeddedRecord);
|
|
1216
|
+
if (embeddeds.length === 0) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
// Copy the visited set per sibling. Sharing one mutable set made two
|
|
1220
|
+
// embeds targeting the same entity (BillTo + ShipTo Address) throw a
|
|
1221
|
+
// false "cycle detected" while the first companion was still in flight.
|
|
1222
|
+
await Promise.all(embeddeds.map(e => e.InitializeInstance(new Set(visited))));
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* Builds a related entity the way `GetEntityObject` does, minus `NewRecord` / `Load`.
|
|
1226
|
+
* Used by {@link EmbeddedRecord} so construction can thread a cycle-detection set.
|
|
1227
|
+
*
|
|
1228
|
+
* @typeParam T - The entity type to construct.
|
|
1229
|
+
* @param entityName - Metadata entity name.
|
|
1230
|
+
* @param visited - Cycle guard, forwarded into the new instance's own embeddeds.
|
|
1231
|
+
*/
|
|
1232
|
+
async ConstructUninitializedEntity(entityName, visited) {
|
|
1233
|
+
const provider = this.ProviderToUse;
|
|
1234
|
+
if (!provider) {
|
|
1235
|
+
throw new Error(`BaseEntity.ConstructUninitializedEntity: no provider; cannot construct '${entityName}'.`);
|
|
1236
|
+
}
|
|
1237
|
+
const entityInfo = provider.EntityByName(entityName);
|
|
1238
|
+
if (!entityInfo) {
|
|
1239
|
+
throw new Error(`BaseEntity.ConstructUninitializedEntity: entity '${entityName}' not found in metadata.`);
|
|
1240
|
+
}
|
|
1241
|
+
let instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, entityName, entityInfo, provider);
|
|
1242
|
+
if (!instance) {
|
|
1243
|
+
instance = MJGlobal.Instance.ClassFactory.CreateInstance(BaseEntity, null, entityInfo, provider);
|
|
1244
|
+
}
|
|
1245
|
+
if (!instance) {
|
|
1246
|
+
throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
|
|
1247
|
+
`Ensure the entity class is registered.`);
|
|
1248
|
+
}
|
|
1249
|
+
await instance.Config(this.ContextCurrentUser);
|
|
1250
|
+
await instance.InitializeParentEntity();
|
|
1251
|
+
// Recurse so a *new* peer's own embeds are constructed (required nested
|
|
1252
|
+
// FKs provision on NewRecord; Ensure on the nested peer does not throw).
|
|
1253
|
+
// InitializeInstance skips when RelatedEntity is already on this path,
|
|
1254
|
+
// so a self-FK / A→B→A cycle still constructs only one extra level.
|
|
1255
|
+
await instance.InitializeEmbeddedRecords(visited);
|
|
1256
|
+
return instance;
|
|
1257
|
+
}
|
|
1111
1258
|
/**
|
|
1112
1259
|
* Serializes every registered companion that has something to send.
|
|
1113
1260
|
*
|
|
1114
1261
|
* Companions returning `null` are omitted entirely, so a header-only save on a composite entity
|
|
1115
1262
|
* ships no companion payload at all and costs nothing extra on the wire.
|
|
1116
1263
|
*
|
|
1264
|
+
* @param mode - `'request'` omits clean saved companions. `'result'` ships
|
|
1265
|
+
* authoritative post-save state so the other tier can mark peers saved.
|
|
1117
1266
|
* @returns The companion payloads, in declaration order.
|
|
1118
1267
|
*/
|
|
1119
|
-
async SerializeCompanions() {
|
|
1268
|
+
async SerializeCompanions(mode = 'request') {
|
|
1120
1269
|
if (!this.HasCompanions) {
|
|
1121
1270
|
return [];
|
|
1122
1271
|
}
|
|
1123
1272
|
const payloads = [];
|
|
1124
1273
|
for (const companion of this.Companions) {
|
|
1125
|
-
const data = await companion.Serialize();
|
|
1274
|
+
const data = await companion.Serialize(mode);
|
|
1126
1275
|
if (data !== null && data !== undefined) {
|
|
1127
1276
|
payloads.push({ Name: companion.Name, Data: data });
|
|
1128
1277
|
}
|
|
@@ -1310,13 +1459,29 @@ export class BaseEntity {
|
|
|
1310
1459
|
}
|
|
1311
1460
|
});
|
|
1312
1461
|
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Seeds the embed-load cycle set for a nested `InnerLoad`. Called by
|
|
1464
|
+
* {@link EmbeddedRecord.LoadEager} so inherit walks share one `entityName:PK`
|
|
1465
|
+
* path and a self-parented row fails cleanly instead of recursing forever.
|
|
1466
|
+
*/
|
|
1467
|
+
SetEmbeddedLoadVisited(visited) {
|
|
1468
|
+
this._embedLoadVisited = visited;
|
|
1469
|
+
}
|
|
1470
|
+
seedEmbedLoadVisited() {
|
|
1471
|
+
const seeded = new Set();
|
|
1472
|
+
const name = this.EntityInfo?.Name;
|
|
1473
|
+
const pk = this.FirstPrimaryKey?.Value;
|
|
1474
|
+
if (name && pk !== null && pk !== undefined && pk !== '') {
|
|
1475
|
+
seeded.add(`${name}:${String(pk)}`);
|
|
1476
|
+
}
|
|
1477
|
+
return seeded;
|
|
1478
|
+
}
|
|
1313
1479
|
async loadEagerCompanions() {
|
|
1314
1480
|
if (!this.HasCompanions) {
|
|
1315
1481
|
return;
|
|
1316
1482
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
}
|
|
1483
|
+
const visited = this._embedLoadVisited ?? this.seedEmbedLoadVisited();
|
|
1484
|
+
await Promise.all(this.Companions.map(c => c.LoadEager(visited)));
|
|
1320
1485
|
}
|
|
1321
1486
|
/**
|
|
1322
1487
|
* Tells every companion that the unit of work committed, so they can clear pending removals and
|
|
@@ -1400,6 +1565,9 @@ export class BaseEntity {
|
|
|
1400
1565
|
Label: this.EntityInfo?.Name ?? 'root',
|
|
1401
1566
|
SelfOnly: true,
|
|
1402
1567
|
});
|
|
1568
|
+
for (const companion of this.Companions) {
|
|
1569
|
+
companion.ContributePostDeleteWork(plan);
|
|
1570
|
+
}
|
|
1403
1571
|
return plan;
|
|
1404
1572
|
}
|
|
1405
1573
|
/**
|
|
@@ -1475,6 +1643,10 @@ export class BaseEntity {
|
|
|
1475
1643
|
childSaveOptions.GraphVisited = visited;
|
|
1476
1644
|
const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
|
|
1477
1645
|
childDeleteOptions.GraphVisited = visited;
|
|
1646
|
+
// Snapshot dirty/saved bookkeeping so a rolled-back graph can be retried.
|
|
1647
|
+
// Node Save() finalizes each participant as saved+clean; DB rollback does
|
|
1648
|
+
// not undo that, and the next Save() would skip the peer and fail the FK.
|
|
1649
|
+
const participants = this.captureGraphParticipants(plan);
|
|
1478
1650
|
// Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
|
|
1479
1651
|
// save, and Save()/Delete() report failure by returning false — an escaping throw here
|
|
1480
1652
|
// would break that contract for exactly one path.
|
|
@@ -1486,13 +1658,14 @@ export class BaseEntity {
|
|
|
1486
1658
|
: null;
|
|
1487
1659
|
const result = await ExecuteEntitySavePlan(plan, {
|
|
1488
1660
|
SaveOptions: childSaveOptions,
|
|
1489
|
-
RootSaveOptions: this.buildRootSaveOptions(saveOptions),
|
|
1490
1661
|
DeleteOptions: childDeleteOptions,
|
|
1491
|
-
RootDeleteOptions: this.buildRootDeleteOptions(deleteOptions),
|
|
1492
1662
|
Visited: visited,
|
|
1663
|
+
SaveSelfOnly: (entity, opts) => entity.saveAsGraphNode(opts),
|
|
1664
|
+
DeleteSelfOnly: (entity, opts) => entity.deleteAsGraphNode(opts),
|
|
1493
1665
|
});
|
|
1494
1666
|
if (!result.Success) {
|
|
1495
1667
|
await scope?.Rollback();
|
|
1668
|
+
this.revertGraphParticipants(participants);
|
|
1496
1669
|
this.registerGraphFailure(result.ErrorMessage, operation);
|
|
1497
1670
|
this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
|
|
1498
1671
|
return false;
|
|
@@ -1513,6 +1686,7 @@ export class BaseEntity {
|
|
|
1513
1686
|
LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
|
|
1514
1687
|
`${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
1515
1688
|
}
|
|
1689
|
+
this.revertGraphParticipants(participants);
|
|
1516
1690
|
const detail = e instanceof Error ? e.message : String(e);
|
|
1517
1691
|
LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
|
|
1518
1692
|
this.registerGraphFailure(detail, operation);
|
|
@@ -1521,31 +1695,54 @@ export class BaseEntity {
|
|
|
1521
1695
|
}
|
|
1522
1696
|
}
|
|
1523
1697
|
/**
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
* `IsGraphNodeSave`, which is what stops the root re-entering graph planning and lets it bypass
|
|
1528
|
-
* its own in-flight save debounce.
|
|
1529
|
-
*
|
|
1530
|
-
* @param source - The caller's options, if any.
|
|
1531
|
-
* @returns Options for the root node.
|
|
1698
|
+
* Persist this record as one already-planned graph node. Private: the public
|
|
1699
|
+
* `Save()` contract must not expose a "skip companions" switch. The executor
|
|
1700
|
+
* binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
|
|
1532
1701
|
*/
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
options.IsGraphNodeSave = true;
|
|
1536
|
-
return options;
|
|
1702
|
+
saveAsGraphNode(options) {
|
|
1703
|
+
return this._InnerSave(options);
|
|
1537
1704
|
}
|
|
1538
1705
|
/**
|
|
1539
|
-
*
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1706
|
+
* Delete-path counterpart of {@link saveAsGraphNode}.
|
|
1707
|
+
*/
|
|
1708
|
+
deleteAsGraphNode(options) {
|
|
1709
|
+
return this._InnerDelete(options);
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* Captures saved/dirty baselines for every plan participant so a rolled-back
|
|
1713
|
+
* graph can be retried without re-INSERTing a "saved" peer or skipping it.
|
|
1544
1714
|
*/
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1715
|
+
captureGraphParticipants(plan) {
|
|
1716
|
+
return plan.Nodes.map(node => ({
|
|
1717
|
+
entity: node.Entity,
|
|
1718
|
+
wasSaved: node.Entity.IsSaved,
|
|
1719
|
+
oldValues: node.Entity.Fields.map(f => ({ name: f.Name, old: f.OldValue })),
|
|
1720
|
+
}));
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Restores in-memory saved/dirty state after the database rolled the graph back.
|
|
1724
|
+
*/
|
|
1725
|
+
revertGraphParticipants(snapshots) {
|
|
1726
|
+
for (const snap of snapshots) {
|
|
1727
|
+
snap.entity.revertUncommittedGraphSave(snap);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* After a graph node Save() the fields look clean and `_everSaved` is true.
|
|
1732
|
+
* The DB rollback does not undo that. Restore the pre-attempt baseline so
|
|
1733
|
+
* a retry still writes the peer and the owner FK still matches.
|
|
1734
|
+
*/
|
|
1735
|
+
revertUncommittedGraphSave(snap) {
|
|
1736
|
+
if (!snap.wasSaved) {
|
|
1737
|
+
this._everSaved = false;
|
|
1738
|
+
this._recordLoaded = false;
|
|
1739
|
+
}
|
|
1740
|
+
for (const captured of snap.oldValues) {
|
|
1741
|
+
const field = this.GetFieldByName(captured.name);
|
|
1742
|
+
if (field) {
|
|
1743
|
+
field.RestoreOldValue(captured.old);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1549
1746
|
}
|
|
1550
1747
|
/**
|
|
1551
1748
|
* Records a graph-level failure on this entity's result history.
|
|
@@ -2050,6 +2247,10 @@ export class BaseEntity {
|
|
|
2050
2247
|
* @param FieldName
|
|
2051
2248
|
* @returns
|
|
2052
2249
|
*/
|
|
2250
|
+
/** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
|
|
2251
|
+
memoizeRawConversion(fieldName, value) {
|
|
2252
|
+
(this._rawConverted ??= new Map()).set(fieldName, value);
|
|
2253
|
+
}
|
|
2053
2254
|
Get(FieldName) {
|
|
2054
2255
|
// IS-A routing: return the authoritative value from the parent entity
|
|
2055
2256
|
if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
|
|
@@ -2070,20 +2271,26 @@ export class BaseEntity {
|
|
|
2070
2271
|
let value = this._raw[FieldName];
|
|
2071
2272
|
if (value === undefined)
|
|
2072
2273
|
return null;
|
|
2073
|
-
//
|
|
2074
|
-
//
|
|
2274
|
+
// Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
|
|
2275
|
+
// back into `_raw` — the row may be shared, frozen cache state. Fields needing no
|
|
2276
|
+
// conversion (the vast majority) never touch the memo at all, so the fast path stays
|
|
2277
|
+
// a single property read.
|
|
2075
2278
|
const fi = this._EntityInfo?.FieldByName(FieldName);
|
|
2076
2279
|
if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
|
|
2280
|
+
const memo = this._rawConverted?.get(FieldName);
|
|
2281
|
+
if (memo !== undefined)
|
|
2282
|
+
return memo;
|
|
2077
2283
|
const d = new Date(value);
|
|
2078
|
-
this.
|
|
2284
|
+
this.memoizeRawConversion(FieldName, d);
|
|
2079
2285
|
return d;
|
|
2080
2286
|
}
|
|
2081
|
-
// Mirror the EntityField.Value setter: rtrim padding for fixed-
|
|
2082
|
-
// width string columns. Memoize back into _raw so we don't
|
|
2083
|
-
// re-trim on every read.
|
|
2287
|
+
// Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
|
|
2084
2288
|
if (typeof value === 'string' && fi?.FixedWidthColumn) {
|
|
2289
|
+
const memo = this._rawConverted?.get(FieldName);
|
|
2290
|
+
if (memo !== undefined)
|
|
2291
|
+
return memo;
|
|
2085
2292
|
value = value.replace(/ +$/, '');
|
|
2086
|
-
this.
|
|
2293
|
+
this.memoizeRawConversion(FieldName, value);
|
|
2087
2294
|
}
|
|
2088
2295
|
return value;
|
|
2089
2296
|
}
|
|
@@ -2346,6 +2553,7 @@ export class BaseEntity {
|
|
|
2346
2553
|
this._Fields = [];
|
|
2347
2554
|
this._fieldsHydrated = false;
|
|
2348
2555
|
this._raw = null;
|
|
2556
|
+
this._rawConverted = null;
|
|
2349
2557
|
this._fieldCache = null;
|
|
2350
2558
|
this._codeNameCache = null;
|
|
2351
2559
|
// Field construction is deferred to hydrateFieldsIfNeeded(). Constructor / init() stays
|
|
@@ -2402,8 +2610,10 @@ export class BaseEntity {
|
|
|
2402
2610
|
}
|
|
2403
2611
|
}
|
|
2404
2612
|
// Raw data has been promoted into Fields — release the reference so we don't carry
|
|
2405
|
-
// duplicate state.
|
|
2613
|
+
// duplicate state. Fields hold their own copies, so a frozen source no longer
|
|
2614
|
+
// constrains anything from here on.
|
|
2406
2615
|
this._raw = null;
|
|
2616
|
+
this._rawConverted = null;
|
|
2407
2617
|
}
|
|
2408
2618
|
}
|
|
2409
2619
|
/**
|
|
@@ -2512,9 +2722,24 @@ export class BaseEntity {
|
|
|
2512
2722
|
this.Set(kv.FieldName, kv.Value);
|
|
2513
2723
|
});
|
|
2514
2724
|
}
|
|
2725
|
+
this.notifyEmbeddedNewRecord();
|
|
2515
2726
|
this.RaiseEvent('new_record', null);
|
|
2516
2727
|
return true;
|
|
2517
2728
|
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Required embeddeds are provisioned here so `{Field}_Object` is usable immediately
|
|
2731
|
+
* after `GetEntityObject` / `NewRecord`. Nullable embeddeds stay unexposed.
|
|
2732
|
+
*/
|
|
2733
|
+
notifyEmbeddedNewRecord() {
|
|
2734
|
+
if (!this.HasCompanions) {
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
for (const companion of this.Companions) {
|
|
2738
|
+
if (companion instanceof EmbeddedRecord) {
|
|
2739
|
+
companion.OnOwnerNewRecord();
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2518
2743
|
/**
|
|
2519
2744
|
* Returns the active restore context for the next save, if any.
|
|
2520
2745
|
*
|
|
@@ -2613,11 +2838,6 @@ export class BaseEntity {
|
|
|
2613
2838
|
if (options?.IsParentEntitySave) {
|
|
2614
2839
|
return this._InnerSave(options);
|
|
2615
2840
|
}
|
|
2616
|
-
// Executing our own node inside a graph we already planned. Bypass both the debounce and
|
|
2617
|
-
// graph routing — see EntitySaveOptions.IsGraphNodeSave for why each matters.
|
|
2618
|
-
if (options?.IsGraphNodeSave) {
|
|
2619
|
-
return this._InnerSave(options);
|
|
2620
|
-
}
|
|
2621
2841
|
// If a save is already in progress, return its promise. This check MUST run before graph
|
|
2622
2842
|
// routing: a composite save is still a save, and two concurrent Save() calls on one record
|
|
2623
2843
|
// (double-click, autosave racing a manual save) must share the in-flight unit of work.
|
|
@@ -2649,8 +2869,7 @@ export class BaseEntity {
|
|
|
2649
2869
|
}
|
|
2650
2870
|
// Run the graph through the same pending-save pipeline as a single-row save, so
|
|
2651
2871
|
// concurrent callers share one execution and one result. The graph's own root node
|
|
2652
|
-
//
|
|
2653
|
-
// self-deadlock.
|
|
2872
|
+
// runs via the private saveAsGraphNode path — no re-entry into this pipeline.
|
|
2654
2873
|
this._pendingSave$ = of(options).pipe(switchMap(opts => from(this.saveGraph(plan, opts))), finalize(() => { this._pendingSave$ = null; }), shareReplay(1));
|
|
2655
2874
|
return firstValueFrom(this._pendingSave$);
|
|
2656
2875
|
}
|
|
@@ -2766,11 +2985,29 @@ export class BaseEntity {
|
|
|
2766
2985
|
else {
|
|
2767
2986
|
// First run synchronous validation
|
|
2768
2987
|
valResult = this.Validate();
|
|
2769
|
-
// Determine if we should run async validation:
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
|
|
2773
|
-
|
|
2988
|
+
// Determine if we should run async validation, in order of authority:
|
|
2989
|
+
// 1. An explicit SkipAsyncValidation in the options.
|
|
2990
|
+
// 2. An explicit DefaultSkipAsyncValidation override on the subclass.
|
|
2991
|
+
// 3. Neither: run it if — and only if — a subclass wrote a ValidateAsync
|
|
2992
|
+
// to run. Overriding the method IS the request to run it.
|
|
2993
|
+
//
|
|
2994
|
+
// Case 3 is the fix for a silent no-op. The default is `true`, and the base
|
|
2995
|
+
// ValidateAsync just returns success, so skipping costs a subclass that did
|
|
2996
|
+
// not override it precisely nothing. The flag's only reachable effect was
|
|
2997
|
+
// therefore to disable the async rules of subclasses that WROTE async rules
|
|
2998
|
+
// and never learned a second, separate getter had to be overridden too —
|
|
2999
|
+
// which the ValidateAsync docstring did not mention while promising the
|
|
3000
|
+
// method was "automatically called by Save()".
|
|
3001
|
+
//
|
|
3002
|
+
// That is how OrderEntityServer.ValidateAsync — holding both the "cannot
|
|
3003
|
+
// confirm an order with no lines" guard and an entire per-line validation
|
|
3004
|
+
// loop — was dead on every save in production, and it is the same reasoning
|
|
3005
|
+
// that already exempts companions below.
|
|
3006
|
+
const skipAsyncValidation = _options.SkipAsyncValidation !== undefined
|
|
3007
|
+
? _options.SkipAsyncValidation
|
|
3008
|
+
: IsMemberOverridden(this, 'DefaultSkipAsyncValidation', BaseEntity)
|
|
3009
|
+
? this.DefaultSkipAsyncValidation
|
|
3010
|
+
: !IsMemberOverridden(this, 'ValidateAsync', BaseEntity);
|
|
2774
3011
|
// If not skipping async validation, run it - even if sync validation failed
|
|
2775
3012
|
// This ensures all validation errors (sync and async) are collected
|
|
2776
3013
|
if (!skipAsyncValidation) {
|
|
@@ -2937,7 +3174,11 @@ export class BaseEntity {
|
|
|
2937
3174
|
finalizeSave(data, saveSubType) {
|
|
2938
3175
|
if (data) {
|
|
2939
3176
|
this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
|
|
2940
|
-
|
|
3177
|
+
const fieldData = (data instanceof BaseEntity || (data && typeof data.GetAll === 'function')) ? data.GetAll() : data;
|
|
3178
|
+
// IS-A GetAll() merges the parent, including parent virtuals this entity
|
|
3179
|
+
// does not own (e.g. OrderHeader on Event Order Line). Keep only columns
|
|
3180
|
+
// this entity defines, and ignore anything leftover.
|
|
3181
|
+
this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
|
|
2941
3182
|
this._everSaved = true; // Mark as saved after successful save
|
|
2942
3183
|
const result = this.LatestResult;
|
|
2943
3184
|
if (result)
|
|
@@ -2975,7 +3216,9 @@ export class BaseEntity {
|
|
|
2975
3216
|
}
|
|
2976
3217
|
// Cache it via this entity's provider so the cache lives on the right connection.
|
|
2977
3218
|
const md = this.ProviderToUse;
|
|
2978
|
-
md
|
|
3219
|
+
if (typeof md?.SetCachedRecordName === 'function') {
|
|
3220
|
+
md.SetCachedRecordName(this.EntityInfo.Name, this.PrimaryKey, recordName);
|
|
3221
|
+
}
|
|
2979
3222
|
}
|
|
2980
3223
|
/**
|
|
2981
3224
|
* Utility method that returns true if the given permission being checked is enabled for the current user, and false if not.
|
|
@@ -3249,6 +3492,10 @@ export class BaseEntity {
|
|
|
3249
3492
|
!this.EntityInfo?.IsParentType;
|
|
3250
3493
|
if (canTakeFastPath) {
|
|
3251
3494
|
this._raw = data;
|
|
3495
|
+
// Drop any conversions memoized from a previously-loaded row — they describe the old
|
|
3496
|
+
// `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
|
|
3497
|
+
// so whether the row is frozen (now, or at any point later) does not affect reads.
|
|
3498
|
+
this._rawConverted = null;
|
|
3252
3499
|
// Mirror the "are PKs present?" check that the hydrated path does, but read straight
|
|
3253
3500
|
// from the raw data so we don't trigger hydration.
|
|
3254
3501
|
const pks = this.EntityInfo?.PrimaryKeys ?? [];
|
|
@@ -3395,11 +3642,19 @@ export class BaseEntity {
|
|
|
3395
3642
|
}
|
|
3396
3643
|
/**
|
|
3397
3644
|
* Default value for whether async validation should be skipped.
|
|
3398
|
-
*
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3645
|
+
*
|
|
3646
|
+
* @remarks
|
|
3647
|
+
* Override this to state a policy explicitly; an explicit override always wins over the
|
|
3648
|
+
* inference described below. When the options object passed to `Save()` includes
|
|
3649
|
+
* `SkipAsyncValidation`, that value takes precedence over both.
|
|
3650
|
+
*
|
|
3651
|
+
* **If no subclass overrides this getter**, the answer is inferred instead: async validation
|
|
3652
|
+
* runs when a subclass has overridden {@link ValidateAsync}, and is skipped when none has.
|
|
3653
|
+
* Reading the literal `true` below as "async validation is off unless you find this getter"
|
|
3654
|
+
* made every hand-written `ValidateAsync` a silent no-op — see the note on that method.
|
|
3401
3655
|
*
|
|
3402
3656
|
* @see {@link Save}
|
|
3657
|
+
* @see {@link ValidateAsync}
|
|
3403
3658
|
*
|
|
3404
3659
|
* @protected
|
|
3405
3660
|
*/
|
|
@@ -3414,6 +3669,15 @@ export class BaseEntity {
|
|
|
3414
3669
|
* 1. This should NEVER be called INSTEAD of the synchronous Validate() method
|
|
3415
3670
|
* 2. This is meant to be overridden by subclasses that need to perform async validations
|
|
3416
3671
|
* 3. The base implementation just returns success - no actual validation is performed
|
|
3672
|
+
* 4. Overriding this method is what turns it on. You do NOT also have to override
|
|
3673
|
+
* {@link DefaultSkipAsyncValidation} — that getter is for stating a policy explicitly, and
|
|
3674
|
+
* an explicit override of it (either value) still wins. To suppress async validation for one
|
|
3675
|
+
* call, pass `SkipAsyncValidation: true` in the save options.
|
|
3676
|
+
*
|
|
3677
|
+
* Point 4 used to be the opposite, and it was not discoverable: `DefaultSkipAsyncValidation`
|
|
3678
|
+
* defaults to `true`, so an override written against this docstring alone never ran. It reads
|
|
3679
|
+
* as enforced, reviews as enforced, and was not — the failure mode that let an order confirm
|
|
3680
|
+
* with no lines in production.
|
|
3417
3681
|
*
|
|
3418
3682
|
* Subclasses should override this to add complex validations that require database queries
|
|
3419
3683
|
* or other async operations that cannot be performed in the synchronous Validate() method.
|
|
@@ -3436,11 +3700,6 @@ export class BaseEntity {
|
|
|
3436
3700
|
* @returns Promise<boolean>
|
|
3437
3701
|
*/
|
|
3438
3702
|
async Delete(options) {
|
|
3439
|
-
// Executing our own node inside a delete graph we already planned. Bypass both the debounce
|
|
3440
|
-
// and graph routing — see EntityDeleteOptions.IsGraphNodeDelete.
|
|
3441
|
-
if (options?.IsGraphNodeDelete) {
|
|
3442
|
-
return this._InnerDelete(options);
|
|
3443
|
-
}
|
|
3444
3703
|
// Composite routing: companions that own their children (OnRemove:'delete') contribute
|
|
3445
3704
|
// child deletions that must run before this row disappears. A single-node plan falls
|
|
3446
3705
|
// through to the ordinary path, so nothing changes for entities without companions.
|
|
@@ -4044,5 +4303,115 @@ export class BaseEntity {
|
|
|
4044
4303
|
ResetVectors() {
|
|
4045
4304
|
this._vectors.clear();
|
|
4046
4305
|
}
|
|
4306
|
+
/**
|
|
4307
|
+
* Resolves the recursive foreign key field for this entity. If `parentFieldName` is provided,
|
|
4308
|
+
* finds that specific field. Otherwise defaults to 'ParentID' if present, or the first
|
|
4309
|
+
* self-referencing foreign key field found on the entity.
|
|
4310
|
+
*/
|
|
4311
|
+
getRecursiveForeignKeyField(parentFieldName) {
|
|
4312
|
+
if (!this.EntityInfo)
|
|
4313
|
+
return null;
|
|
4314
|
+
if (this.PrimaryKeys.length !== 1) {
|
|
4315
|
+
LogError(`BaseEntity hierarchy methods: Entity '${this.EntityInfo.Name}' has ${this.PrimaryKeys.length} primary key fields. MemberJunction hierarchy traversal requires a single-column primary key.`);
|
|
4316
|
+
return null;
|
|
4317
|
+
}
|
|
4318
|
+
const fields = this.EntityInfo.Fields ?? [];
|
|
4319
|
+
if (parentFieldName) {
|
|
4320
|
+
const match = fields.find(f => f.Name.toLowerCase() === parentFieldName.toLowerCase() || f.CodeName.toLowerCase() === parentFieldName.toLowerCase());
|
|
4321
|
+
return match ?? null;
|
|
4322
|
+
}
|
|
4323
|
+
// Check for field explicitly configured with IsHierarchy = true first
|
|
4324
|
+
const explicitHierarchyField = fields.find(f => f.IsHierarchy && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name));
|
|
4325
|
+
if (explicitHierarchyField)
|
|
4326
|
+
return explicitHierarchyField;
|
|
4327
|
+
// Check for 'ParentID' self-referencing foreign key next
|
|
4328
|
+
const parentIdField = fields.find(f => f.Name.toLowerCase() === 'parentid' && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name));
|
|
4329
|
+
if (parentIdField)
|
|
4330
|
+
return parentIdField;
|
|
4331
|
+
// Fall back to first recursive foreign key field
|
|
4332
|
+
return fields.find(f => f.RelatedEntityID && (UUIDsEqual(f.RelatedEntityID, this.EntityInfo?.ID) || f.RelatedEntity === this.EntityInfo?.Name)) ?? null;
|
|
4333
|
+
}
|
|
4334
|
+
/**
|
|
4335
|
+
* Retrieves all descendant records in the hierarchy under this record using a single RunView query.
|
|
4336
|
+
* @param optionsOrMaxDepth Optional maximum relative depth to retrieve, or an options object.
|
|
4337
|
+
* @returns Array of descendant entity instances ordered by hierarchy depth.
|
|
4338
|
+
*/
|
|
4339
|
+
async GetDescendants(options) {
|
|
4340
|
+
const maxDepth = typeof options === 'number' ? options : options?.maxDepth;
|
|
4341
|
+
const parentFieldName = typeof options === 'object' ? options?.parentFieldName : undefined;
|
|
4342
|
+
const fkField = this.getRecursiveForeignKeyField(parentFieldName);
|
|
4343
|
+
if (!fkField) {
|
|
4344
|
+
LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4345
|
+
return [];
|
|
4346
|
+
}
|
|
4347
|
+
const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
|
|
4348
|
+
const rootId = this.Get(pkName);
|
|
4349
|
+
if (!rootId)
|
|
4350
|
+
return [];
|
|
4351
|
+
const rootFieldName = `Root${fkField.Name}`;
|
|
4352
|
+
const depthFieldName = `${fkField.Name}Depth`;
|
|
4353
|
+
const filter = maxDepth != null
|
|
4354
|
+
? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
|
|
4355
|
+
: `${rootFieldName} = '${rootId}'`;
|
|
4356
|
+
const rv = new RunView();
|
|
4357
|
+
const result = await rv.RunView({
|
|
4358
|
+
EntityName: this.EntityInfo.Name,
|
|
4359
|
+
ExtraFilter: filter,
|
|
4360
|
+
OrderBy: `${depthFieldName} ASC`,
|
|
4361
|
+
}, this._contextCurrentUser);
|
|
4362
|
+
return result.Success ? (result.Results ?? []) : [];
|
|
4363
|
+
}
|
|
4364
|
+
/**
|
|
4365
|
+
* Retrieves all ancestor records in the hierarchy from the top-level root down to this record using a single RunView query.
|
|
4366
|
+
* @param parentFieldName Optional recursive foreign key field name (defaults to 'ParentID' or the first recursive FK found).
|
|
4367
|
+
* @returns Array of ancestor entity instances ordered from root down to parent.
|
|
4368
|
+
*/
|
|
4369
|
+
async GetAncestors(parentFieldName) {
|
|
4370
|
+
const fkField = this.getRecursiveForeignKeyField(parentFieldName);
|
|
4371
|
+
if (!fkField) {
|
|
4372
|
+
LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4373
|
+
return [];
|
|
4374
|
+
}
|
|
4375
|
+
const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
|
|
4376
|
+
const currentId = this.Get(pkName);
|
|
4377
|
+
const pathFieldName = `${fkField.Name}Path`;
|
|
4378
|
+
const depthFieldName = `${fkField.Name}Depth`;
|
|
4379
|
+
const path = this.Get(pathFieldName);
|
|
4380
|
+
if (!path)
|
|
4381
|
+
return [];
|
|
4382
|
+
const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
|
|
4383
|
+
if (rawIds.length === 0)
|
|
4384
|
+
return [];
|
|
4385
|
+
const rv = new RunView();
|
|
4386
|
+
const idList = rawIds.map(id => `'${id}'`).join(',');
|
|
4387
|
+
const result = await rv.RunView({
|
|
4388
|
+
EntityName: this.EntityInfo.Name,
|
|
4389
|
+
ExtraFilter: `${pkName} IN (${idList})`,
|
|
4390
|
+
OrderBy: `${depthFieldName} ASC`,
|
|
4391
|
+
}, this._contextCurrentUser);
|
|
4392
|
+
return result.Success ? (result.Results ?? []) : [];
|
|
4393
|
+
}
|
|
4394
|
+
/**
|
|
4395
|
+
* Retrieves all direct child records of this record using a single RunView query.
|
|
4396
|
+
* @param parentFieldName Optional recursive foreign key field name (defaults to 'ParentID' or the first recursive FK found).
|
|
4397
|
+
* @returns Array of direct child entity instances.
|
|
4398
|
+
*/
|
|
4399
|
+
async GetChildren(parentFieldName) {
|
|
4400
|
+
const fkField = this.getRecursiveForeignKeyField(parentFieldName);
|
|
4401
|
+
if (!fkField) {
|
|
4402
|
+
LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4403
|
+
return [];
|
|
4404
|
+
}
|
|
4405
|
+
const pkName = this.FirstPrimaryKey?.Name ?? 'ID';
|
|
4406
|
+
const currentId = this.Get(pkName);
|
|
4407
|
+
if (!currentId)
|
|
4408
|
+
return [];
|
|
4409
|
+
const rv = new RunView();
|
|
4410
|
+
const result = await rv.RunView({
|
|
4411
|
+
EntityName: this.EntityInfo.Name,
|
|
4412
|
+
ExtraFilter: `${fkField.Name} = '${currentId}'`,
|
|
4413
|
+
}, this._contextCurrentUser);
|
|
4414
|
+
return result.Success ? (result.Results ?? []) : [];
|
|
4415
|
+
}
|
|
4047
4416
|
}
|
|
4048
4417
|
//# sourceMappingURL=baseEntity.js.map
|