@memberjunction/core 6.2.0-edge.0 → 6.2.0-edge.2
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/JSONType-interfaces/IEntityConfiguration.d.ts +180 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts +28 -0
- package/dist/generic/JSONType-interfaces/IEntityFieldConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts +76 -0
- package/dist/generic/JSONType-interfaces/IEntityRelationshipConfiguration.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +14 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
- package/dist/generic/QueryCache.d.ts +17 -0
- package/dist/generic/QueryCache.d.ts.map +1 -1
- package/dist/generic/QueryCache.js +25 -5
- package/dist/generic/QueryCache.js.map +1 -1
- package/dist/generic/RegisterForStartup.d.ts +5 -5
- package/dist/generic/RegisterForStartup.js +12 -12
- package/dist/generic/applicationInfo.d.ts +4 -2
- package/dist/generic/applicationInfo.d.ts.map +1 -1
- package/dist/generic/applicationInfo.js +13 -9
- package/dist/generic/applicationInfo.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +9 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +13 -3
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +22 -9
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +43 -25
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +422 -28
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +930 -202
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +12 -0
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +22 -1
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/column-descriptors.d.ts +25 -5
- package/dist/generic/column-descriptors.d.ts.map +1 -1
- package/dist/generic/column-descriptors.js +45 -5
- package/dist/generic/column-descriptors.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +1 -1
- package/dist/generic/compositeKey.js +2 -2
- package/dist/generic/data-table.js +2 -2
- package/dist/generic/data-table.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +13 -7
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +20 -11
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +1 -0
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +199 -39
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +351 -78
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySubtypeResolver.d.ts +51 -5
- package/dist/generic/entitySubtypeResolver.d.ts.map +1 -1
- package/dist/generic/entitySubtypeResolver.js +50 -3
- package/dist/generic/entitySubtypeResolver.js.map +1 -1
- package/dist/generic/filters/compositeFilter.d.ts +19 -19
- package/dist/generic/filters/compositeFilter.js +60 -60
- package/dist/generic/graphqlTypeNames.d.ts +6 -0
- package/dist/generic/graphqlTypeNames.d.ts.map +1 -1
- package/dist/generic/graphqlTypeNames.js +18 -6
- package/dist/generic/graphqlTypeNames.js.map +1 -1
- package/dist/generic/interfaces.d.ts +13 -4
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +3 -3
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/logging.d.ts +2 -0
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -3
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +1 -1
- package/dist/generic/metadata.js +3 -3
- package/dist/generic/providerBase.d.ts +89 -8
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +180 -45
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryResultEnricher.d.ts +2 -0
- package/dist/generic/queryResultEnricher.d.ts.map +1 -1
- package/dist/generic/queryResultEnricher.js +5 -1
- package/dist/generic/queryResultEnricher.js.map +1 -1
- package/dist/generic/querySQLFilters.d.ts +4 -0
- package/dist/generic/querySQLFilters.d.ts.map +1 -1
- package/dist/generic/querySQLFilters.js +10 -2
- package/dist/generic/querySQLFilters.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +2 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +5 -1
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteEventRow.d.ts +55 -0
- package/dist/generic/remoteEventRow.d.ts.map +1 -0
- package/dist/generic/remoteEventRow.js +110 -0
- package/dist/generic/remoteEventRow.js.map +1 -0
- package/dist/generic/remoteOperationDispatch.d.ts +2 -0
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +5 -1
- package/dist/generic/remoteOperationDispatch.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +8 -0
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +20 -4
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/schemaHydration.d.ts +8 -0
- package/dist/generic/schemaHydration.d.ts.map +1 -1
- package/dist/generic/schemaHydration.js +21 -5
- package/dist/generic/schemaHydration.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -9
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +31 -27
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +16 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +52 -20
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +1 -1
- package/dist/generic/transactionGroup.js +4 -4
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/views/viewInfo.d.ts +2 -2
- package/dist/views/viewInfo.js +13 -13
- package/dist/views/viewInfo.js.map +1 -1
- package/package.json +3 -3
|
@@ -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 { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
|
|
11
|
+
import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
13
|
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
14
14
|
import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
|
|
@@ -17,7 +17,7 @@ import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from '.
|
|
|
17
17
|
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
18
18
|
import { Metadata } from './metadata.js';
|
|
19
19
|
import { RunView } from '../views/runView.js';
|
|
20
|
-
import { LogDebug, LogError } from './logging.js';
|
|
20
|
+
import { LogDebug, LogError, LogStatusEx } from './logging.js';
|
|
21
21
|
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
22
22
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
23
23
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
@@ -26,6 +26,18 @@ import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
|
|
|
26
26
|
import { BaseRemotableOperation } from './baseRemotableOperation.js';
|
|
27
27
|
import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
|
|
28
28
|
import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
|
|
29
|
+
/**
|
|
30
|
+
* Strips the trailing spaces SQL Server and PostgreSQL pad fixed-width string columns with. Only
|
|
31
|
+
* spaces: tabs and other whitespace are data. A loop rather than `/ +$/`, which backtracks
|
|
32
|
+
* quadratically on a long run of spaces that doesn't end the string.
|
|
33
|
+
*/
|
|
34
|
+
function trimTrailingSpaces(value) {
|
|
35
|
+
let end = value.length;
|
|
36
|
+
while (end > 0 && value.charCodeAt(end - 1) === 32) {
|
|
37
|
+
end--;
|
|
38
|
+
}
|
|
39
|
+
return end === value.length ? value : value.slice(0, end);
|
|
40
|
+
}
|
|
29
41
|
/**
|
|
30
42
|
* Represents a field in an instance of the BaseEntity class. This class is used to store the value of the field, dirty state, as well as other run-time information about the field. The class encapsulates the underlying field metadata and exposes some of the more commonly
|
|
31
43
|
* used properties from the entity field metadata.
|
|
@@ -70,7 +82,7 @@ let EntityField = class EntityField {
|
|
|
70
82
|
* entities — their fields legitimately hold metadata defaults for INSERT.
|
|
71
83
|
*/
|
|
72
84
|
get NotLoaded() {
|
|
73
|
-
return this.
|
|
85
|
+
return this._notLoaded;
|
|
74
86
|
}
|
|
75
87
|
/**
|
|
76
88
|
* Framework-internal: hydration paths call this for each field whose key the hydration
|
|
@@ -78,7 +90,7 @@ let EntityField = class EntityField {
|
|
|
78
90
|
* clears the flag.
|
|
79
91
|
*/
|
|
80
92
|
MarkNotLoaded() {
|
|
81
|
-
this.
|
|
93
|
+
this._notLoaded = true;
|
|
82
94
|
}
|
|
83
95
|
/**
|
|
84
96
|
* True when field-level security bars this user from supplying the field's value on INSERT,
|
|
@@ -97,11 +109,11 @@ let EntityField = class EntityField {
|
|
|
97
109
|
* users over its lifetime.
|
|
98
110
|
*/
|
|
99
111
|
get CreateSuppressed() {
|
|
100
|
-
return this.
|
|
112
|
+
return this._createSuppressed;
|
|
101
113
|
}
|
|
102
114
|
/** Framework-internal: set by the save path's field-security create gate. */
|
|
103
115
|
SetCreateSuppressed(suppressed) {
|
|
104
|
-
this.
|
|
116
|
+
this._createSuppressed = suppressed;
|
|
105
117
|
}
|
|
106
118
|
get Name() {
|
|
107
119
|
return this._entityFieldInfo.Name;
|
|
@@ -137,7 +149,7 @@ let EntityField = class EntityField {
|
|
|
137
149
|
* serialization, hydration, SQL build) reads this directly and must stay assertion-free.
|
|
138
150
|
*/
|
|
139
151
|
get Value() {
|
|
140
|
-
return this.
|
|
152
|
+
return this._value;
|
|
141
153
|
}
|
|
142
154
|
get ReadOnly() {
|
|
143
155
|
return this._entityFieldInfo.ReadOnly;
|
|
@@ -164,7 +176,7 @@ let EntityField = class EntityField {
|
|
|
164
176
|
// No active-status assertion here — see the Value getter note. Deprecation/disabled checks
|
|
165
177
|
// are enforced at BaseEntity.Get/Set/SetMany, not at this low-level accessor.
|
|
166
178
|
if (!this.ReadOnly ||
|
|
167
|
-
this.
|
|
179
|
+
this._neverSet /* Allow one time set of any field because BaseEntity Object passes in ReadOnly fields when we load,
|
|
168
180
|
after that load for a given INSTANCE of an EntityField object we never set a ReadOnly Field*/) {
|
|
169
181
|
// Strip trailing space-padding on fixed-width string columns
|
|
170
182
|
// (`nchar`/`char`/`bpchar`). SQL Server / PG right-pad these
|
|
@@ -174,24 +186,24 @@ let EntityField = class EntityField {
|
|
|
174
186
|
// value is the logical (un-padded) form. See
|
|
175
187
|
// `EntityFieldInfo.FixedWidthColumn` for the source of truth.
|
|
176
188
|
if (typeof value === 'string' && this._entityFieldInfo.FixedWidthColumn) {
|
|
177
|
-
value = value
|
|
189
|
+
value = trimTrailingSpaces(value);
|
|
178
190
|
}
|
|
179
|
-
this.
|
|
191
|
+
this._value = value;
|
|
180
192
|
// Any explicit set means the field now holds REAL data — including a blind write to
|
|
181
193
|
// a read-denied field (the write-only case), which must flow to the save. Captured
|
|
182
194
|
// BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
|
|
183
195
|
// field is an EDIT (must become dirty and reach the save), never the "initial value
|
|
184
196
|
// set" of record setup — the record's setup moment was the hydration that omitted
|
|
185
197
|
// this field.
|
|
186
|
-
const wasNotLoaded = this.
|
|
187
|
-
this.
|
|
198
|
+
const wasNotLoaded = this._notLoaded;
|
|
199
|
+
this._notLoaded = false;
|
|
188
200
|
// in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
|
|
189
|
-
if (this.
|
|
190
|
-
(value !== null || this.
|
|
201
|
+
if (this._neverSet && !wasNotLoaded &&
|
|
202
|
+
(value !== null || this._oldValue !== null)) {
|
|
191
203
|
// initial value set
|
|
192
|
-
this.
|
|
204
|
+
this._oldValue = value;
|
|
193
205
|
}
|
|
194
|
-
this.
|
|
206
|
+
this._neverSet = false;
|
|
195
207
|
}
|
|
196
208
|
}
|
|
197
209
|
/**
|
|
@@ -200,7 +212,7 @@ let EntityField = class EntityField {
|
|
|
200
212
|
* after the object is created, but only once. This is typically used in the BaseEntity class when loading an entity from an array of values or the DB and reusing an existing object.
|
|
201
213
|
*/
|
|
202
214
|
ResetNeverSetFlag() {
|
|
203
|
-
this.
|
|
215
|
+
this._neverSet = true;
|
|
204
216
|
}
|
|
205
217
|
/**
|
|
206
218
|
* Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
|
|
@@ -208,19 +220,19 @@ let EntityField = class EntityField {
|
|
|
208
220
|
* artifact, not data, and nothing that was never loaded can have been changed.
|
|
209
221
|
*/
|
|
210
222
|
get Dirty() {
|
|
211
|
-
if (this.ReadOnly || this.
|
|
223
|
+
if (this.ReadOnly || this._notLoaded)
|
|
212
224
|
return false;
|
|
213
225
|
else {
|
|
214
|
-
const oldNull = this.
|
|
226
|
+
const oldNull = this._oldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
|
|
215
227
|
const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.OldValue);
|
|
216
228
|
if (oldNull && curNull)
|
|
217
229
|
return false; // BOTH are null or undefined, not dirty
|
|
218
230
|
else {
|
|
219
|
-
let oldCompare = this.
|
|
231
|
+
let oldCompare = this._oldValue;
|
|
220
232
|
let newCompare = this.Value;
|
|
221
|
-
if (this.
|
|
233
|
+
if (this._oldValue instanceof Date) {
|
|
222
234
|
// sometimes we have old values that are date objects, convert to UTC timestamp for comparison
|
|
223
|
-
oldCompare = this.
|
|
235
|
+
oldCompare = this._oldValue.getTime();
|
|
224
236
|
}
|
|
225
237
|
if (this.Value instanceof Date) {
|
|
226
238
|
// and sometimes the new values are date objects, convert to UTC timestamp for comparison
|
|
@@ -348,7 +360,7 @@ let EntityField = class EntityField {
|
|
|
348
360
|
// unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
|
|
349
361
|
// design exists to remove). Length/type checks below still apply to whatever the
|
|
350
362
|
// constructor state is; only the required/null check is meaningless for it.
|
|
351
|
-
if (!ef.AllowsNull && !this.
|
|
363
|
+
if (!ef.AllowsNull && !this._notLoaded && (this.Value === null || this.Value === undefined)) {
|
|
352
364
|
// make sure this isn't a field that has a default value and we are inside a new record
|
|
353
365
|
if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
|
|
354
366
|
// we have no default value, so this is an error
|
|
@@ -357,7 +369,7 @@ let EntityField = class EntityField {
|
|
|
357
369
|
}
|
|
358
370
|
else {
|
|
359
371
|
// we do have a default value, but our current value is null. If we are in an EXISTING record, this is an error, check the OldValue to determine this
|
|
360
|
-
if (this.
|
|
372
|
+
if (this._oldValue !== null && this._oldValue !== undefined) {
|
|
361
373
|
result.Success = false;
|
|
362
374
|
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
|
|
363
375
|
}
|
|
@@ -430,9 +442,9 @@ let EntityField = class EntityField {
|
|
|
430
442
|
return result;
|
|
431
443
|
}
|
|
432
444
|
constructor(fieldInfo, Value) {
|
|
433
|
-
this.
|
|
434
|
-
this.
|
|
435
|
-
this.
|
|
445
|
+
this._neverSet = true;
|
|
446
|
+
this._notLoaded = false;
|
|
447
|
+
this._createSuppressed = false;
|
|
436
448
|
// NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
|
|
437
449
|
// column is physically part of the entity and the instance must exist. Active-status is
|
|
438
450
|
// asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
|
|
@@ -525,25 +537,41 @@ let EntityField = class EntityField {
|
|
|
525
537
|
this.Value = fieldInfo.DefaultValue;
|
|
526
538
|
}
|
|
527
539
|
}
|
|
528
|
-
this.
|
|
540
|
+
this._neverSet = true; // set this back to true because we are setting the default value and we want to be able to set this ONCE from BaseEntity when we load
|
|
529
541
|
}
|
|
530
542
|
else {
|
|
531
543
|
this.Value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
|
|
532
|
-
this.
|
|
544
|
+
this._neverSet = true; // set this back to true because we are setting the value to null;
|
|
533
545
|
}
|
|
534
546
|
}
|
|
535
547
|
/**
|
|
536
548
|
* This method will set the internal Old Value which is used to track dirty state, to the current value of the field. This effectively resets the dirty state of the field to false. Use this method sparingly.
|
|
537
549
|
*/
|
|
538
550
|
ResetOldValue() {
|
|
539
|
-
this.
|
|
551
|
+
this._oldValue = this.Value;
|
|
540
552
|
}
|
|
541
553
|
/**
|
|
542
554
|
* Restores the dirty-tracking baseline to a previously captured value.
|
|
543
|
-
* Used by graph rollback so a retried save still sees the pre-attempt dirty set.
|
|
544
555
|
*/
|
|
545
556
|
RestoreOldValue(value) {
|
|
546
|
-
this.
|
|
557
|
+
this._oldValue = value;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Framework-internal: the field's value and dirty-tracking state, so a unit of work that rolls
|
|
561
|
+
* back can put them back with {@link RestoreState}.
|
|
562
|
+
*/
|
|
563
|
+
GetState() {
|
|
564
|
+
return { Value: this._value, OldValue: this._oldValue, NotLoaded: this._notLoaded, NeverSet: this._neverSet };
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
|
|
568
|
+
* so the read-only and first-set rules of the {@link Value} setter don't apply.
|
|
569
|
+
*/
|
|
570
|
+
RestoreState(state) {
|
|
571
|
+
this._value = state.Value;
|
|
572
|
+
this._oldValue = state.OldValue;
|
|
573
|
+
this._notLoaded = state.NotLoaded;
|
|
574
|
+
this._neverSet = state.NeverSet;
|
|
547
575
|
}
|
|
548
576
|
/**
|
|
549
577
|
* @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
|
|
@@ -563,7 +591,7 @@ let EntityField = class EntityField {
|
|
|
563
591
|
* Returns the old value of the field. This is the value that was set when the field was last loaded from the database.
|
|
564
592
|
*/
|
|
565
593
|
get OldValue() {
|
|
566
|
-
return this.
|
|
594
|
+
return this._oldValue;
|
|
567
595
|
}
|
|
568
596
|
};
|
|
569
597
|
EntityField = EntityField_1 = __decorate([
|
|
@@ -571,6 +599,13 @@ EntityField = EntityField_1 = __decorate([
|
|
|
571
599
|
__metadata("design:paramtypes", [EntityFieldInfo, Object])
|
|
572
600
|
], EntityField);
|
|
573
601
|
export { EntityField };
|
|
602
|
+
/**
|
|
603
|
+
* The `ChangeContext` JSON a clone writes on its Record Change rows (`IRecordChangeContext`,
|
|
604
|
+
* Kind 'Clone'). The one serializer every provider path uses, so the shape can't drift.
|
|
605
|
+
*/
|
|
606
|
+
export function SerializeCloneChangeContext(context) {
|
|
607
|
+
return JSON.stringify({ Version: 1, Kind: 'Clone', Clone: context });
|
|
608
|
+
}
|
|
574
609
|
export class DataObjectRelatedEntityParam {
|
|
575
610
|
}
|
|
576
611
|
export class DataObjectParams {
|
|
@@ -714,6 +749,13 @@ export class BaseEntityResult {
|
|
|
714
749
|
*/
|
|
715
750
|
export class BaseEntityEvent {
|
|
716
751
|
}
|
|
752
|
+
/** Entity names compare the way metadata lookups do: trimmed and case-insensitive. */
|
|
753
|
+
function sameEntityName(a, b) {
|
|
754
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* 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.
|
|
758
|
+
*/
|
|
717
759
|
export class BaseEntity {
|
|
718
760
|
/**
|
|
719
761
|
* Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
|
|
@@ -791,7 +833,7 @@ export class BaseEntity {
|
|
|
791
833
|
* `BaseEntity` are reserved for paths that have already triggered hydration or paths that
|
|
792
834
|
* deliberately want to inspect hydration state (e.g., `Dirty` getter).
|
|
793
835
|
*/
|
|
794
|
-
this.
|
|
836
|
+
this._fields = [];
|
|
795
837
|
/**
|
|
796
838
|
* Tracks whether `_Fields` has been populated. `false` while in "raw mode" (data accessible
|
|
797
839
|
* via `_raw` but no EntityField instances built yet); flips to `true` after the first call to
|
|
@@ -956,6 +998,17 @@ export class BaseEntity {
|
|
|
956
998
|
* loaded record. Reset to false on NewRecord() and init().
|
|
957
999
|
*/
|
|
958
1000
|
this._childEntityDiscoveryDone = false;
|
|
1001
|
+
/**
|
|
1002
|
+
* Set only while this record's parent loads it because the parent's subtype rule named it
|
|
1003
|
+
* (see {@link tryLinkHintedChildEntity}); read by {@link InnerLoad}.
|
|
1004
|
+
*/
|
|
1005
|
+
this._subtypeHintProbe = null;
|
|
1006
|
+
/**
|
|
1007
|
+
* Set only while {@link AttachToParent} loads this record as the parent it promotes. Child
|
|
1008
|
+
* discovery then doesn't ask the subtype rule: the child row being added doesn't exist yet, so
|
|
1009
|
+
* a hint would name it and miss.
|
|
1010
|
+
*/
|
|
1011
|
+
this._skipSubtypeLoadHint = false;
|
|
959
1012
|
/**
|
|
960
1013
|
* For **overlapping** subtype parents (AllowMultipleSubtypes = true), stores
|
|
961
1014
|
* the list of child entity type names that have records for this PK.
|
|
@@ -982,6 +1035,12 @@ export class BaseEntity {
|
|
|
982
1035
|
* routing through the provider: participants stay ignorant of one another.
|
|
983
1036
|
*/
|
|
984
1037
|
this._entityTransactionScope = null;
|
|
1038
|
+
/**
|
|
1039
|
+
* The unit of work this record is saved or deleted in, while it runs as part of a larger one: an
|
|
1040
|
+
* IS-A chain's initiator sets it on each parent for the parent's write, and a graph sets it on
|
|
1041
|
+
* each of its records. See {@link UnitOfWork}.
|
|
1042
|
+
*/
|
|
1043
|
+
this._unitOfWork = null;
|
|
985
1044
|
/**
|
|
986
1045
|
* Companions registered on this entity, keyed by {@link EntityCompanion.Name}.
|
|
987
1046
|
*
|
|
@@ -1014,6 +1073,7 @@ export class BaseEntity {
|
|
|
1014
1073
|
// provider may capture the context now but use it later.
|
|
1015
1074
|
// ────────────────────────────────────────────────────────────────────
|
|
1016
1075
|
this._restoreContext = null;
|
|
1076
|
+
this._cloneContext = null;
|
|
1017
1077
|
// Holds the current pending save observable (if any)
|
|
1018
1078
|
this._pendingSave$ = null;
|
|
1019
1079
|
/**
|
|
@@ -1037,7 +1097,7 @@ export class BaseEntity {
|
|
|
1037
1097
|
*/
|
|
1038
1098
|
this._skipEmbeddings = false;
|
|
1039
1099
|
this._eventSubject = new Subject();
|
|
1040
|
-
this.
|
|
1100
|
+
this._entityInfo = Entity;
|
|
1041
1101
|
EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
|
|
1042
1102
|
this._provider = Provider;
|
|
1043
1103
|
this.init();
|
|
@@ -1096,10 +1156,27 @@ export class BaseEntity {
|
|
|
1096
1156
|
// route the parent through the same connection — not the global default.
|
|
1097
1157
|
const parentProvider = this.ProviderToUse;
|
|
1098
1158
|
this._parentEntity = await parentProvider.GetEntityObject(parentEntityInfo.Name, this._contextCurrentUser);
|
|
1159
|
+
// A chain loaded through the parent is linked the other way too, so the parent's
|
|
1160
|
+
// LeafEntity (and its save hooks) see this child (MJ#4870).
|
|
1161
|
+
this.linkParentBackToThisChild();
|
|
1099
1162
|
// Recursive: the parent's InitializeParentEntity() was called by GetEntityObject()
|
|
1100
1163
|
// Cache the parent field names for O(1) routing lookups
|
|
1101
1164
|
this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
|
|
1102
1165
|
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Points this child's disjoint parent back at this instance (`parent._childEntity = this`).
|
|
1168
|
+
*
|
|
1169
|
+
* Same gate as {@link InitializeParentEntity}: a parent that allows several subtypes keeps no
|
|
1170
|
+
* single child. `NewRecord()` sets `_childEntity` to null, so this has to run AFTER the
|
|
1171
|
+
* parent's `NewRecord()` — calling it before is wiped out.
|
|
1172
|
+
*/
|
|
1173
|
+
linkParentBackToThisChild() {
|
|
1174
|
+
const parentEntityInfo = this.EntityInfo?.ParentEntityInfo;
|
|
1175
|
+
if (!this._parentEntity || !parentEntityInfo || parentEntityInfo.AllowMultipleSubtypes) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
this._parentEntity._childEntity = this;
|
|
1179
|
+
}
|
|
1103
1180
|
/**
|
|
1104
1181
|
* IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
|
|
1105
1182
|
* ADDS a subtype to a person/org/product that already exists instead of trying to create a
|
|
@@ -1165,12 +1242,22 @@ export class BaseEntity {
|
|
|
1165
1242
|
name: pk.Name,
|
|
1166
1243
|
value: this._parentEntity.Get(pk.Name),
|
|
1167
1244
|
}));
|
|
1168
|
-
|
|
1245
|
+
// The parent's subtype rule isn't asked for a load hint here: it would name the child this
|
|
1246
|
+
// promotion is adding, whose row doesn't exist yet, and the miss would cost a round trip.
|
|
1247
|
+
this._parentEntity._skipSubtypeLoadHint = true;
|
|
1248
|
+
let loaded;
|
|
1249
|
+
try {
|
|
1250
|
+
loaded = await this._parentEntity.InnerLoad(parentKey);
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
this._parentEntity._skipSubtypeLoadHint = false;
|
|
1254
|
+
}
|
|
1169
1255
|
if (!loaded) {
|
|
1170
1256
|
// Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
|
|
1171
1257
|
// the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
|
|
1172
|
-
// is bit-for-bit the fresh record they built.
|
|
1258
|
+
// is bit-for-bit the fresh record they built. Re-seed nulls the parent's back-link.
|
|
1173
1259
|
this._parentEntity.NewRecord();
|
|
1260
|
+
this.linkParentBackToThisChild();
|
|
1174
1261
|
for (const pk of freshPkValues) {
|
|
1175
1262
|
if (pk.value != null) {
|
|
1176
1263
|
this._parentEntity.Set(pk.name, pk.value);
|
|
@@ -1197,7 +1284,7 @@ export class BaseEntity {
|
|
|
1197
1284
|
if (!this.GetFieldByName(name)) {
|
|
1198
1285
|
return;
|
|
1199
1286
|
}
|
|
1200
|
-
this.
|
|
1287
|
+
this.setLocal(name, value);
|
|
1201
1288
|
this.GetFieldByName(name)?.ResetNeverSetFlag();
|
|
1202
1289
|
}
|
|
1203
1290
|
/**
|
|
@@ -1211,6 +1298,10 @@ export class BaseEntity {
|
|
|
1211
1298
|
* This ensures that Save/Delete operations always delegate to the leaf entity,
|
|
1212
1299
|
* running the full validation and event chain at every level.
|
|
1213
1300
|
*
|
|
1301
|
+
* For a disjoint parent, the entity's subtype rule is asked first (see
|
|
1302
|
+
* {@link resolveSubtypeLoadHint}); when it names a child, that child's load, which
|
|
1303
|
+
* happens anyway, checks the answer, and the discovery query runs only on a miss.
|
|
1304
|
+
*
|
|
1214
1305
|
* Must be called AFTER a record is loaded (PK must be available).
|
|
1215
1306
|
* Skipped for entities that are not parent types or have already been discovered.
|
|
1216
1307
|
*/
|
|
@@ -1225,12 +1316,48 @@ export class BaseEntity {
|
|
|
1225
1316
|
await this.discoverOverlappingChildren();
|
|
1226
1317
|
}
|
|
1227
1318
|
else {
|
|
1228
|
-
// Disjoint: discover single child
|
|
1229
|
-
|
|
1230
|
-
|
|
1319
|
+
// Disjoint: discover the single child and auto-chain it
|
|
1320
|
+
await this.initializeDisjointChildEntity();
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Finds and links the one child of a loaded disjoint parent.
|
|
1325
|
+
*
|
|
1326
|
+
* Without a hint this is one discovery query plus the child's load. With a hint from the
|
|
1327
|
+
* entity's subtype rule, the child's load checks the hint, and the discovery query runs
|
|
1328
|
+
* only when that load comes back empty or wasn't tried.
|
|
1329
|
+
*
|
|
1330
|
+
* For well-formed data the outcome is the one the discovery query alone would give: a hint
|
|
1331
|
+
* changes the number of round trips, not which child is linked or whether the load succeeds.
|
|
1332
|
+
* Three benign exceptions:
|
|
1333
|
+
* - a failure reading the hinted child's row falls back to the discovery query, so a transient
|
|
1334
|
+
* failure now recovers (a lasting one reads the row twice, then fails as before);
|
|
1335
|
+
* - when the data breaks the disjoint rule with two child rows, the discovery query's
|
|
1336
|
+
* unordered `UNION ALL` links either one, where a hint links the one the rule names;
|
|
1337
|
+
* - a provider without `FindISAChildEntity` links nothing without a hint, and the hinted child
|
|
1338
|
+
* with one.
|
|
1339
|
+
*/
|
|
1340
|
+
async initializeDisjointChildEntity() {
|
|
1341
|
+
const hintedName = await this.resolveSubtypeLoadHint();
|
|
1342
|
+
const hintOutcome = hintedName
|
|
1343
|
+
? await this.tryLinkHintedChildEntity(hintedName)
|
|
1344
|
+
: 'NotTried';
|
|
1345
|
+
if (hintOutcome === 'Linked')
|
|
1346
|
+
return;
|
|
1347
|
+
const childEntityName = await this.discoverChildEntityName();
|
|
1348
|
+
if (hintedName && hintOutcome === 'NotFound') {
|
|
1349
|
+
if (childEntityName && sameEntityName(childEntityName, hintedName)) {
|
|
1350
|
+
// The row exists but its load came back empty (for example, a row filter hides it).
|
|
1351
|
+
// Loading it again would too, and the discovery-only path unlinks it the same way,
|
|
1352
|
+
// after logging the empty load that the hinted attempt kept quiet.
|
|
1353
|
+
BaseEntity.logLoadFoundNoRow(hintedName, this.PrimaryKey);
|
|
1231
1354
|
return;
|
|
1232
|
-
|
|
1355
|
+
}
|
|
1356
|
+
this.reportSubtypeLoadHintMiss(hintedName, childEntityName);
|
|
1233
1357
|
}
|
|
1358
|
+
if (!childEntityName)
|
|
1359
|
+
return;
|
|
1360
|
+
await this.createAndLinkChildEntity(childEntityName);
|
|
1234
1361
|
}
|
|
1235
1362
|
/**
|
|
1236
1363
|
* Uses the provider's FindISAChildEntity method (single UNION ALL query) to
|
|
@@ -1263,12 +1390,29 @@ export class BaseEntity {
|
|
|
1263
1390
|
* Creates the child entity instance, wires up the shared instance chain
|
|
1264
1391
|
* (child._parentEntity = this, this._childEntity = child), loads the
|
|
1265
1392
|
* child's data, and recursively discovers further children.
|
|
1393
|
+
*
|
|
1394
|
+
* @returns true when the child's row loaded and the child is linked; false when the load
|
|
1395
|
+
* came back empty, in which case the link is undone.
|
|
1266
1396
|
*/
|
|
1267
1397
|
async createAndLinkChildEntity(childEntityName) {
|
|
1268
|
-
|
|
1269
|
-
|
|
1398
|
+
const childEntity = await this.createChildEntityObject(childEntityName);
|
|
1399
|
+
return this.linkAndLoadChildEntity(childEntity);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Creates an unlinked child entity instance through this entity's provider, so the child
|
|
1403
|
+
* shares the same connection (correct in multi-provider scenarios — never the global default).
|
|
1404
|
+
*/
|
|
1405
|
+
async createChildEntityObject(childEntityName) {
|
|
1270
1406
|
const childProvider = this.ProviderToUse;
|
|
1271
|
-
|
|
1407
|
+
return childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* Links `childEntity` into this entity's chain and loads its row under the shared key.
|
|
1411
|
+
*
|
|
1412
|
+
* @returns true when the row loaded and the child is linked; false when the load came back
|
|
1413
|
+
* empty, in which case the link is undone.
|
|
1414
|
+
*/
|
|
1415
|
+
async linkAndLoadChildEntity(childEntity) {
|
|
1272
1416
|
// Wire up the shared instance chain: child's parent IS this entity (same object)
|
|
1273
1417
|
// We need to replace the child's auto-initialized parent with our existing instance
|
|
1274
1418
|
this.replaceChildParentChain(childEntity);
|
|
@@ -1280,20 +1424,245 @@ export class BaseEntity {
|
|
|
1280
1424
|
if (!loaded) {
|
|
1281
1425
|
// Load failed — clean up the link
|
|
1282
1426
|
this._childEntity = null;
|
|
1283
|
-
return;
|
|
1427
|
+
return false;
|
|
1284
1428
|
}
|
|
1285
1429
|
// Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
|
|
1286
1430
|
this.restoreChainDirtyState(dirtySnapshots);
|
|
1287
1431
|
// Recursively discover grandchildren (child may also be a parent type)
|
|
1288
1432
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
1289
1433
|
await childEntity.InitializeChildEntity();
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Tries to link the child that the subtype rule named, by loading its row directly. That
|
|
1438
|
+
* load is the check on the hint.
|
|
1439
|
+
*
|
|
1440
|
+
* The hint isn't tried when the child object can't be created or the user can't read that
|
|
1441
|
+
* entity: the discovery-only path decides those cases, so a hint can neither expose a child
|
|
1442
|
+
* nor fail a load that works without it. A failure READING the hinted row is logged and
|
|
1443
|
+
* treated the same way: the discovery path runs, and reads the row again only if the discovery
|
|
1444
|
+
* query finds it. A failure after the row was read (the child's hydration, its eager
|
|
1445
|
+
* companions, its own child discovery) propagates, as it does on the discovery-only path.
|
|
1446
|
+
*
|
|
1447
|
+
* @returns `'Linked'` when the hinted child loaded and is linked, `'NotFound'` when its load
|
|
1448
|
+
* came back empty, and `'NotTried'` when it wasn't loaded or reading its row failed.
|
|
1449
|
+
*/
|
|
1450
|
+
async tryLinkHintedChildEntity(hintedName) {
|
|
1451
|
+
const childEntity = await this.createReadableHintedChild(hintedName);
|
|
1452
|
+
if (!childEntity) {
|
|
1453
|
+
return 'NotTried';
|
|
1454
|
+
}
|
|
1455
|
+
const probe = { RowReadFailed: false };
|
|
1456
|
+
childEntity._subtypeHintProbe = probe;
|
|
1457
|
+
try {
|
|
1458
|
+
return (await this.linkAndLoadChildEntity(childEntity)) ? 'Linked' : 'NotFound';
|
|
1459
|
+
}
|
|
1460
|
+
catch (e) {
|
|
1461
|
+
if (!probe.RowReadFailed) {
|
|
1462
|
+
throw e;
|
|
1463
|
+
}
|
|
1464
|
+
// Nothing after the row read ran, so the parent chain and its unsaved edits are untouched.
|
|
1465
|
+
this._childEntity = null;
|
|
1466
|
+
LogError(`IS-A load hint: reading the '${hintedName}' row for '${this.EntityInfo.Name}' record ${this.PrimaryKey.ToString()} failed (${BaseEntity.errorText(e)}); finding the subtype with the discovery query instead.`);
|
|
1467
|
+
return 'NotTried';
|
|
1468
|
+
}
|
|
1469
|
+
finally {
|
|
1470
|
+
childEntity._subtypeHintProbe = null;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Creates the child object the subtype rule named, or returns null when the user can't read
|
|
1475
|
+
* that entity. A failure to create it is logged and returns null too, so the discovery-only
|
|
1476
|
+
* path decides.
|
|
1477
|
+
*/
|
|
1478
|
+
async createReadableHintedChild(hintedName) {
|
|
1479
|
+
try {
|
|
1480
|
+
const childEntity = await this.createChildEntityObject(hintedName);
|
|
1481
|
+
return childEntity.CheckPermissions(EntityPermissionType.Read, false) ? childEntity : null;
|
|
1482
|
+
}
|
|
1483
|
+
catch (e) {
|
|
1484
|
+
LogError(`IS-A load hint: creating a '${hintedName}' object for '${this.EntityInfo.Name}' record ${this.PrimaryKey.ToString()} failed (${BaseEntity.errorText(e)}); finding the subtype with the discovery query instead.`);
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Asks the entity's subtype rule which child a LOADED record has, without running a query.
|
|
1490
|
+
*
|
|
1491
|
+
* - A registered {@link EntitySubtypeResolver} owns the rule. It gives a hint only when its
|
|
1492
|
+
* class overrides {@link EntitySubtypeResolver.ResolveLoadHint}. A resolver that doesn't
|
|
1493
|
+
* gives none, and the `SubtypeSelector` isn't consulted in its place: at create time the
|
|
1494
|
+
* resolver overrides the selector, so the selector may not describe the same rule.
|
|
1495
|
+
* - Otherwise a `SubtypeSelector` that sets `UseForLoadedRecords` is walked through rows that
|
|
1496
|
+
* loaded `BaseEngine` caches already hold. A hop that isn't cached gives no hint, not a query.
|
|
1497
|
+
* - An entity with no rule gets no hint. The single-child fallback of
|
|
1498
|
+
* {@link ResolveSubtypeEntityName} is a default for new records, not a rule about existing ones.
|
|
1499
|
+
* - While {@link AttachToParent} loads this record, no rule is asked: the child row being added
|
|
1500
|
+
* doesn't exist yet.
|
|
1501
|
+
*
|
|
1502
|
+
* An answer of "no subtype" also gives no hint: only the discovery query can tell whether an
|
|
1503
|
+
* older record still has a child row.
|
|
1504
|
+
*
|
|
1505
|
+
* @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
|
|
1506
|
+
*/
|
|
1507
|
+
async resolveSubtypeLoadHint() {
|
|
1508
|
+
if (this._skipSubtypeLoadHint) {
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
const registration = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1512
|
+
if (registration) {
|
|
1513
|
+
const resolver = BaseEntity.loadHintResolverFor(this.EntityInfo.Name, registration);
|
|
1514
|
+
return resolver ? this.askResolverForLoadHint(resolver) : null;
|
|
1515
|
+
}
|
|
1516
|
+
const selector = this.EntityInfo.SubtypeSelectorConfig;
|
|
1517
|
+
const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
|
|
1518
|
+
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
|
|
1519
|
+
}
|
|
1520
|
+
/**
|
|
1521
|
+
* The resolver to ask for load hints for an entity, decided once per registration and cached:
|
|
1522
|
+
* null when the registered class doesn't override {@link EntitySubtypeResolver.ResolveLoadHint},
|
|
1523
|
+
* which is read from the class without constructing it, or when constructing it failed.
|
|
1524
|
+
*/
|
|
1525
|
+
static loadHintResolverFor(entityName, registration) {
|
|
1526
|
+
const key = entityName.trim().toLowerCase();
|
|
1527
|
+
const cached = BaseEntity._subtypeLoadHintResolvers.get(key);
|
|
1528
|
+
if (cached?.Registration === registration) {
|
|
1529
|
+
return cached.Resolver;
|
|
1530
|
+
}
|
|
1531
|
+
const resolver = BaseEntity.overridesResolveLoadHint(registration)
|
|
1532
|
+
? BaseEntity.constructLoadHintResolver(entityName)
|
|
1533
|
+
: null;
|
|
1534
|
+
BaseEntity._subtypeLoadHintResolvers.set(key, { Registration: registration, Resolver: resolver });
|
|
1535
|
+
return resolver;
|
|
1536
|
+
}
|
|
1537
|
+
/** Whether a registered resolver class overrides `ResolveLoadHint`, read from its prototype. */
|
|
1538
|
+
static overridesResolveLoadHint(registration) {
|
|
1539
|
+
const prototype = registration.SubClass?.prototype;
|
|
1540
|
+
const resolveLoadHint = prototype?.ResolveLoadHint;
|
|
1541
|
+
return typeof resolveLoadHint === 'function' && resolveLoadHint !== EntitySubtypeResolver.prototype.ResolveLoadHint;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Constructs the registered resolver once, to ask it for load hints. A failure is logged and
|
|
1545
|
+
* gives null; since the answer is cached, that is once per entity.
|
|
1546
|
+
*/
|
|
1547
|
+
static constructLoadHintResolver(entityName) {
|
|
1548
|
+
try {
|
|
1549
|
+
const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, entityName);
|
|
1550
|
+
if (resolution.Resolved && resolution.Instance) {
|
|
1551
|
+
return resolution.Instance;
|
|
1552
|
+
}
|
|
1553
|
+
LogError(`IS-A load hint: the EntitySubtypeResolver registered for '${entityName}' did not resolve (${resolution.Reason ?? 'no reason given'}); loading its records without a hint.`);
|
|
1554
|
+
}
|
|
1555
|
+
catch (e) {
|
|
1556
|
+
LogError(`IS-A load hint: constructing the EntitySubtypeResolver registered for '${entityName}' failed (${BaseEntity.errorText(e)}); loading its records without a hint.`);
|
|
1557
|
+
}
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Asks the resolver for a load hint. A resolver that throws or rejects gives no hint for that
|
|
1562
|
+
* record, and is logged once per entity rather than per record.
|
|
1563
|
+
*/
|
|
1564
|
+
async askResolverForLoadHint(resolver) {
|
|
1565
|
+
try {
|
|
1566
|
+
// A synchronous answer is used as-is, with no extra microtask per record.
|
|
1567
|
+
const raw = resolver.ResolveLoadHint(this);
|
|
1568
|
+
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1569
|
+
return this.declaredChildForLoadHint(candidate, `EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}'`);
|
|
1570
|
+
}
|
|
1571
|
+
catch (e) {
|
|
1572
|
+
BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|resolver-failed`, () => LogError(`IS-A load hint: EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}' failed on record ${this.PrimaryKey.ToString()} (${BaseEntity.errorText(e)}). A record it fails on loads without a hint; this is reported once per entity.`));
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
|
|
1578
|
+
* this entity's metadata is logged once and gives no hint; the create path throws on it instead.
|
|
1579
|
+
*/
|
|
1580
|
+
async evaluateSelectorForLoadHint(path) {
|
|
1581
|
+
try {
|
|
1582
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
|
|
1583
|
+
return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
|
|
1584
|
+
}
|
|
1585
|
+
catch (e) {
|
|
1586
|
+
BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|path|${path}`, () => LogError(`IS-A load hint: ${BaseEntity.errorText(e)} Loading '${this.EntityInfo.Name}' records without a hint.`));
|
|
1587
|
+
return null;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Turns a rule's answer into a declared child's canonical entity name. Empty means no subtype,
|
|
1592
|
+
* which gives no hint. A name that isn't a declared IsA child is logged once and gives no hint.
|
|
1593
|
+
*/
|
|
1594
|
+
declaredChildForLoadHint(candidate, source) {
|
|
1595
|
+
const trimmed = candidate?.trim();
|
|
1596
|
+
if (!trimmed) {
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
const match = this.findDeclaredChild(trimmed);
|
|
1600
|
+
if (!match) {
|
|
1601
|
+
BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|undeclared|${trimmed}`, () => LogError(`IS-A load hint: ${source} named '${trimmed}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'. Loading the record without a hint.`));
|
|
1602
|
+
return null;
|
|
1603
|
+
}
|
|
1604
|
+
return match.Name;
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Reports a hint the data didn't bear out: the hinted child's row wasn't there.
|
|
1608
|
+
*
|
|
1609
|
+
* When the discovery query finds no child either, that is a normal state, not an error: core
|
|
1610
|
+
* never creates a subtype row on save, so a typed record can exist without one (saved through
|
|
1611
|
+
* a generic form or an import, or older than its type's subtype). It logs one verbose line per
|
|
1612
|
+
* entity and hinted child. When the discovery query finds a DIFFERENT child, the rule and the
|
|
1613
|
+
* data disagree, and each such load costs an extra round trip until one of them is fixed; that
|
|
1614
|
+
* is logged as an error, once per entity, hinted child and child found.
|
|
1615
|
+
*/
|
|
1616
|
+
reportSubtypeLoadHintMiss(hintedName, discoveredName) {
|
|
1617
|
+
const entityName = this.EntityInfo.Name;
|
|
1618
|
+
if (!discoveredName) {
|
|
1619
|
+
BaseEntity.logSubtypeLoadHintOnce(`${entityName}|no-row|${hintedName}`, () => LogStatusEx({
|
|
1620
|
+
message: `IS-A load hint: a '${entityName}' record whose subtype rule names '${hintedName}' has no subtype row, so its load also ran the discovery query. Normal for a record saved before its subtype row; reported once.`,
|
|
1621
|
+
verboseOnly: true,
|
|
1622
|
+
}));
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
BaseEntity.logSubtypeLoadHintOnce(`${entityName}|disagree|${hintedName}|${discoveredName}`, () => LogError(`IS-A load hint: the subtype rule for '${entityName}' record ${this.PrimaryKey.ToString()} names '${hintedName}', but its subtype row is in '${discoveredName}'. The rule and the data disagree, and each such load costs an extra round trip until one of them is fixed. Reported once for this pair of subtypes.`));
|
|
1626
|
+
}
|
|
1627
|
+
/** Keys of load-hint messages already logged, so each is reported once rather than per record. */
|
|
1628
|
+
static { this._loggedSubtypeLoadHintMessages = new Set(); }
|
|
1629
|
+
/** Runs `log` the first time `key` is seen (case-insensitively), and not again until the caches are cleared. */
|
|
1630
|
+
static logSubtypeLoadHintOnce(key, log) {
|
|
1631
|
+
const normalizedKey = key.toLowerCase();
|
|
1632
|
+
if (BaseEntity._loggedSubtypeLoadHintMessages.has(normalizedKey)) {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
BaseEntity._loggedSubtypeLoadHintMessages.add(normalizedKey);
|
|
1636
|
+
log();
|
|
1637
|
+
}
|
|
1638
|
+
static errorText(e) {
|
|
1639
|
+
return e instanceof Error ? e.message : String(e);
|
|
1290
1640
|
}
|
|
1291
1641
|
static { this._subtypeLookupCache = new Map(); }
|
|
1642
|
+
/** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
|
|
1643
|
+
static { this._subtypeLoadHintResolvers = new Map(); }
|
|
1644
|
+
/** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
|
|
1645
|
+
static { this._engineCachedRowIndexes = new WeakMap(); }
|
|
1292
1646
|
/**
|
|
1293
|
-
* Clears the static
|
|
1647
|
+
* Clears the static caches IsA subtype resolution keeps: the create path's SubtypeSelector
|
|
1648
|
+
* lookup memo, the per-entity decision about which resolver gives load hints, the indexes over
|
|
1649
|
+
* engine-cached rows, and the record of load-hint messages already logged, so each can be
|
|
1650
|
+
* reported again.
|
|
1294
1651
|
*/
|
|
1295
1652
|
static ClearSubtypeLookupCache() {
|
|
1296
1653
|
BaseEntity._subtypeLookupCache.clear();
|
|
1654
|
+
BaseEntity._subtypeLoadHintResolvers.clear();
|
|
1655
|
+
BaseEntity._engineCachedRowIndexes = new WeakMap();
|
|
1656
|
+
BaseEntity._loggedSubtypeLoadHintMessages.clear();
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* The declared IsA child entity named `name`, matched the way metadata lookups match names
|
|
1660
|
+
* (trimmed, case-insensitive), or undefined when this entity declares no such child. The one
|
|
1661
|
+
* lookup behind {@link ResolveSubtypeEntityName}, {@link EnsureISAChild} and load hints: the
|
|
1662
|
+
* create path throws on a miss, the load path logs it.
|
|
1663
|
+
*/
|
|
1664
|
+
findDeclaredChild(name) {
|
|
1665
|
+
return this.EntityInfo.ChildEntities?.find(c => sameEntityName(c.Name, name));
|
|
1297
1666
|
}
|
|
1298
1667
|
/**
|
|
1299
1668
|
* Prospective counterpart to FindISAChildEntity.
|
|
@@ -1303,6 +1672,12 @@ export class BaseEntity {
|
|
|
1303
1672
|
* 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
|
|
1304
1673
|
* 4. Otherwise null (no subtype)
|
|
1305
1674
|
*
|
|
1675
|
+
* This is the create-time ladder, and steps 1 and 2 may query. When a record is loaded,
|
|
1676
|
+
* BaseEntity asks a narrower, query-free version of it for a hint instead: the resolver's
|
|
1677
|
+
* `ResolveLoadHint` (not `Resolve`) when its class overrides it, the selector only when it sets
|
|
1678
|
+
* `UseForLoadedRecords` and only through rows that loaded `BaseEngine` caches hold, and never
|
|
1679
|
+
* step 3.
|
|
1680
|
+
*
|
|
1306
1681
|
* @see plans/sync-composition-axes.md
|
|
1307
1682
|
*/
|
|
1308
1683
|
async ResolveSubtypeEntityName() {
|
|
@@ -1317,8 +1692,7 @@ export class BaseEntity {
|
|
|
1317
1692
|
const raw = resolution.Instance.Resolve(this);
|
|
1318
1693
|
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1319
1694
|
if (candidate != null && candidate.trim() !== '') {
|
|
1320
|
-
const
|
|
1321
|
-
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1695
|
+
const match = this.findDeclaredChild(candidate);
|
|
1322
1696
|
if (!match) {
|
|
1323
1697
|
throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1324
1698
|
}
|
|
@@ -1330,10 +1704,9 @@ export class BaseEntity {
|
|
|
1330
1704
|
// 2. Entity.SubtypeSelector declarative path
|
|
1331
1705
|
const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
|
|
1332
1706
|
if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
|
|
1333
|
-
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
|
|
1707
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim(), (entityName, pkValue) => this.getSubtypePathTargetEntity(entityName, pkValue));
|
|
1334
1708
|
if (pathResult != null && pathResult.trim() !== '') {
|
|
1335
|
-
const
|
|
1336
|
-
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1709
|
+
const match = this.findDeclaredChild(pathResult);
|
|
1337
1710
|
if (!match) {
|
|
1338
1711
|
throw new Error(`SubtypeSelector path '${selectorConfig.Path}' on '${this.EntityInfo.Name}' resolved to '${pathResult}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1339
1712
|
}
|
|
@@ -1364,7 +1737,7 @@ export class BaseEntity {
|
|
|
1364
1737
|
if (!entityName) {
|
|
1365
1738
|
return null;
|
|
1366
1739
|
}
|
|
1367
|
-
const matchedChild = this.
|
|
1740
|
+
const matchedChild = this.findDeclaredChild(entityName);
|
|
1368
1741
|
if (!matchedChild) {
|
|
1369
1742
|
throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1370
1743
|
}
|
|
@@ -1383,15 +1756,7 @@ export class BaseEntity {
|
|
|
1383
1756
|
this.replaceChildParentChain(childEntity);
|
|
1384
1757
|
this._childEntity = childEntity;
|
|
1385
1758
|
const dirtySnapshots = this.captureChainDirtyState();
|
|
1386
|
-
|
|
1387
|
-
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1388
|
-
if (!loaded) {
|
|
1389
|
-
this.mirrorSharedKeysToChild(childEntity);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
else {
|
|
1393
|
-
this.mirrorSharedKeysToChild(childEntity);
|
|
1394
|
-
}
|
|
1759
|
+
await this.loadOrMirrorChildRow(childEntity);
|
|
1395
1760
|
this.restoreChainDirtyState(dirtySnapshots);
|
|
1396
1761
|
// Recursively discover grandchildren if the child is also a parent type
|
|
1397
1762
|
if (childEntity.EntityInfo.IsParentType) {
|
|
@@ -1411,15 +1776,7 @@ export class BaseEntity {
|
|
|
1411
1776
|
const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
|
|
1412
1777
|
this.replaceChildParentChain(childEntity);
|
|
1413
1778
|
const dirtySnapshots = this.captureChainDirtyState();
|
|
1414
|
-
|
|
1415
|
-
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1416
|
-
if (!loaded) {
|
|
1417
|
-
this.mirrorSharedKeysToChild(childEntity);
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
else {
|
|
1421
|
-
this.mirrorSharedKeysToChild(childEntity);
|
|
1422
|
-
}
|
|
1779
|
+
await this.loadOrMirrorChildRow(childEntity);
|
|
1423
1780
|
this.restoreChainDirtyState(dirtySnapshots);
|
|
1424
1781
|
if (childEntity.EntityInfo.IsParentType) {
|
|
1425
1782
|
await childEntity.EnsureISAChild();
|
|
@@ -1427,6 +1784,32 @@ export class BaseEntity {
|
|
|
1427
1784
|
return childEntity;
|
|
1428
1785
|
}
|
|
1429
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* A new parent's child is new too: there is no row to read, so copy the parent's keys.
|
|
1789
|
+
* A saved parent still reads (promotion's usual answer is "no row"), and that miss is not
|
|
1790
|
+
* an error — the same way a subtype-hint probe treats an empty read (MJ#4859).
|
|
1791
|
+
*/
|
|
1792
|
+
async loadOrMirrorChildRow(childEntity) {
|
|
1793
|
+
if (this.IsSaved && this.PrimaryKey?.HasValue) {
|
|
1794
|
+
const loaded = await this.loadChildRowQuietly(childEntity);
|
|
1795
|
+
if (!loaded) {
|
|
1796
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1797
|
+
}
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1801
|
+
}
|
|
1802
|
+
/** Loads the child by the shared key. An empty result is an answer, not a logged error. */
|
|
1803
|
+
async loadChildRowQuietly(childEntity) {
|
|
1804
|
+
const probe = { RowReadFailed: false };
|
|
1805
|
+
childEntity._subtypeHintProbe = probe;
|
|
1806
|
+
try {
|
|
1807
|
+
return await childEntity.InnerLoad(this.PrimaryKey);
|
|
1808
|
+
}
|
|
1809
|
+
finally {
|
|
1810
|
+
childEntity._subtypeHintProbe = null;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1430
1813
|
mirrorSharedKeysToChild(childEntity) {
|
|
1431
1814
|
const parentPks = this.EntityInfo.PrimaryKeys;
|
|
1432
1815
|
if (!parentPks || parentPks.length === 0)
|
|
@@ -1438,7 +1821,19 @@ export class BaseEntity {
|
|
|
1438
1821
|
}
|
|
1439
1822
|
}
|
|
1440
1823
|
}
|
|
1441
|
-
|
|
1824
|
+
/**
|
|
1825
|
+
* Walks a `SubtypeSelector` path from this record to the column holding the subtype's entity
|
|
1826
|
+
* name, and returns that column's value, or null when a hop's foreign key is empty, a target
|
|
1827
|
+
* isn't found, or the terminal value is empty.
|
|
1828
|
+
*
|
|
1829
|
+
* @param path The dotted path, e.g. `ProductTypeID.ProductExtensionEntity`.
|
|
1830
|
+
* @param findTarget Finds the record a foreign key points at. Create time passes a finder that
|
|
1831
|
+
* may query; load time passes one that reads engine caches only, so a hop missing from them
|
|
1832
|
+
* ends the walk with null instead of a query.
|
|
1833
|
+
* @throws When the path doesn't fit this entity's metadata (a field isn't found, or a hop isn't
|
|
1834
|
+
* a foreign key).
|
|
1835
|
+
*/
|
|
1836
|
+
async evaluateSubtypeSelectorPath(path, findTarget) {
|
|
1442
1837
|
const segments = path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1443
1838
|
if (segments.length === 0)
|
|
1444
1839
|
return null;
|
|
@@ -1458,7 +1853,7 @@ export class BaseEntity {
|
|
|
1458
1853
|
if (!relatedEntityName) {
|
|
1459
1854
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
|
|
1460
1855
|
}
|
|
1461
|
-
const targetEntity = await
|
|
1856
|
+
const targetEntity = await findTarget(relatedEntityName, fkValue);
|
|
1462
1857
|
if (!targetEntity) {
|
|
1463
1858
|
return null;
|
|
1464
1859
|
}
|
|
@@ -1482,21 +1877,10 @@ export class BaseEntity {
|
|
|
1482
1877
|
return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
|
|
1483
1878
|
}
|
|
1484
1879
|
// 1. Check BaseEngineRegistry for loaded cached entities
|
|
1485
|
-
const
|
|
1486
|
-
if (
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
const firstPK = r.FirstPrimaryKey; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1490
|
-
if (firstPK) {
|
|
1491
|
-
return String(firstPK.Value).trim().toLowerCase() === String(pkValue).trim().toLowerCase();
|
|
1492
|
-
}
|
|
1493
|
-
return false;
|
|
1494
|
-
});
|
|
1495
|
-
if (found) {
|
|
1496
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, found);
|
|
1497
|
-
return found;
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1880
|
+
const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
|
|
1881
|
+
if (cached) {
|
|
1882
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, cached);
|
|
1883
|
+
return cached;
|
|
1500
1884
|
}
|
|
1501
1885
|
// 2. Fall back to loading via provider
|
|
1502
1886
|
const provider = this.ProviderToUse;
|
|
@@ -1531,6 +1915,84 @@ export class BaseEntity {
|
|
|
1531
1915
|
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1532
1916
|
return null;
|
|
1533
1917
|
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
1920
|
+
* `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
|
|
1921
|
+
* entries can outlive a change to the row: engine caches are kept current by entity events.
|
|
1922
|
+
*
|
|
1923
|
+
* `pkValue` is one foreign-key column's value, matched against the first primary key of the
|
|
1924
|
+
* cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
|
|
1925
|
+
*
|
|
1926
|
+
* @returns The cached record, or null when no loaded engine holds it as an entity object.
|
|
1927
|
+
*/
|
|
1928
|
+
static findSubtypePathTargetInEngineCache(entityName, pkValue) {
|
|
1929
|
+
const key = NormalizeUUID(String(pkValue));
|
|
1930
|
+
for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
|
|
1931
|
+
const found = BaseEntity.findEngineCachedRow(match.records, key);
|
|
1932
|
+
if (found) {
|
|
1933
|
+
return found;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Finds the row whose key is `key` in an engine's live cached array through an index over the
|
|
1940
|
+
* array, not a scan per lookup. Keys are read with `Get()`, which leaves the rows an engine holds
|
|
1941
|
+
* in raw mode unhydrated.
|
|
1942
|
+
*
|
|
1943
|
+
* The array is the engine's own and changes under the index: a refresh replaces it, and a save,
|
|
1944
|
+
* delete or remote invalidation pushes a row, splices one out, or swaps one for a copy with the
|
|
1945
|
+
* same key. So the index is keyed by the array itself, and rebuilt when the array's length or
|
|
1946
|
+
* last element differs from when it was built, which every added or removed row changes. A hit
|
|
1947
|
+
* is checked against the row now at that position, and a row that moved makes the index rebuild
|
|
1948
|
+
* before the answer is trusted. A key that isn't indexed is a miss, which gives no hint on load
|
|
1949
|
+
* and falls back to a query at create time, as for a row no engine caches.
|
|
1950
|
+
*/
|
|
1951
|
+
static findEngineCachedRow(records, key) {
|
|
1952
|
+
let index = BaseEntity.currentEngineCachedRowIndex(records);
|
|
1953
|
+
let position = index.Positions.get(key);
|
|
1954
|
+
if (position !== undefined && BaseEntity.engineCachedRowKey(records[position], index.KeyField) !== key) {
|
|
1955
|
+
index = BaseEntity.buildEngineCachedRowIndex(records);
|
|
1956
|
+
position = index.Positions.get(key);
|
|
1957
|
+
}
|
|
1958
|
+
return position === undefined ? null : records[position];
|
|
1959
|
+
}
|
|
1960
|
+
/** The index over `records`, rebuilt when the array's length or last element changed since it was built. */
|
|
1961
|
+
static currentEngineCachedRowIndex(records) {
|
|
1962
|
+
const index = BaseEntity._engineCachedRowIndexes.get(records);
|
|
1963
|
+
if (index && index.Length === records.length && index.LastRow === records[records.length - 1]) {
|
|
1964
|
+
return index;
|
|
1965
|
+
}
|
|
1966
|
+
return BaseEntity.buildEngineCachedRowIndex(records);
|
|
1967
|
+
}
|
|
1968
|
+
/** Indexes `records` by key. Anything that isn't an entity object (a `'simple'` cache holds plain rows) is skipped. */
|
|
1969
|
+
static buildEngineCachedRowIndex(records) {
|
|
1970
|
+
const firstEntityRow = records.find(r => typeof r?.Get === 'function');
|
|
1971
|
+
const keyField = firstEntityRow?.EntityInfo?.FirstPrimaryKey?.Name ?? null; // first-pk-ok: FK target — a SubtypeSelector hop's foreign key holds one key value
|
|
1972
|
+
const positions = new Map();
|
|
1973
|
+
for (let i = 0; i < records.length; i++) {
|
|
1974
|
+
const rowKey = BaseEntity.engineCachedRowKey(records[i], keyField);
|
|
1975
|
+
if (rowKey !== null && !positions.has(rowKey)) {
|
|
1976
|
+
positions.set(rowKey, i);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
const index = {
|
|
1980
|
+
Length: records.length,
|
|
1981
|
+
LastRow: records[records.length - 1],
|
|
1982
|
+
KeyField: keyField,
|
|
1983
|
+
Positions: positions,
|
|
1984
|
+
};
|
|
1985
|
+
BaseEntity._engineCachedRowIndexes.set(records, index);
|
|
1986
|
+
return index;
|
|
1987
|
+
}
|
|
1988
|
+
/** A cached row's key, normalized with `NormalizeUUID`; null for a row that isn't an entity object or has no key value. */
|
|
1989
|
+
static engineCachedRowKey(row, keyField) {
|
|
1990
|
+
if (!keyField || typeof row?.Get !== 'function') {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
const value = row.Get(keyField);
|
|
1994
|
+
return value == null ? null : NormalizeUUID(String(value));
|
|
1995
|
+
}
|
|
1534
1996
|
captureChainDirtyState() {
|
|
1535
1997
|
const snapshots = [];
|
|
1536
1998
|
let curr = this;
|
|
@@ -1762,6 +2224,20 @@ export class BaseEntity {
|
|
|
1762
2224
|
DeclareRelatedRecords(options) {
|
|
1763
2225
|
return this.RegisterCompanion(new RelatedRecordCollection(this, options));
|
|
1764
2226
|
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Dynamically registers a database-sourced child collection companion on this entity instance.
|
|
2229
|
+
* Used by generic composite persistence engines (Record Cloning, Metadata Sync) when a relationship
|
|
2230
|
+
* has no static collection companion declared on the generated entity class.
|
|
2231
|
+
*
|
|
2232
|
+
* Refuses duplicate names or re-registering an already existing companion.
|
|
2233
|
+
*
|
|
2234
|
+
* @typeParam TChild - The child entity type.
|
|
2235
|
+
* @param options - The collection declaration options.
|
|
2236
|
+
* @returns The registered collection companion.
|
|
2237
|
+
*/
|
|
2238
|
+
DeclareRelatedRecordsDynamic(options) {
|
|
2239
|
+
return this.DeclareRelatedRecords(options);
|
|
2240
|
+
}
|
|
1765
2241
|
/**
|
|
1766
2242
|
* Declares a 1:1 embedded peer on this entity, joined by an owner-held foreign key,
|
|
1767
2243
|
* and registers it as a companion.
|
|
@@ -2223,10 +2699,15 @@ export class BaseEntity {
|
|
|
2223
2699
|
childSaveOptions.GraphVisited = visited;
|
|
2224
2700
|
const childDeleteOptions = Object.assign(new EntityDeleteOptions(), deleteOptions ?? {});
|
|
2225
2701
|
childDeleteOptions.GraphVisited = visited;
|
|
2226
|
-
//
|
|
2227
|
-
//
|
|
2228
|
-
//
|
|
2229
|
-
|
|
2702
|
+
// A graph that holds a scope is a unit of work: what its records write is put back if it
|
|
2703
|
+
// rolls back, and what they delete is reset only once it commits (see UnitOfWork). Nested in
|
|
2704
|
+
// another unit — the companions of an IS-A parent deleted in its child's chain, say — it
|
|
2705
|
+
// joins that one. Without a scope each write stands as it runs, so a failure leaves the
|
|
2706
|
+
// records that already wrote as they are, which is what the database holds.
|
|
2707
|
+
const enclosing = this._unitOfWork;
|
|
2708
|
+
const records = plan.Nodes.map(node => node.Entity);
|
|
2709
|
+
const previousUnits = records.map(record => record._unitOfWork);
|
|
2710
|
+
let part = null;
|
|
2230
2711
|
// Acquired INSIDE the try: a begin failure (pool exhausted, dead connection) is a failed
|
|
2231
2712
|
// save, and Save()/Delete() report failure by returning false — an escaping throw here
|
|
2232
2713
|
// would break that contract for exactly one path.
|
|
@@ -2236,6 +2717,12 @@ export class BaseEntity {
|
|
|
2236
2717
|
provider?.SupportsEntityTransactions === true && provider.BeginEntityTransaction
|
|
2237
2718
|
? await provider.BeginEntityTransaction()
|
|
2238
2719
|
: null;
|
|
2720
|
+
if (scope) {
|
|
2721
|
+
part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains(records));
|
|
2722
|
+
for (const record of records) {
|
|
2723
|
+
record._unitOfWork = part.unit;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2239
2726
|
const result = await ExecuteEntitySavePlan(plan, {
|
|
2240
2727
|
SaveOptions: childSaveOptions,
|
|
2241
2728
|
DeleteOptions: childDeleteOptions,
|
|
@@ -2245,12 +2732,13 @@ export class BaseEntity {
|
|
|
2245
2732
|
});
|
|
2246
2733
|
if (!result.Success) {
|
|
2247
2734
|
await scope?.Rollback();
|
|
2248
|
-
|
|
2735
|
+
BaseEntity.settleRollback(part);
|
|
2249
2736
|
this.registerGraphFailure(result.ErrorMessage, operation);
|
|
2250
2737
|
this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: result.ErrorMessage });
|
|
2251
2738
|
return false;
|
|
2252
2739
|
}
|
|
2253
2740
|
await scope?.Commit();
|
|
2741
|
+
BaseEntity.settleCommit(part);
|
|
2254
2742
|
this.acceptCompanionChanges();
|
|
2255
2743
|
this.RaiseEvent('graph_save', { Success: true, NodeCount: plan.NodeCount });
|
|
2256
2744
|
return true;
|
|
@@ -2266,13 +2754,18 @@ export class BaseEntity {
|
|
|
2266
2754
|
LogError(`BaseEntity.executeGraphLocal: rollback failed after graph error for ` +
|
|
2267
2755
|
`${this.EntityInfo?.Name}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
|
|
2268
2756
|
}
|
|
2269
|
-
|
|
2757
|
+
BaseEntity.settleRollback(part);
|
|
2270
2758
|
const detail = e instanceof Error ? e.message : String(e);
|
|
2271
2759
|
LogError(`BaseEntity.executeGraphLocal failed for ${this.EntityInfo?.Name}: ${detail}`);
|
|
2272
2760
|
this.registerGraphFailure(detail, operation);
|
|
2273
2761
|
this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
|
|
2274
2762
|
return false;
|
|
2275
2763
|
}
|
|
2764
|
+
finally {
|
|
2765
|
+
records.forEach((record, index) => {
|
|
2766
|
+
record._unitOfWork = previousUnits[index];
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2276
2769
|
}
|
|
2277
2770
|
/**
|
|
2278
2771
|
* Persist this record as one already-planned graph node. Private: the public
|
|
@@ -2280,47 +2773,80 @@ export class BaseEntity {
|
|
|
2280
2773
|
* binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
|
|
2281
2774
|
*/
|
|
2282
2775
|
saveAsGraphNode(options) {
|
|
2283
|
-
return this.
|
|
2776
|
+
return this._innerSave(options);
|
|
2284
2777
|
}
|
|
2285
2778
|
/**
|
|
2286
2779
|
* Delete-path counterpart of {@link saveAsGraphNode}.
|
|
2287
2780
|
*/
|
|
2288
2781
|
deleteAsGraphNode(options) {
|
|
2289
|
-
return this.
|
|
2782
|
+
return this._innerDelete(options);
|
|
2290
2783
|
}
|
|
2291
2784
|
/**
|
|
2292
|
-
* Captures
|
|
2293
|
-
*
|
|
2785
|
+
* Captures each record's IS-A chain, from its leaf up to its root, so a unit of work that rolls
|
|
2786
|
+
* back can put the chain back with {@link restoreChains}. A record in more than one chain is
|
|
2787
|
+
* captured once.
|
|
2294
2788
|
*/
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2789
|
+
static captureChains(entities) {
|
|
2790
|
+
const captured = new Set();
|
|
2791
|
+
const snapshots = [];
|
|
2792
|
+
for (const entity of entities) {
|
|
2793
|
+
for (let level = entity.LeafEntity; level && !captured.has(level); level = level._parentEntity) {
|
|
2794
|
+
captured.add(level);
|
|
2795
|
+
snapshots.push({
|
|
2796
|
+
entity: level,
|
|
2797
|
+
wasSaved: level._everSaved,
|
|
2798
|
+
wasLoaded: level._recordLoaded,
|
|
2799
|
+
resultHistory: [...level._resultHistory],
|
|
2800
|
+
fields: level.Fields.map(field => ({ field, state: field.GetState() })),
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
return snapshots;
|
|
2301
2805
|
}
|
|
2302
2806
|
/**
|
|
2303
|
-
*
|
|
2807
|
+
* Puts back, after a rollback, what the unit of work changed in memory. See {@link restoreSnapshot}.
|
|
2304
2808
|
*/
|
|
2305
|
-
|
|
2306
|
-
for (const
|
|
2307
|
-
|
|
2809
|
+
static restoreChains(snapshots) {
|
|
2810
|
+
for (const snapshot of snapshots) {
|
|
2811
|
+
snapshot.entity.restoreSnapshot(snapshot);
|
|
2308
2812
|
}
|
|
2309
2813
|
}
|
|
2310
2814
|
/**
|
|
2311
|
-
*
|
|
2312
|
-
*
|
|
2313
|
-
*
|
|
2815
|
+
* Puts this record back as a snapshot captured it, after the unit of work that finalized it
|
|
2816
|
+
* rolled back.
|
|
2817
|
+
*
|
|
2818
|
+
* A save's `finalizeSave()` rebuilds the record's fields from what its write returned, marks it
|
|
2819
|
+
* saved and empties its result history. The database undid the write, so the record gets back
|
|
2820
|
+
* its saved and loaded flags, its result history, and each field's value and tracking state. A
|
|
2821
|
+
* field edited since then keeps the edit, and is compared with its captured baseline again, since
|
|
2822
|
+
* that is what the database holds after the rollback. (A delete leaves nothing to put back: a
|
|
2823
|
+
* record deleted inside a unit of work isn't reset until the unit commits.)
|
|
2824
|
+
*
|
|
2825
|
+
* The history matters to the caller: a save records its failure only when nothing else did,
|
|
2826
|
+
* judged by the history's length when it started, which an emptied history would make wrong.
|
|
2827
|
+
*
|
|
2828
|
+
* A record still holding the captured field objects was never finalized, so nothing about it
|
|
2829
|
+
* changed and it is left alone.
|
|
2314
2830
|
*/
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2831
|
+
restoreSnapshot(snapshot) {
|
|
2832
|
+
const finalized = snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
|
|
2833
|
+
if (!finalized) {
|
|
2834
|
+
return;
|
|
2319
2835
|
}
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2836
|
+
this._everSaved = snapshot.wasSaved;
|
|
2837
|
+
this._recordLoaded = snapshot.wasLoaded;
|
|
2838
|
+
this._resultHistory = snapshot.resultHistory;
|
|
2839
|
+
this._compositeKey = null; // cached from the finalized key; rebuilt on the next read
|
|
2840
|
+
for (const { field: capturedField, state } of snapshot.fields) {
|
|
2841
|
+
const field = this.GetFieldByName(capturedField.Name);
|
|
2842
|
+
if (!field) {
|
|
2843
|
+
continue;
|
|
2844
|
+
}
|
|
2845
|
+
if (field.Dirty) {
|
|
2846
|
+
field.RestoreOldValue(state.OldValue);
|
|
2847
|
+
}
|
|
2848
|
+
else {
|
|
2849
|
+
field.RestoreState(state);
|
|
2324
2850
|
}
|
|
2325
2851
|
}
|
|
2326
2852
|
}
|
|
@@ -2379,6 +2905,89 @@ export class BaseEntity {
|
|
|
2379
2905
|
LogError(`Error rolling back entity transaction scope for ${this.EntityInfo?.Name}: ${rollbackError}`);
|
|
2380
2906
|
}
|
|
2381
2907
|
}
|
|
2908
|
+
/**
|
|
2909
|
+
* The failure path of a save: rolls back the transaction scope this entity holds, if any, then
|
|
2910
|
+
* settles this save's part in its unit of work (see {@link settleRollback}). `part` is null when
|
|
2911
|
+
* this save took none (see `_innerSave`), and then only the scope is rolled back.
|
|
2912
|
+
*/
|
|
2913
|
+
async rollbackChainSave(part) {
|
|
2914
|
+
await this.rollbackEntityTransactionScope();
|
|
2915
|
+
BaseEntity.settleRollback(part);
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Starts a unit of work's part in a {@link UnitOfWork}: joins `enclosing` when the unit runs
|
|
2919
|
+
* inside one, and owns a new one when it doesn't. `snapshots` are the unit's records as they are
|
|
2920
|
+
* before it writes them. They join the unit's, so that the outermost unit can put them back.
|
|
2921
|
+
*/
|
|
2922
|
+
static beginUnitOfWork(enclosing, snapshots) {
|
|
2923
|
+
const unit = enclosing ?? { Snapshots: [], DeletedRecords: [] };
|
|
2924
|
+
unit.Snapshots.push(...snapshots);
|
|
2925
|
+
return { unit, owns: !enclosing, snapshots, deletedMark: unit.DeletedRecords.length };
|
|
2926
|
+
}
|
|
2927
|
+
/**
|
|
2928
|
+
* Runs `work` on `record` as part of `unit` (see {@link UnitOfWork}), and gives the record back
|
|
2929
|
+
* the unit it was in before, however `work` ends. Used for an IS-A parent's delete, which runs
|
|
2930
|
+
* inside its child's chain, along with the graph of its companions if it has any.
|
|
2931
|
+
*/
|
|
2932
|
+
static async inUnitOfWork(record, unit, work) {
|
|
2933
|
+
const previous = record._unitOfWork;
|
|
2934
|
+
record._unitOfWork = unit;
|
|
2935
|
+
try {
|
|
2936
|
+
return await work(record);
|
|
2937
|
+
}
|
|
2938
|
+
finally {
|
|
2939
|
+
record._unitOfWork = previous;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* After a unit of work rolls back: puts its records back as they were before it wrote them, and
|
|
2944
|
+
* drops the deletes the rollback undid, so that nothing resets their records. The owner does this
|
|
2945
|
+
* for everything the unit and the units nested in it wrote. A nested part does it for its own
|
|
2946
|
+
* records only; when the owner then rolls back too, restoring them again changes nothing.
|
|
2947
|
+
*/
|
|
2948
|
+
static settleRollback(part) {
|
|
2949
|
+
if (!part) {
|
|
2950
|
+
return;
|
|
2951
|
+
}
|
|
2952
|
+
BaseEntity.restoreChains(part.owns ? part.unit.Snapshots : part.snapshots);
|
|
2953
|
+
part.unit.DeletedRecords.length = part.owns ? 0 : part.deletedMark;
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* After a unit of work commits: its owner resets the records it deleted. A nested part leaves
|
|
2957
|
+
* them to the owner, since the enclosing unit can still roll back.
|
|
2958
|
+
*/
|
|
2959
|
+
static settleCommit(part) {
|
|
2960
|
+
if (part?.owns) {
|
|
2961
|
+
BaseEntity.resetDeletedRecords(part.unit.DeletedRecords);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Resets the records a unit of work deleted, once the unit has committed, as each record's own
|
|
2966
|
+
* `Delete()` does when no unit of work is involved. See {@link UnitOfWork}.
|
|
2967
|
+
*
|
|
2968
|
+
* Last-deleted first, skipping a record that no longer reads as saved. `NewRecord()` on a record
|
|
2969
|
+
* also resets every IS-A level above it, and relinks them. A chain's initiator records its delete
|
|
2970
|
+
* after its parents do, so it's reset first, and its parents with it. Resetting a parent again
|
|
2971
|
+
* after that would unlink it from its child and give it a key the child doesn't share.
|
|
2972
|
+
*
|
|
2973
|
+
* The unit has committed, so a reset that throws is logged and the rest still run: the deletes
|
|
2974
|
+
* stand whatever happens here.
|
|
2975
|
+
*/
|
|
2976
|
+
static resetDeletedRecords(records) {
|
|
2977
|
+
for (let index = records.length - 1; index >= 0; index--) {
|
|
2978
|
+
const record = records[index];
|
|
2979
|
+
if (!record.IsSaved) {
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2982
|
+
try {
|
|
2983
|
+
record.NewRecord();
|
|
2984
|
+
}
|
|
2985
|
+
catch (e) {
|
|
2986
|
+
LogError(`BaseEntity.resetDeletedRecords: resetting a deleted ${record.EntityInfo?.Name} record failed: ` +
|
|
2987
|
+
`${e instanceof Error ? e.message : String(e)}`);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2382
2991
|
/**
|
|
2383
2992
|
* 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.
|
|
2384
2993
|
*/
|
|
@@ -2620,12 +3229,12 @@ export class BaseEntity {
|
|
|
2620
3229
|
* Access to the underlying metadata for the entity object.
|
|
2621
3230
|
*/
|
|
2622
3231
|
get EntityInfo() {
|
|
2623
|
-
return this.
|
|
3232
|
+
return this._entityInfo;
|
|
2624
3233
|
}
|
|
2625
3234
|
get Fields() {
|
|
2626
3235
|
// Caller wants to walk Fields — promote raw data into real EntityField instances now.
|
|
2627
3236
|
this.hydrateFieldsIfNeeded();
|
|
2628
|
-
return this.
|
|
3237
|
+
return this._fields;
|
|
2629
3238
|
}
|
|
2630
3239
|
/**
|
|
2631
3240
|
* Convenience method to access a field by name. This method is case-insensitive and will return null if the field is not found. You can do the same thing with more fine tune controlled by accessing the Fields property directly.
|
|
@@ -2641,7 +3250,7 @@ export class BaseEntity {
|
|
|
2641
3250
|
const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
|
|
2642
3251
|
if (this._fieldCache === null) {
|
|
2643
3252
|
this._fieldCache = new Map();
|
|
2644
|
-
for (const f of this.
|
|
3253
|
+
for (const f of this._fields) {
|
|
2645
3254
|
if (!this._fieldCache.has(f.Name.trim().toLowerCase())) {
|
|
2646
3255
|
this._fieldCache.set(f.Name.trim().toLowerCase(), f);
|
|
2647
3256
|
}
|
|
@@ -2704,7 +3313,7 @@ export class BaseEntity {
|
|
|
2704
3313
|
if (this._codeNameCache === null) {
|
|
2705
3314
|
this._codeNameCache = new Map();
|
|
2706
3315
|
// First-write-wins on duplicate code names — matches prior Array.find() behavior
|
|
2707
|
-
for (const f of this.
|
|
3316
|
+
for (const f of this._fields) {
|
|
2708
3317
|
const codeKey = f.CodeName.trim().toLowerCase();
|
|
2709
3318
|
if (!this._codeNameCache.has(codeKey)) {
|
|
2710
3319
|
this._codeNameCache.set(codeKey, f);
|
|
@@ -2726,7 +3335,7 @@ export class BaseEntity {
|
|
|
2726
3335
|
// dirty. Avoid hydrating just to check.
|
|
2727
3336
|
if (!this._fieldsHydrated)
|
|
2728
3337
|
return this._parentEntity?.Dirty ?? false;
|
|
2729
|
-
return this.
|
|
3338
|
+
return this._fields.some(f => f.Dirty) ||
|
|
2730
3339
|
(this._parentEntity?.Dirty ?? false);
|
|
2731
3340
|
}
|
|
2732
3341
|
finally {
|
|
@@ -2791,17 +3400,17 @@ export class BaseEntity {
|
|
|
2791
3400
|
if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
|
|
2792
3401
|
this._parentEntity.Set(FieldName, Value); // recursive for N-level chains (parent asserts its own field)
|
|
2793
3402
|
// Also mirror the value on our own virtual EntityField for UI compatibility
|
|
2794
|
-
this.
|
|
3403
|
+
this.setLocal(FieldName, Value);
|
|
2795
3404
|
}
|
|
2796
3405
|
else {
|
|
2797
3406
|
// Active-status enforcement choke point for own-field writes. Fast-path gated on the
|
|
2798
3407
|
// memoized EntityInfo.HasInactiveFields so all-Active entities (the overwhelming majority)
|
|
2799
3408
|
// pay only a single cached boolean check.
|
|
2800
3409
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2801
|
-
this.
|
|
3410
|
+
this.assertFieldActiveStatus(FieldName, 'BaseEntity.Set');
|
|
2802
3411
|
}
|
|
2803
|
-
this.
|
|
2804
|
-
this.
|
|
3412
|
+
this.assertFieldReadable(FieldName);
|
|
3413
|
+
this.setLocal(FieldName, Value);
|
|
2805
3414
|
}
|
|
2806
3415
|
}
|
|
2807
3416
|
/**
|
|
@@ -2814,7 +3423,7 @@ export class BaseEntity {
|
|
|
2814
3423
|
* Callers should gate this behind `EntityInfo.HasInactiveFields` so the common all-Active entity
|
|
2815
3424
|
* skips the field lookup entirely.
|
|
2816
3425
|
*/
|
|
2817
|
-
|
|
3426
|
+
assertFieldActiveStatus(fieldName, caller) {
|
|
2818
3427
|
const fi = this.EntityInfo?.FieldByName(fieldName);
|
|
2819
3428
|
if (fi) {
|
|
2820
3429
|
EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
|
|
@@ -2841,7 +3450,7 @@ export class BaseEntity {
|
|
|
2841
3450
|
* build) reads `EntityField.Value` directly and never routes through here — that exemption
|
|
2842
3451
|
* is load-bearing, not an oversight. Do not "fix" it.
|
|
2843
3452
|
*/
|
|
2844
|
-
|
|
3453
|
+
assertFieldReadable(fieldName) {
|
|
2845
3454
|
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
2846
3455
|
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
2847
3456
|
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
@@ -2851,7 +3460,7 @@ export class BaseEntity {
|
|
|
2851
3460
|
* Internal helper that sets a field value directly on THIS entity's own Fields array
|
|
2852
3461
|
* without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
|
|
2853
3462
|
*/
|
|
2854
|
-
|
|
3463
|
+
setLocal(FieldName, Value) {
|
|
2855
3464
|
const field = this.GetFieldByName(FieldName);
|
|
2856
3465
|
if (field != null) {
|
|
2857
3466
|
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
|
|
@@ -2912,12 +3521,12 @@ export class BaseEntity {
|
|
|
2912
3521
|
// EntityInfo.HasInactiveFields so all-Active entities pay only a single cached boolean check
|
|
2913
3522
|
// before the hot read paths below.
|
|
2914
3523
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2915
|
-
this.
|
|
3524
|
+
this.assertFieldActiveStatus(FieldName, 'BaseEntity.Get');
|
|
2916
3525
|
}
|
|
2917
3526
|
// Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
|
|
2918
3527
|
// fields are not yet hydrated would otherwise return denied values straight out of _raw
|
|
2919
3528
|
// without the gate ever running.
|
|
2920
|
-
this.
|
|
3529
|
+
this.assertFieldReadable(FieldName);
|
|
2921
3530
|
// Raw mode fast path: read directly from the cached data without building EntityField
|
|
2922
3531
|
// instances. This is the dominant cost in engine warm-loads — generated typed getters
|
|
2923
3532
|
// (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
|
|
@@ -2931,7 +3540,7 @@ export class BaseEntity {
|
|
|
2931
3540
|
// back into `_raw` — the row may be shared, frozen cache state. Fields needing no
|
|
2932
3541
|
// conversion (the vast majority) never touch the memo at all, so the fast path stays
|
|
2933
3542
|
// a single property read.
|
|
2934
|
-
const fi = this.
|
|
3543
|
+
const fi = this._entityInfo?.FieldByName(FieldName);
|
|
2935
3544
|
if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
|
|
2936
3545
|
const memo = this._rawConverted?.get(FieldName);
|
|
2937
3546
|
if (memo !== undefined)
|
|
@@ -2945,7 +3554,7 @@ export class BaseEntity {
|
|
|
2945
3554
|
const memo = this._rawConverted?.get(FieldName);
|
|
2946
3555
|
if (memo !== undefined)
|
|
2947
3556
|
return memo;
|
|
2948
|
-
value = value
|
|
3557
|
+
value = trimTrailingSpaces(value);
|
|
2949
3558
|
this.memoizeRawConversion(FieldName, value);
|
|
2950
3559
|
}
|
|
2951
3560
|
return value;
|
|
@@ -2991,7 +3600,7 @@ export class BaseEntity {
|
|
|
2991
3600
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
2992
3601
|
}
|
|
2993
3602
|
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
2994
|
-
this.
|
|
3603
|
+
this.setLocal(key, object[key]);
|
|
2995
3604
|
if (replaceOldValues) {
|
|
2996
3605
|
field.ResetOldValue();
|
|
2997
3606
|
}
|
|
@@ -3005,7 +3614,7 @@ export class BaseEntity {
|
|
|
3005
3614
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
3006
3615
|
}
|
|
3007
3616
|
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
3008
|
-
this.
|
|
3617
|
+
this.setLocal(field.Name, object[key]);
|
|
3009
3618
|
if (replaceOldValues) {
|
|
3010
3619
|
field.ResetOldValue();
|
|
3011
3620
|
}
|
|
@@ -3045,7 +3654,7 @@ export class BaseEntity {
|
|
|
3045
3654
|
// load — without this, the parent would think it's a new record and attempt CREATE
|
|
3046
3655
|
// instead of UPDATE on save.
|
|
3047
3656
|
if (replaceOldValues) {
|
|
3048
|
-
this.
|
|
3657
|
+
this.updateSavedStateFromPrimaryKeys();
|
|
3049
3658
|
}
|
|
3050
3659
|
}
|
|
3051
3660
|
/**
|
|
@@ -3053,7 +3662,7 @@ export class BaseEntity {
|
|
|
3053
3662
|
* a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
|
|
3054
3663
|
* entities) to correctly recognize themselves as existing records.
|
|
3055
3664
|
*/
|
|
3056
|
-
|
|
3665
|
+
updateSavedStateFromPrimaryKeys() {
|
|
3057
3666
|
if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
|
|
3058
3667
|
return;
|
|
3059
3668
|
const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
|
|
@@ -3197,8 +3806,8 @@ export class BaseEntity {
|
|
|
3197
3806
|
}
|
|
3198
3807
|
if (params.includeRelatedEntityData) {
|
|
3199
3808
|
// now, get the related entity data
|
|
3200
|
-
for (let i = 0; i < this.
|
|
3201
|
-
const re = this.
|
|
3809
|
+
for (let i = 0; i < this._entityInfo.RelatedEntities.length; i++) {
|
|
3810
|
+
const re = this._entityInfo.RelatedEntities[i];
|
|
3202
3811
|
const pre = params.relatedEntityList ? params.relatedEntityList.find(r => r.relatedEntityName === re.RelatedEntity) : null;
|
|
3203
3812
|
if (pre) {
|
|
3204
3813
|
// pre now has the param that matches the related entity (re) that we are looking at
|
|
@@ -3256,7 +3865,7 @@ export class BaseEntity {
|
|
|
3256
3865
|
this._compositeKey = null;
|
|
3257
3866
|
this._resultHistory = [];
|
|
3258
3867
|
this._recordLoaded = false;
|
|
3259
|
-
this.
|
|
3868
|
+
this._fields = [];
|
|
3260
3869
|
this._fieldsHydrated = false;
|
|
3261
3870
|
this._raw = null;
|
|
3262
3871
|
this._rawConverted = null;
|
|
@@ -3292,14 +3901,14 @@ export class BaseEntity {
|
|
|
3292
3901
|
for (const rawField of this.EntityInfo.Fields) {
|
|
3293
3902
|
const key = this.EntityInfo.Name + '.' + rawField.Name;
|
|
3294
3903
|
const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
|
|
3295
|
-
this.
|
|
3904
|
+
this._fields.push(newField);
|
|
3296
3905
|
}
|
|
3297
3906
|
// 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
|
|
3298
3907
|
// runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
|
|
3299
3908
|
// Value AND OldValue (so the field is not marked dirty). This matches the old
|
|
3300
3909
|
// SetMany(replaceOldValues=true) semantics on initial load.
|
|
3301
3910
|
if (this._raw) {
|
|
3302
|
-
for (const field of this.
|
|
3911
|
+
for (const field of this._fields) {
|
|
3303
3912
|
const value = this._raw[field.Name];
|
|
3304
3913
|
if (value !== undefined) {
|
|
3305
3914
|
// Populating fields from the loaded row writes EntityField.Value directly, which does
|
|
@@ -3408,10 +4017,12 @@ export class BaseEntity {
|
|
|
3408
4017
|
// when setting keys.
|
|
3409
4018
|
if (this._parentEntity) {
|
|
3410
4019
|
this._parentEntity.NewRecord();
|
|
4020
|
+
// The parent's NewRecord() just nulled its back-link. Put this child back.
|
|
4021
|
+
this.linkParentBackToThisChild();
|
|
3411
4022
|
for (const pk of this.EntityInfo.PrimaryKeys) {
|
|
3412
4023
|
const parentValue = this._parentEntity.Get(pk.Name);
|
|
3413
4024
|
if (parentValue != null) {
|
|
3414
|
-
this.
|
|
4025
|
+
this.setLocal(pk.Name, parentValue);
|
|
3415
4026
|
// The shared PK is ReadOnly, so SetLocal above consumed its
|
|
3416
4027
|
// one-time write (_NeverSet -> false). Restore _NeverSet so the
|
|
3417
4028
|
// child's OWN mirror can be re-written if the shared key is set
|
|
@@ -3516,6 +4127,42 @@ export class BaseEntity {
|
|
|
3516
4127
|
ClearRestoreContext() {
|
|
3517
4128
|
this._restoreContext = null;
|
|
3518
4129
|
}
|
|
4130
|
+
/**
|
|
4131
|
+
* Returns the active clone context for the next save, if any.
|
|
4132
|
+
*
|
|
4133
|
+
* Read by the data provider when generating the RecordChange SQL: when
|
|
4134
|
+
* non-null, the resulting RecordChange row is written with
|
|
4135
|
+
* `Source='Clone'` and `ChangeContext` populated with structured JSON provenance.
|
|
4136
|
+
* Returns null for ordinary saves.
|
|
4137
|
+
*/
|
|
4138
|
+
get CloneContext() {
|
|
4139
|
+
return this._cloneContext;
|
|
4140
|
+
}
|
|
4141
|
+
/**
|
|
4142
|
+
* Marks the next Save() as part of a record clone operation.
|
|
4143
|
+
*
|
|
4144
|
+
* The provider will write a new RecordChange entry with `Source='Clone'`
|
|
4145
|
+
* and `ChangeContext` populated with structured JSON provenance.
|
|
4146
|
+
*
|
|
4147
|
+
* The context persists on the entity until either (a) overwritten by a
|
|
4148
|
+
* subsequent SetCloneContext() call or (b) explicitly cleared via
|
|
4149
|
+
* ClearCloneContext(). It is NOT auto-cleared inside Save() because
|
|
4150
|
+
* TransactionGroup execution is deferred.
|
|
4151
|
+
*
|
|
4152
|
+
* @param context Structured clone context. Throws if missing required fields.
|
|
4153
|
+
*/
|
|
4154
|
+
SetCloneContext(context) {
|
|
4155
|
+
if (!context || !context.CloneLogID || !context.SourceRecordID || !context.RootEntityName) {
|
|
4156
|
+
throw new Error('BaseEntity.SetCloneContext: context is required with CloneLogID, SourceRecordID, and RootEntityName');
|
|
4157
|
+
}
|
|
4158
|
+
this._cloneContext = context;
|
|
4159
|
+
}
|
|
4160
|
+
/**
|
|
4161
|
+
* Clears any pending clone context. Safe to call when no context is set.
|
|
4162
|
+
*/
|
|
4163
|
+
ClearCloneContext() {
|
|
4164
|
+
this._cloneContext = null;
|
|
4165
|
+
}
|
|
3519
4166
|
/**
|
|
3520
4167
|
* Saves the current state of the object to the database. Uses the active provider to handle the actual saving of the record.
|
|
3521
4168
|
* If the record is new, it will be created, if it already exists, it will be updated.
|
|
@@ -3563,7 +4210,7 @@ export class BaseEntity {
|
|
|
3563
4210
|
// Root.Save() would wait on the first _pendingSave$ (which is itself waiting
|
|
3564
4211
|
// for the leaf chain), creating a circular wait that hangs forever.
|
|
3565
4212
|
if (options?.IsParentEntitySave) {
|
|
3566
|
-
return this.
|
|
4213
|
+
return this._innerSave(options);
|
|
3567
4214
|
}
|
|
3568
4215
|
// If a save is already in progress, return its promise. This check MUST run before graph
|
|
3569
4216
|
// routing: a composite save is still a save, and two concurrent Save() calls on one record
|
|
@@ -3604,7 +4251,7 @@ export class BaseEntity {
|
|
|
3604
4251
|
// Create a new observable that debounces duplicative calls, and executes the save.
|
|
3605
4252
|
this._pendingSave$ = of(options).pipe(
|
|
3606
4253
|
// Execute the actual save logic.
|
|
3607
|
-
switchMap(opts => from(this.
|
|
4254
|
+
switchMap(opts => from(this._innerSave(opts))),
|
|
3608
4255
|
// When the save completes (whether successfully or not), clear the pending save observable.
|
|
3609
4256
|
finalize(() => { this._pendingSave$ = null; }),
|
|
3610
4257
|
// Ensure that all subscribers get the same result.
|
|
@@ -3617,10 +4264,14 @@ export class BaseEntity {
|
|
|
3617
4264
|
* @param options
|
|
3618
4265
|
* @returns
|
|
3619
4266
|
*/
|
|
3620
|
-
async
|
|
4267
|
+
async _innerSave(options) {
|
|
3621
4268
|
const currentResultCount = this.ResultHistory.length;
|
|
3622
4269
|
const newResult = new BaseEntityResult();
|
|
3623
4270
|
newResult.StartedAt = new Date();
|
|
4271
|
+
// The unit of work this save runs inside, if any (see UnitOfWork), and this save's own part
|
|
4272
|
+
// in one, taken only when this entity starts an IS-A chain save whose writes a failure undoes.
|
|
4273
|
+
const enclosing = this._unitOfWork;
|
|
4274
|
+
let part = null;
|
|
3624
4275
|
try {
|
|
3625
4276
|
const initialDirtyState = this.Dirty; // save this because parent entity save cycle, if any, will clear their dirty flags
|
|
3626
4277
|
const _options = options ? options : new EntitySaveOptions();
|
|
@@ -3638,7 +4289,20 @@ export class BaseEntity {
|
|
|
3638
4289
|
// save — this joins it as a savepoint rather than starting a second physical
|
|
3639
4290
|
// transaction. Before 6.2 this path called BeginISATransaction(), which was blind to
|
|
3640
4291
|
// any existing transaction and produced torn writes; see EntityTransactionScope.
|
|
3641
|
-
await this.beginEntityTransactionScope(isISAInitiator);
|
|
4292
|
+
const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
|
|
4293
|
+
// Each level of the chain is finalized as saved and clean when its own write returns,
|
|
4294
|
+
// before the chain commits. A failure that undoes those writes has to put the chain
|
|
4295
|
+
// back in memory too, so the chain save is a unit of work, captured before the parents
|
|
4296
|
+
// save. The writes are undone when this save holds a scope, and on a provider without
|
|
4297
|
+
// entity transactions, which doesn't talk to a database directly: there each parent's
|
|
4298
|
+
// save is recorded in memory and the leaf's one write carries the chain
|
|
4299
|
+
// (GraphQLDataProvider). Anywhere else a level may really have written (in a
|
|
4300
|
+
// TransactionGroup, a level outside the group does), and marking it unsaved would make
|
|
4301
|
+
// the retry insert it twice.
|
|
4302
|
+
if (isISAInitiator && !this.TransactionGroup &&
|
|
4303
|
+
(scopeOpened || this.ProviderToUse?.SupportsEntityTransactions !== true)) {
|
|
4304
|
+
part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
|
|
4305
|
+
}
|
|
3642
4306
|
// Save parent chain first (root → branch → immediate parent)
|
|
3643
4307
|
// Parent calls Save() recursively which handles its own parents, permissions, validation
|
|
3644
4308
|
if (this._parentEntity) {
|
|
@@ -3653,8 +4317,9 @@ export class BaseEntity {
|
|
|
3653
4317
|
parentSaveOptions.ISAActiveChildEntityName = this.EntityInfo.Name;
|
|
3654
4318
|
const parentResult = await this._parentEntity.Save(parentSaveOptions); // we know parent entity exists hre
|
|
3655
4319
|
if (!parentResult) {
|
|
3656
|
-
// Parent save failed —
|
|
3657
|
-
|
|
4320
|
+
// Parent save failed — roll back if we started the transaction, and put back
|
|
4321
|
+
// the levels above it that had already saved
|
|
4322
|
+
await this.rollbackChainSave(part);
|
|
3658
4323
|
// RECORD the failure on THIS entity's ResultHistory before returning. Without
|
|
3659
4324
|
// this the caller gets `false` with LatestResult === null and an empty
|
|
3660
4325
|
// ResultHistory, because every result was written to the PARENT object — which
|
|
@@ -3765,7 +4430,7 @@ export class BaseEntity {
|
|
|
3765
4430
|
}
|
|
3766
4431
|
if (valResult.Success) {
|
|
3767
4432
|
// Run registered PreSave hooks (e.g., tenant validation)
|
|
3768
|
-
await this.
|
|
4433
|
+
await this.runPreSaveHooks();
|
|
3769
4434
|
// Optimistic-UI hook: every pre-flight check (Validate, ValidateAsync,
|
|
3770
4435
|
// PreSave hooks) has passed and the DB write is imminent. Fire the optional
|
|
3771
4436
|
// OnValidated callback so a UI can render the now-known-valid change before
|
|
@@ -3793,9 +4458,10 @@ export class BaseEntity {
|
|
|
3793
4458
|
// this scope exists to prevent.
|
|
3794
4459
|
if (result) {
|
|
3795
4460
|
await this.commitEntityTransactionScope();
|
|
4461
|
+
BaseEntity.settleCommit(part);
|
|
3796
4462
|
}
|
|
3797
4463
|
else {
|
|
3798
|
-
await this.
|
|
4464
|
+
await this.rollbackChainSave(part);
|
|
3799
4465
|
}
|
|
3800
4466
|
return result;
|
|
3801
4467
|
}
|
|
@@ -3843,12 +4509,15 @@ export class BaseEntity {
|
|
|
3843
4509
|
// commit, so later "successful" saves were silently non-durable. Committing an
|
|
3844
4510
|
// empty scope writes nothing; it only releases the transaction.
|
|
3845
4511
|
await this.commitEntityTransactionScope();
|
|
4512
|
+
BaseEntity.settleCommit(part);
|
|
3846
4513
|
return true; // nothing to save since we're not dirty
|
|
3847
4514
|
}
|
|
3848
4515
|
}
|
|
3849
4516
|
catch (e) {
|
|
3850
|
-
// Roll back the scope this entity opened, if any
|
|
3851
|
-
|
|
4517
|
+
// Roll back the scope this entity opened, if any (a no-op when it holds none), and put
|
|
4518
|
+
// back what the chain's saves changed in memory, this entity included when the commit
|
|
4519
|
+
// is what threw. Before the result below, which reads IsSaved and the old values.
|
|
4520
|
+
await this.rollbackChainSave(part);
|
|
3852
4521
|
if (currentResultCount === this.ResultHistory.length) {
|
|
3853
4522
|
// this means that NO new results were added to the history anywhere
|
|
3854
4523
|
// so we need to add a new result to the history here
|
|
@@ -3867,7 +4536,7 @@ export class BaseEntity {
|
|
|
3867
4536
|
* Runs all registered PreSave hooks. If any hook returns false or a string
|
|
3868
4537
|
* (error message), the save is rejected by throwing an Error.
|
|
3869
4538
|
*/
|
|
3870
|
-
async
|
|
4539
|
+
async runPreSaveHooks() {
|
|
3871
4540
|
const preSaveHooks = GetDataHooks('PreSave');
|
|
3872
4541
|
for (const hook of preSaveHooks) {
|
|
3873
4542
|
const hookResult = await hook(this, this.ActiveUser);
|
|
@@ -3924,7 +4593,7 @@ export class BaseEntity {
|
|
|
3924
4593
|
if (result)
|
|
3925
4594
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
3926
4595
|
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
3927
|
-
this.
|
|
4596
|
+
this.cacheRecordName();
|
|
3928
4597
|
this.RaiseEvent('save', null, saveSubType);
|
|
3929
4598
|
return true;
|
|
3930
4599
|
}
|
|
@@ -3944,7 +4613,7 @@ export class BaseEntity {
|
|
|
3944
4613
|
* Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
|
|
3945
4614
|
* Called automatically after successful Load(), LoadFromData(), and Save() operations.
|
|
3946
4615
|
*/
|
|
3947
|
-
|
|
4616
|
+
cacheRecordName() {
|
|
3948
4617
|
// Only cache if we have a valid primary key
|
|
3949
4618
|
if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
|
|
3950
4619
|
return;
|
|
@@ -4235,9 +4904,12 @@ export class BaseEntity {
|
|
|
4235
4904
|
if (!this.IsSaved) {
|
|
4236
4905
|
this.init(); // wipe out current data if we're loading on top of existing record
|
|
4237
4906
|
}
|
|
4238
|
-
const data = await this.
|
|
4907
|
+
const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
|
|
4239
4908
|
if (!data) {
|
|
4240
|
-
|
|
4909
|
+
// A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
|
|
4910
|
+
if (!this._subtypeHintProbe) {
|
|
4911
|
+
BaseEntity.logLoadFoundNoRow(this.EntityInfo.Name, CompositeKey);
|
|
4912
|
+
}
|
|
4241
4913
|
return false; // no data loaded, return false
|
|
4242
4914
|
}
|
|
4243
4915
|
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
@@ -4273,7 +4945,7 @@ export class BaseEntity {
|
|
|
4273
4945
|
this._everSaved = true; // Mark as saved since we loaded from database
|
|
4274
4946
|
this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
|
|
4275
4947
|
// Cache the record name for faster lookups
|
|
4276
|
-
this.
|
|
4948
|
+
this.cacheRecordName();
|
|
4277
4949
|
// IS-A: discover child entity records after load completes.
|
|
4278
4950
|
// This populates _childEntity so Save/Delete can delegate to the leaf.
|
|
4279
4951
|
// Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
|
|
@@ -4296,6 +4968,27 @@ export class BaseEntity {
|
|
|
4296
4968
|
this._isLoading = false;
|
|
4297
4969
|
}
|
|
4298
4970
|
}
|
|
4971
|
+
/**
|
|
4972
|
+
* The provider's read of the row {@link InnerLoad} loads. While this record is a subtype-hint
|
|
4973
|
+
* probe, a throw from the read is recorded on the probe before it propagates, so the parent
|
|
4974
|
+
* that asked can tell it apart from a failure after the row loaded.
|
|
4975
|
+
*/
|
|
4976
|
+
async readRowForLoad(key, relationshipsToLoad) {
|
|
4977
|
+
const user = this.ActiveUser;
|
|
4978
|
+
try {
|
|
4979
|
+
return await this.ProviderToUse.Load(this, key, relationshipsToLoad, user);
|
|
4980
|
+
}
|
|
4981
|
+
catch (e) {
|
|
4982
|
+
if (this._subtypeHintProbe) {
|
|
4983
|
+
this._subtypeHintProbe.RowReadFailed = true;
|
|
4984
|
+
}
|
|
4985
|
+
throw e;
|
|
4986
|
+
}
|
|
4987
|
+
}
|
|
4988
|
+
/** The error {@link InnerLoad} logs when the provider finds no row for the key. */
|
|
4989
|
+
static logLoadFoundNoRow(entityName, key) {
|
|
4990
|
+
LogError(`Error in BaseEntity.Load(${entityName}, Key: ${key.ToString()}`);
|
|
4991
|
+
}
|
|
4299
4992
|
/**
|
|
4300
4993
|
* Re-fetches the current record from the database using its existing primary key, replacing all in-memory field values
|
|
4301
4994
|
* with the latest data from the database. This is useful when you know (or suspect) the record has been modified externally
|
|
@@ -4383,7 +5076,7 @@ export class BaseEntity {
|
|
|
4383
5076
|
const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
|
|
4384
5077
|
const canTakeFastPath = isPlainObject &&
|
|
4385
5078
|
!this._fieldsHydrated &&
|
|
4386
|
-
this.
|
|
5079
|
+
this._fields.length === 0 &&
|
|
4387
5080
|
!this._parentEntity &&
|
|
4388
5081
|
!this.EntityInfo?.IsParentType;
|
|
4389
5082
|
if (canTakeFastPath) {
|
|
@@ -4439,7 +5132,7 @@ export class BaseEntity {
|
|
|
4439
5132
|
}
|
|
4440
5133
|
// Cache the record name for faster lookups if successfully loaded
|
|
4441
5134
|
if (this._recordLoaded) {
|
|
4442
|
-
this.
|
|
5135
|
+
this.cacheRecordName();
|
|
4443
5136
|
// IS-A: discover child entity records after data load completes.
|
|
4444
5137
|
await this.InitializeChildEntity();
|
|
4445
5138
|
}
|
|
@@ -4614,6 +5307,12 @@ export class BaseEntity {
|
|
|
4614
5307
|
return this.deleteGraph(plan, options);
|
|
4615
5308
|
}
|
|
4616
5309
|
}
|
|
5310
|
+
// IS-A parent chain deletes bypass the debounce, as parent chain saves do: the leaf's
|
|
5311
|
+
// call back up the chain would otherwise wait on the pending delete that handed the
|
|
5312
|
+
// delete to the leaf, and Delete() would never return (MJ#4850).
|
|
5313
|
+
if (options?.IsParentEntityDelete) {
|
|
5314
|
+
return this._innerDelete(options);
|
|
5315
|
+
}
|
|
4617
5316
|
// If a delete is already in progress, return its promise.
|
|
4618
5317
|
if (this._pendingDelete$) {
|
|
4619
5318
|
return firstValueFrom(this._pendingDelete$);
|
|
@@ -4621,7 +5320,7 @@ export class BaseEntity {
|
|
|
4621
5320
|
// Create a new observable that debounces duplicative calls, and executes the delete.
|
|
4622
5321
|
this._pendingDelete$ = of(options).pipe(
|
|
4623
5322
|
// Execute the actual delete logic.
|
|
4624
|
-
switchMap(opts => from(this.
|
|
5323
|
+
switchMap(opts => from(this._innerDelete(opts))),
|
|
4625
5324
|
// When the delete completes (whether successfully or not), clear the pending delete observable.
|
|
4626
5325
|
finalize(() => { this._pendingDelete$ = null; }),
|
|
4627
5326
|
// Ensure that all subscribers get the same result.
|
|
@@ -4634,10 +5333,17 @@ export class BaseEntity {
|
|
|
4634
5333
|
* @param options
|
|
4635
5334
|
* @returns
|
|
4636
5335
|
*/
|
|
4637
|
-
async
|
|
5336
|
+
async _innerDelete(options) {
|
|
4638
5337
|
const currentResultCount = this.ResultHistory.length;
|
|
4639
5338
|
const newResult = new BaseEntityResult();
|
|
4640
5339
|
newResult.StartedAt = new Date();
|
|
5340
|
+
// The unit of work this delete runs inside, if any (see UnitOfWork), and this delete's own
|
|
5341
|
+
// part in one, taken when it opens a scope for an IS-A chain.
|
|
5342
|
+
const enclosing = this._unitOfWork;
|
|
5343
|
+
let part = null;
|
|
5344
|
+
// Set once this record's own row is deleted. From then on its history holds the provider's
|
|
5345
|
+
// entry for that delete, which doesn't report a failure later in the chain.
|
|
5346
|
+
let ownRowDeleted = false;
|
|
4641
5347
|
try {
|
|
4642
5348
|
const _options = options ? options : new EntityDeleteOptions();
|
|
4643
5349
|
// IS-A leaf delegation: if this entity has a child entity and we are NOT
|
|
@@ -4684,7 +5390,15 @@ export class BaseEntity {
|
|
|
4684
5390
|
// Open (or join) a transaction scope for the parent chain — see the matching
|
|
4685
5391
|
// comment in _InnerSave and EntityTransactionScope for why this is provider-
|
|
4686
5392
|
// arbitrated rather than IS-A-specific.
|
|
4687
|
-
await this.beginEntityTransactionScope(isISAInitiator);
|
|
5393
|
+
const scopeOpened = await this.beginEntityTransactionScope(isISAInitiator);
|
|
5394
|
+
// A chain delete that holds a scope is a unit of work. Each parent is deleted inside
|
|
5395
|
+
// it and isn't reset when its delete returns: the unit resets the chain once it
|
|
5396
|
+
// commits, so a failure that rolls it back leaves every level as it was. Without a
|
|
5397
|
+
// scope each delete stands as it runs and resets its record at once, as before.
|
|
5398
|
+
if (scopeOpened) {
|
|
5399
|
+
part = BaseEntity.beginUnitOfWork(enclosing, BaseEntity.captureChains([this]));
|
|
5400
|
+
}
|
|
5401
|
+
const unit = part?.unit ?? enclosing;
|
|
4688
5402
|
this.CheckPermissions(EntityPermissionType.Delete, true); // this will throw an error and exit out if we don't have permission
|
|
4689
5403
|
// Raise delete_started event before the actual delete operation begins
|
|
4690
5404
|
this.RaiseEvent('delete_started', null);
|
|
@@ -4699,6 +5413,7 @@ export class BaseEntity {
|
|
|
4699
5413
|
});
|
|
4700
5414
|
// Delete OWN row first (FK constraint: child must be deleted before parent)
|
|
4701
5415
|
if (await this.ProviderToUse.Delete(this, _options, this.ActiveUser)) {
|
|
5416
|
+
ownRowDeleted = true;
|
|
4702
5417
|
// IS-A: after own delete succeeds, cascade to parent chain
|
|
4703
5418
|
if (hasParentChain) {
|
|
4704
5419
|
// For overlapping subtypes, check if other children still reference
|
|
@@ -4710,38 +5425,40 @@ export class BaseEntity {
|
|
|
4710
5425
|
parentDeleteOptions.SkipEntityActions = _options.SkipEntityActions;
|
|
4711
5426
|
parentDeleteOptions.ReplayOnly = _options.ReplayOnly;
|
|
4712
5427
|
parentDeleteOptions.IsParentEntityDelete = true;
|
|
4713
|
-
|
|
5428
|
+
// The parent's delete belongs to this chain's unit of work, so it doesn't
|
|
5429
|
+
// reset the parent when it returns: the unit does, once it commits.
|
|
5430
|
+
const parentResult = await BaseEntity.inUnitOfWork(this._parentEntity, unit, parent => parent.Delete(parentDeleteOptions));
|
|
4714
5431
|
if (!parentResult) {
|
|
4715
5432
|
// Parent delete failed — rollback if we started the transaction
|
|
4716
5433
|
await this.rollbackEntityTransactionScope();
|
|
5434
|
+
BaseEntity.settleRollback(part);
|
|
4717
5435
|
// RECORD the failure on THIS entity's ResultHistory before returning —
|
|
4718
|
-
// symmetric with the parent-SAVE-failure path in _InnerSave.
|
|
4719
|
-
// the
|
|
4720
|
-
//
|
|
4721
|
-
//
|
|
4722
|
-
//
|
|
4723
|
-
//
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
}
|
|
5436
|
+
// symmetric with the parent-SAVE-failure path in _InnerSave. Every result
|
|
5437
|
+
// for the parent's failure was written to the PARENT object, which callers
|
|
5438
|
+
// have no reference to (`_parentEntity` is private). THIS entity's own row
|
|
5439
|
+
// was already deleted successfully above, and the only entry its history
|
|
5440
|
+
// gained is the provider's for that delete, which the rollback just undid.
|
|
5441
|
+
// So the failure is recorded whatever that entry says: skipping it when
|
|
5442
|
+
// the history had grown left the caller holding the provider's entry.
|
|
5443
|
+
const parentLatest = this._parentEntity.LatestResult;
|
|
5444
|
+
const parentErrors = parentLatest?.Errors ?? [];
|
|
5445
|
+
// A failed parent commonly reports its detail ONLY in Errors, so fall
|
|
5446
|
+
// back to the error text rather than a message that says nothing.
|
|
5447
|
+
const detail = parentLatest?.Message ||
|
|
5448
|
+
parentErrors.map(e => e?.Message ?? String(e)).filter(Boolean).join('; ') ||
|
|
5449
|
+
'no error detail was reported by the parent';
|
|
5450
|
+
newResult.Success = false;
|
|
5451
|
+
newResult.Type = 'delete';
|
|
5452
|
+
newResult.Message =
|
|
5453
|
+
`Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
|
|
5454
|
+
// Surface the parent's field-level errors so the caller can act on them.
|
|
5455
|
+
newResult.Errors = parentErrors;
|
|
5456
|
+
// When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
|
|
5457
|
+
// them — say so, or CompleteMessage repeats every one.
|
|
5458
|
+
newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
|
|
5459
|
+
newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
|
|
5460
|
+
newResult.EndedAt = new Date();
|
|
5461
|
+
this.RegisterResultHistoryEntry(newResult);
|
|
4745
5462
|
return false;
|
|
4746
5463
|
}
|
|
4747
5464
|
}
|
|
@@ -4752,8 +5469,16 @@ export class BaseEntity {
|
|
|
4752
5469
|
// NOT part of a transaction - raise event immediately
|
|
4753
5470
|
// record deleted correctly
|
|
4754
5471
|
this.RaiseEvent('delete', { OldValues: oldVals });
|
|
4755
|
-
|
|
4756
|
-
|
|
5472
|
+
if (unit) {
|
|
5473
|
+
// Deleted inside a unit of work, which resets the record once it commits:
|
|
5474
|
+
// now, when this delete owns the unit and has just committed it.
|
|
5475
|
+
unit.DeletedRecords.push(this);
|
|
5476
|
+
BaseEntity.settleCommit(part);
|
|
5477
|
+
}
|
|
5478
|
+
else {
|
|
5479
|
+
// wipe out the current data to flush out the DIRTY flags by calling NewRecord()
|
|
5480
|
+
this.NewRecord(); // will trigger a new record event here too
|
|
5481
|
+
}
|
|
4757
5482
|
}
|
|
4758
5483
|
else {
|
|
4759
5484
|
// part of a transaction, wait for the transaction to submit successfully and then
|
|
@@ -4796,6 +5521,7 @@ export class BaseEntity {
|
|
|
4796
5521
|
// every subsequent "committed" write on this provider silently never commits.
|
|
4797
5522
|
// (Also: don't wipe out the entity like we do when the Delete() worked.)
|
|
4798
5523
|
await this.rollbackEntityTransactionScope();
|
|
5524
|
+
BaseEntity.settleRollback(part);
|
|
4799
5525
|
return false;
|
|
4800
5526
|
}
|
|
4801
5527
|
}
|
|
@@ -4803,9 +5529,11 @@ export class BaseEntity {
|
|
|
4803
5529
|
catch (e) {
|
|
4804
5530
|
// Roll back the scope this entity opened, if any. No-op when it holds none.
|
|
4805
5531
|
await this.rollbackEntityTransactionScope();
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
5532
|
+
BaseEntity.settleRollback(part);
|
|
5533
|
+
// Record the failure when nothing else did. Once this record's own row was deleted, its
|
|
5534
|
+
// history holds the provider's entry for that delete, which doesn't report this failure
|
|
5535
|
+
// (a failed commit, most often), so the failure is recorded then too.
|
|
5536
|
+
if (ownRowDeleted || currentResultCount === this.ResultHistory.length) {
|
|
4809
5537
|
newResult.Success = false;
|
|
4810
5538
|
newResult.Type = 'delete';
|
|
4811
5539
|
newResult.Message = e.message || null;
|
|
@@ -4892,13 +5620,13 @@ export class BaseEntity {
|
|
|
4892
5620
|
if (!entityInfo) {
|
|
4893
5621
|
return { LeafEntityName: entityName, IsLeaf: true };
|
|
4894
5622
|
}
|
|
4895
|
-
return BaseEntity.
|
|
5623
|
+
return BaseEntity.resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
|
|
4896
5624
|
}
|
|
4897
5625
|
/**
|
|
4898
5626
|
* Internal recursive helper for leaf entity resolution.
|
|
4899
5627
|
* Walks down the child hierarchy until no more children are found.
|
|
4900
5628
|
*/
|
|
4901
|
-
static async
|
|
5629
|
+
static async resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
|
|
4902
5630
|
const childEntities = entityInfo.ChildEntities;
|
|
4903
5631
|
if (childEntities.length === 0) {
|
|
4904
5632
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
@@ -4918,7 +5646,7 @@ export class BaseEntity {
|
|
|
4918
5646
|
}, contextUser);
|
|
4919
5647
|
if (result?.Success && result.Results?.length > 0) {
|
|
4920
5648
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
4921
|
-
return BaseEntity.
|
|
5649
|
+
return BaseEntity.resolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
|
|
4922
5650
|
}
|
|
4923
5651
|
}
|
|
4924
5652
|
// No child found — this entity IS the leaf
|