@memberjunction/core 6.1.4 → 6.2.0-edge.1
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/IEntitySubtypeSelectorConfig.d.ts +14 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -1
- package/dist/generic/JSONType-interfaces/IQueryConfiguration.d.ts +60 -0
- package/dist/generic/JSONType-interfaces/IQueryConfiguration.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IQueryConfiguration.js +6 -0
- package/dist/generic/JSONType-interfaces/IQueryConfiguration.js.map +1 -0
- 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 +16 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +61 -20
- 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 +232 -18
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +581 -113
- 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 +7 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +19 -4
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +209 -38
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +362 -77
- 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 +35 -1
- 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 +35 -7
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +87 -27
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +47 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +76 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +6 -0
- package/dist/generic/queryInfoInterfaces.d.ts.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/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/generic/util.d.ts +22 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +33 -0
- package/dist/generic/util.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 } 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,25 @@ 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
555
|
* Used by graph rollback so a retried save still sees the pre-attempt dirty set.
|
|
544
556
|
*/
|
|
545
557
|
RestoreOldValue(value) {
|
|
546
|
-
this.
|
|
558
|
+
this._oldValue = value;
|
|
547
559
|
}
|
|
548
560
|
/**
|
|
549
561
|
* @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
|
|
@@ -563,7 +575,7 @@ let EntityField = class EntityField {
|
|
|
563
575
|
* Returns the old value of the field. This is the value that was set when the field was last loaded from the database.
|
|
564
576
|
*/
|
|
565
577
|
get OldValue() {
|
|
566
|
-
return this.
|
|
578
|
+
return this._oldValue;
|
|
567
579
|
}
|
|
568
580
|
};
|
|
569
581
|
EntityField = EntityField_1 = __decorate([
|
|
@@ -714,6 +726,10 @@ export class BaseEntityResult {
|
|
|
714
726
|
*/
|
|
715
727
|
export class BaseEntityEvent {
|
|
716
728
|
}
|
|
729
|
+
/** Entity names compare the way metadata lookups do: trimmed and case-insensitive. */
|
|
730
|
+
function sameEntityName(a, b) {
|
|
731
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
732
|
+
}
|
|
717
733
|
export class BaseEntity {
|
|
718
734
|
/**
|
|
719
735
|
* Maximum number of `BaseEntityResult` entries retained in `_resultHistory` per entity
|
|
@@ -791,7 +807,7 @@ export class BaseEntity {
|
|
|
791
807
|
* `BaseEntity` are reserved for paths that have already triggered hydration or paths that
|
|
792
808
|
* deliberately want to inspect hydration state (e.g., `Dirty` getter).
|
|
793
809
|
*/
|
|
794
|
-
this.
|
|
810
|
+
this._fields = [];
|
|
795
811
|
/**
|
|
796
812
|
* Tracks whether `_Fields` has been populated. `false` while in "raw mode" (data accessible
|
|
797
813
|
* via `_raw` but no EntityField instances built yet); flips to `true` after the first call to
|
|
@@ -956,6 +972,17 @@ export class BaseEntity {
|
|
|
956
972
|
* loaded record. Reset to false on NewRecord() and init().
|
|
957
973
|
*/
|
|
958
974
|
this._childEntityDiscoveryDone = false;
|
|
975
|
+
/**
|
|
976
|
+
* Set only while this record's parent loads it because the parent's subtype rule named it
|
|
977
|
+
* (see {@link tryLinkHintedChildEntity}); read by {@link InnerLoad}.
|
|
978
|
+
*/
|
|
979
|
+
this._subtypeHintProbe = null;
|
|
980
|
+
/**
|
|
981
|
+
* Set only while {@link AttachToParent} loads this record as the parent it promotes. Child
|
|
982
|
+
* discovery then doesn't ask the subtype rule: the child row being added doesn't exist yet, so
|
|
983
|
+
* a hint would name it and miss.
|
|
984
|
+
*/
|
|
985
|
+
this._skipSubtypeLoadHint = false;
|
|
959
986
|
/**
|
|
960
987
|
* For **overlapping** subtype parents (AllowMultipleSubtypes = true), stores
|
|
961
988
|
* the list of child entity type names that have records for this PK.
|
|
@@ -1037,7 +1064,7 @@ export class BaseEntity {
|
|
|
1037
1064
|
*/
|
|
1038
1065
|
this._skipEmbeddings = false;
|
|
1039
1066
|
this._eventSubject = new Subject();
|
|
1040
|
-
this.
|
|
1067
|
+
this._entityInfo = Entity;
|
|
1041
1068
|
EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
|
|
1042
1069
|
this._provider = Provider;
|
|
1043
1070
|
this.init();
|
|
@@ -1165,7 +1192,16 @@ export class BaseEntity {
|
|
|
1165
1192
|
name: pk.Name,
|
|
1166
1193
|
value: this._parentEntity.Get(pk.Name),
|
|
1167
1194
|
}));
|
|
1168
|
-
|
|
1195
|
+
// The parent's subtype rule isn't asked for a load hint here: it would name the child this
|
|
1196
|
+
// promotion is adding, whose row doesn't exist yet, and the miss would cost a round trip.
|
|
1197
|
+
this._parentEntity._skipSubtypeLoadHint = true;
|
|
1198
|
+
let loaded;
|
|
1199
|
+
try {
|
|
1200
|
+
loaded = await this._parentEntity.InnerLoad(parentKey);
|
|
1201
|
+
}
|
|
1202
|
+
finally {
|
|
1203
|
+
this._parentEntity._skipSubtypeLoadHint = false;
|
|
1204
|
+
}
|
|
1169
1205
|
if (!loaded) {
|
|
1170
1206
|
// Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
|
|
1171
1207
|
// the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
|
|
@@ -1197,7 +1233,7 @@ export class BaseEntity {
|
|
|
1197
1233
|
if (!this.GetFieldByName(name)) {
|
|
1198
1234
|
return;
|
|
1199
1235
|
}
|
|
1200
|
-
this.
|
|
1236
|
+
this.setLocal(name, value);
|
|
1201
1237
|
this.GetFieldByName(name)?.ResetNeverSetFlag();
|
|
1202
1238
|
}
|
|
1203
1239
|
/**
|
|
@@ -1211,6 +1247,10 @@ export class BaseEntity {
|
|
|
1211
1247
|
* This ensures that Save/Delete operations always delegate to the leaf entity,
|
|
1212
1248
|
* running the full validation and event chain at every level.
|
|
1213
1249
|
*
|
|
1250
|
+
* For a disjoint parent, the entity's subtype rule is asked first (see
|
|
1251
|
+
* {@link resolveSubtypeLoadHint}); when it names a child, that child's load, which
|
|
1252
|
+
* happens anyway, checks the answer, and the discovery query runs only on a miss.
|
|
1253
|
+
*
|
|
1214
1254
|
* Must be called AFTER a record is loaded (PK must be available).
|
|
1215
1255
|
* Skipped for entities that are not parent types or have already been discovered.
|
|
1216
1256
|
*/
|
|
@@ -1225,12 +1265,48 @@ export class BaseEntity {
|
|
|
1225
1265
|
await this.discoverOverlappingChildren();
|
|
1226
1266
|
}
|
|
1227
1267
|
else {
|
|
1228
|
-
// Disjoint: discover single child
|
|
1229
|
-
|
|
1230
|
-
|
|
1268
|
+
// Disjoint: discover the single child and auto-chain it
|
|
1269
|
+
await this.initializeDisjointChildEntity();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Finds and links the one child of a loaded disjoint parent.
|
|
1274
|
+
*
|
|
1275
|
+
* Without a hint this is one discovery query plus the child's load. With a hint from the
|
|
1276
|
+
* entity's subtype rule, the child's load checks the hint, and the discovery query runs
|
|
1277
|
+
* only when that load comes back empty or wasn't tried.
|
|
1278
|
+
*
|
|
1279
|
+
* For well-formed data the outcome is the one the discovery query alone would give: a hint
|
|
1280
|
+
* changes the number of round trips, not which child is linked or whether the load succeeds.
|
|
1281
|
+
* Three benign exceptions:
|
|
1282
|
+
* - a failure reading the hinted child's row falls back to the discovery query, so a transient
|
|
1283
|
+
* failure now recovers (a lasting one reads the row twice, then fails as before);
|
|
1284
|
+
* - when the data breaks the disjoint rule with two child rows, the discovery query's
|
|
1285
|
+
* unordered `UNION ALL` links either one, where a hint links the one the rule names;
|
|
1286
|
+
* - a provider without `FindISAChildEntity` links nothing without a hint, and the hinted child
|
|
1287
|
+
* with one.
|
|
1288
|
+
*/
|
|
1289
|
+
async initializeDisjointChildEntity() {
|
|
1290
|
+
const hintedName = await this.resolveSubtypeLoadHint();
|
|
1291
|
+
const hintOutcome = hintedName
|
|
1292
|
+
? await this.tryLinkHintedChildEntity(hintedName)
|
|
1293
|
+
: 'NotTried';
|
|
1294
|
+
if (hintOutcome === 'Linked')
|
|
1295
|
+
return;
|
|
1296
|
+
const childEntityName = await this.discoverChildEntityName();
|
|
1297
|
+
if (hintedName && hintOutcome === 'NotFound') {
|
|
1298
|
+
if (childEntityName && sameEntityName(childEntityName, hintedName)) {
|
|
1299
|
+
// The row exists but its load came back empty (for example, a row filter hides it).
|
|
1300
|
+
// Loading it again would too, and the discovery-only path unlinks it the same way,
|
|
1301
|
+
// after logging the empty load that the hinted attempt kept quiet.
|
|
1302
|
+
BaseEntity.logLoadFoundNoRow(hintedName, this.PrimaryKey);
|
|
1231
1303
|
return;
|
|
1232
|
-
|
|
1304
|
+
}
|
|
1305
|
+
this.reportSubtypeLoadHintMiss(hintedName, childEntityName);
|
|
1233
1306
|
}
|
|
1307
|
+
if (!childEntityName)
|
|
1308
|
+
return;
|
|
1309
|
+
await this.createAndLinkChildEntity(childEntityName);
|
|
1234
1310
|
}
|
|
1235
1311
|
/**
|
|
1236
1312
|
* Uses the provider's FindISAChildEntity method (single UNION ALL query) to
|
|
@@ -1263,12 +1339,29 @@ export class BaseEntity {
|
|
|
1263
1339
|
* Creates the child entity instance, wires up the shared instance chain
|
|
1264
1340
|
* (child._parentEntity = this, this._childEntity = child), loads the
|
|
1265
1341
|
* child's data, and recursively discovers further children.
|
|
1342
|
+
*
|
|
1343
|
+
* @returns true when the child's row loaded and the child is linked; false when the load
|
|
1344
|
+
* came back empty, in which case the link is undone.
|
|
1266
1345
|
*/
|
|
1267
1346
|
async createAndLinkChildEntity(childEntityName) {
|
|
1268
|
-
|
|
1269
|
-
|
|
1347
|
+
const childEntity = await this.createChildEntityObject(childEntityName);
|
|
1348
|
+
return this.linkAndLoadChildEntity(childEntity);
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Creates an unlinked child entity instance through this entity's provider, so the child
|
|
1352
|
+
* shares the same connection (correct in multi-provider scenarios — never the global default).
|
|
1353
|
+
*/
|
|
1354
|
+
async createChildEntityObject(childEntityName) {
|
|
1270
1355
|
const childProvider = this.ProviderToUse;
|
|
1271
|
-
|
|
1356
|
+
return childProvider.GetEntityObject(childEntityName, this._contextCurrentUser);
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Links `childEntity` into this entity's chain and loads its row under the shared key.
|
|
1360
|
+
*
|
|
1361
|
+
* @returns true when the row loaded and the child is linked; false when the load came back
|
|
1362
|
+
* empty, in which case the link is undone.
|
|
1363
|
+
*/
|
|
1364
|
+
async linkAndLoadChildEntity(childEntity) {
|
|
1272
1365
|
// Wire up the shared instance chain: child's parent IS this entity (same object)
|
|
1273
1366
|
// We need to replace the child's auto-initialized parent with our existing instance
|
|
1274
1367
|
this.replaceChildParentChain(childEntity);
|
|
@@ -1280,20 +1373,245 @@ export class BaseEntity {
|
|
|
1280
1373
|
if (!loaded) {
|
|
1281
1374
|
// Load failed — clean up the link
|
|
1282
1375
|
this._childEntity = null;
|
|
1283
|
-
return;
|
|
1376
|
+
return false;
|
|
1284
1377
|
}
|
|
1285
1378
|
// Re-apply any pending modifications so child hydration does not overwrite unsaved in-memory edits
|
|
1286
1379
|
this.restoreChainDirtyState(dirtySnapshots);
|
|
1287
1380
|
// Recursively discover grandchildren (child may also be a parent type)
|
|
1288
1381
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
1289
1382
|
await childEntity.InitializeChildEntity();
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Tries to link the child that the subtype rule named, by loading its row directly. That
|
|
1387
|
+
* load is the check on the hint.
|
|
1388
|
+
*
|
|
1389
|
+
* The hint isn't tried when the child object can't be created or the user can't read that
|
|
1390
|
+
* entity: the discovery-only path decides those cases, so a hint can neither expose a child
|
|
1391
|
+
* nor fail a load that works without it. A failure READING the hinted row is logged and
|
|
1392
|
+
* treated the same way: the discovery path runs, and reads the row again only if the discovery
|
|
1393
|
+
* query finds it. A failure after the row was read (the child's hydration, its eager
|
|
1394
|
+
* companions, its own child discovery) propagates, as it does on the discovery-only path.
|
|
1395
|
+
*
|
|
1396
|
+
* @returns `'Linked'` when the hinted child loaded and is linked, `'NotFound'` when its load
|
|
1397
|
+
* came back empty, and `'NotTried'` when it wasn't loaded or reading its row failed.
|
|
1398
|
+
*/
|
|
1399
|
+
async tryLinkHintedChildEntity(hintedName) {
|
|
1400
|
+
const childEntity = await this.createReadableHintedChild(hintedName);
|
|
1401
|
+
if (!childEntity) {
|
|
1402
|
+
return 'NotTried';
|
|
1403
|
+
}
|
|
1404
|
+
const probe = { RowReadFailed: false };
|
|
1405
|
+
childEntity._subtypeHintProbe = probe;
|
|
1406
|
+
try {
|
|
1407
|
+
return (await this.linkAndLoadChildEntity(childEntity)) ? 'Linked' : 'NotFound';
|
|
1408
|
+
}
|
|
1409
|
+
catch (e) {
|
|
1410
|
+
if (!probe.RowReadFailed) {
|
|
1411
|
+
throw e;
|
|
1412
|
+
}
|
|
1413
|
+
// Nothing after the row read ran, so the parent chain and its unsaved edits are untouched.
|
|
1414
|
+
this._childEntity = null;
|
|
1415
|
+
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.`);
|
|
1416
|
+
return 'NotTried';
|
|
1417
|
+
}
|
|
1418
|
+
finally {
|
|
1419
|
+
childEntity._subtypeHintProbe = null;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Creates the child object the subtype rule named, or returns null when the user can't read
|
|
1424
|
+
* that entity. A failure to create it is logged and returns null too, so the discovery-only
|
|
1425
|
+
* path decides.
|
|
1426
|
+
*/
|
|
1427
|
+
async createReadableHintedChild(hintedName) {
|
|
1428
|
+
try {
|
|
1429
|
+
const childEntity = await this.createChildEntityObject(hintedName);
|
|
1430
|
+
return childEntity.CheckPermissions(EntityPermissionType.Read, false) ? childEntity : null;
|
|
1431
|
+
}
|
|
1432
|
+
catch (e) {
|
|
1433
|
+
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.`);
|
|
1434
|
+
return null;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Asks the entity's subtype rule which child a LOADED record has, without running a query.
|
|
1439
|
+
*
|
|
1440
|
+
* - A registered {@link EntitySubtypeResolver} owns the rule. It gives a hint only when its
|
|
1441
|
+
* class overrides {@link EntitySubtypeResolver.ResolveLoadHint}. A resolver that doesn't
|
|
1442
|
+
* gives none, and the `SubtypeSelector` isn't consulted in its place: at create time the
|
|
1443
|
+
* resolver overrides the selector, so the selector may not describe the same rule.
|
|
1444
|
+
* - Otherwise a `SubtypeSelector` that sets `UseForLoadedRecords` is walked through rows that
|
|
1445
|
+
* loaded `BaseEngine` caches already hold. A hop that isn't cached gives no hint, not a query.
|
|
1446
|
+
* - An entity with no rule gets no hint. The single-child fallback of
|
|
1447
|
+
* {@link ResolveSubtypeEntityName} is a default for new records, not a rule about existing ones.
|
|
1448
|
+
* - While {@link AttachToParent} loads this record, no rule is asked: the child row being added
|
|
1449
|
+
* doesn't exist yet.
|
|
1450
|
+
*
|
|
1451
|
+
* An answer of "no subtype" also gives no hint: only the discovery query can tell whether an
|
|
1452
|
+
* older record still has a child row.
|
|
1453
|
+
*
|
|
1454
|
+
* @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
|
|
1455
|
+
*/
|
|
1456
|
+
async resolveSubtypeLoadHint() {
|
|
1457
|
+
if (this._skipSubtypeLoadHint) {
|
|
1458
|
+
return null;
|
|
1459
|
+
}
|
|
1460
|
+
const registration = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1461
|
+
if (registration) {
|
|
1462
|
+
const resolver = BaseEntity.loadHintResolverFor(this.EntityInfo.Name, registration);
|
|
1463
|
+
return resolver ? this.askResolverForLoadHint(resolver) : null;
|
|
1464
|
+
}
|
|
1465
|
+
const selector = this.EntityInfo.SubtypeSelectorConfig;
|
|
1466
|
+
const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
|
|
1467
|
+
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* The resolver to ask for load hints for an entity, decided once per registration and cached:
|
|
1471
|
+
* null when the registered class doesn't override {@link EntitySubtypeResolver.ResolveLoadHint},
|
|
1472
|
+
* which is read from the class without constructing it, or when constructing it failed.
|
|
1473
|
+
*/
|
|
1474
|
+
static loadHintResolverFor(entityName, registration) {
|
|
1475
|
+
const key = entityName.trim().toLowerCase();
|
|
1476
|
+
const cached = BaseEntity._subtypeLoadHintResolvers.get(key);
|
|
1477
|
+
if (cached?.Registration === registration) {
|
|
1478
|
+
return cached.Resolver;
|
|
1479
|
+
}
|
|
1480
|
+
const resolver = BaseEntity.overridesResolveLoadHint(registration)
|
|
1481
|
+
? BaseEntity.constructLoadHintResolver(entityName)
|
|
1482
|
+
: null;
|
|
1483
|
+
BaseEntity._subtypeLoadHintResolvers.set(key, { Registration: registration, Resolver: resolver });
|
|
1484
|
+
return resolver;
|
|
1485
|
+
}
|
|
1486
|
+
/** Whether a registered resolver class overrides `ResolveLoadHint`, read from its prototype. */
|
|
1487
|
+
static overridesResolveLoadHint(registration) {
|
|
1488
|
+
const prototype = registration.SubClass?.prototype;
|
|
1489
|
+
const resolveLoadHint = prototype?.ResolveLoadHint;
|
|
1490
|
+
return typeof resolveLoadHint === 'function' && resolveLoadHint !== EntitySubtypeResolver.prototype.ResolveLoadHint;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Constructs the registered resolver once, to ask it for load hints. A failure is logged and
|
|
1494
|
+
* gives null; since the answer is cached, that is once per entity.
|
|
1495
|
+
*/
|
|
1496
|
+
static constructLoadHintResolver(entityName) {
|
|
1497
|
+
try {
|
|
1498
|
+
const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, entityName);
|
|
1499
|
+
if (resolution.Resolved && resolution.Instance) {
|
|
1500
|
+
return resolution.Instance;
|
|
1501
|
+
}
|
|
1502
|
+
LogError(`IS-A load hint: the EntitySubtypeResolver registered for '${entityName}' did not resolve (${resolution.Reason ?? 'no reason given'}); loading its records without a hint.`);
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
LogError(`IS-A load hint: constructing the EntitySubtypeResolver registered for '${entityName}' failed (${BaseEntity.errorText(e)}); loading its records without a hint.`);
|
|
1506
|
+
}
|
|
1507
|
+
return null;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* Asks the resolver for a load hint. A resolver that throws or rejects gives no hint for that
|
|
1511
|
+
* record, and is logged once per entity rather than per record.
|
|
1512
|
+
*/
|
|
1513
|
+
async askResolverForLoadHint(resolver) {
|
|
1514
|
+
try {
|
|
1515
|
+
// A synchronous answer is used as-is, with no extra microtask per record.
|
|
1516
|
+
const raw = resolver.ResolveLoadHint(this);
|
|
1517
|
+
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1518
|
+
return this.declaredChildForLoadHint(candidate, `EntitySubtypeResolver.ResolveLoadHint for '${this.EntityInfo.Name}'`);
|
|
1519
|
+
}
|
|
1520
|
+
catch (e) {
|
|
1521
|
+
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.`));
|
|
1522
|
+
return null;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
|
|
1527
|
+
* this entity's metadata is logged once and gives no hint; the create path throws on it instead.
|
|
1528
|
+
*/
|
|
1529
|
+
async evaluateSelectorForLoadHint(path) {
|
|
1530
|
+
try {
|
|
1531
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
|
|
1532
|
+
return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
|
|
1533
|
+
}
|
|
1534
|
+
catch (e) {
|
|
1535
|
+
BaseEntity.logSubtypeLoadHintOnce(`${this.EntityInfo.Name}|path|${path}`, () => LogError(`IS-A load hint: ${BaseEntity.errorText(e)} Loading '${this.EntityInfo.Name}' records without a hint.`));
|
|
1536
|
+
return null;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Turns a rule's answer into a declared child's canonical entity name. Empty means no subtype,
|
|
1541
|
+
* which gives no hint. A name that isn't a declared IsA child is logged once and gives no hint.
|
|
1542
|
+
*/
|
|
1543
|
+
declaredChildForLoadHint(candidate, source) {
|
|
1544
|
+
const trimmed = candidate?.trim();
|
|
1545
|
+
if (!trimmed) {
|
|
1546
|
+
return null;
|
|
1547
|
+
}
|
|
1548
|
+
const match = this.findDeclaredChild(trimmed);
|
|
1549
|
+
if (!match) {
|
|
1550
|
+
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.`));
|
|
1551
|
+
return null;
|
|
1552
|
+
}
|
|
1553
|
+
return match.Name;
|
|
1554
|
+
}
|
|
1555
|
+
/**
|
|
1556
|
+
* Reports a hint the data didn't bear out: the hinted child's row wasn't there.
|
|
1557
|
+
*
|
|
1558
|
+
* When the discovery query finds no child either, that is a normal state, not an error: core
|
|
1559
|
+
* never creates a subtype row on save, so a typed record can exist without one (saved through
|
|
1560
|
+
* a generic form or an import, or older than its type's subtype). It logs one verbose line per
|
|
1561
|
+
* entity and hinted child. When the discovery query finds a DIFFERENT child, the rule and the
|
|
1562
|
+
* data disagree, and each such load costs an extra round trip until one of them is fixed; that
|
|
1563
|
+
* is logged as an error, once per entity, hinted child and child found.
|
|
1564
|
+
*/
|
|
1565
|
+
reportSubtypeLoadHintMiss(hintedName, discoveredName) {
|
|
1566
|
+
const entityName = this.EntityInfo.Name;
|
|
1567
|
+
if (!discoveredName) {
|
|
1568
|
+
BaseEntity.logSubtypeLoadHintOnce(`${entityName}|no-row|${hintedName}`, () => LogStatusEx({
|
|
1569
|
+
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.`,
|
|
1570
|
+
verboseOnly: true,
|
|
1571
|
+
}));
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
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.`));
|
|
1575
|
+
}
|
|
1576
|
+
/** Keys of load-hint messages already logged, so each is reported once rather than per record. */
|
|
1577
|
+
static { this._loggedSubtypeLoadHintMessages = new Set(); }
|
|
1578
|
+
/** Runs `log` the first time `key` is seen (case-insensitively), and not again until the caches are cleared. */
|
|
1579
|
+
static logSubtypeLoadHintOnce(key, log) {
|
|
1580
|
+
const normalizedKey = key.toLowerCase();
|
|
1581
|
+
if (BaseEntity._loggedSubtypeLoadHintMessages.has(normalizedKey)) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
BaseEntity._loggedSubtypeLoadHintMessages.add(normalizedKey);
|
|
1585
|
+
log();
|
|
1586
|
+
}
|
|
1587
|
+
static errorText(e) {
|
|
1588
|
+
return e instanceof Error ? e.message : String(e);
|
|
1290
1589
|
}
|
|
1291
1590
|
static { this._subtypeLookupCache = new Map(); }
|
|
1591
|
+
/** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
|
|
1592
|
+
static { this._subtypeLoadHintResolvers = new Map(); }
|
|
1593
|
+
/** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
|
|
1594
|
+
static { this._engineCachedRowIndexes = new WeakMap(); }
|
|
1292
1595
|
/**
|
|
1293
|
-
* Clears the static
|
|
1596
|
+
* Clears the static caches IsA subtype resolution keeps: the create path's SubtypeSelector
|
|
1597
|
+
* lookup memo, the per-entity decision about which resolver gives load hints, the indexes over
|
|
1598
|
+
* engine-cached rows, and the record of load-hint messages already logged, so each can be
|
|
1599
|
+
* reported again.
|
|
1294
1600
|
*/
|
|
1295
1601
|
static ClearSubtypeLookupCache() {
|
|
1296
1602
|
BaseEntity._subtypeLookupCache.clear();
|
|
1603
|
+
BaseEntity._subtypeLoadHintResolvers.clear();
|
|
1604
|
+
BaseEntity._engineCachedRowIndexes = new WeakMap();
|
|
1605
|
+
BaseEntity._loggedSubtypeLoadHintMessages.clear();
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* The declared IsA child entity named `name`, matched the way metadata lookups match names
|
|
1609
|
+
* (trimmed, case-insensitive), or undefined when this entity declares no such child. The one
|
|
1610
|
+
* lookup behind {@link ResolveSubtypeEntityName}, {@link EnsureISAChild} and load hints: the
|
|
1611
|
+
* create path throws on a miss, the load path logs it.
|
|
1612
|
+
*/
|
|
1613
|
+
findDeclaredChild(name) {
|
|
1614
|
+
return this.EntityInfo.ChildEntities?.find(c => sameEntityName(c.Name, name));
|
|
1297
1615
|
}
|
|
1298
1616
|
/**
|
|
1299
1617
|
* Prospective counterpart to FindISAChildEntity.
|
|
@@ -1303,6 +1621,12 @@ export class BaseEntity {
|
|
|
1303
1621
|
* 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
|
|
1304
1622
|
* 4. Otherwise null (no subtype)
|
|
1305
1623
|
*
|
|
1624
|
+
* This is the create-time ladder, and steps 1 and 2 may query. When a record is loaded,
|
|
1625
|
+
* BaseEntity asks a narrower, query-free version of it for a hint instead: the resolver's
|
|
1626
|
+
* `ResolveLoadHint` (not `Resolve`) when its class overrides it, the selector only when it sets
|
|
1627
|
+
* `UseForLoadedRecords` and only through rows that loaded `BaseEngine` caches hold, and never
|
|
1628
|
+
* step 3.
|
|
1629
|
+
*
|
|
1306
1630
|
* @see plans/sync-composition-axes.md
|
|
1307
1631
|
*/
|
|
1308
1632
|
async ResolveSubtypeEntityName() {
|
|
@@ -1317,8 +1641,7 @@ export class BaseEntity {
|
|
|
1317
1641
|
const raw = resolution.Instance.Resolve(this);
|
|
1318
1642
|
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1319
1643
|
if (candidate != null && candidate.trim() !== '') {
|
|
1320
|
-
const
|
|
1321
|
-
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1644
|
+
const match = this.findDeclaredChild(candidate);
|
|
1322
1645
|
if (!match) {
|
|
1323
1646
|
throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1324
1647
|
}
|
|
@@ -1330,10 +1653,9 @@ export class BaseEntity {
|
|
|
1330
1653
|
// 2. Entity.SubtypeSelector declarative path
|
|
1331
1654
|
const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
|
|
1332
1655
|
if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
|
|
1333
|
-
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
|
|
1656
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim(), (entityName, pkValue) => this.getSubtypePathTargetEntity(entityName, pkValue));
|
|
1334
1657
|
if (pathResult != null && pathResult.trim() !== '') {
|
|
1335
|
-
const
|
|
1336
|
-
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1658
|
+
const match = this.findDeclaredChild(pathResult);
|
|
1337
1659
|
if (!match) {
|
|
1338
1660
|
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
1661
|
}
|
|
@@ -1364,7 +1686,7 @@ export class BaseEntity {
|
|
|
1364
1686
|
if (!entityName) {
|
|
1365
1687
|
return null;
|
|
1366
1688
|
}
|
|
1367
|
-
const matchedChild = this.
|
|
1689
|
+
const matchedChild = this.findDeclaredChild(entityName);
|
|
1368
1690
|
if (!matchedChild) {
|
|
1369
1691
|
throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1370
1692
|
}
|
|
@@ -1438,7 +1760,19 @@ export class BaseEntity {
|
|
|
1438
1760
|
}
|
|
1439
1761
|
}
|
|
1440
1762
|
}
|
|
1441
|
-
|
|
1763
|
+
/**
|
|
1764
|
+
* Walks a `SubtypeSelector` path from this record to the column holding the subtype's entity
|
|
1765
|
+
* name, and returns that column's value, or null when a hop's foreign key is empty, a target
|
|
1766
|
+
* isn't found, or the terminal value is empty.
|
|
1767
|
+
*
|
|
1768
|
+
* @param path The dotted path, e.g. `ProductTypeID.ProductExtensionEntity`.
|
|
1769
|
+
* @param findTarget Finds the record a foreign key points at. Create time passes a finder that
|
|
1770
|
+
* may query; load time passes one that reads engine caches only, so a hop missing from them
|
|
1771
|
+
* ends the walk with null instead of a query.
|
|
1772
|
+
* @throws When the path doesn't fit this entity's metadata (a field isn't found, or a hop isn't
|
|
1773
|
+
* a foreign key).
|
|
1774
|
+
*/
|
|
1775
|
+
async evaluateSubtypeSelectorPath(path, findTarget) {
|
|
1442
1776
|
const segments = path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1443
1777
|
if (segments.length === 0)
|
|
1444
1778
|
return null;
|
|
@@ -1458,7 +1792,7 @@ export class BaseEntity {
|
|
|
1458
1792
|
if (!relatedEntityName) {
|
|
1459
1793
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
|
|
1460
1794
|
}
|
|
1461
|
-
const targetEntity = await
|
|
1795
|
+
const targetEntity = await findTarget(relatedEntityName, fkValue);
|
|
1462
1796
|
if (!targetEntity) {
|
|
1463
1797
|
return null;
|
|
1464
1798
|
}
|
|
@@ -1482,21 +1816,10 @@ export class BaseEntity {
|
|
|
1482
1816
|
return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
|
|
1483
1817
|
}
|
|
1484
1818
|
// 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
|
-
}
|
|
1819
|
+
const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
|
|
1820
|
+
if (cached) {
|
|
1821
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, cached);
|
|
1822
|
+
return cached;
|
|
1500
1823
|
}
|
|
1501
1824
|
// 2. Fall back to loading via provider
|
|
1502
1825
|
const provider = this.ProviderToUse;
|
|
@@ -1531,6 +1854,84 @@ export class BaseEntity {
|
|
|
1531
1854
|
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1532
1855
|
return null;
|
|
1533
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
1859
|
+
* `BaseEngine` caches already hold. Never queries, and never reads the create path's memo, whose
|
|
1860
|
+
* entries can outlive a change to the row: engine caches are kept current by entity events.
|
|
1861
|
+
*
|
|
1862
|
+
* `pkValue` is one foreign-key column's value, matched against the first primary key of the
|
|
1863
|
+
* cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
|
|
1864
|
+
*
|
|
1865
|
+
* @returns The cached record, or null when no loaded engine holds it as an entity object.
|
|
1866
|
+
*/
|
|
1867
|
+
static findSubtypePathTargetInEngineCache(entityName, pkValue) {
|
|
1868
|
+
const key = NormalizeUUID(String(pkValue));
|
|
1869
|
+
for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
|
|
1870
|
+
const found = BaseEntity.findEngineCachedRow(match.records, key);
|
|
1871
|
+
if (found) {
|
|
1872
|
+
return found;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
return null;
|
|
1876
|
+
}
|
|
1877
|
+
/**
|
|
1878
|
+
* Finds the row whose key is `key` in an engine's live cached array through an index over the
|
|
1879
|
+
* array, not a scan per lookup. Keys are read with `Get()`, which leaves the rows an engine holds
|
|
1880
|
+
* in raw mode unhydrated.
|
|
1881
|
+
*
|
|
1882
|
+
* The array is the engine's own and changes under the index: a refresh replaces it, and a save,
|
|
1883
|
+
* delete or remote invalidation pushes a row, splices one out, or swaps one for a copy with the
|
|
1884
|
+
* same key. So the index is keyed by the array itself, and rebuilt when the array's length or
|
|
1885
|
+
* last element differs from when it was built, which every added or removed row changes. A hit
|
|
1886
|
+
* is checked against the row now at that position, and a row that moved makes the index rebuild
|
|
1887
|
+
* before the answer is trusted. A key that isn't indexed is a miss, which gives no hint on load
|
|
1888
|
+
* and falls back to a query at create time, as for a row no engine caches.
|
|
1889
|
+
*/
|
|
1890
|
+
static findEngineCachedRow(records, key) {
|
|
1891
|
+
let index = BaseEntity.currentEngineCachedRowIndex(records);
|
|
1892
|
+
let position = index.Positions.get(key);
|
|
1893
|
+
if (position !== undefined && BaseEntity.engineCachedRowKey(records[position], index.KeyField) !== key) {
|
|
1894
|
+
index = BaseEntity.buildEngineCachedRowIndex(records);
|
|
1895
|
+
position = index.Positions.get(key);
|
|
1896
|
+
}
|
|
1897
|
+
return position === undefined ? null : records[position];
|
|
1898
|
+
}
|
|
1899
|
+
/** The index over `records`, rebuilt when the array's length or last element changed since it was built. */
|
|
1900
|
+
static currentEngineCachedRowIndex(records) {
|
|
1901
|
+
const index = BaseEntity._engineCachedRowIndexes.get(records);
|
|
1902
|
+
if (index && index.Length === records.length && index.LastRow === records[records.length - 1]) {
|
|
1903
|
+
return index;
|
|
1904
|
+
}
|
|
1905
|
+
return BaseEntity.buildEngineCachedRowIndex(records);
|
|
1906
|
+
}
|
|
1907
|
+
/** Indexes `records` by key. Anything that isn't an entity object (a `'simple'` cache holds plain rows) is skipped. */
|
|
1908
|
+
static buildEngineCachedRowIndex(records) {
|
|
1909
|
+
const firstEntityRow = records.find(r => typeof r?.Get === 'function');
|
|
1910
|
+
const keyField = firstEntityRow?.EntityInfo?.FirstPrimaryKey?.Name ?? null; // first-pk-ok: FK target — a SubtypeSelector hop's foreign key holds one key value
|
|
1911
|
+
const positions = new Map();
|
|
1912
|
+
for (let i = 0; i < records.length; i++) {
|
|
1913
|
+
const rowKey = BaseEntity.engineCachedRowKey(records[i], keyField);
|
|
1914
|
+
if (rowKey !== null && !positions.has(rowKey)) {
|
|
1915
|
+
positions.set(rowKey, i);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
const index = {
|
|
1919
|
+
Length: records.length,
|
|
1920
|
+
LastRow: records[records.length - 1],
|
|
1921
|
+
KeyField: keyField,
|
|
1922
|
+
Positions: positions,
|
|
1923
|
+
};
|
|
1924
|
+
BaseEntity._engineCachedRowIndexes.set(records, index);
|
|
1925
|
+
return index;
|
|
1926
|
+
}
|
|
1927
|
+
/** A cached row's key, normalized with `NormalizeUUID`; null for a row that isn't an entity object or has no key value. */
|
|
1928
|
+
static engineCachedRowKey(row, keyField) {
|
|
1929
|
+
if (!keyField || typeof row?.Get !== 'function') {
|
|
1930
|
+
return null;
|
|
1931
|
+
}
|
|
1932
|
+
const value = row.Get(keyField);
|
|
1933
|
+
return value == null ? null : NormalizeUUID(String(value));
|
|
1934
|
+
}
|
|
1534
1935
|
captureChainDirtyState() {
|
|
1535
1936
|
const snapshots = [];
|
|
1536
1937
|
let curr = this;
|
|
@@ -2280,13 +2681,13 @@ export class BaseEntity {
|
|
|
2280
2681
|
* binds this via {@link ExecuteEntitySavePlan}'s `SaveSelfOnly` callback.
|
|
2281
2682
|
*/
|
|
2282
2683
|
saveAsGraphNode(options) {
|
|
2283
|
-
return this.
|
|
2684
|
+
return this._innerSave(options);
|
|
2284
2685
|
}
|
|
2285
2686
|
/**
|
|
2286
2687
|
* Delete-path counterpart of {@link saveAsGraphNode}.
|
|
2287
2688
|
*/
|
|
2288
2689
|
deleteAsGraphNode(options) {
|
|
2289
|
-
return this.
|
|
2690
|
+
return this._innerDelete(options);
|
|
2290
2691
|
}
|
|
2291
2692
|
/**
|
|
2292
2693
|
* Captures saved/dirty baselines for every plan participant so a rolled-back
|
|
@@ -2620,12 +3021,12 @@ export class BaseEntity {
|
|
|
2620
3021
|
* Access to the underlying metadata for the entity object.
|
|
2621
3022
|
*/
|
|
2622
3023
|
get EntityInfo() {
|
|
2623
|
-
return this.
|
|
3024
|
+
return this._entityInfo;
|
|
2624
3025
|
}
|
|
2625
3026
|
get Fields() {
|
|
2626
3027
|
// Caller wants to walk Fields — promote raw data into real EntityField instances now.
|
|
2627
3028
|
this.hydrateFieldsIfNeeded();
|
|
2628
|
-
return this.
|
|
3029
|
+
return this._fields;
|
|
2629
3030
|
}
|
|
2630
3031
|
/**
|
|
2631
3032
|
* 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 +3042,7 @@ export class BaseEntity {
|
|
|
2641
3042
|
const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
|
|
2642
3043
|
if (this._fieldCache === null) {
|
|
2643
3044
|
this._fieldCache = new Map();
|
|
2644
|
-
for (const f of this.
|
|
3045
|
+
for (const f of this._fields) {
|
|
2645
3046
|
if (!this._fieldCache.has(f.Name.trim().toLowerCase())) {
|
|
2646
3047
|
this._fieldCache.set(f.Name.trim().toLowerCase(), f);
|
|
2647
3048
|
}
|
|
@@ -2704,7 +3105,7 @@ export class BaseEntity {
|
|
|
2704
3105
|
if (this._codeNameCache === null) {
|
|
2705
3106
|
this._codeNameCache = new Map();
|
|
2706
3107
|
// First-write-wins on duplicate code names — matches prior Array.find() behavior
|
|
2707
|
-
for (const f of this.
|
|
3108
|
+
for (const f of this._fields) {
|
|
2708
3109
|
const codeKey = f.CodeName.trim().toLowerCase();
|
|
2709
3110
|
if (!this._codeNameCache.has(codeKey)) {
|
|
2710
3111
|
this._codeNameCache.set(codeKey, f);
|
|
@@ -2726,7 +3127,7 @@ export class BaseEntity {
|
|
|
2726
3127
|
// dirty. Avoid hydrating just to check.
|
|
2727
3128
|
if (!this._fieldsHydrated)
|
|
2728
3129
|
return this._parentEntity?.Dirty ?? false;
|
|
2729
|
-
return this.
|
|
3130
|
+
return this._fields.some(f => f.Dirty) ||
|
|
2730
3131
|
(this._parentEntity?.Dirty ?? false);
|
|
2731
3132
|
}
|
|
2732
3133
|
finally {
|
|
@@ -2791,17 +3192,17 @@ export class BaseEntity {
|
|
|
2791
3192
|
if (this._parentEntity && this._parentEntityFieldNames?.has(FieldName)) {
|
|
2792
3193
|
this._parentEntity.Set(FieldName, Value); // recursive for N-level chains (parent asserts its own field)
|
|
2793
3194
|
// Also mirror the value on our own virtual EntityField for UI compatibility
|
|
2794
|
-
this.
|
|
3195
|
+
this.setLocal(FieldName, Value);
|
|
2795
3196
|
}
|
|
2796
3197
|
else {
|
|
2797
3198
|
// Active-status enforcement choke point for own-field writes. Fast-path gated on the
|
|
2798
3199
|
// memoized EntityInfo.HasInactiveFields so all-Active entities (the overwhelming majority)
|
|
2799
3200
|
// pay only a single cached boolean check.
|
|
2800
3201
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2801
|
-
this.
|
|
3202
|
+
this.assertFieldActiveStatus(FieldName, 'BaseEntity.Set');
|
|
2802
3203
|
}
|
|
2803
|
-
this.
|
|
2804
|
-
this.
|
|
3204
|
+
this.assertFieldReadable(FieldName);
|
|
3205
|
+
this.setLocal(FieldName, Value);
|
|
2805
3206
|
}
|
|
2806
3207
|
}
|
|
2807
3208
|
/**
|
|
@@ -2814,7 +3215,7 @@ export class BaseEntity {
|
|
|
2814
3215
|
* Callers should gate this behind `EntityInfo.HasInactiveFields` so the common all-Active entity
|
|
2815
3216
|
* skips the field lookup entirely.
|
|
2816
3217
|
*/
|
|
2817
|
-
|
|
3218
|
+
assertFieldActiveStatus(fieldName, caller) {
|
|
2818
3219
|
const fi = this.EntityInfo?.FieldByName(fieldName);
|
|
2819
3220
|
if (fi) {
|
|
2820
3221
|
EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
|
|
@@ -2841,7 +3242,7 @@ export class BaseEntity {
|
|
|
2841
3242
|
* build) reads `EntityField.Value` directly and never routes through here — that exemption
|
|
2842
3243
|
* is load-bearing, not an oversight. Do not "fix" it.
|
|
2843
3244
|
*/
|
|
2844
|
-
|
|
3245
|
+
assertFieldReadable(fieldName) {
|
|
2845
3246
|
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
2846
3247
|
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
2847
3248
|
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
@@ -2851,7 +3252,7 @@ export class BaseEntity {
|
|
|
2851
3252
|
* Internal helper that sets a field value directly on THIS entity's own Fields array
|
|
2852
3253
|
* without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
|
|
2853
3254
|
*/
|
|
2854
|
-
|
|
3255
|
+
setLocal(FieldName, Value) {
|
|
2855
3256
|
const field = this.GetFieldByName(FieldName);
|
|
2856
3257
|
if (field != null) {
|
|
2857
3258
|
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
|
|
@@ -2912,12 +3313,12 @@ export class BaseEntity {
|
|
|
2912
3313
|
// EntityInfo.HasInactiveFields so all-Active entities pay only a single cached boolean check
|
|
2913
3314
|
// before the hot read paths below.
|
|
2914
3315
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2915
|
-
this.
|
|
3316
|
+
this.assertFieldActiveStatus(FieldName, 'BaseEntity.Get');
|
|
2916
3317
|
}
|
|
2917
3318
|
// Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
|
|
2918
3319
|
// fields are not yet hydrated would otherwise return denied values straight out of _raw
|
|
2919
3320
|
// without the gate ever running.
|
|
2920
|
-
this.
|
|
3321
|
+
this.assertFieldReadable(FieldName);
|
|
2921
3322
|
// Raw mode fast path: read directly from the cached data without building EntityField
|
|
2922
3323
|
// instances. This is the dominant cost in engine warm-loads — generated typed getters
|
|
2923
3324
|
// (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
|
|
@@ -2931,7 +3332,7 @@ export class BaseEntity {
|
|
|
2931
3332
|
// back into `_raw` — the row may be shared, frozen cache state. Fields needing no
|
|
2932
3333
|
// conversion (the vast majority) never touch the memo at all, so the fast path stays
|
|
2933
3334
|
// a single property read.
|
|
2934
|
-
const fi = this.
|
|
3335
|
+
const fi = this._entityInfo?.FieldByName(FieldName);
|
|
2935
3336
|
if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
|
|
2936
3337
|
const memo = this._rawConverted?.get(FieldName);
|
|
2937
3338
|
if (memo !== undefined)
|
|
@@ -2945,7 +3346,7 @@ export class BaseEntity {
|
|
|
2945
3346
|
const memo = this._rawConverted?.get(FieldName);
|
|
2946
3347
|
if (memo !== undefined)
|
|
2947
3348
|
return memo;
|
|
2948
|
-
value = value
|
|
3349
|
+
value = trimTrailingSpaces(value);
|
|
2949
3350
|
this.memoizeRawConversion(FieldName, value);
|
|
2950
3351
|
}
|
|
2951
3352
|
return value;
|
|
@@ -2991,7 +3392,7 @@ export class BaseEntity {
|
|
|
2991
3392
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
2992
3393
|
}
|
|
2993
3394
|
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
2994
|
-
this.
|
|
3395
|
+
this.setLocal(key, object[key]);
|
|
2995
3396
|
if (replaceOldValues) {
|
|
2996
3397
|
field.ResetOldValue();
|
|
2997
3398
|
}
|
|
@@ -3005,7 +3406,7 @@ export class BaseEntity {
|
|
|
3005
3406
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
3006
3407
|
}
|
|
3007
3408
|
// Use SetLocal here so we set on OUR fields (mirrors for parent fields)
|
|
3008
|
-
this.
|
|
3409
|
+
this.setLocal(field.Name, object[key]);
|
|
3009
3410
|
if (replaceOldValues) {
|
|
3010
3411
|
field.ResetOldValue();
|
|
3011
3412
|
}
|
|
@@ -3045,7 +3446,7 @@ export class BaseEntity {
|
|
|
3045
3446
|
// load — without this, the parent would think it's a new record and attempt CREATE
|
|
3046
3447
|
// instead of UPDATE on save.
|
|
3047
3448
|
if (replaceOldValues) {
|
|
3048
|
-
this.
|
|
3449
|
+
this.updateSavedStateFromPrimaryKeys();
|
|
3049
3450
|
}
|
|
3050
3451
|
}
|
|
3051
3452
|
/**
|
|
@@ -3053,7 +3454,7 @@ export class BaseEntity {
|
|
|
3053
3454
|
* a saved/loaded record. This enables entities populated via SetMany (e.g., IS-A parent
|
|
3054
3455
|
* entities) to correctly recognize themselves as existing records.
|
|
3055
3456
|
*/
|
|
3056
|
-
|
|
3457
|
+
updateSavedStateFromPrimaryKeys() {
|
|
3057
3458
|
if (!this.PrimaryKeys || this.PrimaryKeys.length === 0)
|
|
3058
3459
|
return;
|
|
3059
3460
|
const allPKsSet = this.PrimaryKeys.every(pk => pk.Value !== null && pk.Value !== undefined);
|
|
@@ -3118,6 +3519,49 @@ export class BaseEntity {
|
|
|
3118
3519
|
GetChangesSinceLastSave() {
|
|
3119
3520
|
return this.GetAll(false, true);
|
|
3120
3521
|
}
|
|
3522
|
+
/**
|
|
3523
|
+
* Computes a deterministic SHA-256 content hash of the entity's field values using
|
|
3524
|
+
* the canonicalizer and Web Crypto API from `@memberjunction/global`.
|
|
3525
|
+
*
|
|
3526
|
+
* By default, hashes all loaded non-system fields (excluding `__mj_*` columns).
|
|
3527
|
+
* The result is byte-for-byte identical across client and server tiers.
|
|
3528
|
+
*
|
|
3529
|
+
* @param options Optional configuration specifying subset of fields and/or whether to exclude system fields.
|
|
3530
|
+
*/
|
|
3531
|
+
async ComputeContentHash(options) {
|
|
3532
|
+
const excludeSystem = options?.ExcludeSystemFields ?? true;
|
|
3533
|
+
const allData = this.GetAll(false, false);
|
|
3534
|
+
// If specific fields are requested, validate each exists (case-insensitively) and resolve canonical name
|
|
3535
|
+
let targetFieldNames;
|
|
3536
|
+
if (options?.Fields && options.Fields.length > 0) {
|
|
3537
|
+
targetFieldNames = options.Fields.map((req) => {
|
|
3538
|
+
const fieldInfo = this.GetFieldByName(req);
|
|
3539
|
+
if (!fieldInfo) {
|
|
3540
|
+
throw new Error(`ComputeContentHash: field '${req}' does not exist on entity '${this.EntityInfo.Name}'`);
|
|
3541
|
+
}
|
|
3542
|
+
return fieldInfo.Name;
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
else {
|
|
3546
|
+
targetFieldNames = Object.keys(allData);
|
|
3547
|
+
}
|
|
3548
|
+
const excludeSet = new Set((options?.ExcludeFields ?? []).map((f) => f.trim().toLowerCase()));
|
|
3549
|
+
const data = {};
|
|
3550
|
+
for (const name of targetFieldNames) {
|
|
3551
|
+
if (excludeSystem && name.startsWith('__mj_'))
|
|
3552
|
+
continue;
|
|
3553
|
+
if (excludeSet.has(name.toLowerCase()))
|
|
3554
|
+
continue;
|
|
3555
|
+
const val = allData[name];
|
|
3556
|
+
if (val !== undefined) {
|
|
3557
|
+
data[name] = val;
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
if (Object.keys(data).length === 0) {
|
|
3561
|
+
throw new Error(`ComputeContentHash: cannot compute content hash on empty basis for entity '${this.EntityInfo.Name}'`);
|
|
3562
|
+
}
|
|
3563
|
+
return ComputeContentHashAsync(data);
|
|
3564
|
+
}
|
|
3121
3565
|
/**
|
|
3122
3566
|
* This utility method calls GetDataObject() internally and formats the result as a JSON string. If you want to get the data as an object instead of a string, call GetDataObject() directly.
|
|
3123
3567
|
* @param params
|
|
@@ -3154,8 +3598,8 @@ export class BaseEntity {
|
|
|
3154
3598
|
}
|
|
3155
3599
|
if (params.includeRelatedEntityData) {
|
|
3156
3600
|
// now, get the related entity data
|
|
3157
|
-
for (let i = 0; i < this.
|
|
3158
|
-
const re = this.
|
|
3601
|
+
for (let i = 0; i < this._entityInfo.RelatedEntities.length; i++) {
|
|
3602
|
+
const re = this._entityInfo.RelatedEntities[i];
|
|
3159
3603
|
const pre = params.relatedEntityList ? params.relatedEntityList.find(r => r.relatedEntityName === re.RelatedEntity) : null;
|
|
3160
3604
|
if (pre) {
|
|
3161
3605
|
// pre now has the param that matches the related entity (re) that we are looking at
|
|
@@ -3213,7 +3657,7 @@ export class BaseEntity {
|
|
|
3213
3657
|
this._compositeKey = null;
|
|
3214
3658
|
this._resultHistory = [];
|
|
3215
3659
|
this._recordLoaded = false;
|
|
3216
|
-
this.
|
|
3660
|
+
this._fields = [];
|
|
3217
3661
|
this._fieldsHydrated = false;
|
|
3218
3662
|
this._raw = null;
|
|
3219
3663
|
this._rawConverted = null;
|
|
@@ -3249,14 +3693,14 @@ export class BaseEntity {
|
|
|
3249
3693
|
for (const rawField of this.EntityInfo.Fields) {
|
|
3250
3694
|
const key = this.EntityInfo.Name + '.' + rawField.Name;
|
|
3251
3695
|
const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, key, rawField);
|
|
3252
|
-
this.
|
|
3696
|
+
this._fields.push(newField);
|
|
3253
3697
|
}
|
|
3254
3698
|
// 2. If we have raw data from LoadFromData, populate Fields. The first set of each Field
|
|
3255
3699
|
// runs through the EntityField setter with `_NeverSet=true`, which simultaneously sets
|
|
3256
3700
|
// Value AND OldValue (so the field is not marked dirty). This matches the old
|
|
3257
3701
|
// SetMany(replaceOldValues=true) semantics on initial load.
|
|
3258
3702
|
if (this._raw) {
|
|
3259
|
-
for (const field of this.
|
|
3703
|
+
for (const field of this._fields) {
|
|
3260
3704
|
const value = this._raw[field.Name];
|
|
3261
3705
|
if (value !== undefined) {
|
|
3262
3706
|
// Populating fields from the loaded row writes EntityField.Value directly, which does
|
|
@@ -3368,7 +3812,7 @@ export class BaseEntity {
|
|
|
3368
3812
|
for (const pk of this.EntityInfo.PrimaryKeys) {
|
|
3369
3813
|
const parentValue = this._parentEntity.Get(pk.Name);
|
|
3370
3814
|
if (parentValue != null) {
|
|
3371
|
-
this.
|
|
3815
|
+
this.setLocal(pk.Name, parentValue);
|
|
3372
3816
|
// The shared PK is ReadOnly, so SetLocal above consumed its
|
|
3373
3817
|
// one-time write (_NeverSet -> false). Restore _NeverSet so the
|
|
3374
3818
|
// child's OWN mirror can be re-written if the shared key is set
|
|
@@ -3520,7 +3964,7 @@ export class BaseEntity {
|
|
|
3520
3964
|
// Root.Save() would wait on the first _pendingSave$ (which is itself waiting
|
|
3521
3965
|
// for the leaf chain), creating a circular wait that hangs forever.
|
|
3522
3966
|
if (options?.IsParentEntitySave) {
|
|
3523
|
-
return this.
|
|
3967
|
+
return this._innerSave(options);
|
|
3524
3968
|
}
|
|
3525
3969
|
// If a save is already in progress, return its promise. This check MUST run before graph
|
|
3526
3970
|
// routing: a composite save is still a save, and two concurrent Save() calls on one record
|
|
@@ -3561,7 +4005,7 @@ export class BaseEntity {
|
|
|
3561
4005
|
// Create a new observable that debounces duplicative calls, and executes the save.
|
|
3562
4006
|
this._pendingSave$ = of(options).pipe(
|
|
3563
4007
|
// Execute the actual save logic.
|
|
3564
|
-
switchMap(opts => from(this.
|
|
4008
|
+
switchMap(opts => from(this._innerSave(opts))),
|
|
3565
4009
|
// When the save completes (whether successfully or not), clear the pending save observable.
|
|
3566
4010
|
finalize(() => { this._pendingSave$ = null; }),
|
|
3567
4011
|
// Ensure that all subscribers get the same result.
|
|
@@ -3574,7 +4018,7 @@ export class BaseEntity {
|
|
|
3574
4018
|
* @param options
|
|
3575
4019
|
* @returns
|
|
3576
4020
|
*/
|
|
3577
|
-
async
|
|
4021
|
+
async _innerSave(options) {
|
|
3578
4022
|
const currentResultCount = this.ResultHistory.length;
|
|
3579
4023
|
const newResult = new BaseEntityResult();
|
|
3580
4024
|
newResult.StartedAt = new Date();
|
|
@@ -3722,7 +4166,7 @@ export class BaseEntity {
|
|
|
3722
4166
|
}
|
|
3723
4167
|
if (valResult.Success) {
|
|
3724
4168
|
// Run registered PreSave hooks (e.g., tenant validation)
|
|
3725
|
-
await this.
|
|
4169
|
+
await this.runPreSaveHooks();
|
|
3726
4170
|
// Optimistic-UI hook: every pre-flight check (Validate, ValidateAsync,
|
|
3727
4171
|
// PreSave hooks) has passed and the DB write is imminent. Fire the optional
|
|
3728
4172
|
// OnValidated callback so a UI can render the now-known-valid change before
|
|
@@ -3824,7 +4268,7 @@ export class BaseEntity {
|
|
|
3824
4268
|
* Runs all registered PreSave hooks. If any hook returns false or a string
|
|
3825
4269
|
* (error message), the save is rejected by throwing an Error.
|
|
3826
4270
|
*/
|
|
3827
|
-
async
|
|
4271
|
+
async runPreSaveHooks() {
|
|
3828
4272
|
const preSaveHooks = GetDataHooks('PreSave');
|
|
3829
4273
|
for (const hook of preSaveHooks) {
|
|
3830
4274
|
const hookResult = await hook(this, this.ActiveUser);
|
|
@@ -3881,7 +4325,7 @@ export class BaseEntity {
|
|
|
3881
4325
|
if (result)
|
|
3882
4326
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
3883
4327
|
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
3884
|
-
this.
|
|
4328
|
+
this.cacheRecordName();
|
|
3885
4329
|
this.RaiseEvent('save', null, saveSubType);
|
|
3886
4330
|
return true;
|
|
3887
4331
|
}
|
|
@@ -3901,7 +4345,7 @@ export class BaseEntity {
|
|
|
3901
4345
|
* Caches the entity record name in the provider's EntityRecordNameCache for faster lookups.
|
|
3902
4346
|
* Called automatically after successful Load(), LoadFromData(), and Save() operations.
|
|
3903
4347
|
*/
|
|
3904
|
-
|
|
4348
|
+
cacheRecordName() {
|
|
3905
4349
|
// Only cache if we have a valid primary key
|
|
3906
4350
|
if (!this.PrimaryKey || !this.PrimaryKey.HasValue) {
|
|
3907
4351
|
return;
|
|
@@ -4192,9 +4636,12 @@ export class BaseEntity {
|
|
|
4192
4636
|
if (!this.IsSaved) {
|
|
4193
4637
|
this.init(); // wipe out current data if we're loading on top of existing record
|
|
4194
4638
|
}
|
|
4195
|
-
const data = await this.
|
|
4639
|
+
const data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
|
|
4196
4640
|
if (!data) {
|
|
4197
|
-
|
|
4641
|
+
// A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
|
|
4642
|
+
if (!this._subtypeHintProbe) {
|
|
4643
|
+
BaseEntity.logLoadFoundNoRow(this.EntityInfo.Name, CompositeKey);
|
|
4644
|
+
}
|
|
4198
4645
|
return false; // no data loaded, return false
|
|
4199
4646
|
}
|
|
4200
4647
|
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
@@ -4230,7 +4677,7 @@ export class BaseEntity {
|
|
|
4230
4677
|
this._everSaved = true; // Mark as saved since we loaded from database
|
|
4231
4678
|
this._compositeKey = CompositeKey; // set the composite key to the one we just loaded
|
|
4232
4679
|
// Cache the record name for faster lookups
|
|
4233
|
-
this.
|
|
4680
|
+
this.cacheRecordName();
|
|
4234
4681
|
// IS-A: discover child entity records after load completes.
|
|
4235
4682
|
// This populates _childEntity so Save/Delete can delegate to the leaf.
|
|
4236
4683
|
// Only runs for parent-type entities; idempotent via _childEntityDiscoveryDone.
|
|
@@ -4253,6 +4700,27 @@ export class BaseEntity {
|
|
|
4253
4700
|
this._isLoading = false;
|
|
4254
4701
|
}
|
|
4255
4702
|
}
|
|
4703
|
+
/**
|
|
4704
|
+
* The provider's read of the row {@link InnerLoad} loads. While this record is a subtype-hint
|
|
4705
|
+
* probe, a throw from the read is recorded on the probe before it propagates, so the parent
|
|
4706
|
+
* that asked can tell it apart from a failure after the row loaded.
|
|
4707
|
+
*/
|
|
4708
|
+
async readRowForLoad(key, relationshipsToLoad) {
|
|
4709
|
+
const user = this.ActiveUser;
|
|
4710
|
+
try {
|
|
4711
|
+
return await this.ProviderToUse.Load(this, key, relationshipsToLoad, user);
|
|
4712
|
+
}
|
|
4713
|
+
catch (e) {
|
|
4714
|
+
if (this._subtypeHintProbe) {
|
|
4715
|
+
this._subtypeHintProbe.RowReadFailed = true;
|
|
4716
|
+
}
|
|
4717
|
+
throw e;
|
|
4718
|
+
}
|
|
4719
|
+
}
|
|
4720
|
+
/** The error {@link InnerLoad} logs when the provider finds no row for the key. */
|
|
4721
|
+
static logLoadFoundNoRow(entityName, key) {
|
|
4722
|
+
LogError(`Error in BaseEntity.Load(${entityName}, Key: ${key.ToString()}`);
|
|
4723
|
+
}
|
|
4256
4724
|
/**
|
|
4257
4725
|
* Re-fetches the current record from the database using its existing primary key, replacing all in-memory field values
|
|
4258
4726
|
* with the latest data from the database. This is useful when you know (or suspect) the record has been modified externally
|
|
@@ -4340,7 +4808,7 @@ export class BaseEntity {
|
|
|
4340
4808
|
const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
|
|
4341
4809
|
const canTakeFastPath = isPlainObject &&
|
|
4342
4810
|
!this._fieldsHydrated &&
|
|
4343
|
-
this.
|
|
4811
|
+
this._fields.length === 0 &&
|
|
4344
4812
|
!this._parentEntity &&
|
|
4345
4813
|
!this.EntityInfo?.IsParentType;
|
|
4346
4814
|
if (canTakeFastPath) {
|
|
@@ -4396,7 +4864,7 @@ export class BaseEntity {
|
|
|
4396
4864
|
}
|
|
4397
4865
|
// Cache the record name for faster lookups if successfully loaded
|
|
4398
4866
|
if (this._recordLoaded) {
|
|
4399
|
-
this.
|
|
4867
|
+
this.cacheRecordName();
|
|
4400
4868
|
// IS-A: discover child entity records after data load completes.
|
|
4401
4869
|
await this.InitializeChildEntity();
|
|
4402
4870
|
}
|
|
@@ -4578,7 +5046,7 @@ export class BaseEntity {
|
|
|
4578
5046
|
// Create a new observable that debounces duplicative calls, and executes the delete.
|
|
4579
5047
|
this._pendingDelete$ = of(options).pipe(
|
|
4580
5048
|
// Execute the actual delete logic.
|
|
4581
|
-
switchMap(opts => from(this.
|
|
5049
|
+
switchMap(opts => from(this._innerDelete(opts))),
|
|
4582
5050
|
// When the delete completes (whether successfully or not), clear the pending delete observable.
|
|
4583
5051
|
finalize(() => { this._pendingDelete$ = null; }),
|
|
4584
5052
|
// Ensure that all subscribers get the same result.
|
|
@@ -4591,7 +5059,7 @@ export class BaseEntity {
|
|
|
4591
5059
|
* @param options
|
|
4592
5060
|
* @returns
|
|
4593
5061
|
*/
|
|
4594
|
-
async
|
|
5062
|
+
async _innerDelete(options) {
|
|
4595
5063
|
const currentResultCount = this.ResultHistory.length;
|
|
4596
5064
|
const newResult = new BaseEntityResult();
|
|
4597
5065
|
newResult.StartedAt = new Date();
|
|
@@ -4849,13 +5317,13 @@ export class BaseEntity {
|
|
|
4849
5317
|
if (!entityInfo) {
|
|
4850
5318
|
return { LeafEntityName: entityName, IsLeaf: true };
|
|
4851
5319
|
}
|
|
4852
|
-
return BaseEntity.
|
|
5320
|
+
return BaseEntity.resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
|
|
4853
5321
|
}
|
|
4854
5322
|
/**
|
|
4855
5323
|
* Internal recursive helper for leaf entity resolution.
|
|
4856
5324
|
* Walks down the child hierarchy until no more children are found.
|
|
4857
5325
|
*/
|
|
4858
|
-
static async
|
|
5326
|
+
static async resolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
|
|
4859
5327
|
const childEntities = entityInfo.ChildEntities;
|
|
4860
5328
|
if (childEntities.length === 0) {
|
|
4861
5329
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
@@ -4875,7 +5343,7 @@ export class BaseEntity {
|
|
|
4875
5343
|
}, contextUser);
|
|
4876
5344
|
if (result?.Success && result.Results?.length > 0) {
|
|
4877
5345
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
4878
|
-
return BaseEntity.
|
|
5346
|
+
return BaseEntity.resolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
|
|
4879
5347
|
}
|
|
4880
5348
|
}
|
|
4881
5349
|
// No child found — this entity IS the leaf
|