@memberjunction/core 6.2.0-edge.6 → 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/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- 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 +165 -12
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +622 -98
- 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 +121 -6
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +1144 -64
- 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/databaseProviderBase.d.ts +37 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +262 -46
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +25 -1
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +70 -12
- package/dist/generic/entityInfo.js.map +1 -1
- 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 +11 -2
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.js +6 -2
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -1
- package/dist/generic/interfaces.d.ts +50 -1
- 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 +69 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +253 -18
- 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 +198 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +721 -223
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +5 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +11 -2
- 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/runQuerySQLFilterImplementations.d.ts +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +81 -27
- package/dist/generic/runQuerySQLFilterImplementations.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/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 +13 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +53 -4
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +23 -10
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +5 -1
- package/dist/views/runView.js.map +1 -1
- package/package.json +3 -3
|
@@ -8,16 +8,18 @@ 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, UserCacheKeySegment, 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';
|
|
22
|
+
import { TelemetryManager } from './telemetryManager.js';
|
|
21
23
|
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
22
24
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
23
25
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
@@ -130,6 +132,18 @@ let EntityField = class EntityField {
|
|
|
130
132
|
SetCreateSuppressed(suppressed) {
|
|
131
133
|
this._createSuppressed = suppressed;
|
|
132
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Framework-internal: resets all mutable field state in place to match a freshly constructed
|
|
137
|
+
* `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
|
|
138
|
+
*/
|
|
139
|
+
ResetForInPlaceHydration() {
|
|
140
|
+
this._oldValue = null;
|
|
141
|
+
this._value = null;
|
|
142
|
+
this._neverSet = true;
|
|
143
|
+
this._notLoaded = false;
|
|
144
|
+
this._writtenAfterNotLoaded = false;
|
|
145
|
+
this._createSuppressed = false;
|
|
146
|
+
}
|
|
133
147
|
get Name() {
|
|
134
148
|
return this._entityFieldInfo.Name;
|
|
135
149
|
}
|
|
@@ -1077,6 +1091,7 @@ export class BaseEntity {
|
|
|
1077
1091
|
* pay nothing for the feature, not even an empty Map per instance.
|
|
1078
1092
|
*/
|
|
1079
1093
|
this._companions = null;
|
|
1094
|
+
this._finalizeGeneration = 0;
|
|
1080
1095
|
/**
|
|
1081
1096
|
* 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.
|
|
1082
1097
|
*
|
|
@@ -1532,7 +1547,7 @@ export class BaseEntity {
|
|
|
1532
1547
|
*
|
|
1533
1548
|
* @returns A declared IsA child's entity name, or null when there is no usable hint. Never throws.
|
|
1534
1549
|
*/
|
|
1535
|
-
async resolveSubtypeLoadHint() {
|
|
1550
|
+
async resolveSubtypeLoadHint(batchUnfilteredCache) {
|
|
1536
1551
|
if (this._skipSubtypeLoadHint) {
|
|
1537
1552
|
return null;
|
|
1538
1553
|
}
|
|
@@ -1543,7 +1558,7 @@ export class BaseEntity {
|
|
|
1543
1558
|
}
|
|
1544
1559
|
const selector = this.EntityInfo.SubtypeSelectorConfig;
|
|
1545
1560
|
const selectorPath = selector?.UseForLoadedRecords ? selector.Path?.trim() : null;
|
|
1546
|
-
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath) : null;
|
|
1561
|
+
return selectorPath ? this.evaluateSelectorForLoadHint(selectorPath, batchUnfilteredCache) : null;
|
|
1547
1562
|
}
|
|
1548
1563
|
/**
|
|
1549
1564
|
* The resolver to ask for load hints for an entity, decided once per registration and cached:
|
|
@@ -1605,9 +1620,9 @@ export class BaseEntity {
|
|
|
1605
1620
|
* Walks the `SubtypeSelector` path through engine caches only. A path that isn't valid for
|
|
1606
1621
|
* this entity's metadata is logged once and gives no hint; the create path throws on it instead.
|
|
1607
1622
|
*/
|
|
1608
|
-
async evaluateSelectorForLoadHint(path) {
|
|
1623
|
+
async evaluateSelectorForLoadHint(path, batchUnfilteredCache) {
|
|
1609
1624
|
try {
|
|
1610
|
-
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));
|
|
1611
1626
|
return this.declaredChildForLoadHint(pathResult, `SubtypeSelector path '${path}' on '${this.EntityInfo.Name}'`);
|
|
1612
1627
|
}
|
|
1613
1628
|
catch (e) {
|
|
@@ -1996,15 +2011,16 @@ export class BaseEntity {
|
|
|
1996
2011
|
}
|
|
1997
2012
|
/**
|
|
1998
2013
|
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
1999
|
-
* `BaseEngine` caches already hold. Never queries, and never
|
|
2000
|
-
* 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.
|
|
2001
2017
|
*
|
|
2002
2018
|
* `pkValue` is one foreign-key column's value, matched against the first primary key of the
|
|
2003
2019
|
* cached rows' entity the way UUIDs compare (trimmed, case-insensitive).
|
|
2004
2020
|
*
|
|
2005
2021
|
* @returns The cached record, or null when no loaded engine holds it as an entity object.
|
|
2006
2022
|
*/
|
|
2007
|
-
static findSubtypePathTargetInEngineCache(entityName, pkValue) {
|
|
2023
|
+
static findSubtypePathTargetInEngineCache(entityName, pkValue, batchUnfilteredCache) {
|
|
2008
2024
|
const key = NormalizeUUID(String(pkValue));
|
|
2009
2025
|
for (const match of BaseEngineRegistry.Instance.FindCachedEntity(entityName)) {
|
|
2010
2026
|
const found = BaseEntity.findEngineCachedRow(match.records, key);
|
|
@@ -2012,6 +2028,13 @@ export class BaseEntity {
|
|
|
2012
2028
|
return found;
|
|
2013
2029
|
}
|
|
2014
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
|
+
}
|
|
2015
2038
|
return null;
|
|
2016
2039
|
}
|
|
2017
2040
|
/**
|
|
@@ -2193,25 +2216,102 @@ export class BaseEntity {
|
|
|
2193
2216
|
* (and potentially parent entities in the IS-A chain).
|
|
2194
2217
|
*/
|
|
2195
2218
|
Hydrate(data) {
|
|
2196
|
-
// Reset this entity to pristine state: clears _compositeKey, _recordLoaded,
|
|
2197
|
-
// _everSaved, and recreates all EntityField instances with _NeverSet = true
|
|
2198
|
-
this.init();
|
|
2199
2219
|
this.discardJSONFieldObjects();
|
|
2200
2220
|
// Field security: denied values never enter this object (see narrowHydrationSource).
|
|
2201
2221
|
data = this.narrowHydrationSource(data);
|
|
2202
2222
|
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
2203
|
-
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
2204
2223
|
if (this._parentEntity) {
|
|
2205
2224
|
this._parentEntity.Hydrate(data);
|
|
2206
2225
|
}
|
|
2207
|
-
// Populate this entity's fields
|
|
2208
|
-
//
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2226
|
+
// Populate this entity's fields in place without destroying hydrated EntityField instances
|
|
2227
|
+
// or invalidating _fieldCache / _codeNameCache.
|
|
2228
|
+
this.resetAndApplyOwnedFieldsInPlace(data);
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* Resets transient record state and applies `fieldData` to this entity's own fields in a
|
|
2232
|
+
* single pass over `this._fields`, reusing existing `EntityField` instances and preserving
|
|
2233
|
+
* `_fieldCache` / `_codeNameCache` when already hydrated.
|
|
2234
|
+
*
|
|
2235
|
+
* Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
|
|
2236
|
+
* with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
|
|
2237
|
+
*/
|
|
2238
|
+
resetAndApplyOwnedFieldsInPlace(fieldData) {
|
|
2239
|
+
this._finalizeGeneration++;
|
|
2240
|
+
this._compositeKey = null;
|
|
2241
|
+
this._resultHistory = [];
|
|
2242
|
+
this._recordLoaded = false;
|
|
2243
|
+
this._raw = null;
|
|
2244
|
+
this._rawConverted = null;
|
|
2245
|
+
this.hydrateFieldsIfNeeded();
|
|
2246
|
+
if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
|
|
2247
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2248
|
+
const field = this._fields[i];
|
|
2249
|
+
field.ResetForInPlaceHydration();
|
|
2250
|
+
field.ClearAsNotLoaded();
|
|
2251
|
+
}
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
let normalizedMap = null;
|
|
2255
|
+
let sharedParentMirrors = null;
|
|
2256
|
+
const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
|
|
2257
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2258
|
+
const field = this._fields[i];
|
|
2259
|
+
const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
|
|
2260
|
+
if (normalizedMap === null) {
|
|
2261
|
+
normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
|
|
2262
|
+
}
|
|
2263
|
+
return normalizedMap;
|
|
2264
|
+
});
|
|
2265
|
+
field.ResetForInPlaceHydration();
|
|
2266
|
+
if (match.found) {
|
|
2267
|
+
this.setFieldValue(field, match.val);
|
|
2268
|
+
field.ResetOldValue();
|
|
2269
|
+
if (parentFieldNames) {
|
|
2270
|
+
if (parentFieldNames.has(match.key)) {
|
|
2271
|
+
(sharedParentMirrors ??= {})[match.key] = match.val;
|
|
2272
|
+
}
|
|
2273
|
+
else if (parentFieldNames.has(field.Name)) {
|
|
2274
|
+
(sharedParentMirrors ??= {})[field.Name] = match.val;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
else {
|
|
2279
|
+
field.ClearAsNotLoaded();
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
if (sharedParentMirrors && this._parentEntity) {
|
|
2283
|
+
this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
|
|
2284
|
+
}
|
|
2285
|
+
this.updateSavedStateFromPrimaryKeys();
|
|
2286
|
+
}
|
|
2287
|
+
static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
|
|
2288
|
+
if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
|
|
2289
|
+
return { found: true, key: field.Name, val: fieldData[field.Name] };
|
|
2290
|
+
}
|
|
2291
|
+
if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
|
|
2292
|
+
return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
|
|
2293
|
+
}
|
|
2294
|
+
const normalizedMap = getNormalizedMap();
|
|
2295
|
+
const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
|
|
2296
|
+
(field.CodeName !== field.Name
|
|
2297
|
+
? normalizedMap.get(field.CodeName.trim().toLowerCase())
|
|
2298
|
+
: undefined);
|
|
2299
|
+
if (hit !== undefined) {
|
|
2300
|
+
return { found: true, key: hit.key, val: hit.val };
|
|
2301
|
+
}
|
|
2302
|
+
return { found: false, key: field.Name, val: undefined };
|
|
2303
|
+
}
|
|
2304
|
+
static buildNormalizedHydrationMap(fieldData) {
|
|
2305
|
+
const normalizedMap = new Map();
|
|
2306
|
+
const keys = Object.keys(fieldData);
|
|
2307
|
+
for (let kIdx = 0; kIdx < keys.length; kIdx++) {
|
|
2308
|
+
const k = keys[kIdx];
|
|
2309
|
+
const v = fieldData[k];
|
|
2310
|
+
if (v !== undefined) {
|
|
2311
|
+
normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
return normalizedMap;
|
|
2215
2315
|
}
|
|
2216
2316
|
/**
|
|
2217
2317
|
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
@@ -2906,6 +3006,7 @@ export class BaseEntity {
|
|
|
2906
3006
|
entity: level,
|
|
2907
3007
|
wasSaved: level._everSaved,
|
|
2908
3008
|
wasLoaded: level._recordLoaded,
|
|
3009
|
+
finalizeGeneration: level._finalizeGeneration,
|
|
2909
3010
|
resultHistory: [...level._resultHistory],
|
|
2910
3011
|
fields: level.Fields.map(field => ({ field, state: field.GetState() })),
|
|
2911
3012
|
});
|
|
@@ -2925,7 +3026,7 @@ export class BaseEntity {
|
|
|
2925
3026
|
* Puts this record back as a snapshot captured it, after the unit of work that finalized it
|
|
2926
3027
|
* rolled back.
|
|
2927
3028
|
*
|
|
2928
|
-
* A save's `finalizeSave()`
|
|
3029
|
+
* A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
|
|
2929
3030
|
* saved and empties its result history. The database undid the write, so the record gets back
|
|
2930
3031
|
* its saved and loaded flags, its result history, and each field's value and tracking state. A
|
|
2931
3032
|
* field edited since then keeps the edit, and is compared with its captured baseline again, since
|
|
@@ -2935,14 +3036,16 @@ export class BaseEntity {
|
|
|
2935
3036
|
* The history matters to the caller: a save records its failure only when nothing else did,
|
|
2936
3037
|
* judged by the history's length when it started, which an emptied history would make wrong.
|
|
2937
3038
|
*
|
|
2938
|
-
* A record
|
|
2939
|
-
* changed and it is left alone.
|
|
3039
|
+
* A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
|
|
3040
|
+
* was never finalized, so nothing about it changed and it is left alone.
|
|
2940
3041
|
*/
|
|
2941
3042
|
restoreSnapshot(snapshot) {
|
|
2942
|
-
const finalized =
|
|
3043
|
+
const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
|
|
3044
|
+
snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
|
|
2943
3045
|
if (!finalized) {
|
|
2944
3046
|
return;
|
|
2945
3047
|
}
|
|
3048
|
+
this._finalizeGeneration = snapshot.finalizeGeneration;
|
|
2946
3049
|
this._everSaved = snapshot.wasSaved;
|
|
2947
3050
|
this._recordLoaded = snapshot.wasLoaded;
|
|
2948
3051
|
this._resultHistory = snapshot.resultHistory;
|
|
@@ -4787,7 +4890,23 @@ export class BaseEntity {
|
|
|
4787
4890
|
LogError(`EntitySaveOptions.OnValidated callback threw (ignored): ${cbErr?.message ?? String(cbErr)}`);
|
|
4788
4891
|
}
|
|
4789
4892
|
}
|
|
4790
|
-
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
|
+
}
|
|
4791
4910
|
if (!this.TransactionGroup) {
|
|
4792
4911
|
// no transaction group, so we have our results here
|
|
4793
4912
|
const result = this.finalizeSave(data, saveSubType, _options);
|
|
@@ -4949,19 +5068,11 @@ export class BaseEntity {
|
|
|
4949
5068
|
// Record Changes payload from this entity's pre-save state, so narrowing the response
|
|
4950
5069
|
// here costs the audit nothing; it only keeps denied values out of this object.
|
|
4951
5070
|
fieldData = this.narrowHydrationSource(fieldData);
|
|
4952
|
-
|
|
4953
|
-
//
|
|
4954
|
-
//
|
|
4955
|
-
//
|
|
4956
|
-
this.
|
|
4957
|
-
// finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
|
|
4958
|
-
// omitted (e.g. fields the server stripped for field security) must be marked
|
|
4959
|
-
// not-loaded, or the defaults init() just produced would masquerade as confirmed
|
|
4960
|
-
// values and be resent on the NEXT save (the create/update-response corner: a
|
|
4961
|
-
// trigger/system adjustment or concurrent change would be silently stomped).
|
|
4962
|
-
if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
|
|
4963
|
-
this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
|
|
4964
|
-
}
|
|
5071
|
+
// Reset transient record state and update owned EntityField values in place so
|
|
5072
|
+
// hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
|
|
5073
|
+
// Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
|
|
5074
|
+
// across Save() remain attached and live.
|
|
5075
|
+
this.resetAndApplyOwnedFieldsInPlace(fieldData);
|
|
4965
5076
|
this._everSaved = true; // Mark as saved after successful save
|
|
4966
5077
|
const result = this.LatestResult;
|
|
4967
5078
|
if (result)
|
|
@@ -5601,6 +5712,867 @@ export class BaseEntity {
|
|
|
5601
5712
|
await this.deserializeCompanionsFromData(data);
|
|
5602
5713
|
return true;
|
|
5603
5714
|
}
|
|
5715
|
+
/**
|
|
5716
|
+
* Framework-internal batch IS-A parent hydration entry point used by
|
|
5717
|
+
* `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
|
|
5718
|
+
*
|
|
5719
|
+
* Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
|
|
5720
|
+
* 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
|
|
5721
|
+
* (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
|
|
5722
|
+
*/
|
|
5723
|
+
static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
|
|
5724
|
+
/**
|
|
5725
|
+
* Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
|
|
5726
|
+
* during IS-A child discovery or hydration.
|
|
5727
|
+
*/
|
|
5728
|
+
static get ISABatchChunkSize() {
|
|
5729
|
+
return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
|
|
5730
|
+
}
|
|
5731
|
+
async LoadFromDataBatchAsISAParent(peers, md, contextUser, batchUnfilteredCache) {
|
|
5732
|
+
const loadedParents = [];
|
|
5733
|
+
for (let i = 0; i < peers.length; i++) {
|
|
5734
|
+
const { entity, data } = peers[i];
|
|
5735
|
+
if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
|
|
5736
|
+
await entity.LoadFromData(data);
|
|
5737
|
+
continue;
|
|
5738
|
+
}
|
|
5739
|
+
const allPksSet = await entity.hydrateSingleISAParentPeer(data);
|
|
5740
|
+
if (allPksSet) {
|
|
5741
|
+
loadedParents.push(entity);
|
|
5742
|
+
}
|
|
5743
|
+
}
|
|
5744
|
+
if (loadedParents.length > 0) {
|
|
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;
|
|
5783
|
+
}
|
|
5784
|
+
await target.LoadFromData(source.GetAll());
|
|
5785
|
+
}
|
|
5786
|
+
async hydrateSingleISAParentPeer(rawData) {
|
|
5787
|
+
this.discardJSONFieldObjects();
|
|
5788
|
+
const dataObj = rawData instanceof BaseEntity
|
|
5789
|
+
? rawData.GetAll()
|
|
5790
|
+
: rawData;
|
|
5791
|
+
const narrowed = this.narrowHydrationSource(dataObj);
|
|
5792
|
+
if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
|
|
5793
|
+
this._parentEntity.Hydrate(narrowed);
|
|
5794
|
+
}
|
|
5795
|
+
const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
|
|
5796
|
+
this.SetMany(narrowed, true, false, true);
|
|
5797
|
+
if (isPlain) {
|
|
5798
|
+
this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
|
|
5799
|
+
}
|
|
5800
|
+
let allPksSet = false;
|
|
5801
|
+
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
5802
|
+
allPksSet = true;
|
|
5803
|
+
for (const pkey of this.PrimaryKeys) {
|
|
5804
|
+
if (pkey.Value === null || pkey.Value === undefined) {
|
|
5805
|
+
allPksSet = false;
|
|
5806
|
+
break;
|
|
5807
|
+
}
|
|
5808
|
+
}
|
|
5809
|
+
this._recordLoaded = allPksSet;
|
|
5810
|
+
this._everSaved = allPksSet;
|
|
5811
|
+
if (allPksSet) {
|
|
5812
|
+
this.cacheRecordName();
|
|
5813
|
+
}
|
|
5814
|
+
}
|
|
5815
|
+
else {
|
|
5816
|
+
LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
|
|
5817
|
+
this._recordLoaded = false;
|
|
5818
|
+
this._everSaved = false;
|
|
5819
|
+
}
|
|
5820
|
+
await this.deserializeCompanionsFromData(narrowed);
|
|
5821
|
+
return allPksSet;
|
|
5822
|
+
}
|
|
5823
|
+
static async hydrateISAChildrenForLevel(levelEntities, md, contextUser, batchUnfilteredCache) {
|
|
5824
|
+
const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
|
|
5825
|
+
if (activeParents.length === 0) {
|
|
5826
|
+
return;
|
|
5827
|
+
}
|
|
5828
|
+
const groupsByEntityId = new Map();
|
|
5829
|
+
for (const p of activeParents) {
|
|
5830
|
+
p._childEntityDiscoveryDone = true;
|
|
5831
|
+
let group = groupsByEntityId.get(p.EntityInfo.ID);
|
|
5832
|
+
if (!group) {
|
|
5833
|
+
group = [];
|
|
5834
|
+
groupsByEntityId.set(p.EntityInfo.ID, group);
|
|
5835
|
+
}
|
|
5836
|
+
group.push(p);
|
|
5837
|
+
}
|
|
5838
|
+
const nextLevelChildren = [];
|
|
5839
|
+
for (const [, groupParents] of groupsByEntityId) {
|
|
5840
|
+
const sampleParent = groupParents[0];
|
|
5841
|
+
if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
|
|
5842
|
+
await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache);
|
|
5843
|
+
}
|
|
5844
|
+
else {
|
|
5845
|
+
await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache);
|
|
5846
|
+
}
|
|
5847
|
+
}
|
|
5848
|
+
if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
|
|
5849
|
+
await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser, batchUnfilteredCache);
|
|
5850
|
+
}
|
|
5851
|
+
}
|
|
5852
|
+
static async hydrateOverlappingChildrenForGroup(groupParents, md, contextUser, batchUnfilteredCache) {
|
|
5853
|
+
const sampleParent = groupParents[0];
|
|
5854
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
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
|
+
}
|
|
5890
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5891
|
+
const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
|
|
5892
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, effectiveUser);
|
|
5893
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5894
|
+
for (const p of groupParents) {
|
|
5895
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
5896
|
+
const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
|
|
5897
|
+
const matched = batchMap.get(pkKey) ?? [];
|
|
5898
|
+
p._childEntities = matched.map(name => ({ entityName: name }));
|
|
5899
|
+
}
|
|
5900
|
+
return;
|
|
5901
|
+
}
|
|
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;
|
|
5957
|
+
}
|
|
5958
|
+
await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
|
|
5959
|
+
}
|
|
5960
|
+
static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren, batchUnfilteredCache) {
|
|
5961
|
+
const sampleParent = groupParents[0];
|
|
5962
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
5963
|
+
const entityProvider = sampleParent.ProviderToUse;
|
|
5964
|
+
const rvProvider = sampleParent.RunViewProviderToUse;
|
|
5965
|
+
const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
|
|
5966
|
+
const deniedChildLogged = new Set();
|
|
5967
|
+
// 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
|
|
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();
|
|
5974
|
+
if (hintedByChild.size > 0) {
|
|
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');
|
|
6030
|
+
}
|
|
6031
|
+
continue;
|
|
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
|
+
});
|
|
6042
|
+
}
|
|
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
|
+
}
|
|
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);
|
|
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;
|
|
6330
|
+
}
|
|
6331
|
+
static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser, batchUnfilteredCache) {
|
|
6332
|
+
const hintedByChild = new Map();
|
|
6333
|
+
const hintedNameByParent = new Map();
|
|
6334
|
+
const needsDiscovery = [];
|
|
6335
|
+
for (const p of groupParents) {
|
|
6336
|
+
const hintedName = await p.resolveSubtypeLoadHint(batchUnfilteredCache);
|
|
6337
|
+
if (hintedName) {
|
|
6338
|
+
const childInfo = md.EntityByName?.(hintedName) ??
|
|
6339
|
+
parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
|
|
6340
|
+
const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
|
|
6341
|
+
const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
6342
|
+
if (childInfo && canRead) {
|
|
6343
|
+
BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
|
|
6344
|
+
hintedNameByParent.set(p, childInfo.Name);
|
|
6345
|
+
}
|
|
6346
|
+
else {
|
|
6347
|
+
needsDiscovery.push(p);
|
|
6348
|
+
}
|
|
6349
|
+
}
|
|
6350
|
+
else {
|
|
6351
|
+
needsDiscovery.push(p);
|
|
6352
|
+
}
|
|
6353
|
+
}
|
|
6354
|
+
return { hintedByChild, hintedNameByParent, needsDiscovery };
|
|
6355
|
+
}
|
|
6356
|
+
static appendParentToChildGroup(grouped, childName, parent) {
|
|
6357
|
+
let bucket = grouped.get(childName);
|
|
6358
|
+
if (!bucket) {
|
|
6359
|
+
bucket = [];
|
|
6360
|
+
grouped.set(childName, bucket);
|
|
6361
|
+
}
|
|
6362
|
+
bucket.push(parent);
|
|
6363
|
+
}
|
|
6364
|
+
static buildChildPkInFilter(pkField, rawPkValues) {
|
|
6365
|
+
const formatted = rawPkValues.map(val => {
|
|
6366
|
+
if (!pkField.NeedsQuotes) {
|
|
6367
|
+
// Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
|
|
6368
|
+
const text = String(val).trim();
|
|
6369
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
6370
|
+
return text;
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
return `'${EscapeSQLString(String(val))}'`;
|
|
6374
|
+
});
|
|
6375
|
+
return `${pkField.Name} IN (${formatted.join(', ')})`;
|
|
6376
|
+
}
|
|
6377
|
+
static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
|
|
6378
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6379
|
+
const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
|
|
6380
|
+
const grouped = new Map();
|
|
6381
|
+
const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
|
|
6382
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
6383
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
|
|
6384
|
+
for (const p of parents) {
|
|
6385
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
6386
|
+
const firstMatch = batchMap.get(pkKey)?.[0];
|
|
6387
|
+
if (firstMatch) {
|
|
6388
|
+
BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
return grouped;
|
|
6392
|
+
}
|
|
6393
|
+
// Last-resort per-parent fallback when FindISAChildEntitiesBatch is absent
|
|
6394
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
6395
|
+
for (const p of parents) {
|
|
6396
|
+
const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
|
|
6397
|
+
if (found?.ChildEntityName) {
|
|
6398
|
+
BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
|
|
6399
|
+
}
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
return grouped;
|
|
6403
|
+
}
|
|
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);
|
|
6442
|
+
}
|
|
6443
|
+
}
|
|
6444
|
+
if (cacheMisses.length > 0) {
|
|
6445
|
+
remainingByChildName.set(childName, cacheMisses);
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
else {
|
|
6449
|
+
remainingByChildName.set(childName, parents);
|
|
6450
|
+
}
|
|
6451
|
+
}
|
|
6452
|
+
if (remainingByChildName.size === 0) {
|
|
6453
|
+
return unlinkedParents;
|
|
6454
|
+
}
|
|
6455
|
+
if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
|
|
6456
|
+
for (const [childName, parents] of remainingByChildName) {
|
|
6457
|
+
for (const p of parents) {
|
|
6458
|
+
const linked = await p.createAndLinkChildEntity(childName);
|
|
6459
|
+
if (!linked) {
|
|
6460
|
+
unlinkedParents.push(p);
|
|
6461
|
+
outNotFound.add(p);
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
6465
|
+
return unlinkedParents;
|
|
6466
|
+
}
|
|
6467
|
+
const slices = BaseEntity.buildDisjointChildQuerySlices(remainingByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
|
|
6468
|
+
if (slices.length === 0) {
|
|
6469
|
+
return unlinkedParents;
|
|
6470
|
+
}
|
|
6471
|
+
const rv = new RunView(rvProvider);
|
|
6472
|
+
const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
|
|
6473
|
+
for (let sIdx = 0; sIdx < slices.length; sIdx++) {
|
|
6474
|
+
await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound);
|
|
6475
|
+
}
|
|
6476
|
+
return unlinkedParents;
|
|
6477
|
+
}
|
|
6478
|
+
static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
|
|
6479
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
6480
|
+
const slices = [];
|
|
6481
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
6482
|
+
const childInfo = md.EntityByName?.(childName) ??
|
|
6483
|
+
parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
|
|
6484
|
+
const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6485
|
+
if (!childInfo || !pkField) {
|
|
6486
|
+
for (const p of parents)
|
|
6487
|
+
unlinkedParents.push(p);
|
|
6488
|
+
continue;
|
|
6489
|
+
}
|
|
6490
|
+
const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
|
|
6491
|
+
const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
6492
|
+
if (!canRead) {
|
|
6493
|
+
if (!deniedChildLogged.has(childInfo.Name)) {
|
|
6494
|
+
deniedChildLogged.add(childInfo.Name);
|
|
6495
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6496
|
+
}
|
|
6497
|
+
for (const p of parents)
|
|
6498
|
+
unlinkedParents.push(p);
|
|
6499
|
+
continue;
|
|
6500
|
+
}
|
|
6501
|
+
for (let start = 0; start < parents.length; start += chunkSize) {
|
|
6502
|
+
const sliceParents = parents.slice(start, start + chunkSize);
|
|
6503
|
+
const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
|
|
6504
|
+
slices.push({
|
|
6505
|
+
childName: childInfo.Name,
|
|
6506
|
+
pkFieldName: pkField.Name,
|
|
6507
|
+
pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
|
|
6508
|
+
parents: sliceParents,
|
|
6509
|
+
param: {
|
|
6510
|
+
EntityName: childInfo.Name,
|
|
6511
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
|
|
6512
|
+
ResultType: 'simple',
|
|
6513
|
+
// Internal hydration read: see batchDiscoverDisjointChildren — keep these
|
|
6514
|
+
// per-batch IN-list reads out of the shared RunView cache.
|
|
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,
|
|
6519
|
+
},
|
|
6520
|
+
});
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
return slices;
|
|
6524
|
+
}
|
|
6525
|
+
static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged, outNotFound) {
|
|
6526
|
+
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
6527
|
+
// Not silent: the per-row path surfaced a failed child load; here the parents stay
|
|
6528
|
+
// unlinked (plain parent records) and the cause goes to the log.
|
|
6529
|
+
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');
|
|
6530
|
+
for (const p of slice.parents)
|
|
6531
|
+
unlinkedParents.push(p);
|
|
6532
|
+
return;
|
|
6533
|
+
}
|
|
6534
|
+
const rowByPk = new Map();
|
|
6535
|
+
for (const row of res.Results) {
|
|
6536
|
+
const pkVal = row[slice.pkFieldName];
|
|
6537
|
+
if (pkVal != null) {
|
|
6538
|
+
rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
|
|
6539
|
+
}
|
|
6540
|
+
}
|
|
6541
|
+
for (const p of slice.parents) {
|
|
6542
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
|
|
6543
|
+
const childRow = rowByPk.get(pkKey);
|
|
6544
|
+
if (!childRow) {
|
|
6545
|
+
unlinkedParents.push(p);
|
|
6546
|
+
outNotFound.add(p);
|
|
6547
|
+
continue;
|
|
6548
|
+
}
|
|
6549
|
+
const childEntity = await p.createChildEntityObject(slice.childName);
|
|
6550
|
+
if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
|
|
6551
|
+
// The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
|
|
6552
|
+
// path degrades to a plain parent record instead, and says so once per slice.
|
|
6553
|
+
if (!deniedChildLogged.has(slice.childName)) {
|
|
6554
|
+
deniedChildLogged.add(slice.childName);
|
|
6555
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6556
|
+
}
|
|
6557
|
+
unlinkedParents.push(p);
|
|
6558
|
+
continue;
|
|
6559
|
+
}
|
|
6560
|
+
await p.linkHydratedChildFromBatchRow(childEntity, childRow);
|
|
6561
|
+
outLinkedChildren.push(childEntity);
|
|
6562
|
+
}
|
|
6563
|
+
}
|
|
6564
|
+
async linkHydratedChildFromBatchRow(childEntity, childRow) {
|
|
6565
|
+
this.replaceChildParentChain(childEntity);
|
|
6566
|
+
this._childEntity = childEntity;
|
|
6567
|
+
childEntity.discardJSONFieldObjects();
|
|
6568
|
+
const narrowed = childEntity.narrowHydrationSource(childRow);
|
|
6569
|
+
childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
|
|
6570
|
+
childEntity._recordLoaded = true;
|
|
6571
|
+
childEntity._everSaved = true;
|
|
6572
|
+
childEntity._compositeKey = this.PrimaryKey;
|
|
6573
|
+
childEntity.cacheRecordName();
|
|
6574
|
+
await childEntity.deserializeCompanionsFromData(narrowed);
|
|
6575
|
+
}
|
|
5604
6576
|
/**
|
|
5605
6577
|
* Extracts and applies a companion payload carried inside a plain data object, if present.
|
|
5606
6578
|
*
|
|
@@ -5950,7 +6922,23 @@ export class BaseEntity {
|
|
|
5950
6922
|
relatedEntityList: null
|
|
5951
6923
|
});
|
|
5952
6924
|
// Delete OWN row first (FK constraint: child must be deleted before parent)
|
|
5953
|
-
|
|
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) {
|
|
5954
6942
|
ownRowDeleted = true;
|
|
5955
6943
|
// IS-A: after own delete succeeds, cascade to parent chain
|
|
5956
6944
|
if (hasParentChain) {
|
|
@@ -6094,20 +7082,53 @@ export class BaseEntity {
|
|
|
6094
7082
|
if (childEntities.length === 0) {
|
|
6095
7083
|
return { HasChildren: false, ChildEntityName: '' };
|
|
6096
7084
|
}
|
|
6097
|
-
// Use RunView on this instance's provider — a host RunView cannot see
|
|
6098
|
-
// uncommitted child rows on a graph-scoped connection.
|
|
6099
|
-
const rv = new RunView(this.RunViewProviderToUse);
|
|
6100
7085
|
const pkValue = this.PrimaryKey.Values();
|
|
6101
|
-
|
|
7086
|
+
// Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
|
|
7087
|
+
// (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
|
|
7088
|
+
const entityProvider = this.ProviderToUse;
|
|
7089
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
7090
|
+
const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
|
|
7091
|
+
if (found?.ChildEntityName) {
|
|
7092
|
+
return { HasChildren: true, ChildEntityName: found.ChildEntityName };
|
|
7093
|
+
}
|
|
7094
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
7095
|
+
}
|
|
7096
|
+
// Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
|
|
7097
|
+
const rvProvider = this.RunViewProviderToUse;
|
|
7098
|
+
const rv = new RunView(rvProvider);
|
|
7099
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
7100
|
+
if (validChildren.length === 0) {
|
|
7101
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
7102
|
+
}
|
|
7103
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
7104
|
+
const viewParams = validChildren.map(childEntity => {
|
|
7105
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
7106
|
+
return {
|
|
7107
|
+
EntityName: childEntity.Name,
|
|
7108
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
7109
|
+
ResultType: 'simple',
|
|
7110
|
+
Fields: [pkField.Name],
|
|
7111
|
+
MaxRows: 1,
|
|
7112
|
+
};
|
|
7113
|
+
});
|
|
7114
|
+
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
7115
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
7116
|
+
const result = results[i];
|
|
7117
|
+
if (result?.Success && result.Results && result.Results.length > 0) {
|
|
7118
|
+
return { HasChildren: true, ChildEntityName: validChildren[i].Name };
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
7122
|
+
}
|
|
7123
|
+
for (const childEntity of validChildren) {
|
|
6102
7124
|
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6103
|
-
if (!pkField)
|
|
6104
|
-
continue;
|
|
6105
7125
|
const result = await rv.RunView({
|
|
6106
7126
|
EntityName: childEntity.Name,
|
|
6107
|
-
ExtraFilter: `${pkField.Name} = '${pkValue}'`,
|
|
7127
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6108
7128
|
ResultType: 'simple',
|
|
6109
7129
|
Fields: [pkField.Name],
|
|
6110
|
-
MaxRows: 1
|
|
7130
|
+
MaxRows: 1,
|
|
7131
|
+
SkipTotalRowCount: true
|
|
6111
7132
|
}, this._contextCurrentUser);
|
|
6112
7133
|
if (result && result.Success && result.Results && result.Results.length > 0) {
|
|
6113
7134
|
return { HasChildren: true, ChildEntityName: childEntity.Name };
|
|
@@ -6169,18 +7190,50 @@ export class BaseEntity {
|
|
|
6169
7190
|
if (childEntities.length === 0) {
|
|
6170
7191
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6171
7192
|
}
|
|
6172
|
-
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
6173
7193
|
const pkValue = primaryKey.Values();
|
|
6174
|
-
|
|
7194
|
+
const candidateEntityProvider = (provider ?? BaseEntity.Provider);
|
|
7195
|
+
if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
|
|
7196
|
+
const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
|
|
7197
|
+
if (found?.ChildEntityName) {
|
|
7198
|
+
const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
|
|
7199
|
+
if (matchedChild) {
|
|
7200
|
+
return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
|
|
7201
|
+
}
|
|
7202
|
+
}
|
|
7203
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
7204
|
+
}
|
|
7205
|
+
const rvProvider = (provider ?? BaseEntity.Provider);
|
|
7206
|
+
const rv = new RunView(rvProvider);
|
|
7207
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
7208
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
|
|
7209
|
+
const viewParams = validChildren.map(child => {
|
|
7210
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
7211
|
+
return {
|
|
7212
|
+
EntityName: child.Name,
|
|
7213
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
7214
|
+
ResultType: 'simple',
|
|
7215
|
+
Fields: [childPK.Name],
|
|
7216
|
+
MaxRows: 1,
|
|
7217
|
+
};
|
|
7218
|
+
});
|
|
7219
|
+
const results = await rv.RunViews(viewParams, contextUser);
|
|
7220
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
7221
|
+
const result = results[i];
|
|
7222
|
+
if (result?.Success && result.Results?.length > 0) {
|
|
7223
|
+
return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
7227
|
+
}
|
|
7228
|
+
for (const child of validChildren) {
|
|
6175
7229
|
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6176
|
-
if (!childPK)
|
|
6177
|
-
continue;
|
|
6178
7230
|
const result = await rv.RunView({
|
|
6179
7231
|
EntityName: child.Name,
|
|
6180
|
-
ExtraFilter: `${childPK.Name} = '${pkValue}'`,
|
|
7232
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6181
7233
|
ResultType: 'simple',
|
|
6182
7234
|
Fields: [childPK.Name],
|
|
6183
|
-
MaxRows: 1
|
|
7235
|
+
MaxRows: 1,
|
|
7236
|
+
SkipTotalRowCount: true
|
|
6184
7237
|
}, contextUser);
|
|
6185
7238
|
if (result?.Success && result.Results?.length > 0) {
|
|
6186
7239
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
@@ -6227,10 +7280,11 @@ export class BaseEntity {
|
|
|
6227
7280
|
return;
|
|
6228
7281
|
const viewParams = validSiblings.map(sibling => ({
|
|
6229
7282
|
EntityName: sibling.Name,
|
|
6230
|
-
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
7283
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
|
|
6231
7284
|
ResultType: 'simple',
|
|
6232
7285
|
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
6233
|
-
MaxRows: 1
|
|
7286
|
+
MaxRows: 1,
|
|
7287
|
+
SkipTotalRowCount: true
|
|
6234
7288
|
}));
|
|
6235
7289
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
6236
7290
|
for (let i = 0; i < results.length; i++) {
|
|
@@ -6434,7 +7488,8 @@ export class BaseEntity {
|
|
|
6434
7488
|
}
|
|
6435
7489
|
/**
|
|
6436
7490
|
* Generates a vector embedding for a single text field using AI engine.
|
|
6437
|
-
* Only generates embeddings for new records
|
|
7491
|
+
* Only generates embeddings for new records, when the source field has changed, or when
|
|
7492
|
+
* {@link IsEmbeddingStale} reports the stored vector came from another model.
|
|
6438
7493
|
* Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
|
|
6439
7494
|
* vector's binary companion.
|
|
6440
7495
|
*
|
|
@@ -6452,33 +7507,46 @@ export class BaseEntity {
|
|
|
6452
7507
|
* @returns Promise that resolves to true if embedding was generated successfully, false otherwise
|
|
6453
7508
|
*/
|
|
6454
7509
|
async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
|
|
7510
|
+
const clearVector = () => {
|
|
7511
|
+
vectorField.Value = null;
|
|
7512
|
+
if (binaryVectorField)
|
|
7513
|
+
binaryVectorField.Value = null;
|
|
7514
|
+
if (modelField)
|
|
7515
|
+
modelField.Value = null;
|
|
7516
|
+
};
|
|
7517
|
+
// After an edit to a saved record, its stored vector describes the old text, so a failed
|
|
7518
|
+
// re-embed clears it rather than leaving it searchable.
|
|
7519
|
+
const edited = this.IsSaved && field.Dirty;
|
|
6455
7520
|
try {
|
|
6456
7521
|
if (this._skipEmbeddings)
|
|
6457
7522
|
return true;
|
|
6458
|
-
|
|
7523
|
+
const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
|
|
7524
|
+
if (!this.IsSaved || field.Dirty || stale) {
|
|
6459
7525
|
if (field.Value?.trim().length > 0) {
|
|
6460
7526
|
// recalc vector
|
|
6461
7527
|
const e = await this.EmbedTextLocal(field.Value);
|
|
6462
|
-
|
|
7528
|
+
// An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
|
|
7529
|
+
if (e?.vector?.length) {
|
|
6463
7530
|
vectorField.Value = JSON.stringify(e.vector);
|
|
6464
7531
|
if (binaryVectorField)
|
|
6465
7532
|
binaryVectorField.Value = Float32VectorToBase64(e.vector);
|
|
6466
7533
|
if (modelField)
|
|
6467
7534
|
modelField.Value = e.modelID;
|
|
6468
7535
|
}
|
|
7536
|
+
else if (edited) {
|
|
7537
|
+
clearVector();
|
|
7538
|
+
}
|
|
6469
7539
|
}
|
|
6470
7540
|
else {
|
|
6471
|
-
|
|
6472
|
-
if (binaryVectorField)
|
|
6473
|
-
binaryVectorField.Value = null;
|
|
6474
|
-
if (modelField)
|
|
6475
|
-
modelField.Value = null;
|
|
7541
|
+
clearVector();
|
|
6476
7542
|
}
|
|
6477
7543
|
}
|
|
6478
7544
|
return true;
|
|
6479
7545
|
}
|
|
6480
7546
|
catch (e) {
|
|
6481
7547
|
console.error("Error generating embedding:", e);
|
|
7548
|
+
if (edited)
|
|
7549
|
+
clearVector();
|
|
6482
7550
|
return false;
|
|
6483
7551
|
}
|
|
6484
7552
|
}
|
|
@@ -6492,6 +7560,18 @@ export class BaseEntity {
|
|
|
6492
7560
|
async EmbedTextLocal(textToEmbed) {
|
|
6493
7561
|
throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
|
|
6494
7562
|
}
|
|
7563
|
+
/**
|
|
7564
|
+
* Whether a stored vector is stale: it came from a model other than the one this entity embeds
|
|
7565
|
+
* with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
|
|
7566
|
+
* is regenerated on the next save even when its text is unchanged, which is how stored vectors
|
|
7567
|
+
* follow a change of embedding model. The base implementation returns false (only new or changed
|
|
7568
|
+
* text is embedded); server-side sub-classes that embed with the platform's default local model
|
|
7569
|
+
* override it.
|
|
7570
|
+
* @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
|
|
7571
|
+
*/
|
|
7572
|
+
async IsEmbeddingStale(embeddingModelID) {
|
|
7573
|
+
return false;
|
|
7574
|
+
}
|
|
6495
7575
|
/**
|
|
6496
7576
|
* Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
|
|
6497
7577
|
* 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
|