@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7
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/BaseEntitySaveQueue.d.ts +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +171 -17
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +722 -117
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +216 -21
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +969 -100
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +381 -52
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +234 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +592 -77
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +902 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +64 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +126 -14
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +348 -48
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +408 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1051 -310
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +18 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +55 -7
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +102 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +40 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
|
@@ -8,16 +8,17 @@ 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 { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
|
|
11
|
+
import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, UserCacheKeySegment, WarningManager, ComputeContentHashAsync, EscapeSQLString } 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';
|
|
15
|
+
import { CanonicalPrimaryKeyValue } from './util.js';
|
|
15
16
|
import { BaseEngineRegistry } from './baseEngineRegistry.js';
|
|
16
17
|
import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
|
|
17
18
|
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
18
19
|
import { Metadata } from './metadata.js';
|
|
19
20
|
import { RunView } from '../views/runView.js';
|
|
20
|
-
import { LogDebug, LogError, LogStatusEx } from './logging.js';
|
|
21
|
+
import { LogDebug, LogError, LogStatusEx, LogWarning } from './logging.js';
|
|
21
22
|
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
22
23
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
23
24
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
@@ -25,6 +26,7 @@ import { EmbeddedRecord } from './embeddedRecord.js';
|
|
|
25
26
|
import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
|
|
26
27
|
import { JSONFieldBinding, ValidateJSONFieldValue } from './jsonFieldBinding.js';
|
|
27
28
|
import { BaseRemotableOperation } from './baseRemotableOperation.js';
|
|
29
|
+
import { OmitDeniedFieldKeys, OmitFieldsUnreadableByActiveUser } from './entityValueFieldSecurity.js';
|
|
28
30
|
import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
|
|
29
31
|
import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
|
|
30
32
|
/**
|
|
@@ -79,8 +81,9 @@ let EntityField = class EntityField {
|
|
|
79
81
|
* `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
|
|
80
82
|
* by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
|
|
81
83
|
* cleared by ANY explicit set (an intentional blind write to a read-denied field is a
|
|
82
|
-
* legitimate write-only update and must save)
|
|
83
|
-
*
|
|
84
|
+
* legitimate write-only update and must save). On a new record it marks only the fields the
|
|
85
|
+
* record's user may not read; every other field legitimately holds its metadata default for
|
|
86
|
+
* INSERT.
|
|
84
87
|
*/
|
|
85
88
|
get NotLoaded() {
|
|
86
89
|
return this._notLoaded;
|
|
@@ -128,6 +131,18 @@ let EntityField = class EntityField {
|
|
|
128
131
|
SetCreateSuppressed(suppressed) {
|
|
129
132
|
this._createSuppressed = suppressed;
|
|
130
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Framework-internal: resets all mutable field state in place to match a freshly constructed
|
|
136
|
+
* `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
|
|
137
|
+
*/
|
|
138
|
+
ResetForInPlaceHydration() {
|
|
139
|
+
this._oldValue = null;
|
|
140
|
+
this._value = null;
|
|
141
|
+
this._neverSet = true;
|
|
142
|
+
this._notLoaded = false;
|
|
143
|
+
this._writtenAfterNotLoaded = false;
|
|
144
|
+
this._createSuppressed = false;
|
|
145
|
+
}
|
|
131
146
|
get Name() {
|
|
132
147
|
return this._entityFieldInfo.Name;
|
|
133
148
|
}
|
|
@@ -183,7 +198,18 @@ let EntityField = class EntityField {
|
|
|
183
198
|
return this._entityFieldInfo.HasDefaultValue;
|
|
184
199
|
}
|
|
185
200
|
/**
|
|
186
|
-
* Sets the value of the field.
|
|
201
|
+
* Sets the value of the field.
|
|
202
|
+
*
|
|
203
|
+
* - A read-only field accepts ONE set after construction or {@link ResetNeverSetFlag} (how a
|
|
204
|
+
* load populates it); later sets are ignored.
|
|
205
|
+
* - Dirty state is not stored here: {@link Dirty} compares the value with {@link OldValue} when
|
|
206
|
+
* it is read. A set during record setup (the first set of a never-set field) also becomes
|
|
207
|
+
* the old value, so it is not dirty.
|
|
208
|
+
* - A set onto a {@link NotLoaded} field clears that flag and is always dirty, whatever the
|
|
209
|
+
* value, even one equal to the field's construction state. The write therefore reaches the
|
|
210
|
+
* save and its update-permission check.
|
|
211
|
+
* - No field-level security or active-status check happens here. Those live at
|
|
212
|
+
* `BaseEntity.Get`/`Set` and at the save; this accessor is framework-internal.
|
|
187
213
|
*/
|
|
188
214
|
set Value(value) {
|
|
189
215
|
// No active-status assertion here — see the Value getter note. Deprecation/disabled checks
|
|
@@ -527,6 +553,11 @@ let EntityField = class EntityField {
|
|
|
527
553
|
}
|
|
528
554
|
/**
|
|
529
555
|
* 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.
|
|
556
|
+
*
|
|
557
|
+
* It also clears the written-after-not-loaded flag: the current value becomes the field's
|
|
558
|
+
* loaded baseline. The hydration paths rely on that (a reload that supplies the field must not
|
|
559
|
+
* leave it dirty), so code outside hydration must not call this to "accept" a write onto a
|
|
560
|
+
* not-loaded field, or the write would be saved without being seen as a change.
|
|
530
561
|
*/
|
|
531
562
|
ResetOldValue() {
|
|
532
563
|
this._oldValue = this.Value;
|
|
@@ -1059,6 +1090,7 @@ export class BaseEntity {
|
|
|
1059
1090
|
* pay nothing for the feature, not even an empty Map per instance.
|
|
1060
1091
|
*/
|
|
1061
1092
|
this._companions = null;
|
|
1093
|
+
this._finalizeGeneration = 0;
|
|
1062
1094
|
/**
|
|
1063
1095
|
* Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
|
|
1064
1096
|
*
|
|
@@ -1649,20 +1681,35 @@ export class BaseEntity {
|
|
|
1649
1681
|
return e instanceof Error ? e.message : String(e);
|
|
1650
1682
|
}
|
|
1651
1683
|
/**
|
|
1652
|
-
* The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup
|
|
1653
|
-
* shared across providers would hand provider B a row loaded
|
|
1654
|
-
* another
|
|
1655
|
-
*
|
|
1684
|
+
* The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup and
|
|
1685
|
+
* then by the user it ran for. A memo shared across providers would hand provider B a row loaded
|
|
1686
|
+
* through provider A (another connection) or a `null` miss recorded against A's database. A memo
|
|
1687
|
+
* shared across users would hand one user an entity object shaped to another: an object holds only
|
|
1688
|
+
* the fields its own user may read, so a record loaded for an administrator carries values a
|
|
1689
|
+
* restricted user is denied. Keyed weakly so a request-scoped provider's memo is collected with it.
|
|
1656
1690
|
*/
|
|
1657
1691
|
static { this._subtypeLookupCache = new WeakMap(); }
|
|
1658
1692
|
/** Partition key for lookups made by an entity that has no provider at all. */
|
|
1659
1693
|
static { this._noProviderSubtypeKey = {}; }
|
|
1660
|
-
|
|
1694
|
+
/**
|
|
1695
|
+
* The memo for one provider and user, or null when no user resolves: an entry keyed without a
|
|
1696
|
+
* user would be shared by every caller, so such a lookup is not memoized at all.
|
|
1697
|
+
*/
|
|
1698
|
+
static subtypeLookupCacheFor(provider, user) {
|
|
1699
|
+
const userKey = UserCacheKeySegment(user?.ID);
|
|
1700
|
+
if (userKey === null) {
|
|
1701
|
+
return null;
|
|
1702
|
+
}
|
|
1661
1703
|
const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
|
|
1662
|
-
let
|
|
1704
|
+
let byUser = BaseEntity._subtypeLookupCache.get(partitionKey);
|
|
1705
|
+
if (!byUser) {
|
|
1706
|
+
byUser = new Map();
|
|
1707
|
+
BaseEntity._subtypeLookupCache.set(partitionKey, byUser);
|
|
1708
|
+
}
|
|
1709
|
+
let cache = byUser.get(userKey);
|
|
1663
1710
|
if (!cache) {
|
|
1664
1711
|
cache = new Map();
|
|
1665
|
-
|
|
1712
|
+
byUser.set(userKey, cache);
|
|
1666
1713
|
}
|
|
1667
1714
|
return cache;
|
|
1668
1715
|
}
|
|
@@ -1866,12 +1913,16 @@ export class BaseEntity {
|
|
|
1866
1913
|
return null;
|
|
1867
1914
|
let currentEntity = this;
|
|
1868
1915
|
let currentEntityInfo = this.EntityInfo;
|
|
1916
|
+
const actingUser = this.resolveActiveUserOrNull();
|
|
1869
1917
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
1870
1918
|
const fieldName = segments[i];
|
|
1871
1919
|
const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
|
|
1872
1920
|
if (!fieldInfo) {
|
|
1873
1921
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1874
1922
|
}
|
|
1923
|
+
if (!this.subtypeSelectorMayRead(currentEntity, fieldInfo.Name, actingUser)) {
|
|
1924
|
+
return null; // a hop the user may not read gives no subtype, as a denied name field gives no name
|
|
1925
|
+
}
|
|
1875
1926
|
const fkValue = currentEntity.Get(fieldInfo.Name);
|
|
1876
1927
|
if (fkValue == null || fkValue === '') {
|
|
1877
1928
|
return null;
|
|
@@ -1892,34 +1943,54 @@ export class BaseEntity {
|
|
|
1892
1943
|
if (!terminalField) {
|
|
1893
1944
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1894
1945
|
}
|
|
1946
|
+
if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1895
1949
|
const terminalValue = currentEntity.Get(terminalField.Name);
|
|
1896
1950
|
if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
|
|
1897
1951
|
return null;
|
|
1898
1952
|
}
|
|
1899
1953
|
return terminalValue.trim();
|
|
1900
1954
|
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Whether a `SubtypeSelector` hop may read `fieldName` on `record`, which is this record or
|
|
1957
|
+
* one the path reached. Both the record's own user and the user resolving the subtype must be
|
|
1958
|
+
* allowed: a record reached through an engine cache was loaded as the system user and holds
|
|
1959
|
+
* every column, so its own answer says nothing about the acting user.
|
|
1960
|
+
*/
|
|
1961
|
+
subtypeSelectorMayRead(record, fieldName, actingUser) {
|
|
1962
|
+
if (!record.IsFieldReadableByActiveUser(fieldName)) {
|
|
1963
|
+
return false;
|
|
1964
|
+
}
|
|
1965
|
+
return record === this || !actingUser || record.EntityInfo.IsFieldReadableByUser(fieldName, actingUser);
|
|
1966
|
+
}
|
|
1901
1967
|
async getSubtypePathTargetEntity(entityName, pkValue) {
|
|
1902
1968
|
const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
|
|
1903
1969
|
const provider = this.ProviderToUse;
|
|
1904
|
-
const lookupCache = BaseEntity.subtypeLookupCacheFor(provider);
|
|
1905
|
-
if (lookupCache
|
|
1970
|
+
const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
|
|
1971
|
+
if (lookupCache?.has(cacheKey)) {
|
|
1906
1972
|
return lookupCache.get(cacheKey) ?? null;
|
|
1907
1973
|
}
|
|
1908
|
-
|
|
1974
|
+
const found = await this.findSubtypePathTarget(provider, entityName, pkValue);
|
|
1975
|
+
lookupCache?.set(cacheKey, found);
|
|
1976
|
+
return found;
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Finds the record a create-time `SubtypeSelector` hop points at: in a loaded engine cache
|
|
1980
|
+
* first, then by loading it through the provider for this record's user. Null when neither
|
|
1981
|
+
* has it, or the load fails.
|
|
1982
|
+
*/
|
|
1983
|
+
async findSubtypePathTarget(provider, entityName, pkValue) {
|
|
1909
1984
|
const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
|
|
1910
1985
|
if (cached) {
|
|
1911
|
-
lookupCache.set(cacheKey, cached);
|
|
1912
1986
|
return cached;
|
|
1913
1987
|
}
|
|
1914
|
-
// 2. Fall back to loading via provider
|
|
1915
1988
|
if (!provider?.GetEntityObject) {
|
|
1916
|
-
lookupCache.set(cacheKey, null);
|
|
1917
1989
|
return null;
|
|
1918
1990
|
}
|
|
1919
1991
|
try {
|
|
1920
1992
|
const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
|
|
1921
1993
|
if (!targetObj) {
|
|
1922
|
-
lookupCache.set(cacheKey, null);
|
|
1923
1994
|
return null;
|
|
1924
1995
|
}
|
|
1925
1996
|
let key;
|
|
@@ -1931,17 +2002,11 @@ export class BaseEntity {
|
|
|
1931
2002
|
const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1932
2003
|
key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
|
|
1933
2004
|
}
|
|
1934
|
-
|
|
1935
|
-
if (loaded) {
|
|
1936
|
-
lookupCache.set(cacheKey, targetObj);
|
|
1937
|
-
return targetObj;
|
|
1938
|
-
}
|
|
2005
|
+
return (await targetObj.InnerLoad(key)) ? targetObj : null;
|
|
1939
2006
|
}
|
|
1940
2007
|
catch {
|
|
1941
|
-
//
|
|
2008
|
+
return null; // on load failure, no target
|
|
1942
2009
|
}
|
|
1943
|
-
lookupCache.set(cacheKey, null);
|
|
1944
|
-
return null;
|
|
1945
2010
|
}
|
|
1946
2011
|
/**
|
|
1947
2012
|
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
@@ -2142,23 +2207,102 @@ export class BaseEntity {
|
|
|
2142
2207
|
* (and potentially parent entities in the IS-A chain).
|
|
2143
2208
|
*/
|
|
2144
2209
|
Hydrate(data) {
|
|
2145
|
-
// Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
|
|
2146
|
-
// _everSaved, and recreates all EntityField instances with _NeverSet = true
|
|
2147
|
-
this.init();
|
|
2148
2210
|
this.discardJSONFieldObjects();
|
|
2211
|
+
// Field security: denied values never enter this object (see narrowHydrationSource).
|
|
2212
|
+
data = this.narrowHydrationSource(data);
|
|
2149
2213
|
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
2150
|
-
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
2151
2214
|
if (this._parentEntity) {
|
|
2152
2215
|
this._parentEntity.Hydrate(data);
|
|
2153
2216
|
}
|
|
2154
|
-
// Populate this entity's fields
|
|
2155
|
-
//
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2217
|
+
// Populate this entity's fields in place without destroying hydrated EntityField instances
|
|
2218
|
+
// or invalidating _fieldCache / _codeNameCache.
|
|
2219
|
+
this.resetAndApplyOwnedFieldsInPlace(data);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Resets transient record state and applies `fieldData` to this entity's own fields in a
|
|
2223
|
+
* single pass over `this._fields`, reusing existing `EntityField` instances and preserving
|
|
2224
|
+
* `_fieldCache` / `_codeNameCache` when already hydrated.
|
|
2225
|
+
*
|
|
2226
|
+
* Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
|
|
2227
|
+
* with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
|
|
2228
|
+
*/
|
|
2229
|
+
resetAndApplyOwnedFieldsInPlace(fieldData) {
|
|
2230
|
+
this._finalizeGeneration++;
|
|
2231
|
+
this._compositeKey = null;
|
|
2232
|
+
this._resultHistory = [];
|
|
2233
|
+
this._recordLoaded = false;
|
|
2234
|
+
this._raw = null;
|
|
2235
|
+
this._rawConverted = null;
|
|
2236
|
+
this.hydrateFieldsIfNeeded();
|
|
2237
|
+
if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
|
|
2238
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2239
|
+
const field = this._fields[i];
|
|
2240
|
+
field.ResetForInPlaceHydration();
|
|
2241
|
+
field.ClearAsNotLoaded();
|
|
2242
|
+
}
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
let normalizedMap = null;
|
|
2246
|
+
let sharedParentMirrors = null;
|
|
2247
|
+
const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
|
|
2248
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2249
|
+
const field = this._fields[i];
|
|
2250
|
+
const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
|
|
2251
|
+
if (normalizedMap === null) {
|
|
2252
|
+
normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
|
|
2253
|
+
}
|
|
2254
|
+
return normalizedMap;
|
|
2255
|
+
});
|
|
2256
|
+
field.ResetForInPlaceHydration();
|
|
2257
|
+
if (match.found) {
|
|
2258
|
+
this.setFieldValue(field, match.val);
|
|
2259
|
+
field.ResetOldValue();
|
|
2260
|
+
if (parentFieldNames) {
|
|
2261
|
+
if (parentFieldNames.has(match.key)) {
|
|
2262
|
+
(sharedParentMirrors ??= {})[match.key] = match.val;
|
|
2263
|
+
}
|
|
2264
|
+
else if (parentFieldNames.has(field.Name)) {
|
|
2265
|
+
(sharedParentMirrors ??= {})[field.Name] = match.val;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
else {
|
|
2270
|
+
field.ClearAsNotLoaded();
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
if (sharedParentMirrors && this._parentEntity) {
|
|
2274
|
+
this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
|
|
2275
|
+
}
|
|
2276
|
+
this.updateSavedStateFromPrimaryKeys();
|
|
2277
|
+
}
|
|
2278
|
+
static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
|
|
2279
|
+
if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
|
|
2280
|
+
return { found: true, key: field.Name, val: fieldData[field.Name] };
|
|
2281
|
+
}
|
|
2282
|
+
if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
|
|
2283
|
+
return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
|
|
2284
|
+
}
|
|
2285
|
+
const normalizedMap = getNormalizedMap();
|
|
2286
|
+
const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
|
|
2287
|
+
(field.CodeName !== field.Name
|
|
2288
|
+
? normalizedMap.get(field.CodeName.trim().toLowerCase())
|
|
2289
|
+
: undefined);
|
|
2290
|
+
if (hit !== undefined) {
|
|
2291
|
+
return { found: true, key: hit.key, val: hit.val };
|
|
2292
|
+
}
|
|
2293
|
+
return { found: false, key: field.Name, val: undefined };
|
|
2294
|
+
}
|
|
2295
|
+
static buildNormalizedHydrationMap(fieldData) {
|
|
2296
|
+
const normalizedMap = new Map();
|
|
2297
|
+
const keys = Object.keys(fieldData);
|
|
2298
|
+
for (let kIdx = 0; kIdx < keys.length; kIdx++) {
|
|
2299
|
+
const k = keys[kIdx];
|
|
2300
|
+
const v = fieldData[k];
|
|
2301
|
+
if (v !== undefined) {
|
|
2302
|
+
normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return normalizedMap;
|
|
2162
2306
|
}
|
|
2163
2307
|
/**
|
|
2164
2308
|
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
@@ -2410,7 +2554,9 @@ export class BaseEntity {
|
|
|
2410
2554
|
}
|
|
2411
2555
|
const result = await operation.Execute({
|
|
2412
2556
|
EntityName: this.EntityInfo.Name,
|
|
2413
|
-
|
|
2557
|
+
// Without the fields this user may not read: the server refuses a restricted
|
|
2558
|
+
// caller's input that names one, as the transaction-group serializer knows.
|
|
2559
|
+
Fields: OmitFieldsUnreadableByActiveUser(this, this.GetAll()),
|
|
2414
2560
|
Companions: await this.SerializeCompanions(),
|
|
2415
2561
|
IsExistingRecord: this.IsSaved,
|
|
2416
2562
|
}, { provider: this.ProviderToUse, user: this.ActiveUser });
|
|
@@ -2449,9 +2595,14 @@ export class BaseEntity {
|
|
|
2449
2595
|
// 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
|
|
2450
2596
|
// report (and notify subscribers) as an update.
|
|
2451
2597
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
2598
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
2452
2599
|
// Root: rebase field values and dirty state onto what the database now holds.
|
|
2453
2600
|
this.init();
|
|
2454
|
-
this.
|
|
2601
|
+
const fields = this.narrowHydrationSource(output.Fields);
|
|
2602
|
+
this.SetMany(fields, false, true, true);
|
|
2603
|
+
// A hydration source like any other: a field the result omitted (the server withheld it, or
|
|
2604
|
+
// this user may not hold it) is not loaded, so its construction state is never resent as data.
|
|
2605
|
+
this.markFieldsOmittedBySourceAsNotLoaded(fields);
|
|
2455
2606
|
this._recordLoaded = true;
|
|
2456
2607
|
this._everSaved = true;
|
|
2457
2608
|
// Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
|
|
@@ -2465,7 +2616,28 @@ export class BaseEntity {
|
|
|
2465
2616
|
result.StartedAt = new Date();
|
|
2466
2617
|
result.EndedAt = new Date();
|
|
2467
2618
|
this.RegisterResultHistoryEntry(result);
|
|
2468
|
-
this.RaiseEvent('save', null, saveSubType);
|
|
2619
|
+
this.RaiseEvent('save', null, saveSubType, changedFieldNames);
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* The names of the fields that are dirty right now. Cheaper than walking {@link Fields}: an
|
|
2623
|
+
* object still in raw mode (loaded and never written to) cannot be dirty, so it answers `[]`
|
|
2624
|
+
* without building its field objects.
|
|
2625
|
+
*/
|
|
2626
|
+
GetDirtyFieldNames() {
|
|
2627
|
+
if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
|
|
2628
|
+
return [];
|
|
2629
|
+
}
|
|
2630
|
+
const names = [];
|
|
2631
|
+
for (const f of this.Fields) {
|
|
2632
|
+
if (f.Dirty) {
|
|
2633
|
+
names.push(f.Name);
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
return names;
|
|
2637
|
+
}
|
|
2638
|
+
/** The names of the fields that are dirty right now, for the `save` event's {@link BaseEntityEvent.ChangedFieldNames}. */
|
|
2639
|
+
dirtyFieldNames() {
|
|
2640
|
+
return this.GetDirtyFieldNames();
|
|
2469
2641
|
}
|
|
2470
2642
|
/**
|
|
2471
2643
|
* Gives every eager companion a chance to populate itself.
|
|
@@ -2825,6 +2997,7 @@ export class BaseEntity {
|
|
|
2825
2997
|
entity: level,
|
|
2826
2998
|
wasSaved: level._everSaved,
|
|
2827
2999
|
wasLoaded: level._recordLoaded,
|
|
3000
|
+
finalizeGeneration: level._finalizeGeneration,
|
|
2828
3001
|
resultHistory: [...level._resultHistory],
|
|
2829
3002
|
fields: level.Fields.map(field => ({ field, state: field.GetState() })),
|
|
2830
3003
|
});
|
|
@@ -2844,7 +3017,7 @@ export class BaseEntity {
|
|
|
2844
3017
|
* Puts this record back as a snapshot captured it, after the unit of work that finalized it
|
|
2845
3018
|
* rolled back.
|
|
2846
3019
|
*
|
|
2847
|
-
* A save's `finalizeSave()`
|
|
3020
|
+
* A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
|
|
2848
3021
|
* saved and empties its result history. The database undid the write, so the record gets back
|
|
2849
3022
|
* its saved and loaded flags, its result history, and each field's value and tracking state. A
|
|
2850
3023
|
* field edited since then keeps the edit, and is compared with its captured baseline again, since
|
|
@@ -2854,14 +3027,16 @@ export class BaseEntity {
|
|
|
2854
3027
|
* The history matters to the caller: a save records its failure only when nothing else did,
|
|
2855
3028
|
* judged by the history's length when it started, which an emptied history would make wrong.
|
|
2856
3029
|
*
|
|
2857
|
-
* A record
|
|
2858
|
-
* changed and it is left alone.
|
|
3030
|
+
* A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
|
|
3031
|
+
* was never finalized, so nothing about it changed and it is left alone.
|
|
2859
3032
|
*/
|
|
2860
3033
|
restoreSnapshot(snapshot) {
|
|
2861
|
-
const finalized =
|
|
3034
|
+
const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
|
|
3035
|
+
snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
|
|
2862
3036
|
if (!finalized) {
|
|
2863
3037
|
return;
|
|
2864
3038
|
}
|
|
3039
|
+
this._finalizeGeneration = snapshot.finalizeGeneration;
|
|
2865
3040
|
this._everSaved = snapshot.wasSaved;
|
|
2866
3041
|
this._recordLoaded = snapshot.wasLoaded;
|
|
2867
3042
|
this._resultHistory = snapshot.resultHistory;
|
|
@@ -3080,7 +3255,7 @@ export class BaseEntity {
|
|
|
3080
3255
|
/**
|
|
3081
3256
|
* Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
|
|
3082
3257
|
*/
|
|
3083
|
-
RaiseEvent(type, payload, saveSubType = undefined) {
|
|
3258
|
+
RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
|
|
3084
3259
|
// Resolve the entity's bound provider once so every consumer (local subscribers
|
|
3085
3260
|
// AND MJGlobal listeners) receives consistent provider context. This matters in
|
|
3086
3261
|
// multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
|
|
@@ -3091,7 +3266,7 @@ export class BaseEntity {
|
|
|
3091
3266
|
const provider = this.ProviderToUse;
|
|
3092
3267
|
// this is the local event handler that is specific to THIS instance of the entity object
|
|
3093
3268
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
|
|
3094
|
-
this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider });
|
|
3269
|
+
this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider, ChangedFieldNames: changedFieldNames });
|
|
3095
3270
|
// this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
|
|
3096
3271
|
// we broadcast save/delete/load events and their _started counterparts
|
|
3097
3272
|
if (BaseEntity._globalEventTypes.has(type)) {
|
|
@@ -3101,6 +3276,7 @@ export class BaseEntity {
|
|
|
3101
3276
|
event.type = type;
|
|
3102
3277
|
event.saveSubType = saveSubType;
|
|
3103
3278
|
event.provider = provider;
|
|
3279
|
+
event.ChangedFieldNames = changedFieldNames;
|
|
3104
3280
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
|
|
3105
3281
|
MJGlobal.Instance.RaiseEvent({
|
|
3106
3282
|
component: this,
|
|
@@ -3241,6 +3417,7 @@ export class BaseEntity {
|
|
|
3241
3417
|
get TransactionGroup() {
|
|
3242
3418
|
return this._transactionGroup;
|
|
3243
3419
|
}
|
|
3420
|
+
/** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
|
|
3244
3421
|
set TransactionGroup(group) {
|
|
3245
3422
|
this._transactionGroup = group;
|
|
3246
3423
|
}
|
|
@@ -3492,11 +3669,14 @@ export class BaseEntity {
|
|
|
3492
3669
|
* nothing like field security.
|
|
3493
3670
|
*
|
|
3494
3671
|
* Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
|
|
3495
|
-
* build) reads `EntityField.Value` directly and never routes through here
|
|
3496
|
-
*
|
|
3672
|
+
* build) reads `EntityField.Value` directly and never routes through here. It needs no gate
|
|
3673
|
+
* of its own: every hydration point leaves a denied field not-loaded and empty for a
|
|
3674
|
+
* restricted user (see {@link narrowHydrationSource}), so there is no denied value for that
|
|
3675
|
+
* machinery to read. This assertion is what turns asking for one into an error rather than a
|
|
3676
|
+
* silent null.
|
|
3497
3677
|
*/
|
|
3498
3678
|
assertFieldReadable(fieldName) {
|
|
3499
|
-
const denied = this.
|
|
3679
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
3500
3680
|
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
3501
3681
|
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
3502
3682
|
}
|
|
@@ -3812,6 +3992,53 @@ export class BaseEntity {
|
|
|
3812
3992
|
}
|
|
3813
3993
|
return obj;
|
|
3814
3994
|
}
|
|
3995
|
+
/**
|
|
3996
|
+
* Whether {@link GetAll} would carry a key for every non-binary field of this entity: the
|
|
3997
|
+
* object holds a value for each one (none is not loaded, or withheld from its user), here or,
|
|
3998
|
+
* for an IS-A child, on an ancestor. Answers the question
|
|
3999
|
+
* `LocalCacheManager.RowHasEveryField(this.EntityInfo, this.GetAll())` answers, without
|
|
4000
|
+
* building the row, and without building field objects for an object in raw mode.
|
|
4001
|
+
*/
|
|
4002
|
+
HoldsEveryField() {
|
|
4003
|
+
const infos = this.EntityInfo?.Fields;
|
|
4004
|
+
if (!Array.isArray(infos) || infos.length === 0) {
|
|
4005
|
+
return true;
|
|
4006
|
+
}
|
|
4007
|
+
if (!this._fieldsHydrated && this._raw !== null) {
|
|
4008
|
+
for (const fi of infos) {
|
|
4009
|
+
if (!fi.IsBinaryFieldType && this.rawValueFor(this._raw, fi) === undefined && !this.ancestorHoldsField(fi.Name)) {
|
|
4010
|
+
return false;
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
return true;
|
|
4014
|
+
}
|
|
4015
|
+
for (const field of this.Fields) {
|
|
4016
|
+
if (!field.EntityFieldInfo.IsBinaryFieldType && field.NotLoaded && !this.ancestorHoldsField(field.Name)) {
|
|
4017
|
+
return false;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
return true;
|
|
4021
|
+
}
|
|
4022
|
+
/** Whether an IS-A ancestor's {@link GetAll} would carry the key `name` (an exact field name). */
|
|
4023
|
+
ancestorHoldsField(name) {
|
|
4024
|
+
return this._parentEntity?.holdsFieldNamed(name) ?? false;
|
|
4025
|
+
}
|
|
4026
|
+
/** Whether this object's {@link GetAll} would carry the key `name`, counting its own ancestors. */
|
|
4027
|
+
holdsFieldNamed(name) {
|
|
4028
|
+
if (!this._fieldsHydrated && this._raw !== null) {
|
|
4029
|
+
const fi = this.EntityInfo?.FieldByName(name);
|
|
4030
|
+
if (fi && fi.Name === name && this.rawValueFor(this._raw, fi) !== undefined) {
|
|
4031
|
+
return true;
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
else {
|
|
4035
|
+
const field = this.GetFieldByName(name);
|
|
4036
|
+
if (field && field.Name === name && !field.NotLoaded) {
|
|
4037
|
+
return true;
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
return this.ancestorHoldsField(name);
|
|
4041
|
+
}
|
|
3815
4042
|
/**
|
|
3816
4043
|
* Returns a partial object that contains only the fields that have changed since the last time the record was saved. This is useful for scenarios where you want to send only the changes to the server or to a client.
|
|
3817
4044
|
* It is also helpful for quickly finding the fields that are "dirty".
|
|
@@ -4039,19 +4266,43 @@ export class BaseEntity {
|
|
|
4039
4266
|
* Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
|
|
4040
4267
|
* {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
|
|
4041
4268
|
* where omitting a field means "leave it alone," not "this field was never loaded."
|
|
4042
|
-
* Both the field Name and CodeName are checked, matching SetMany's key
|
|
4269
|
+
* Both the field Name and CodeName are checked, case-insensitively, matching SetMany's key
|
|
4270
|
+
* acceptance: SetMany applies `{name: 'b'}` to a field called `Name`, so an exact-case lookup
|
|
4271
|
+
* here would mark the value it just applied as not loaded, and GetAll and the save would then
|
|
4272
|
+
* drop it.
|
|
4043
4273
|
*/
|
|
4044
4274
|
markFieldsOmittedBySourceAsNotLoaded(source) {
|
|
4045
4275
|
if (!source || typeof source !== 'object')
|
|
4046
4276
|
return;
|
|
4277
|
+
// Exact keys first: a source keyed by the field names, the usual case, never builds the
|
|
4278
|
+
// case-insensitive key set. It is built once, on the first field the exact check misses.
|
|
4279
|
+
let presentKeys = null;
|
|
4047
4280
|
for (const field of this.Fields) {
|
|
4048
|
-
if (source
|
|
4281
|
+
if (BaseEntity.ownValuePresent(source, field.Name) || BaseEntity.ownValuePresent(source, field.CodeName)) {
|
|
4282
|
+
continue;
|
|
4283
|
+
}
|
|
4284
|
+
presentKeys ??= BaseEntity.normalizedPresentKeys(source);
|
|
4285
|
+
if (!presentKeys.has(field.Name.trim().toLowerCase()) && !presentKeys.has(field.CodeName.trim().toLowerCase())) {
|
|
4049
4286
|
// Cleared, not just flagged: otherwise a default from construction, or the previous
|
|
4050
4287
|
// record's value on a reload, reads back through Get() as if it had been loaded.
|
|
4051
4288
|
field.ClearAsNotLoaded();
|
|
4052
4289
|
}
|
|
4053
4290
|
}
|
|
4054
4291
|
}
|
|
4292
|
+
/** Whether `source` holds `key` as its own property with a value (an inherited `constructor` does not count). */
|
|
4293
|
+
static ownValuePresent(source, key) {
|
|
4294
|
+
return source[key] !== undefined && Object.prototype.hasOwnProperty.call(source, key);
|
|
4295
|
+
}
|
|
4296
|
+
/** The keys of `source` that carry a value, trimmed and lowercased. */
|
|
4297
|
+
static normalizedPresentKeys(source) {
|
|
4298
|
+
const keys = new Set();
|
|
4299
|
+
for (const key of Object.keys(source)) {
|
|
4300
|
+
if (source[key] !== undefined) {
|
|
4301
|
+
keys.add(key.trim().toLowerCase());
|
|
4302
|
+
}
|
|
4303
|
+
}
|
|
4304
|
+
return keys;
|
|
4305
|
+
}
|
|
4055
4306
|
/**
|
|
4056
4307
|
* This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
|
|
4057
4308
|
* This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
|
|
@@ -4061,8 +4312,15 @@ export class BaseEntity {
|
|
|
4061
4312
|
*/
|
|
4062
4313
|
CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
|
|
4063
4314
|
try {
|
|
4315
|
+
// Field security: a field THIS object's user may not read is skipped, not copied. The
|
|
4316
|
+
// source can be full width (another user's object, or a server-internal one), and
|
|
4317
|
+
// Set() refuses a denied field, which used to end the copy at the first one.
|
|
4318
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
4064
4319
|
// iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
|
|
4065
4320
|
for (let field of this.Fields) {
|
|
4321
|
+
if (denied?.has(field.Name.trim().toLowerCase())) {
|
|
4322
|
+
continue;
|
|
4323
|
+
}
|
|
4066
4324
|
if (!field.IsPrimaryKey || includePrimaryKeys) {
|
|
4067
4325
|
const otherField = other.GetFieldByName(field.Name);
|
|
4068
4326
|
// Skip fields the SOURCE never loaded (D-3): copying their construction
|
|
@@ -4086,9 +4344,27 @@ export class BaseEntity {
|
|
|
4086
4344
|
* The ContextCurrentUser is a property used to manually set the "current" user for scenarios, primarily on the server side, where the user changes per request. For situations where there is no global CurrentUser in the Metadata.Provider,
|
|
4087
4345
|
* you MUST set this property to the user you want to use for the current operation. If you used Metadata.GetEntityObject() to get the entity object, this property will be set automatically for you as that method has a parameter that can
|
|
4088
4346
|
* be provided for the ContextCurrentUser.
|
|
4347
|
+
*
|
|
4348
|
+
* **Field security.** An object holds only what its user may read, so assigning a different
|
|
4349
|
+
* user to an object that already holds a record narrows it to the new user: values the new
|
|
4350
|
+
* user may not read are dropped (the fields become not loaded) from this object, its IS-A
|
|
4351
|
+
* parent, its embedded records and the loaded children of its related-record collections,
|
|
4352
|
+
* which are given the same user. A read-only cache collection's children are the engine's own
|
|
4353
|
+
* instances and are left as they are. Nothing is widened: a field
|
|
4354
|
+
* the previous user could not read stays not loaded for a user who can, because its value was
|
|
4355
|
+
* never loaded. Set the user before loading wherever possible, so the record is loaded at the
|
|
4356
|
+
* right width in the first place.
|
|
4089
4357
|
*/
|
|
4090
4358
|
set ContextCurrentUser(user) {
|
|
4359
|
+
if (user === this._contextCurrentUser) {
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4091
4362
|
this._contextCurrentUser = user;
|
|
4363
|
+
this.narrowHeldValuesForActiveUser(false);
|
|
4364
|
+
if (this._parentEntity) {
|
|
4365
|
+
this._parentEntity.ContextCurrentUser = user;
|
|
4366
|
+
}
|
|
4367
|
+
this.notifyCompanionsContextUserChanged(user);
|
|
4092
4368
|
}
|
|
4093
4369
|
get ContextCurrentUser() {
|
|
4094
4370
|
return this._contextCurrentUser;
|
|
@@ -4155,6 +4431,11 @@ export class BaseEntity {
|
|
|
4155
4431
|
}
|
|
4156
4432
|
}
|
|
4157
4433
|
}
|
|
4434
|
+
// Field security: a new record holds no default for a field its user may not read. The
|
|
4435
|
+
// field is not loaded instead, so GetAll() and every payload built from it leave it out
|
|
4436
|
+
// (the server refuses a restricted caller's input that names it), and the save omits it,
|
|
4437
|
+
// so the column takes its database default.
|
|
4438
|
+
this.narrowHeldValuesForActiveUser(true);
|
|
4158
4439
|
// Apply caller-supplied values LAST so an explicit PK (or any routed
|
|
4159
4440
|
// parent field) wins over the generated/adopted value instead of being
|
|
4160
4441
|
// clobbered by the parent's NewRecord().
|
|
@@ -4181,6 +4462,53 @@ export class BaseEntity {
|
|
|
4181
4462
|
}
|
|
4182
4463
|
}
|
|
4183
4464
|
}
|
|
4465
|
+
/**
|
|
4466
|
+
* Gives every companion this object's new user: embedded records and the loaded children of
|
|
4467
|
+
* related-record collections take it. See {@link ContextCurrentUser}.
|
|
4468
|
+
*/
|
|
4469
|
+
notifyCompanionsContextUserChanged(user) {
|
|
4470
|
+
if (!this.HasCompanions) {
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
for (const companion of this.Companions) {
|
|
4474
|
+
companion.OnOwnerContextUserChanged(user);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
/**
|
|
4478
|
+
* Drops every value this object holds that its user may not read: the field is left not
|
|
4479
|
+
* loaded and empty, or, before the fields are built, the key leaves the raw row (as a copy;
|
|
4480
|
+
* the row can be shared). A no-op when field security does not apply or the user is denied
|
|
4481
|
+
* nothing, which is the one boolean most entities pay.
|
|
4482
|
+
*
|
|
4483
|
+
* For the points where the user or the record changes WITHOUT a hydration source to narrow:
|
|
4484
|
+
* a user assigned to an object that already holds a record, and a new record's defaults.
|
|
4485
|
+
*
|
|
4486
|
+
* @param buildFields true to build the fields first when they are not built yet, as a new
|
|
4487
|
+
* record needs: its defaults live in fields that are only created on first use.
|
|
4488
|
+
*/
|
|
4489
|
+
narrowHeldValuesForActiveUser(buildFields) {
|
|
4490
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
4491
|
+
if (!denied) {
|
|
4492
|
+
return;
|
|
4493
|
+
}
|
|
4494
|
+
if (!this._fieldsHydrated && !buildFields) {
|
|
4495
|
+
if (this._raw) {
|
|
4496
|
+
this._raw = OmitDeniedFieldKeys(this._raw, denied);
|
|
4497
|
+
this._rawConverted = null;
|
|
4498
|
+
}
|
|
4499
|
+
return;
|
|
4500
|
+
}
|
|
4501
|
+
let cleared = false;
|
|
4502
|
+
for (const field of this.Fields) {
|
|
4503
|
+
if (denied.has(field.Name.trim().toLowerCase()) && (!field.NotLoaded || field.Value !== null)) {
|
|
4504
|
+
field.ClearAsNotLoaded();
|
|
4505
|
+
cleared = true;
|
|
4506
|
+
}
|
|
4507
|
+
}
|
|
4508
|
+
if (cleared) {
|
|
4509
|
+
this.discardJSONFieldObjects(); // a parsed JSON object can hold a value just dropped
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4184
4512
|
/**
|
|
4185
4513
|
* Returns the active restore context for the next save, if any.
|
|
4186
4514
|
*
|
|
@@ -4681,6 +5009,8 @@ export class BaseEntity {
|
|
|
4681
5009
|
}
|
|
4682
5010
|
finalizeSave(data, saveSubType, options) {
|
|
4683
5011
|
if (data) {
|
|
5012
|
+
// The 'save' event names the fields this save wrote; init() below clears every dirty flag.
|
|
5013
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
4684
5014
|
const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
|
|
4685
5015
|
const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
|
|
4686
5016
|
const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
|
|
@@ -4709,26 +5039,22 @@ export class BaseEntity {
|
|
|
4709
5039
|
else {
|
|
4710
5040
|
fieldData = rawFieldData;
|
|
4711
5041
|
}
|
|
4712
|
-
|
|
4713
|
-
//
|
|
4714
|
-
//
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
//
|
|
4718
|
-
//
|
|
4719
|
-
//
|
|
4720
|
-
|
|
4721
|
-
// trigger/system adjustment or concurrent change would be silently stomped).
|
|
4722
|
-
if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
|
|
4723
|
-
this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
|
|
4724
|
-
}
|
|
5042
|
+
// The save returns the whole row, whoever saved it. The provider has already built the
|
|
5043
|
+
// Record Changes payload from this entity's pre-save state, so narrowing the response
|
|
5044
|
+
// here costs the audit nothing; it only keeps denied values out of this object.
|
|
5045
|
+
fieldData = this.narrowHydrationSource(fieldData);
|
|
5046
|
+
// Reset transient record state and update owned EntityField values in place so
|
|
5047
|
+
// hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
|
|
5048
|
+
// Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
|
|
5049
|
+
// across Save() remain attached and live.
|
|
5050
|
+
this.resetAndApplyOwnedFieldsInPlace(fieldData);
|
|
4725
5051
|
this._everSaved = true; // Mark as saved after successful save
|
|
4726
5052
|
const result = this.LatestResult;
|
|
4727
5053
|
if (result)
|
|
4728
5054
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
4729
5055
|
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
4730
5056
|
this.cacheRecordName();
|
|
4731
|
-
this.RaiseEvent('save', null, saveSubType);
|
|
5057
|
+
this.RaiseEvent('save', null, saveSubType, changedFieldNames);
|
|
4732
5058
|
return true;
|
|
4733
5059
|
}
|
|
4734
5060
|
else {
|
|
@@ -4864,16 +5190,17 @@ export class BaseEntity {
|
|
|
4864
5190
|
* entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
|
|
4865
5191
|
* carry that premise, and BOTH are load-bearing:
|
|
4866
5192
|
*
|
|
4867
|
-
* 1. `
|
|
4868
|
-
* cannot READ,
|
|
4869
|
-
*
|
|
5193
|
+
* 1. `AssertClientInputFieldSecurity` (through `AssertClientInputWritableForUser`) rejects
|
|
5194
|
+
* client input naming a field the caller cannot READ, or no field at all, which `SetMany`
|
|
5195
|
+
* would otherwise make genuinely dirty with fabricated data.
|
|
5196
|
+
* 2. `entityInfo.FieldLevelSecurityApplies` forces the truth-load branch, so the entity's
|
|
4870
5197
|
* non-dirty baseline is the real stored row rather than the client's `OldValues___`.
|
|
4871
5198
|
*
|
|
4872
5199
|
* (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
|
|
4873
5200
|
* EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
|
|
4874
5201
|
* never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
|
|
4875
5202
|
* `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
|
|
4876
|
-
* canonical FLS configuration, and one that leaves (1) with nothing to
|
|
5203
|
+
* canonical FLS configuration, and one that leaves (1) with nothing to reject — could write an
|
|
4877
5204
|
* update-denied field just by pinning its value in `OldValues___` and never naming it in the
|
|
4878
5205
|
* mutation. If you are considering relaxing that branch condition, this check is what breaks.
|
|
4879
5206
|
*
|
|
@@ -4906,7 +5233,7 @@ export class BaseEntity {
|
|
|
4906
5233
|
// deliberately skips the readability assertion (hydration / resolver-apply path),
|
|
4907
5234
|
// so server-side code can dirty a read-denied field and land here.
|
|
4908
5235
|
if (deniedRead === undefined) {
|
|
4909
|
-
deniedRead = this.
|
|
5236
|
+
deniedRead = this.deniedReadFieldsForActiveUser();
|
|
4910
5237
|
}
|
|
4911
5238
|
throw deniedRead?.has(key)
|
|
4912
5239
|
? new FieldSecurityError(field.Name, this.EntityInfo.Name)
|
|
@@ -4955,15 +5282,32 @@ export class BaseEntity {
|
|
|
4955
5282
|
`omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
|
|
4956
5283
|
}
|
|
4957
5284
|
}
|
|
5285
|
+
/**
|
|
5286
|
+
* Whether this object's user may READ the named field: the question `Get()` asks before it
|
|
5287
|
+
* throws a `FieldSecurityError`, answered without throwing.
|
|
5288
|
+
*
|
|
5289
|
+
* For code that reads a field it did not choose, such as a configured path or a display
|
|
5290
|
+
* column, and must treat a denied field as having no value rather than fail. Fails open the
|
|
5291
|
+
* way `Get()` does: true when field security does not apply to this entity (or any IS-A
|
|
5292
|
+
* ancestor) or no user resolves.
|
|
5293
|
+
*
|
|
5294
|
+
* @param fieldName the field's Name, matched case-insensitively
|
|
5295
|
+
*/
|
|
5296
|
+
IsFieldReadableByActiveUser(fieldName) {
|
|
5297
|
+
if (!fieldName) {
|
|
5298
|
+
return true;
|
|
5299
|
+
}
|
|
5300
|
+
return !this.deniedReadFieldsForActiveUser()?.has(fieldName.trim().toLowerCase());
|
|
5301
|
+
}
|
|
4958
5302
|
/**
|
|
4959
5303
|
* The denied-field set for the acting user, or null when field security does not apply —
|
|
4960
|
-
* the entity
|
|
5304
|
+
* it is off on the entity and every IS-A ancestor, no user resolves, or the user is denied nothing.
|
|
4961
5305
|
*
|
|
4962
5306
|
* Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
|
|
4963
5307
|
* the three cheap short-circuits in one place instead of repeated at each gate.
|
|
4964
5308
|
*/
|
|
4965
5309
|
deniedFieldsForActiveUser(select) {
|
|
4966
|
-
if (!this.EntityInfo?.
|
|
5310
|
+
if (!this.EntityInfo?.FieldLevelSecurityApplies) {
|
|
4967
5311
|
return null; // one boolean for the overwhelming majority of entities
|
|
4968
5312
|
}
|
|
4969
5313
|
const u = this.resolveActiveUserOrNull();
|
|
@@ -4973,6 +5317,34 @@ export class BaseEntity {
|
|
|
4973
5317
|
const denied = select(u);
|
|
4974
5318
|
return denied.size > 0 ? denied : null;
|
|
4975
5319
|
}
|
|
5320
|
+
/**
|
|
5321
|
+
* The fields this object's user may not READ, lowercased by Name and CodeName, or null when
|
|
5322
|
+
* field security does not apply. Every readability question this class asks, at hydration,
|
|
5323
|
+
* at the typed accessors and at the save gates, goes through here.
|
|
5324
|
+
*/
|
|
5325
|
+
deniedReadFieldsForActiveUser() {
|
|
5326
|
+
return this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
5327
|
+
}
|
|
5328
|
+
/**
|
|
5329
|
+
* A hydration source reduced to what this object's user may hold: the same object when the
|
|
5330
|
+
* user is denied nothing (or field security does not apply), otherwise a shallow copy without
|
|
5331
|
+
* the denied keys, matched case-insensitively under Name and CodeName.
|
|
5332
|
+
*
|
|
5333
|
+
* Every point that fills this object from a row passes the row through here first, which is
|
|
5334
|
+
* what makes the object's width a property of its user rather than of the path that built it.
|
|
5335
|
+
* A key absent from a hydration source already means "not loaded" everywhere (`GetAll`, field
|
|
5336
|
+
* building, the save), so omitting a denied key is all it takes for the value never to enter
|
|
5337
|
+
* the object, and for every accessor and serializer to be correct without a check of its own.
|
|
5338
|
+
*
|
|
5339
|
+
* The caller's row is never modified, because it can be a frozen row shared through a cache,
|
|
5340
|
+
* and an unrestricted user's row is returned by reference, so the raw load path stays zero-copy.
|
|
5341
|
+
*/
|
|
5342
|
+
narrowHydrationSource(source) {
|
|
5343
|
+
if (!source || typeof source !== 'object' || Array.isArray(source) || source instanceof Date) {
|
|
5344
|
+
return source;
|
|
5345
|
+
}
|
|
5346
|
+
return OmitDeniedFieldKeys(source, this.deniedReadFieldsForActiveUser());
|
|
5347
|
+
}
|
|
4976
5348
|
/**
|
|
4977
5349
|
* {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
|
|
4978
5350
|
* one from.
|
|
@@ -5040,7 +5412,7 @@ export class BaseEntity {
|
|
|
5040
5412
|
if (!this.IsSaved) {
|
|
5041
5413
|
this.init(); // wipe out current data if we're loading on top of existing record
|
|
5042
5414
|
}
|
|
5043
|
-
|
|
5415
|
+
let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
|
|
5044
5416
|
this.discardJSONFieldObjects(); // a reload replaces the record's data
|
|
5045
5417
|
if (!data) {
|
|
5046
5418
|
// A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
|
|
@@ -5049,6 +5421,9 @@ export class BaseEntity {
|
|
|
5049
5421
|
}
|
|
5050
5422
|
return false; // no data loaded, return false
|
|
5051
5423
|
}
|
|
5424
|
+
// Field security: denied values never enter this object, even from a provider that
|
|
5425
|
+
// did not narrow its SELECT (see narrowHydrationSource).
|
|
5426
|
+
data = this.narrowHydrationSource(data);
|
|
5052
5427
|
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
5053
5428
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
5054
5429
|
// then populates from data. This must happen before self's SetMany so parents
|
|
@@ -5199,6 +5574,16 @@ export class BaseEntity {
|
|
|
5199
5574
|
*/
|
|
5200
5575
|
async LoadFromData(data, _replaceOldValues = false) {
|
|
5201
5576
|
this.discardJSONFieldObjects(); // the record's data is being replaced: parsed JSON objects are stale
|
|
5577
|
+
// An entity object passed as the row is read through its own GetAll(): what ITS user may
|
|
5578
|
+
// read, as a plain row. Kept as the row itself, it would be read through its typed getters,
|
|
5579
|
+
// which answer for the source's user and so return values this object's user is denied
|
|
5580
|
+
// (narrowing below sees only an object's own keys, and an entity's values are not those).
|
|
5581
|
+
if (data instanceof BaseEntity) {
|
|
5582
|
+
data = data.GetAll();
|
|
5583
|
+
}
|
|
5584
|
+
// Field security: a restricted user's object holds a copy of the row without the keys they
|
|
5585
|
+
// are denied; anyone else's keeps the caller's row by reference (see narrowHydrationSource).
|
|
5586
|
+
data = this.narrowHydrationSource(data);
|
|
5202
5587
|
// IS-A: hydrate parent chain from data before populating self.
|
|
5203
5588
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
5204
5589
|
// then populates from data, ensuring correct PK and saved state on parents.
|
|
@@ -5302,6 +5687,398 @@ export class BaseEntity {
|
|
|
5302
5687
|
await this.deserializeCompanionsFromData(data);
|
|
5303
5688
|
return true;
|
|
5304
5689
|
}
|
|
5690
|
+
/**
|
|
5691
|
+
* Framework-internal batch IS-A parent hydration entry point used by
|
|
5692
|
+
* `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
|
|
5693
|
+
*
|
|
5694
|
+
* Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
|
|
5695
|
+
* 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
|
|
5696
|
+
* (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
|
|
5697
|
+
*/
|
|
5698
|
+
static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
|
|
5699
|
+
/**
|
|
5700
|
+
* Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
|
|
5701
|
+
* during IS-A child discovery or hydration.
|
|
5702
|
+
*/
|
|
5703
|
+
static get ISABatchChunkSize() {
|
|
5704
|
+
return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
|
|
5705
|
+
}
|
|
5706
|
+
async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
|
|
5707
|
+
const loadedParents = [];
|
|
5708
|
+
for (let i = 0; i < peers.length; i++) {
|
|
5709
|
+
const { entity, data } = peers[i];
|
|
5710
|
+
if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
|
|
5711
|
+
await entity.LoadFromData(data);
|
|
5712
|
+
continue;
|
|
5713
|
+
}
|
|
5714
|
+
const allPksSet = await entity.hydrateSingleISAParentPeer(data);
|
|
5715
|
+
if (allPksSet) {
|
|
5716
|
+
loadedParents.push(entity);
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
if (loadedParents.length > 0) {
|
|
5720
|
+
await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5723
|
+
async hydrateSingleISAParentPeer(rawData) {
|
|
5724
|
+
this.discardJSONFieldObjects();
|
|
5725
|
+
const dataObj = rawData instanceof BaseEntity
|
|
5726
|
+
? rawData.GetAll()
|
|
5727
|
+
: rawData;
|
|
5728
|
+
const narrowed = this.narrowHydrationSource(dataObj);
|
|
5729
|
+
if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
|
|
5730
|
+
this._parentEntity.Hydrate(narrowed);
|
|
5731
|
+
}
|
|
5732
|
+
const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
|
|
5733
|
+
this.SetMany(narrowed, true, false, true);
|
|
5734
|
+
if (isPlain) {
|
|
5735
|
+
this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
|
|
5736
|
+
}
|
|
5737
|
+
let allPksSet = false;
|
|
5738
|
+
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
5739
|
+
allPksSet = true;
|
|
5740
|
+
for (const pkey of this.PrimaryKeys) {
|
|
5741
|
+
if (pkey.Value === null || pkey.Value === undefined) {
|
|
5742
|
+
allPksSet = false;
|
|
5743
|
+
break;
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
this._recordLoaded = allPksSet;
|
|
5747
|
+
this._everSaved = allPksSet;
|
|
5748
|
+
if (allPksSet) {
|
|
5749
|
+
this.cacheRecordName();
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
else {
|
|
5753
|
+
LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
|
|
5754
|
+
this._recordLoaded = false;
|
|
5755
|
+
this._everSaved = false;
|
|
5756
|
+
}
|
|
5757
|
+
await this.deserializeCompanionsFromData(narrowed);
|
|
5758
|
+
return allPksSet;
|
|
5759
|
+
}
|
|
5760
|
+
static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
|
|
5761
|
+
const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
|
|
5762
|
+
if (activeParents.length === 0) {
|
|
5763
|
+
return;
|
|
5764
|
+
}
|
|
5765
|
+
const groupsByEntityId = new Map();
|
|
5766
|
+
for (const p of activeParents) {
|
|
5767
|
+
p._childEntityDiscoveryDone = true;
|
|
5768
|
+
let group = groupsByEntityId.get(p.EntityInfo.ID);
|
|
5769
|
+
if (!group) {
|
|
5770
|
+
group = [];
|
|
5771
|
+
groupsByEntityId.set(p.EntityInfo.ID, group);
|
|
5772
|
+
}
|
|
5773
|
+
group.push(p);
|
|
5774
|
+
}
|
|
5775
|
+
const nextLevelChildren = [];
|
|
5776
|
+
for (const [, groupParents] of groupsByEntityId) {
|
|
5777
|
+
const sampleParent = groupParents[0];
|
|
5778
|
+
if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
|
|
5779
|
+
await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
|
|
5780
|
+
}
|
|
5781
|
+
else {
|
|
5782
|
+
await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
|
|
5783
|
+
}
|
|
5784
|
+
}
|
|
5785
|
+
if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
|
|
5786
|
+
await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
|
|
5787
|
+
}
|
|
5788
|
+
}
|
|
5789
|
+
static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
|
|
5790
|
+
const sampleParent = groupParents[0];
|
|
5791
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
5792
|
+
const entityProvider = sampleParent.ProviderToUse;
|
|
5793
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5794
|
+
const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
|
|
5795
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, contextUser ?? sampleParent._contextCurrentUser);
|
|
5796
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5797
|
+
for (const p of groupParents) {
|
|
5798
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
5799
|
+
const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
|
|
5800
|
+
const matched = batchMap.get(pkKey) ?? [];
|
|
5801
|
+
p._childEntities = matched.map(name => ({ entityName: name }));
|
|
5802
|
+
}
|
|
5803
|
+
}
|
|
5804
|
+
else {
|
|
5805
|
+
await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
|
|
5809
|
+
const sampleParent = groupParents[0];
|
|
5810
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
5811
|
+
const entityProvider = sampleParent.ProviderToUse;
|
|
5812
|
+
const rvProvider = sampleParent.RunViewProviderToUse;
|
|
5813
|
+
const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
|
|
5814
|
+
// 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
|
|
5815
|
+
const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser);
|
|
5816
|
+
// 2) Batch-load hinted child rows in 1 RunViews call; any missing/failed row falls back to discovery
|
|
5817
|
+
if (hintedByChild.size > 0) {
|
|
5818
|
+
const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, rvProvider, effectiveUser, nextLevelChildren);
|
|
5819
|
+
for (const unlinked of unlinkedFromHints) {
|
|
5820
|
+
// Same diagnostic the per-row path emits when a SubtypeSelector hint names a
|
|
5821
|
+
// child that has no row: operators need the signal that a selector is wrong.
|
|
5822
|
+
const hinted = hintedNameByParent.get(unlinked);
|
|
5823
|
+
if (hinted) {
|
|
5824
|
+
BaseEntity.logLoadFoundNoRow(hinted, unlinked.PrimaryKey);
|
|
5825
|
+
}
|
|
5826
|
+
needsDiscovery.push(unlinked);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
// 3) Batch-discover remaining unresolved parents in 1 query, then batch-load their child rows in 1 RunViews call
|
|
5830
|
+
if (needsDiscovery.length > 0) {
|
|
5831
|
+
const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, needsDiscovery, entityProvider, rvProvider, effectiveUser);
|
|
5832
|
+
if (discoveredByChild.size > 0) {
|
|
5833
|
+
await BaseEntity.batchLoadAndLinkDisjointChildren(discoveredByChild, md, rvProvider, effectiveUser, nextLevelChildren);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
}
|
|
5837
|
+
static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
|
|
5838
|
+
const hintedByChild = new Map();
|
|
5839
|
+
const hintedNameByParent = new Map();
|
|
5840
|
+
const needsDiscovery = [];
|
|
5841
|
+
for (const p of groupParents) {
|
|
5842
|
+
const hintedName = await p.resolveSubtypeLoadHint();
|
|
5843
|
+
if (hintedName) {
|
|
5844
|
+
const childInfo = md.EntityByName?.(hintedName) ??
|
|
5845
|
+
parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
|
|
5846
|
+
const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
|
|
5847
|
+
const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
5848
|
+
if (childInfo && canRead) {
|
|
5849
|
+
BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
|
|
5850
|
+
hintedNameByParent.set(p, childInfo.Name);
|
|
5851
|
+
}
|
|
5852
|
+
else {
|
|
5853
|
+
needsDiscovery.push(p);
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
else {
|
|
5857
|
+
needsDiscovery.push(p);
|
|
5858
|
+
}
|
|
5859
|
+
}
|
|
5860
|
+
return { hintedByChild, hintedNameByParent, needsDiscovery };
|
|
5861
|
+
}
|
|
5862
|
+
static appendParentToChildGroup(grouped, childName, parent) {
|
|
5863
|
+
let bucket = grouped.get(childName);
|
|
5864
|
+
if (!bucket) {
|
|
5865
|
+
bucket = [];
|
|
5866
|
+
grouped.set(childName, bucket);
|
|
5867
|
+
}
|
|
5868
|
+
bucket.push(parent);
|
|
5869
|
+
}
|
|
5870
|
+
static buildChildPkInFilter(pkField, rawPkValues) {
|
|
5871
|
+
const formatted = rawPkValues.map(val => {
|
|
5872
|
+
if (!pkField.NeedsQuotes) {
|
|
5873
|
+
// Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
|
|
5874
|
+
const text = String(val).trim();
|
|
5875
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
5876
|
+
return text;
|
|
5877
|
+
}
|
|
5878
|
+
}
|
|
5879
|
+
return `'${EscapeSQLString(String(val))}'`;
|
|
5880
|
+
});
|
|
5881
|
+
return `${pkField.Name} IN (${formatted.join(', ')})`;
|
|
5882
|
+
}
|
|
5883
|
+
static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
|
|
5884
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5885
|
+
const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
|
|
5886
|
+
const grouped = new Map();
|
|
5887
|
+
const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
|
|
5888
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5889
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
|
|
5890
|
+
for (const p of parents) {
|
|
5891
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
5892
|
+
const firstMatch = batchMap.get(pkKey)?.[0];
|
|
5893
|
+
if (firstMatch) {
|
|
5894
|
+
BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
return grouped;
|
|
5898
|
+
}
|
|
5899
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
5900
|
+
return BaseEntity.discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser);
|
|
5901
|
+
}
|
|
5902
|
+
// Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
|
|
5903
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
5904
|
+
for (const p of parents) {
|
|
5905
|
+
const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
|
|
5906
|
+
if (found?.ChildEntityName) {
|
|
5907
|
+
BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
}
|
|
5911
|
+
return grouped;
|
|
5912
|
+
}
|
|
5913
|
+
static async discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser) {
|
|
5914
|
+
const grouped = new Map();
|
|
5915
|
+
const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
5916
|
+
if (validChildren.length === 0)
|
|
5917
|
+
return grouped;
|
|
5918
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
5919
|
+
const winnerByPk = new Map();
|
|
5920
|
+
const rv = new RunView(rvProvider);
|
|
5921
|
+
for (let start = 0; start < pkValues.length; start += chunkSize) {
|
|
5922
|
+
const chunkPks = pkValues.slice(start, start + chunkSize);
|
|
5923
|
+
const probeParams = validChildren.map(child => {
|
|
5924
|
+
const childPk = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5925
|
+
return {
|
|
5926
|
+
EntityName: child.Name,
|
|
5927
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(childPk, chunkPks),
|
|
5928
|
+
Fields: [childPk.Name],
|
|
5929
|
+
ResultType: 'simple',
|
|
5930
|
+
// Internal probe: the pre-batch path (InnerLoad) never touched the RunView
|
|
5931
|
+
// cache. Every distinct IN-list would otherwise become its own filtered
|
|
5932
|
+
// cache slot that is invalidated (never maintained) on each child save.
|
|
5933
|
+
BypassCache: true,
|
|
5934
|
+
};
|
|
5935
|
+
});
|
|
5936
|
+
const probeResults = await rv.RunViews(probeParams, contextUser);
|
|
5937
|
+
for (let cIdx = 0; cIdx < validChildren.length; cIdx++) {
|
|
5938
|
+
const child = validChildren[cIdx];
|
|
5939
|
+
const pkName = child.FirstPrimaryKey.Name; // first-pk-ok: IS-A children share the parent's single key
|
|
5940
|
+
const res = probeResults[cIdx];
|
|
5941
|
+
if (!res?.Success || !Array.isArray(res.Results))
|
|
5942
|
+
continue;
|
|
5943
|
+
for (const row of res.Results) {
|
|
5944
|
+
const rawVal = row[pkName];
|
|
5945
|
+
if (rawVal != null) {
|
|
5946
|
+
const norm = CanonicalPrimaryKeyValue(String(rawVal), pkIsUuid);
|
|
5947
|
+
if (!winnerByPk.has(norm)) {
|
|
5948
|
+
winnerByPk.set(norm, child.Name);
|
|
5949
|
+
}
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
for (const p of parents) {
|
|
5955
|
+
const norm = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
5956
|
+
const childName = winnerByPk.get(norm);
|
|
5957
|
+
if (childName) {
|
|
5958
|
+
BaseEntity.appendParentToChildGroup(grouped, childName, p);
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
return grouped;
|
|
5962
|
+
}
|
|
5963
|
+
static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, rvProvider, contextUser, outLinkedChildren) {
|
|
5964
|
+
const unlinkedParents = [];
|
|
5965
|
+
if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
|
|
5966
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
5967
|
+
for (const p of parents) {
|
|
5968
|
+
const linked = await p.createAndLinkChildEntity(childName);
|
|
5969
|
+
if (!linked) {
|
|
5970
|
+
unlinkedParents.push(p);
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
return unlinkedParents;
|
|
5975
|
+
}
|
|
5976
|
+
const deniedChildLogged = new Set();
|
|
5977
|
+
const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
|
|
5978
|
+
if (slices.length === 0) {
|
|
5979
|
+
return unlinkedParents;
|
|
5980
|
+
}
|
|
5981
|
+
const rv = new RunView(rvProvider);
|
|
5982
|
+
const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
|
|
5983
|
+
for (let sIdx = 0; sIdx < slices.length; sIdx++) {
|
|
5984
|
+
await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
|
|
5985
|
+
}
|
|
5986
|
+
return unlinkedParents;
|
|
5987
|
+
}
|
|
5988
|
+
static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
|
|
5989
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
5990
|
+
const slices = [];
|
|
5991
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
5992
|
+
const childInfo = md.EntityByName?.(childName) ??
|
|
5993
|
+
parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
|
|
5994
|
+
const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5995
|
+
if (!childInfo || !pkField) {
|
|
5996
|
+
for (const p of parents)
|
|
5997
|
+
unlinkedParents.push(p);
|
|
5998
|
+
continue;
|
|
5999
|
+
}
|
|
6000
|
+
const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
|
|
6001
|
+
const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
6002
|
+
if (!canRead) {
|
|
6003
|
+
if (!deniedChildLogged.has(childInfo.Name)) {
|
|
6004
|
+
deniedChildLogged.add(childInfo.Name);
|
|
6005
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6006
|
+
}
|
|
6007
|
+
for (const p of parents)
|
|
6008
|
+
unlinkedParents.push(p);
|
|
6009
|
+
continue;
|
|
6010
|
+
}
|
|
6011
|
+
for (let start = 0; start < parents.length; start += chunkSize) {
|
|
6012
|
+
const sliceParents = parents.slice(start, start + chunkSize);
|
|
6013
|
+
const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
|
|
6014
|
+
slices.push({
|
|
6015
|
+
childName: childInfo.Name,
|
|
6016
|
+
pkFieldName: pkField.Name,
|
|
6017
|
+
pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
|
|
6018
|
+
parents: sliceParents,
|
|
6019
|
+
param: {
|
|
6020
|
+
EntityName: childInfo.Name,
|
|
6021
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
|
|
6022
|
+
ResultType: 'simple',
|
|
6023
|
+
// Internal hydration read: see batchDiscoverDisjointChildren — keep these
|
|
6024
|
+
// per-batch IN-list reads out of the shared RunView cache.
|
|
6025
|
+
BypassCache: true,
|
|
6026
|
+
},
|
|
6027
|
+
});
|
|
6028
|
+
}
|
|
6029
|
+
}
|
|
6030
|
+
return slices;
|
|
6031
|
+
}
|
|
6032
|
+
static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
|
|
6033
|
+
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
6034
|
+
// Not silent: the per-row path surfaced a failed child load; here the parents stay
|
|
6035
|
+
// unlinked (plain parent records) and the cause goes to the log.
|
|
6036
|
+
LogWarning(`BaseEntity: batched IS-A child load of '${slice.childName}' failed (${res?.ErrorMessage || 'no result'}); ${slice.parents.length} parent record(s) continue without their subtype`, 'IS-A');
|
|
6037
|
+
for (const p of slice.parents)
|
|
6038
|
+
unlinkedParents.push(p);
|
|
6039
|
+
return;
|
|
6040
|
+
}
|
|
6041
|
+
const rowByPk = new Map();
|
|
6042
|
+
for (const row of res.Results) {
|
|
6043
|
+
const pkVal = row[slice.pkFieldName];
|
|
6044
|
+
if (pkVal != null) {
|
|
6045
|
+
rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
6048
|
+
for (const p of slice.parents) {
|
|
6049
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
|
|
6050
|
+
const childRow = rowByPk.get(pkKey);
|
|
6051
|
+
if (!childRow) {
|
|
6052
|
+
unlinkedParents.push(p);
|
|
6053
|
+
continue;
|
|
6054
|
+
}
|
|
6055
|
+
const childEntity = await p.createChildEntityObject(slice.childName);
|
|
6056
|
+
if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
|
|
6057
|
+
// The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
|
|
6058
|
+
// path degrades to a plain parent record instead, and says so once per slice.
|
|
6059
|
+
if (!deniedChildLogged.has(slice.childName)) {
|
|
6060
|
+
deniedChildLogged.add(slice.childName);
|
|
6061
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6062
|
+
}
|
|
6063
|
+
unlinkedParents.push(p);
|
|
6064
|
+
continue;
|
|
6065
|
+
}
|
|
6066
|
+
await p.linkHydratedChildFromBatchRow(childEntity, childRow);
|
|
6067
|
+
outLinkedChildren.push(childEntity);
|
|
6068
|
+
}
|
|
6069
|
+
}
|
|
6070
|
+
async linkHydratedChildFromBatchRow(childEntity, childRow) {
|
|
6071
|
+
this.replaceChildParentChain(childEntity);
|
|
6072
|
+
this._childEntity = childEntity;
|
|
6073
|
+
childEntity.discardJSONFieldObjects();
|
|
6074
|
+
const narrowed = childEntity.narrowHydrationSource(childRow);
|
|
6075
|
+
childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
|
|
6076
|
+
childEntity._recordLoaded = true;
|
|
6077
|
+
childEntity._everSaved = true;
|
|
6078
|
+
childEntity._compositeKey = this.PrimaryKey;
|
|
6079
|
+
childEntity.cacheRecordName();
|
|
6080
|
+
await childEntity.deserializeCompanionsFromData(narrowed);
|
|
6081
|
+
}
|
|
5305
6082
|
/**
|
|
5306
6083
|
* Extracts and applies a companion payload carried inside a plain data object, if present.
|
|
5307
6084
|
*
|
|
@@ -5795,20 +6572,53 @@ export class BaseEntity {
|
|
|
5795
6572
|
if (childEntities.length === 0) {
|
|
5796
6573
|
return { HasChildren: false, ChildEntityName: '' };
|
|
5797
6574
|
}
|
|
5798
|
-
// Use RunView on this instance's provider — a host RunView cannot see
|
|
5799
|
-
// uncommitted child rows on a graph-scoped connection.
|
|
5800
|
-
const rv = new RunView(this.RunViewProviderToUse);
|
|
5801
6575
|
const pkValue = this.PrimaryKey.Values();
|
|
5802
|
-
|
|
6576
|
+
// Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
|
|
6577
|
+
// (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
|
|
6578
|
+
const entityProvider = this.ProviderToUse;
|
|
6579
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
6580
|
+
const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
|
|
6581
|
+
if (found?.ChildEntityName) {
|
|
6582
|
+
return { HasChildren: true, ChildEntityName: found.ChildEntityName };
|
|
6583
|
+
}
|
|
6584
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6585
|
+
}
|
|
6586
|
+
// Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
|
|
6587
|
+
const rvProvider = this.RunViewProviderToUse;
|
|
6588
|
+
const rv = new RunView(rvProvider);
|
|
6589
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
6590
|
+
if (validChildren.length === 0) {
|
|
6591
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6592
|
+
}
|
|
6593
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
6594
|
+
const viewParams = validChildren.map(childEntity => {
|
|
6595
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6596
|
+
return {
|
|
6597
|
+
EntityName: childEntity.Name,
|
|
6598
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6599
|
+
ResultType: 'simple',
|
|
6600
|
+
Fields: [pkField.Name],
|
|
6601
|
+
MaxRows: 1,
|
|
6602
|
+
};
|
|
6603
|
+
});
|
|
6604
|
+
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
6605
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
6606
|
+
const result = results[i];
|
|
6607
|
+
if (result?.Success && result.Results && result.Results.length > 0) {
|
|
6608
|
+
return { HasChildren: true, ChildEntityName: validChildren[i].Name };
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6612
|
+
}
|
|
6613
|
+
for (const childEntity of validChildren) {
|
|
5803
6614
|
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5804
|
-
if (!pkField)
|
|
5805
|
-
continue;
|
|
5806
6615
|
const result = await rv.RunView({
|
|
5807
6616
|
EntityName: childEntity.Name,
|
|
5808
|
-
ExtraFilter: `${pkField.Name} = '${pkValue}'`,
|
|
6617
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
5809
6618
|
ResultType: 'simple',
|
|
5810
6619
|
Fields: [pkField.Name],
|
|
5811
|
-
MaxRows: 1
|
|
6620
|
+
MaxRows: 1,
|
|
6621
|
+
SkipTotalRowCount: true
|
|
5812
6622
|
}, this._contextCurrentUser);
|
|
5813
6623
|
if (result && result.Success && result.Results && result.Results.length > 0) {
|
|
5814
6624
|
return { HasChildren: true, ChildEntityName: childEntity.Name };
|
|
@@ -5870,18 +6680,50 @@ export class BaseEntity {
|
|
|
5870
6680
|
if (childEntities.length === 0) {
|
|
5871
6681
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
5872
6682
|
}
|
|
5873
|
-
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
5874
6683
|
const pkValue = primaryKey.Values();
|
|
5875
|
-
|
|
6684
|
+
const candidateEntityProvider = (provider ?? BaseEntity.Provider);
|
|
6685
|
+
if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
|
|
6686
|
+
const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
|
|
6687
|
+
if (found?.ChildEntityName) {
|
|
6688
|
+
const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
|
|
6689
|
+
if (matchedChild) {
|
|
6690
|
+
return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6694
|
+
}
|
|
6695
|
+
const rvProvider = (provider ?? BaseEntity.Provider);
|
|
6696
|
+
const rv = new RunView(rvProvider);
|
|
6697
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
6698
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
|
|
6699
|
+
const viewParams = validChildren.map(child => {
|
|
6700
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6701
|
+
return {
|
|
6702
|
+
EntityName: child.Name,
|
|
6703
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6704
|
+
ResultType: 'simple',
|
|
6705
|
+
Fields: [childPK.Name],
|
|
6706
|
+
MaxRows: 1,
|
|
6707
|
+
};
|
|
6708
|
+
});
|
|
6709
|
+
const results = await rv.RunViews(viewParams, contextUser);
|
|
6710
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
6711
|
+
const result = results[i];
|
|
6712
|
+
if (result?.Success && result.Results?.length > 0) {
|
|
6713
|
+
return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6717
|
+
}
|
|
6718
|
+
for (const child of validChildren) {
|
|
5876
6719
|
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5877
|
-
if (!childPK)
|
|
5878
|
-
continue;
|
|
5879
6720
|
const result = await rv.RunView({
|
|
5880
6721
|
EntityName: child.Name,
|
|
5881
|
-
ExtraFilter: `${childPK.Name} = '${pkValue}'`,
|
|
6722
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
5882
6723
|
ResultType: 'simple',
|
|
5883
6724
|
Fields: [childPK.Name],
|
|
5884
|
-
MaxRows: 1
|
|
6725
|
+
MaxRows: 1,
|
|
6726
|
+
SkipTotalRowCount: true
|
|
5885
6727
|
}, contextUser);
|
|
5886
6728
|
if (result?.Success && result.Results?.length > 0) {
|
|
5887
6729
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
@@ -5928,10 +6770,11 @@ export class BaseEntity {
|
|
|
5928
6770
|
return;
|
|
5929
6771
|
const viewParams = validSiblings.map(sibling => ({
|
|
5930
6772
|
EntityName: sibling.Name,
|
|
5931
|
-
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
6773
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
|
|
5932
6774
|
ResultType: 'simple',
|
|
5933
6775
|
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
5934
|
-
MaxRows: 1
|
|
6776
|
+
MaxRows: 1,
|
|
6777
|
+
SkipTotalRowCount: true
|
|
5935
6778
|
}));
|
|
5936
6779
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
5937
6780
|
for (let i = 0; i < results.length; i++) {
|
|
@@ -6005,7 +6848,7 @@ export class BaseEntity {
|
|
|
6005
6848
|
//
|
|
6006
6849
|
// Returning null is the same answer callers already handle for "this entity has no name
|
|
6007
6850
|
// field", and every one of them degrades to the primary key.
|
|
6008
|
-
if (!this.
|
|
6851
|
+
if (!this.IsFieldReadableByActiveUser(f.Name)) {
|
|
6009
6852
|
return null;
|
|
6010
6853
|
}
|
|
6011
6854
|
return this.Get(f.Name);
|
|
@@ -6135,7 +6978,8 @@ export class BaseEntity {
|
|
|
6135
6978
|
}
|
|
6136
6979
|
/**
|
|
6137
6980
|
* Generates a vector embedding for a single text field using AI engine.
|
|
6138
|
-
* Only generates embeddings for new records
|
|
6981
|
+
* Only generates embeddings for new records, when the source field has changed, or when
|
|
6982
|
+
* {@link IsEmbeddingStale} reports the stored vector came from another model.
|
|
6139
6983
|
* Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
|
|
6140
6984
|
* vector's binary companion.
|
|
6141
6985
|
*
|
|
@@ -6153,33 +6997,46 @@ export class BaseEntity {
|
|
|
6153
6997
|
* @returns Promise that resolves to true if embedding was generated successfully, false otherwise
|
|
6154
6998
|
*/
|
|
6155
6999
|
async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
|
|
7000
|
+
const clearVector = () => {
|
|
7001
|
+
vectorField.Value = null;
|
|
7002
|
+
if (binaryVectorField)
|
|
7003
|
+
binaryVectorField.Value = null;
|
|
7004
|
+
if (modelField)
|
|
7005
|
+
modelField.Value = null;
|
|
7006
|
+
};
|
|
7007
|
+
// After an edit to a saved record, its stored vector describes the old text, so a failed
|
|
7008
|
+
// re-embed clears it rather than leaving it searchable.
|
|
7009
|
+
const edited = this.IsSaved && field.Dirty;
|
|
6156
7010
|
try {
|
|
6157
7011
|
if (this._skipEmbeddings)
|
|
6158
7012
|
return true;
|
|
6159
|
-
|
|
7013
|
+
const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
|
|
7014
|
+
if (!this.IsSaved || field.Dirty || stale) {
|
|
6160
7015
|
if (field.Value?.trim().length > 0) {
|
|
6161
7016
|
// recalc vector
|
|
6162
7017
|
const e = await this.EmbedTextLocal(field.Value);
|
|
6163
|
-
|
|
7018
|
+
// An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
|
|
7019
|
+
if (e?.vector?.length) {
|
|
6164
7020
|
vectorField.Value = JSON.stringify(e.vector);
|
|
6165
7021
|
if (binaryVectorField)
|
|
6166
7022
|
binaryVectorField.Value = Float32VectorToBase64(e.vector);
|
|
6167
7023
|
if (modelField)
|
|
6168
7024
|
modelField.Value = e.modelID;
|
|
6169
7025
|
}
|
|
7026
|
+
else if (edited) {
|
|
7027
|
+
clearVector();
|
|
7028
|
+
}
|
|
6170
7029
|
}
|
|
6171
7030
|
else {
|
|
6172
|
-
|
|
6173
|
-
if (binaryVectorField)
|
|
6174
|
-
binaryVectorField.Value = null;
|
|
6175
|
-
if (modelField)
|
|
6176
|
-
modelField.Value = null;
|
|
7031
|
+
clearVector();
|
|
6177
7032
|
}
|
|
6178
7033
|
}
|
|
6179
7034
|
return true;
|
|
6180
7035
|
}
|
|
6181
7036
|
catch (e) {
|
|
6182
7037
|
console.error("Error generating embedding:", e);
|
|
7038
|
+
if (edited)
|
|
7039
|
+
clearVector();
|
|
6183
7040
|
return false;
|
|
6184
7041
|
}
|
|
6185
7042
|
}
|
|
@@ -6193,6 +7050,18 @@ export class BaseEntity {
|
|
|
6193
7050
|
async EmbedTextLocal(textToEmbed) {
|
|
6194
7051
|
throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
|
|
6195
7052
|
}
|
|
7053
|
+
/**
|
|
7054
|
+
* Whether a stored vector is stale: it came from a model other than the one this entity embeds
|
|
7055
|
+
* with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
|
|
7056
|
+
* is regenerated on the next save even when its text is unchanged, which is how stored vectors
|
|
7057
|
+
* follow a change of embedding model. The base implementation returns false (only new or changed
|
|
7058
|
+
* text is embedded); server-side sub-classes that embed with the platform's default local model
|
|
7059
|
+
* override it.
|
|
7060
|
+
* @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
|
|
7061
|
+
*/
|
|
7062
|
+
async IsEmbeddingStale(embeddingModelID) {
|
|
7063
|
+
return false;
|
|
7064
|
+
}
|
|
6196
7065
|
/**
|
|
6197
7066
|
* Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
|
|
6198
7067
|
* code that has fallbacks as needed. BaseEntity does not implement this method but server-side sub-classes often do, but it is not mandatory for
|