@memberjunction/core 6.2.0-edge.7 → 6.2.0-edge.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +32 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/baseEngine.d.ts +47 -4
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +96 -18
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts +10 -2
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +33 -5
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +60 -4
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +603 -93
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +43 -9
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +19 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +52 -0
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +21 -0
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +107 -1
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts +105 -38
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +196 -5
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/providerBase.d.ts +43 -1
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +359 -99
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/sharedLoad.d.ts +42 -0
- package/dist/generic/sharedLoad.d.ts.map +1 -0
- package/dist/generic/sharedLoad.js +74 -0
- package/dist/generic/sharedLoad.js.map +1 -0
- package/dist/generic/telemetryManager.d.ts +31 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +85 -9
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/transactionGroup.d.ts +19 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +55 -7
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/generic/util.d.ts +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +2 -2
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -19,6 +19,7 @@ import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfac
|
|
|
19
19
|
import { Metadata } from './metadata.js';
|
|
20
20
|
import { RunView } from '../views/runView.js';
|
|
21
21
|
import { LogDebug, LogError, LogStatusEx, LogWarning } from './logging.js';
|
|
22
|
+
import { TelemetryManager } from './telemetryManager.js';
|
|
22
23
|
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
23
24
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
24
25
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
@@ -1546,7 +1547,7 @@ export class BaseEntity {
|
|
|
1546
1547
|
*
|
|
1547
1548
|
* @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
|
|
1548
1549
|
*/
|
|
1549
|
-
async resolveSubtypeLoadHint() {
|
|
1550
|
+
async resolveSubtypeLoadHint(batchUnfilteredCache) {
|
|
1550
1551
|
if (this._skipSubtypeLoadHint) {
|
|
1551
1552
|
return null;
|
|
1552
1553
|
}
|
|
@@ -1557,7 +1558,7 @@ export class BaseEntity {
|
|
|
1557
1558
|
}
|
|
1558
1559
|
const selector = this.EntityInfo.SubtypeSelectorConfig;
|
|
1559
1560
|
const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
|
|
1560
|
-
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
|
|
1561
|
+
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath, batchUnfilteredCache) : null;
|
|
1561
1562
|
}
|
|
1562
1563
|
/**
|
|
1563
1564
|
* The resolver to ask for load hints for an entity, decided once per registration and cached:
|
|
@@ -1619,9 +1620,9 @@ export class BaseEntity {
|
|
|
1619
1620
|
* Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
|
|
1620
1621
|
* this entity's metadata is logged once and gives no hint; the create path throws on it instead.
|
|
1621
1622
|
*/
|
|
1622
|
-
async evaluateSelectorForLoadHint(path) {
|
|
1623
|
+
async evaluateSelectorForLoadHint(path, batchUnfilteredCache) {
|
|
1623
1624
|
try {
|
|
1624
|
-
const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue));
|
|
1625
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(path, (entityName, pkValue) => BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache));
|
|
1625
1626
|
return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
|
|
1626
1627
|
}
|
|
1627
1628
|
catch (e) {
|
|
@@ -2010,15 +2011,16 @@ export class BaseEntity {
|
|
|
2010
2011
|
}
|
|
2011
2012
|
/**
|
|
2012
2013
|
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
2013
|
-
* `BaseEngine` caches already hold. Never queries, and never
|
|
2014
|
-
* entries can outlive a change to the row: engine caches
|
|
2014
|
+
* `BaseEngine` caches (or the active `RunViews` batch) already hold. Never queries, and never
|
|
2015
|
+
* reads the create path's memo, whose entries can outlive a change to the row: engine caches
|
|
2016
|
+
* are kept current by entity events.
|
|
2015
2017
|
*
|
|
2016
2018
|
* `pkValue` is one foreign-key column's value, matched against the first primary key of the
|
|
2017
2019
|
* cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
|
|
2018
2020
|
*
|
|
2019
2021
|
* @returns The cached record, or null when no loaded engine holds it as an entity object.
|
|
2020
2022
|
*/
|
|
2021
|
-
static findSubtypePathTargetInEngineCache(entityName, pkValue) {
|
|
2023
|
+
static findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache) {
|
|
2022
2024
|
const key = NormalizeUUID(String(pkValue));
|
|
2023
2025
|
for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
|
|
2024
2026
|
const found = BaseEntity.findEngineCachedRow(match.records, key);
|
|
@@ -2026,6 +2028,13 @@ export class BaseEntity {
|
|
|
2026
2028
|
return found;
|
|
2027
2029
|
}
|
|
2028
2030
|
}
|
|
2031
|
+
const batchRecords = batchUnfilteredCache?.get(entityName.trim().toLowerCase());
|
|
2032
|
+
if (batchRecords) {
|
|
2033
|
+
const found = BaseEntity.findEngineCachedRow(batchRecords, key);
|
|
2034
|
+
if (found) {
|
|
2035
|
+
return found;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2029
2038
|
return null;
|
|
2030
2039
|
}
|
|
2031
2040
|
/**
|
|
@@ -4881,7 +4890,23 @@ export class BaseEntity {
|
|
|
4881
4890
|
LogError(`EntitySaveOptions.OnValidated callback threw (ignored): ${cbErr?.message ?? String(cbErr)}`);
|
|
4882
4891
|
}
|
|
4883
4892
|
}
|
|
4884
|
-
const
|
|
4893
|
+
const saveTelemetryId = TelemetryManager.Instance.StartEvent('Custom', 'BaseEntity.Save', {
|
|
4894
|
+
operationName: 'BaseEntity.Save',
|
|
4895
|
+
EntityName: this.EntityInfo?.Name,
|
|
4896
|
+
SaveSubType: saveSubType,
|
|
4897
|
+
}, this.ActiveUser?.ID);
|
|
4898
|
+
let data;
|
|
4899
|
+
try {
|
|
4900
|
+
data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
|
|
4901
|
+
TelemetryManager.Instance.EndEvent(saveTelemetryId, { success: !!data });
|
|
4902
|
+
}
|
|
4903
|
+
catch (saveErr) {
|
|
4904
|
+
TelemetryManager.Instance.EndEvent(saveTelemetryId, {
|
|
4905
|
+
success: false,
|
|
4906
|
+
error: saveErr instanceof Error ? saveErr.message : String(saveErr),
|
|
4907
|
+
});
|
|
4908
|
+
throw saveErr;
|
|
4909
|
+
}
|
|
4885
4910
|
if (!this.TransactionGroup) {
|
|
4886
4911
|
// no transaction group, so we have our results here
|
|
4887
4912
|
const result = this.finalizeSave(data, saveSubType, _options);
|
|
@@ -5703,7 +5728,7 @@ export class BaseEntity {
|
|
|
5703
5728
|
static get ISABatchChunkSize() {
|
|
5704
5729
|
return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
|
|
5705
5730
|
}
|
|
5706
|
-
async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
|
|
5731
|
+
async LoadFromDataBatchAsISAParent(peers, md, contextUser, batchUnfilteredCache) {
|
|
5707
5732
|
const loadedParents = [];
|
|
5708
5733
|
for (let i = 0; i < peers.length; i++) {
|
|
5709
5734
|
const { entity, data } = peers[i];
|
|
@@ -5717,8 +5742,46 @@ export class BaseEntity {
|
|
|
5717
5742
|
}
|
|
5718
5743
|
}
|
|
5719
5744
|
if (loadedParents.length > 0) {
|
|
5720
|
-
await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
|
|
5745
|
+
await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser, batchUnfilteredCache);
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
/**
|
|
5749
|
+
* Clones `source` into `target` (already instantiated and bound to the destination provider/user)
|
|
5750
|
+
* while preserving an existing in-memory `ISAChild` chain without issuing redundant database
|
|
5751
|
+
* discovery or load queries. When `source` is a disjoint IS-A parent without an attached
|
|
5752
|
+
* `ISAChild`, delegates child resolution to `hydrateISAChildrenForLevel` so `BaseEngine`
|
|
5753
|
+
* caches are consulted before falling back to the provider.
|
|
5754
|
+
*/
|
|
5755
|
+
static async CloneEntityWithISAChain(source, target, md, contextUser) {
|
|
5756
|
+
if (source.EntityInfo?.IsParentType &&
|
|
5757
|
+
!source.EntityInfo.AllowMultipleSubtypes &&
|
|
5758
|
+
!IsMemberOverridden(target, 'LoadFromData', BaseEntity)) {
|
|
5759
|
+
const allPksSet = await target.hydrateSingleISAParentPeer(source);
|
|
5760
|
+
if (!allPksSet) {
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
if (source.ISAChild) {
|
|
5764
|
+
const deniedChildLogged = new Set();
|
|
5765
|
+
let currParent = target;
|
|
5766
|
+
let currSourceChild = source.ISAChild;
|
|
5767
|
+
while (currSourceChild) {
|
|
5768
|
+
const linked = [];
|
|
5769
|
+
const ok = await BaseEntity.linkSingleParentFromRawOrEntityRecord(currParent, currSourceChild.EntityInfo.Name, currSourceChild, linked, deniedChildLogged);
|
|
5770
|
+
currParent._childEntityDiscoveryDone = true;
|
|
5771
|
+
if (!ok || linked.length === 0) {
|
|
5772
|
+
break;
|
|
5773
|
+
}
|
|
5774
|
+
currParent = linked[0];
|
|
5775
|
+
currSourceChild = currSourceChild.ISAChild;
|
|
5776
|
+
}
|
|
5777
|
+
currParent._childEntityDiscoveryDone = true;
|
|
5778
|
+
}
|
|
5779
|
+
else {
|
|
5780
|
+
await BaseEntity.hydrateISAChildrenForLevel([target], md, contextUser);
|
|
5781
|
+
}
|
|
5782
|
+
return;
|
|
5721
5783
|
}
|
|
5784
|
+
await target.LoadFromData(source.GetAll());
|
|
5722
5785
|
}
|
|
5723
5786
|
async hydrateSingleISAParentPeer(rawData) {
|
|
5724
5787
|
this.discardJSONFieldObjects();
|
|
@@ -5757,7 +5820,7 @@ export class BaseEntity {
|
|
|
5757
5820
|
await this.deserializeCompanionsFromData(narrowed);
|
|
5758
5821
|
return allPksSet;
|
|
5759
5822
|
}
|
|
5760
|
-
static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
|
|
5823
|
+
static async hydrateISAChildrenForLevel(levelEntities, md, contextUser, batchUnfilteredCache) {
|
|
5761
5824
|
const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
|
|
5762
5825
|
if (activeParents.length === 0) {
|
|
5763
5826
|
return;
|
|
@@ -5776,23 +5839,57 @@ export class BaseEntity {
|
|
|
5776
5839
|
for (const [, groupParents] of groupsByEntityId) {
|
|
5777
5840
|
const sampleParent = groupParents[0];
|
|
5778
5841
|
if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
|
|
5779
|
-
await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
|
|
5842
|
+
await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache);
|
|
5780
5843
|
}
|
|
5781
5844
|
else {
|
|
5782
|
-
await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
|
|
5845
|
+
await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache);
|
|
5783
5846
|
}
|
|
5784
5847
|
}
|
|
5785
5848
|
if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
|
|
5786
|
-
await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
|
|
5849
|
+
await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser, batchUnfilteredCache);
|
|
5787
5850
|
}
|
|
5788
5851
|
}
|
|
5789
|
-
static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
|
|
5852
|
+
static async hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache) {
|
|
5790
5853
|
const sampleParent = groupParents[0];
|
|
5791
5854
|
const parentInfo = sampleParent.EntityInfo;
|
|
5792
5855
|
const entityProvider = sampleParent.ProviderToUse;
|
|
5856
|
+
const rvProvider = sampleParent.RunViewProviderToUse;
|
|
5857
|
+
const effectiveUser = contextUser ?? sampleParent._contextCurrentUser ?? md.CurrentUser;
|
|
5858
|
+
const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
5859
|
+
if (validChildren.length === 0) {
|
|
5860
|
+
for (const p of groupParents) {
|
|
5861
|
+
p._childEntities = [];
|
|
5862
|
+
}
|
|
5863
|
+
return;
|
|
5864
|
+
}
|
|
5865
|
+
const childLookups = validChildren.map(childInfo => {
|
|
5866
|
+
const pkField = childInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5867
|
+
const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
|
|
5868
|
+
const cachedRecords = BaseEntity.findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, effectiveUser, batchUnfilteredCache);
|
|
5869
|
+
return {
|
|
5870
|
+
childInfo,
|
|
5871
|
+
pkField,
|
|
5872
|
+
pkIsUuid,
|
|
5873
|
+
cachedByPk: cachedRecords ? BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid) : null,
|
|
5874
|
+
};
|
|
5875
|
+
});
|
|
5876
|
+
if (childLookups.every(c => c.cachedByPk !== null)) {
|
|
5877
|
+
for (const p of groupParents) {
|
|
5878
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
5879
|
+
const matched = [];
|
|
5880
|
+
for (const c of childLookups) {
|
|
5881
|
+
const pkKey = CanonicalPrimaryKeyValue(rawPk, c.pkIsUuid);
|
|
5882
|
+
if (c.cachedByPk?.has(pkKey)) {
|
|
5883
|
+
matched.push({ entityName: c.childInfo.Name });
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5886
|
+
p._childEntities = matched;
|
|
5887
|
+
}
|
|
5888
|
+
return;
|
|
5889
|
+
}
|
|
5793
5890
|
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5794
5891
|
const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
|
|
5795
|
-
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks,
|
|
5892
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, effectiveUser);
|
|
5796
5893
|
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5797
5894
|
for (const p of groupParents) {
|
|
5798
5895
|
const rawPk = String(p.PrimaryKey.Values());
|
|
@@ -5800,46 +5897,443 @@ export class BaseEntity {
|
|
|
5800
5897
|
const matched = batchMap.get(pkKey) ?? [];
|
|
5801
5898
|
p._childEntities = matched.map(name => ({ entityName: name }));
|
|
5802
5899
|
}
|
|
5900
|
+
return;
|
|
5803
5901
|
}
|
|
5804
|
-
|
|
5805
|
-
|
|
5902
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
5903
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
5904
|
+
const rawPks = Array.from(new Set(groupParents.map(p => String(p.PrimaryKey.Values()))));
|
|
5905
|
+
const uncachedLookups = childLookups.filter(c => c.cachedByPk === null);
|
|
5906
|
+
const params = [];
|
|
5907
|
+
const meta = [];
|
|
5908
|
+
for (const c of uncachedLookups) {
|
|
5909
|
+
for (let i = 0; i < rawPks.length; i += chunkSize) {
|
|
5910
|
+
const chunk = rawPks.slice(i, i + chunkSize);
|
|
5911
|
+
params.push({
|
|
5912
|
+
EntityName: c.childInfo.Name,
|
|
5913
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(c.pkField, chunk),
|
|
5914
|
+
Fields: [c.pkField.Name],
|
|
5915
|
+
ResultType: 'simple',
|
|
5916
|
+
BypassCache: true,
|
|
5917
|
+
// Key-bounded (at most one row per PK in the chunk): the entity's
|
|
5918
|
+
// UserViewMaxRows cap protects nothing here and would drop subtypes.
|
|
5919
|
+
IgnoreMaxRows: true,
|
|
5920
|
+
});
|
|
5921
|
+
meta.push(c);
|
|
5922
|
+
}
|
|
5923
|
+
}
|
|
5924
|
+
for (const c of uncachedLookups) {
|
|
5925
|
+
c.cachedByPk = new Map();
|
|
5926
|
+
}
|
|
5927
|
+
if (params.length > 0) {
|
|
5928
|
+
const rv = new RunView(rvProvider);
|
|
5929
|
+
const results = await rv.RunViews(params, effectiveUser);
|
|
5930
|
+
for (let i = 0; i < results.length; i++) {
|
|
5931
|
+
const res = results[i];
|
|
5932
|
+
const c = meta[i];
|
|
5933
|
+
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
5934
|
+
LogWarning(`BaseEntity: batched IS-A child load of '${c.childInfo.Name}' failed (${res?.ErrorMessage || 'no result'}); matching parent record(s) continue without their subtype`, 'IS-A');
|
|
5935
|
+
continue;
|
|
5936
|
+
}
|
|
5937
|
+
for (const row of res.Results) {
|
|
5938
|
+
const pkVal = row[c.pkField.Name];
|
|
5939
|
+
if (pkVal != null) {
|
|
5940
|
+
c.cachedByPk?.set(CanonicalPrimaryKeyValue(String(pkVal), c.pkIsUuid), row);
|
|
5941
|
+
}
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
}
|
|
5945
|
+
for (const p of groupParents) {
|
|
5946
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
5947
|
+
const matched = [];
|
|
5948
|
+
for (const c of childLookups) {
|
|
5949
|
+
const pkKey = CanonicalPrimaryKeyValue(rawPk, c.pkIsUuid);
|
|
5950
|
+
if (c.cachedByPk?.has(pkKey)) {
|
|
5951
|
+
matched.push({ entityName: c.childInfo.Name });
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
p._childEntities = matched;
|
|
5955
|
+
}
|
|
5956
|
+
return;
|
|
5806
5957
|
}
|
|
5958
|
+
await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
|
|
5807
5959
|
}
|
|
5808
|
-
static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
|
|
5960
|
+
static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache) {
|
|
5809
5961
|
const sampleParent = groupParents[0];
|
|
5810
5962
|
const parentInfo = sampleParent.EntityInfo;
|
|
5811
5963
|
const entityProvider = sampleParent.ProviderToUse;
|
|
5812
5964
|
const rvProvider = sampleParent.RunViewProviderToUse;
|
|
5813
5965
|
const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
|
|
5966
|
+
const deniedChildLogged = new Set();
|
|
5814
5967
|
// 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
|
|
5968
|
+
const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache);
|
|
5969
|
+
// 2) Batch-load hinted child rows (from unfiltered memory caches first, then at most 1 RunViews call);
|
|
5970
|
+
// any missing/failed row falls back to discovery
|
|
5971
|
+
// Hinted parents whose child read succeeded and had no row (as opposed to a read that
|
|
5972
|
+
// failed or was not tried): only those are hint misses, as in the per-row path.
|
|
5973
|
+
const hintNotFound = new Set();
|
|
5817
5974
|
if (hintedByChild.size > 0) {
|
|
5818
|
-
const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, rvProvider, effectiveUser, nextLevelChildren);
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5975
|
+
const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, entityProvider, rvProvider, effectiveUser, nextLevelChildren, deniedChildLogged, batchUnfilteredCache, hintNotFound);
|
|
5976
|
+
needsDiscovery.push(...unlinkedFromHints);
|
|
5977
|
+
}
|
|
5978
|
+
// 3) Discover and hydrate remaining unresolved parents (consulting unfiltered memory caches first,
|
|
5979
|
+
// then 1 single-pass RunViews call when FindISAChildEntitiesBatch is absent)
|
|
5980
|
+
if (needsDiscovery.length > 0) {
|
|
5981
|
+
const discoveredByParent = await BaseEntity.discoverAndHydrateDisjointChildren(parentInfo, needsDiscovery, md, entityProvider, rvProvider, effectiveUser, nextLevelChildren, deniedChildLogged, batchUnfilteredCache, new Map(Array.from(hintNotFound, p => [p, hintedNameByParent.get(p) ?? ''])));
|
|
5982
|
+
for (const p of hintNotFound) {
|
|
5983
|
+
BaseEntity.reportBatchSubtypeLoadHintMiss(p, hintedNameByParent.get(p), discoveredByParent.get(p) ?? null);
|
|
5984
|
+
}
|
|
5985
|
+
}
|
|
5986
|
+
}
|
|
5987
|
+
/**
|
|
5988
|
+
* The batch twin of the per-row path's diagnostics in {@link initializeDisjointChildEntity}, for
|
|
5989
|
+
* a parent whose hinted child had no row, run once discovery has had its say. When discovery
|
|
5990
|
+
* found the hinted child but could not link it, the row exists and its load came back empty, so
|
|
5991
|
+
* the empty load is logged, as the discovery-only path logs it. Anything else goes to
|
|
5992
|
+
* {@link reportSubtypeLoadHintMiss}: quiet (verbose, once) when no subtype row exists at all,
|
|
5993
|
+
* which is a normal state, and an error once per pair when the row is in a different child.
|
|
5994
|
+
*/
|
|
5995
|
+
static reportBatchSubtypeLoadHintMiss(parent, hintedName, discoveredName) {
|
|
5996
|
+
if (!hintedName) {
|
|
5997
|
+
return;
|
|
5998
|
+
}
|
|
5999
|
+
if (discoveredName && sameEntityName(discoveredName, hintedName)) {
|
|
6000
|
+
if (!parent._childEntity) {
|
|
6001
|
+
BaseEntity.logLoadFoundNoRow(hintedName, parent.PrimaryKey);
|
|
6002
|
+
}
|
|
6003
|
+
return;
|
|
6004
|
+
}
|
|
6005
|
+
parent.reportSubtypeLoadHintMiss(hintedName, discoveredName);
|
|
6006
|
+
}
|
|
6007
|
+
/**
|
|
6008
|
+
* Discovers and links the child of each disjoint parent.
|
|
6009
|
+
*
|
|
6010
|
+
* @param readFoundNoRow - The child each parent's hinted read already found no row in. When
|
|
6011
|
+
* discovery names that same child, its row exists but reads back empty (a row filter), so it
|
|
6012
|
+
* is not read again; the per-row path stops there too.
|
|
6013
|
+
* @returns The child each parent was found to have, whether or not it could then be linked.
|
|
6014
|
+
* A parent with no child, or whose child could not be determined, is absent.
|
|
6015
|
+
*/
|
|
6016
|
+
static async discoverAndHydrateDisjointChildren(parentInfo, parents, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged, batchUnfilteredCache, readFoundNoRow = new Map()) {
|
|
6017
|
+
const discoveredByParent = new Map();
|
|
6018
|
+
const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
6019
|
+
if (validChildren.length === 0) {
|
|
6020
|
+
return discoveredByParent;
|
|
6021
|
+
}
|
|
6022
|
+
const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
|
|
6023
|
+
const readableChildren = [];
|
|
6024
|
+
for (const child of validChildren) {
|
|
6025
|
+
const canRead = userForPerm ? child.GetUserPermisions(userForPerm).CanRead : true;
|
|
6026
|
+
if (!canRead) {
|
|
6027
|
+
if (!deniedChildLogged.has(child.Name)) {
|
|
6028
|
+
deniedChildLogged.add(child.Name);
|
|
6029
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${child.Name}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
5825
6030
|
}
|
|
5826
|
-
|
|
6031
|
+
continue;
|
|
5827
6032
|
}
|
|
6033
|
+
const pkField = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6034
|
+
const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
|
|
6035
|
+
const cachedRecords = BaseEntity.findUnfilteredInMemoryChildRecords(child, entityProvider, md, userForPerm, batchUnfilteredCache);
|
|
6036
|
+
readableChildren.push({
|
|
6037
|
+
childInfo: child,
|
|
6038
|
+
pkField,
|
|
6039
|
+
pkIsUuid,
|
|
6040
|
+
cachedByPk: cachedRecords ? BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid) : null,
|
|
6041
|
+
});
|
|
5828
6042
|
}
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
|
|
6043
|
+
if (readableChildren.length === 0) {
|
|
6044
|
+
return discoveredByParent;
|
|
6045
|
+
}
|
|
6046
|
+
// In a disjoint IS-A hierarchy (`AllowMultipleSubtypes === false`), each parent record
|
|
6047
|
+
// belongs to at most one child subtype across all `ChildEntities`. Resolve any parent whose
|
|
6048
|
+
// PK matches an unfiltered in-memory child cache (from BaseEngineRegistry or the active
|
|
6049
|
+
// RunViews batch) immediately with 0 queries, and only probe uncached child entities for
|
|
6050
|
+
// the remaining unresolved parents.
|
|
6051
|
+
const cachedChildren = readableChildren.filter(rc => rc.cachedByPk !== null);
|
|
6052
|
+
const uncachedChildren = readableChildren.filter(rc => rc.cachedByPk === null);
|
|
6053
|
+
let remainingParents = [];
|
|
6054
|
+
if (cachedChildren.length > 0) {
|
|
6055
|
+
for (const p of parents) {
|
|
6056
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
6057
|
+
let linkedFromMemory = false;
|
|
6058
|
+
for (const rc of cachedChildren) {
|
|
6059
|
+
const pkKey = CanonicalPrimaryKeyValue(rawPk, rc.pkIsUuid);
|
|
6060
|
+
const matched = rc.cachedByPk?.get(pkKey);
|
|
6061
|
+
if (matched) {
|
|
6062
|
+
discoveredByParent.set(p, rc.childInfo.Name);
|
|
6063
|
+
await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, rc.childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
|
|
6064
|
+
linkedFromMemory = true;
|
|
6065
|
+
break;
|
|
6066
|
+
}
|
|
6067
|
+
}
|
|
6068
|
+
if (!linkedFromMemory) {
|
|
6069
|
+
remainingParents.push(p);
|
|
6070
|
+
}
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
6073
|
+
else {
|
|
6074
|
+
remainingParents = Array.from(parents);
|
|
6075
|
+
}
|
|
6076
|
+
if (remainingParents.length === 0 || uncachedChildren.length === 0) {
|
|
6077
|
+
return discoveredByParent;
|
|
6078
|
+
}
|
|
6079
|
+
// Server-side batch UNION ALL discovery when available
|
|
6080
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
6081
|
+
const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, remainingParents, entityProvider, rvProvider, contextUser);
|
|
6082
|
+
BaseEntity.recordDiscoveredChildren(discoveredByChild, discoveredByParent);
|
|
6083
|
+
const toRead = BaseEntity.withoutChildrenAlreadyRead(discoveredByChild, readFoundNoRow);
|
|
6084
|
+
if (toRead.size > 0) {
|
|
6085
|
+
await BaseEntity.batchLoadAndLinkDisjointChildren(toRead, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
|
|
6086
|
+
}
|
|
6087
|
+
return discoveredByParent;
|
|
6088
|
+
}
|
|
6089
|
+
// Single-pass RunViews discovery + hydration when FindISAChildEntitiesBatch is absent (e.g. GraphQLDataProvider)
|
|
6090
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
6091
|
+
await BaseEntity.discoverAndLoadDisjointChildrenViaRunViews(readableChildren, remainingParents, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
|
|
6092
|
+
// Here discovery and the load are one read, so a child is known only where it linked.
|
|
6093
|
+
for (const p of remainingParents) {
|
|
6094
|
+
if (p._childEntity) {
|
|
6095
|
+
discoveredByParent.set(p, p._childEntity.EntityInfo.Name);
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
return discoveredByParent;
|
|
6099
|
+
}
|
|
6100
|
+
// Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
|
|
6101
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
6102
|
+
const discoveredByChild = new Map();
|
|
6103
|
+
for (const p of remainingParents) {
|
|
6104
|
+
const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
|
|
6105
|
+
if (found?.ChildEntityName) {
|
|
6106
|
+
BaseEntity.appendParentToChildGroup(discoveredByChild, found.ChildEntityName, p);
|
|
6107
|
+
}
|
|
5834
6108
|
}
|
|
6109
|
+
BaseEntity.recordDiscoveredChildren(discoveredByChild, discoveredByParent);
|
|
6110
|
+
const toRead = BaseEntity.withoutChildrenAlreadyRead(discoveredByChild, readFoundNoRow);
|
|
6111
|
+
if (toRead.size > 0) {
|
|
6112
|
+
await BaseEntity.batchLoadAndLinkDisjointChildren(toRead, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged);
|
|
6113
|
+
}
|
|
6114
|
+
}
|
|
6115
|
+
return discoveredByParent;
|
|
6116
|
+
}
|
|
6117
|
+
/** `byChild` without each parent whose read of that same child already found no row. */
|
|
6118
|
+
static withoutChildrenAlreadyRead(byChild, readFoundNoRow) {
|
|
6119
|
+
const out = new Map();
|
|
6120
|
+
for (const [childName, parents] of byChild) {
|
|
6121
|
+
const toRead = parents.filter(p => {
|
|
6122
|
+
const alreadyRead = readFoundNoRow.get(p);
|
|
6123
|
+
return !alreadyRead || !sameEntityName(alreadyRead, childName);
|
|
6124
|
+
});
|
|
6125
|
+
if (toRead.length > 0) {
|
|
6126
|
+
out.set(childName, toRead);
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
6129
|
+
return out;
|
|
6130
|
+
}
|
|
6131
|
+
/** Copies a child-name → parents grouping into a parent → child-name map. */
|
|
6132
|
+
static recordDiscoveredChildren(byChild, out) {
|
|
6133
|
+
for (const [childName, parents] of byChild) {
|
|
6134
|
+
for (const p of parents) {
|
|
6135
|
+
out.set(p, childName);
|
|
6136
|
+
}
|
|
6137
|
+
}
|
|
6138
|
+
}
|
|
6139
|
+
/**
|
|
6140
|
+
* Single-pass `RunViews` child discovery and hydration for providers without `FindISAChildEntitiesBatch`
|
|
6141
|
+
* (such as `GraphQLDataProvider`). Queries only the readable child entities not already present in an
|
|
6142
|
+
* unfiltered memory cache, fetches full child rows (`SELECT *`, no `Fields` narrowing) in 1 `RunViews`
|
|
6143
|
+
* call per chunk, and links the highest-priority matching child row per parent immediately.
|
|
6144
|
+
*/
|
|
6145
|
+
static async discoverAndLoadDisjointChildrenViaRunViews(readableChildren, parents, rvProvider, contextUser, outLinkedChildren, deniedChildLogged) {
|
|
6146
|
+
const uncachedChildren = readableChildren.filter(rc => rc.cachedByPk === null);
|
|
6147
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
6148
|
+
const rv = new RunView(rvProvider);
|
|
6149
|
+
for (let start = 0; start < parents.length; start += chunkSize) {
|
|
6150
|
+
const chunkParents = parents.slice(start, start + chunkSize);
|
|
6151
|
+
const chunkPks = chunkParents.map(p => String(p.PrimaryKey.Values()));
|
|
6152
|
+
const queriedRowsByChildName = new Map();
|
|
6153
|
+
if (uncachedChildren.length > 0) {
|
|
6154
|
+
const params = uncachedChildren.map(rc => ({
|
|
6155
|
+
EntityName: rc.childInfo.Name,
|
|
6156
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(rc.pkField, chunkPks),
|
|
6157
|
+
ResultType: 'simple',
|
|
6158
|
+
// Internal hydration read: keep per-batch IN-list reads out of the shared RunView cache.
|
|
6159
|
+
BypassCache: true,
|
|
6160
|
+
// Key-bounded (at most one row per PK in the chunk): the entity's
|
|
6161
|
+
// UserViewMaxRows cap protects nothing here and would drop subtypes.
|
|
6162
|
+
IgnoreMaxRows: true,
|
|
6163
|
+
}));
|
|
6164
|
+
const results = await rv.RunViews(params, contextUser);
|
|
6165
|
+
for (let cIdx = 0; cIdx < uncachedChildren.length; cIdx++) {
|
|
6166
|
+
const rc = uncachedChildren[cIdx];
|
|
6167
|
+
const res = results[cIdx];
|
|
6168
|
+
const rowByPk = new Map();
|
|
6169
|
+
queriedRowsByChildName.set(rc.childInfo.Name, rowByPk);
|
|
6170
|
+
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
6171
|
+
LogWarning(`BaseEntity: batched IS-A child load of '${rc.childInfo.Name}' failed (${res?.ErrorMessage || 'no result'}); matching parent record(s) continue without their subtype`, 'IS-A');
|
|
6172
|
+
continue;
|
|
6173
|
+
}
|
|
6174
|
+
for (const row of res.Results) {
|
|
6175
|
+
const rawVal = row[rc.pkField.Name];
|
|
6176
|
+
if (rawVal != null) {
|
|
6177
|
+
const norm = CanonicalPrimaryKeyValue(String(rawVal), rc.pkIsUuid);
|
|
6178
|
+
if (!rowByPk.has(norm)) {
|
|
6179
|
+
rowByPk.set(norm, row);
|
|
6180
|
+
}
|
|
6181
|
+
}
|
|
6182
|
+
}
|
|
6183
|
+
}
|
|
6184
|
+
}
|
|
6185
|
+
for (const p of chunkParents) {
|
|
6186
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
6187
|
+
for (const rc of readableChildren) {
|
|
6188
|
+
const norm = CanonicalPrimaryKeyValue(rawPk, rc.pkIsUuid);
|
|
6189
|
+
const matched = rc.cachedByPk
|
|
6190
|
+
? rc.cachedByPk.get(norm)
|
|
6191
|
+
: queriedRowsByChildName.get(rc.childInfo.Name)?.get(norm);
|
|
6192
|
+
if (matched) {
|
|
6193
|
+
await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, rc.childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
|
|
6194
|
+
break;
|
|
6195
|
+
}
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
}
|
|
6200
|
+
static findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, userForPerm, batchUnfilteredCache) {
|
|
6201
|
+
if (userForPerm && !childInfo.GetUserPermisions(userForPerm).CanRead) {
|
|
6202
|
+
return null;
|
|
6203
|
+
}
|
|
6204
|
+
// Check unfiltered views in the caller's own RunViews batch first (fetched in the same call for the same user)
|
|
6205
|
+
const batchMatch = batchUnfilteredCache?.get(childInfo.Name.trim().toLowerCase());
|
|
6206
|
+
if (batchMatch) {
|
|
6207
|
+
return batchMatch;
|
|
6208
|
+
}
|
|
6209
|
+
// Server-side BaseEngines load as the system user; do not serve from a shared engine cache when
|
|
6210
|
+
// the calling user has Row-Level Security filters on this child entity.
|
|
6211
|
+
const providerAsMd = entityProvider ?? md;
|
|
6212
|
+
const isDatabaseProvider = providerAsMd?.ProviderType === ProviderType.Database;
|
|
6213
|
+
if (isDatabaseProvider &&
|
|
6214
|
+
userForPerm &&
|
|
6215
|
+
childInfo.GetEffectiveRowFilterWhereClause(userForPerm, EntityPermissionType.Read, '').length > 0) {
|
|
6216
|
+
return null;
|
|
6217
|
+
}
|
|
6218
|
+
const providerConn = providerAsMd?.InstanceConnectionString;
|
|
6219
|
+
for (const match of BaseEngineRegistry.Instance.FindCachedEntity(childInfo.Name, { unfilteredOnly: true })) {
|
|
6220
|
+
const engineObj = match.engine;
|
|
6221
|
+
const engineConn = engineObj?.ProviderToUse?.InstanceConnectionString;
|
|
6222
|
+
if (providerConn && engineConn && providerConn !== engineConn) {
|
|
6223
|
+
continue;
|
|
6224
|
+
}
|
|
6225
|
+
return match.records;
|
|
6226
|
+
}
|
|
6227
|
+
return null;
|
|
6228
|
+
}
|
|
6229
|
+
static buildInMemoryChildLookupMap(records, pkFieldName, pkIsUuid) {
|
|
6230
|
+
const byPk = new Map();
|
|
6231
|
+
for (const rec of records) {
|
|
6232
|
+
if (!rec || typeof rec !== 'object')
|
|
6233
|
+
continue;
|
|
6234
|
+
const rawPk = typeof rec.Get === 'function'
|
|
6235
|
+
? rec.Get(pkFieldName)
|
|
6236
|
+
: rec[pkFieldName];
|
|
6237
|
+
if (rawPk != null) {
|
|
6238
|
+
const norm = CanonicalPrimaryKeyValue(String(rawPk), pkIsUuid);
|
|
6239
|
+
if (!byPk.has(norm)) {
|
|
6240
|
+
byPk.set(norm, rec);
|
|
6241
|
+
}
|
|
6242
|
+
}
|
|
6243
|
+
}
|
|
6244
|
+
return byPk;
|
|
6245
|
+
}
|
|
6246
|
+
static async linkSingleParentFromRawOrEntityRecord(parent, childName, matchedRecord, outLinkedChildren, deniedChildLogged) {
|
|
6247
|
+
const childEntity = await parent.createChildEntityObject(childName);
|
|
6248
|
+
if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
|
|
6249
|
+
if (!deniedChildLogged.has(childName)) {
|
|
6250
|
+
deniedChildLogged.add(childName);
|
|
6251
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${childName}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6252
|
+
}
|
|
6253
|
+
return false;
|
|
6254
|
+
}
|
|
6255
|
+
const rawChildRow = (typeof matchedRecord.GetAll === 'function'
|
|
6256
|
+
? matchedRecord.GetAll()
|
|
6257
|
+
: matchedRecord);
|
|
6258
|
+
const childRow = BaseEntity.inheritedFieldsFromParent(parent, childEntity, rawChildRow);
|
|
6259
|
+
await parent.linkHydratedChildFromBatchRow(childEntity, childRow);
|
|
6260
|
+
outLinkedChildren.push(childEntity);
|
|
6261
|
+
return true;
|
|
6262
|
+
}
|
|
6263
|
+
/**
|
|
6264
|
+
* Replaces the fields `parent` (or its IS-A ancestors) owns in an in-memory child row with
|
|
6265
|
+
* `parent`'s own loaded values, before linking the row downward onto `parent`. Keeps the
|
|
6266
|
+
* freshly loaded parent row authoritative for its own columns even when an engine cache holds
|
|
6267
|
+
* an older snapshot of the child view's joined parent columns, and still hydrates the child's
|
|
6268
|
+
* mirror fields: a mirror left NotLoaded would have no old value, so a later `child.Set()` of
|
|
6269
|
+
* an inherited field would report it as changing from null in `GetAll(true)` (and so in
|
|
6270
|
+
* Record Changes).
|
|
6271
|
+
*
|
|
6272
|
+
* "Owned by an ancestor" means the mirrored columns (`ParentEntityFieldNames`) and the
|
|
6273
|
+
* ancestors' virtual columns, which the child view also carries. The shared key and `__mj_`
|
|
6274
|
+
* timestamps are not: the child view's timestamps are its own table's. A replaced field the
|
|
6275
|
+
* parent did not load stays out of the row.
|
|
6276
|
+
*/
|
|
6277
|
+
static inheritedFieldsFromParent(parent, childEntity, childRow) {
|
|
6278
|
+
if (!childRow || typeof childRow !== 'object') {
|
|
6279
|
+
return childRow;
|
|
6280
|
+
}
|
|
6281
|
+
const pkNamesLower = new Set();
|
|
6282
|
+
for (const pk of [...childEntity.EntityInfo.PrimaryKeys, ...parent.EntityInfo.PrimaryKeys]) {
|
|
6283
|
+
pkNamesLower.add(pk.Name.trim().toLowerCase());
|
|
6284
|
+
if (pk.CodeName)
|
|
6285
|
+
pkNamesLower.add(pk.CodeName.trim().toLowerCase());
|
|
6286
|
+
}
|
|
6287
|
+
// Canonical field name → its lowercased name and code name, for every replaced field.
|
|
6288
|
+
const replaced = new Map();
|
|
6289
|
+
const addReplaced = (name, codeName) => {
|
|
6290
|
+
const lower = name.trim().toLowerCase();
|
|
6291
|
+
if (pkNamesLower.has(lower) || lower.startsWith('__mj_')) {
|
|
6292
|
+
return;
|
|
6293
|
+
}
|
|
6294
|
+
replaced.set(name, codeName ? [lower, codeName.trim().toLowerCase()] : [lower]);
|
|
6295
|
+
};
|
|
6296
|
+
for (const name of childEntity.EntityInfo.ParentEntityFieldNames) {
|
|
6297
|
+
addReplaced(name, childEntity.EntityInfo.Fields.find(f => f.Name === name)?.CodeName);
|
|
6298
|
+
}
|
|
6299
|
+
for (const ancestorInfo of childEntity.EntityInfo.ParentChain) {
|
|
6300
|
+
for (const f of ancestorInfo.Fields) {
|
|
6301
|
+
if (f.IsVirtual) {
|
|
6302
|
+
addReplaced(f.Name, f.CodeName);
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
if (replaced.size === 0) {
|
|
6307
|
+
return childRow;
|
|
6308
|
+
}
|
|
6309
|
+
const replacedLower = new Set();
|
|
6310
|
+
for (const keys of replaced.values()) {
|
|
6311
|
+
for (const k of keys)
|
|
6312
|
+
replacedLower.add(k);
|
|
5835
6313
|
}
|
|
6314
|
+
const copy = {};
|
|
6315
|
+
for (const key of Object.keys(childRow)) {
|
|
6316
|
+
if (!replacedLower.has(key.trim().toLowerCase())) {
|
|
6317
|
+
copy[key] = childRow[key];
|
|
6318
|
+
}
|
|
6319
|
+
}
|
|
6320
|
+
// GetAll() holds every field the parent loaded (NotLoaded ones are omitted), keyed by
|
|
6321
|
+
// field name, with its current values. The parent was loaded in this same batch, so its
|
|
6322
|
+
// current values are its loaded values.
|
|
6323
|
+
const parentValues = parent.GetAll();
|
|
6324
|
+
for (const name of replaced.keys()) {
|
|
6325
|
+
if (Object.prototype.hasOwnProperty.call(parentValues, name)) {
|
|
6326
|
+
copy[name] = parentValues[name];
|
|
6327
|
+
}
|
|
6328
|
+
}
|
|
6329
|
+
return copy;
|
|
5836
6330
|
}
|
|
5837
|
-
static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
|
|
6331
|
+
static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache) {
|
|
5838
6332
|
const hintedByChild = new Map();
|
|
5839
6333
|
const hintedNameByParent = new Map();
|
|
5840
6334
|
const needsDiscovery = [];
|
|
5841
6335
|
for (const p of groupParents) {
|
|
5842
|
-
const hintedName = await p.resolveSubtypeLoadHint();
|
|
6336
|
+
const hintedName = await p.resolveSubtypeLoadHint(batchUnfilteredCache);
|
|
5843
6337
|
if (hintedName) {
|
|
5844
6338
|
const childInfo = md.EntityByName?.(hintedName) ??
|
|
5845
6339
|
parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
|
|
@@ -5896,10 +6390,7 @@ export class BaseEntity {
|
|
|
5896
6390
|
}
|
|
5897
6391
|
return grouped;
|
|
5898
6392
|
}
|
|
5899
|
-
|
|
5900
|
-
return BaseEntity.discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser);
|
|
5901
|
-
}
|
|
5902
|
-
// Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
|
|
6393
|
+
// Last-resort per-parent fallback when FindISAChildEntitiesBatch is absent
|
|
5903
6394
|
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
5904
6395
|
for (const p of parents) {
|
|
5905
6396
|
const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
|
|
@@ -5910,78 +6401,77 @@ export class BaseEntity {
|
|
|
5910
6401
|
}
|
|
5911
6402
|
return grouped;
|
|
5912
6403
|
}
|
|
5913
|
-
static async
|
|
5914
|
-
const
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
6404
|
+
static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, entityProvider, rvProvider, contextUser, outLinkedChildren, deniedChildLogged = new Set(), batchUnfilteredCache, outNotFound = new Set()) {
|
|
6405
|
+
const unlinkedParents = [];
|
|
6406
|
+
// Every parent here already has a known subtype (a load hint, or discovery against the
|
|
6407
|
+
// DB), so a miss in a BaseEngine cache means the cache is behind the DB (the engine's event
|
|
6408
|
+
// debounce window, another server node, an out-of-band write), not that the row is absent.
|
|
6409
|
+
// Those parents are read from the DB with the uncached children below. A miss in the
|
|
6410
|
+
// caller's own RunViews batch is final: that read ran in this same call, for this user.
|
|
6411
|
+
// Unhinted discovery is different: there a complete cache does prove absence, and
|
|
6412
|
+
// discoverAndHydrateDisjointChildren never sends such parents here.
|
|
6413
|
+
const remainingByChildName = new Map();
|
|
6414
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
6415
|
+
const childInfo = md.EntityByName?.(childName) ??
|
|
6416
|
+
parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
|
|
6417
|
+
const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6418
|
+
const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
|
|
6419
|
+
const cachedRecords = childInfo && pkField
|
|
6420
|
+
? BaseEntity.findUnfilteredInMemoryChildRecords(childInfo, entityProvider, md, userForPerm, batchUnfilteredCache)
|
|
6421
|
+
: null;
|
|
6422
|
+
if (childInfo && pkField && cachedRecords) {
|
|
6423
|
+
const pkIsUuid = Boolean(pkField.IsUniqueIdentifier);
|
|
6424
|
+
const byPk = BaseEntity.buildInMemoryChildLookupMap(cachedRecords, pkField.Name, pkIsUuid);
|
|
6425
|
+
const fromCallerBatch = batchUnfilteredCache?.has(childInfo.Name.trim().toLowerCase()) === true;
|
|
6426
|
+
const cacheMisses = [];
|
|
6427
|
+
for (const p of parents) {
|
|
6428
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
6429
|
+
const matched = byPk.get(pkKey);
|
|
6430
|
+
if (!matched && fromCallerBatch) {
|
|
6431
|
+
unlinkedParents.push(p);
|
|
6432
|
+
outNotFound.add(p);
|
|
6433
|
+
continue;
|
|
6434
|
+
}
|
|
6435
|
+
if (!matched) {
|
|
6436
|
+
cacheMisses.push(p);
|
|
6437
|
+
continue;
|
|
6438
|
+
}
|
|
6439
|
+
const linked = await BaseEntity.linkSingleParentFromRawOrEntityRecord(p, childInfo.Name, matched, outLinkedChildren, deniedChildLogged);
|
|
6440
|
+
if (!linked) {
|
|
6441
|
+
unlinkedParents.push(p);
|
|
5950
6442
|
}
|
|
5951
6443
|
}
|
|
6444
|
+
if (cacheMisses.length > 0) {
|
|
6445
|
+
remainingByChildName.set(childName, cacheMisses);
|
|
6446
|
+
}
|
|
5952
6447
|
}
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
const norm = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
5956
|
-
const childName = winnerByPk.get(norm);
|
|
5957
|
-
if (childName) {
|
|
5958
|
-
BaseEntity.appendParentToChildGroup(grouped, childName, p);
|
|
6448
|
+
else {
|
|
6449
|
+
remainingByChildName.set(childName, parents);
|
|
5959
6450
|
}
|
|
5960
6451
|
}
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
const unlinkedParents = [];
|
|
6452
|
+
if (remainingByChildName.size === 0) {
|
|
6453
|
+
return unlinkedParents;
|
|
6454
|
+
}
|
|
5965
6455
|
if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
|
|
5966
|
-
for (const [childName, parents] of
|
|
6456
|
+
for (const [childName, parents] of remainingByChildName) {
|
|
5967
6457
|
for (const p of parents) {
|
|
5968
6458
|
const linked = await p.createAndLinkChildEntity(childName);
|
|
5969
6459
|
if (!linked) {
|
|
5970
6460
|
unlinkedParents.push(p);
|
|
6461
|
+
outNotFound.add(p);
|
|
5971
6462
|
}
|
|
5972
6463
|
}
|
|
5973
6464
|
}
|
|
5974
6465
|
return unlinkedParents;
|
|
5975
6466
|
}
|
|
5976
|
-
const
|
|
5977
|
-
const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
|
|
6467
|
+
const slices = BaseEntity.buildDisjointChildQuerySlices(remainingByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
|
|
5978
6468
|
if (slices.length === 0) {
|
|
5979
6469
|
return unlinkedParents;
|
|
5980
6470
|
}
|
|
5981
6471
|
const rv = new RunView(rvProvider);
|
|
5982
6472
|
const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
|
|
5983
6473
|
for (let sIdx = 0; sIdx < slices.length; sIdx++) {
|
|
5984
|
-
await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
|
|
6474
|
+
await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound);
|
|
5985
6475
|
}
|
|
5986
6476
|
return unlinkedParents;
|
|
5987
6477
|
}
|
|
@@ -6023,13 +6513,16 @@ export class BaseEntity {
|
|
|
6023
6513
|
// Internal hydration read: see batchDiscoverDisjointChildren — keep these
|
|
6024
6514
|
// per-batch IN-list reads out of the shared RunView cache.
|
|
6025
6515
|
BypassCache: true,
|
|
6516
|
+
// Key-bounded (at most one row per PK in the slice): the entity's
|
|
6517
|
+
// UserViewMaxRows cap protects nothing here and would drop subtypes.
|
|
6518
|
+
IgnoreMaxRows: true,
|
|
6026
6519
|
},
|
|
6027
6520
|
});
|
|
6028
6521
|
}
|
|
6029
6522
|
}
|
|
6030
6523
|
return slices;
|
|
6031
6524
|
}
|
|
6032
|
-
static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
|
|
6525
|
+
static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound) {
|
|
6033
6526
|
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
6034
6527
|
// Not silent: the per-row path surfaced a failed child load; here the parents stay
|
|
6035
6528
|
// unlinked (plain parent records) and the cause goes to the log.
|
|
@@ -6050,6 +6543,7 @@ export class BaseEntity {
|
|
|
6050
6543
|
const childRow = rowByPk.get(pkKey);
|
|
6051
6544
|
if (!childRow) {
|
|
6052
6545
|
unlinkedParents.push(p);
|
|
6546
|
+
outNotFound.add(p);
|
|
6053
6547
|
continue;
|
|
6054
6548
|
}
|
|
6055
6549
|
const childEntity = await p.createChildEntityObject(slice.childName);
|
|
@@ -6428,7 +6922,23 @@ export class BaseEntity {
|
|
|
6428
6922
|
relatedEntityList: null
|
|
6429
6923
|
});
|
|
6430
6924
|
// Delete OWN row first (FK constraint: child must be deleted before parent)
|
|
6431
|
-
|
|
6925
|
+
const deleteTelemetryId = TelemetryManager.Instance.StartEvent('Custom', 'BaseEntity.Delete', {
|
|
6926
|
+
operationName: 'BaseEntity.Delete',
|
|
6927
|
+
EntityName: this.EntityInfo?.Name,
|
|
6928
|
+
}, this.ActiveUser?.ID);
|
|
6929
|
+
let deletedOwnRow = false;
|
|
6930
|
+
try {
|
|
6931
|
+
deletedOwnRow = await this.ProviderToUse.Delete(this, _options, this.ActiveUser);
|
|
6932
|
+
TelemetryManager.Instance.EndEvent(deleteTelemetryId, { success: deletedOwnRow });
|
|
6933
|
+
}
|
|
6934
|
+
catch (delErr) {
|
|
6935
|
+
TelemetryManager.Instance.EndEvent(deleteTelemetryId, {
|
|
6936
|
+
success: false,
|
|
6937
|
+
error: delErr instanceof Error ? delErr.message : String(delErr),
|
|
6938
|
+
});
|
|
6939
|
+
throw delErr;
|
|
6940
|
+
}
|
|
6941
|
+
if (deletedOwnRow) {
|
|
6432
6942
|
ownRowDeleted = true;
|
|
6433
6943
|
// IS-A: after own delete succeeds, cascade to parent chain
|
|
6434
6944
|
if (hasParentChain) {
|