@memberjunction/core 6.2.0-edge.6 → 6.2.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.js.map +1 -1
- package/dist/generic/baseEngine.d.ts +118 -8
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +528 -82
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +63 -4
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +625 -55
- 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 +222 -40
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +6 -0
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +18 -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 +48 -5
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +146 -17
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +155 -2
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +362 -124
- 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/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -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,17 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
var EntityField_1;
|
|
11
|
-
import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, 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';
|
|
21
22
|
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
22
23
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
23
24
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
@@ -130,6 +131,18 @@ let EntityField = class EntityField {
|
|
|
130
131
|
SetCreateSuppressed(suppressed) {
|
|
131
132
|
this._createSuppressed = suppressed;
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Framework-internal: resets all mutable field state in place to match a freshly constructed
|
|
136
|
+
* `EntityField` instance without allocating a new object or invalidating `BaseEntity` field maps.
|
|
137
|
+
*/
|
|
138
|
+
ResetForInPlaceHydration() {
|
|
139
|
+
this._oldValue = null;
|
|
140
|
+
this._value = null;
|
|
141
|
+
this._neverSet = true;
|
|
142
|
+
this._notLoaded = false;
|
|
143
|
+
this._writtenAfterNotLoaded = false;
|
|
144
|
+
this._createSuppressed = false;
|
|
145
|
+
}
|
|
133
146
|
get Name() {
|
|
134
147
|
return this._entityFieldInfo.Name;
|
|
135
148
|
}
|
|
@@ -1077,6 +1090,7 @@ export class BaseEntity {
|
|
|
1077
1090
|
* pay nothing for the feature, not even an empty Map per instance.
|
|
1078
1091
|
*/
|
|
1079
1092
|
this._companions = null;
|
|
1093
|
+
this._finalizeGeneration = 0;
|
|
1080
1094
|
/**
|
|
1081
1095
|
* Returns true if the object is Dirty, meaning something has changed since it was last saved to the database, and false otherwise. For new records, this will always return true.
|
|
1082
1096
|
*
|
|
@@ -2193,25 +2207,102 @@ export class BaseEntity {
|
|
|
2193
2207
|
* (and potentially parent entities in the IS-A chain).
|
|
2194
2208
|
*/
|
|
2195
2209
|
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
2210
|
this.discardJSONFieldObjects();
|
|
2200
2211
|
// Field security: denied values never enter this object (see narrowHydrationSource).
|
|
2201
2212
|
data = this.narrowHydrationSource(data);
|
|
2202
2213
|
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
2203
|
-
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
2204
2214
|
if (this._parentEntity) {
|
|
2205
2215
|
this._parentEntity.Hydrate(data);
|
|
2206
2216
|
}
|
|
2207
|
-
// Populate this entity's fields
|
|
2208
|
-
//
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2217
|
+
// Populate this entity's fields in place without destroying hydrated EntityField instances
|
|
2218
|
+
// or invalidating _fieldCache / _codeNameCache.
|
|
2219
|
+
this.resetAndApplyOwnedFieldsInPlace(data);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Resets transient record state and applies `fieldData` to this entity's own fields in a
|
|
2223
|
+
* single pass over `this._fields`, reusing existing `EntityField` instances and preserving
|
|
2224
|
+
* `_fieldCache` / `_codeNameCache` when already hydrated.
|
|
2225
|
+
*
|
|
2226
|
+
* Equivalent to `init() + SetMany(ownedFieldsFrom(fieldData), true, true, true) + markFieldsOmittedBySourceAsNotLoaded(fieldData)`
|
|
2227
|
+
* with zero `EntityField` reallocations and zero intermediate `ownedFieldsFrom` object allocation.
|
|
2228
|
+
*/
|
|
2229
|
+
resetAndApplyOwnedFieldsInPlace(fieldData) {
|
|
2230
|
+
this._finalizeGeneration++;
|
|
2231
|
+
this._compositeKey = null;
|
|
2232
|
+
this._resultHistory = [];
|
|
2233
|
+
this._recordLoaded = false;
|
|
2234
|
+
this._raw = null;
|
|
2235
|
+
this._rawConverted = null;
|
|
2236
|
+
this.hydrateFieldsIfNeeded();
|
|
2237
|
+
if (!fieldData || typeof fieldData !== 'object' || Array.isArray(fieldData)) {
|
|
2238
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2239
|
+
const field = this._fields[i];
|
|
2240
|
+
field.ResetForInPlaceHydration();
|
|
2241
|
+
field.ClearAsNotLoaded();
|
|
2242
|
+
}
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
let normalizedMap = null;
|
|
2246
|
+
let sharedParentMirrors = null;
|
|
2247
|
+
const parentFieldNames = this._parentEntity ? this._parentEntityFieldNames : null;
|
|
2248
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
2249
|
+
const field = this._fields[i];
|
|
2250
|
+
const match = BaseEntity.findOwnedFieldValueInSource(field, fieldData, () => {
|
|
2251
|
+
if (normalizedMap === null) {
|
|
2252
|
+
normalizedMap = BaseEntity.buildNormalizedHydrationMap(fieldData);
|
|
2253
|
+
}
|
|
2254
|
+
return normalizedMap;
|
|
2255
|
+
});
|
|
2256
|
+
field.ResetForInPlaceHydration();
|
|
2257
|
+
if (match.found) {
|
|
2258
|
+
this.setFieldValue(field, match.val);
|
|
2259
|
+
field.ResetOldValue();
|
|
2260
|
+
if (parentFieldNames) {
|
|
2261
|
+
if (parentFieldNames.has(match.key)) {
|
|
2262
|
+
(sharedParentMirrors ??= {})[match.key] = match.val;
|
|
2263
|
+
}
|
|
2264
|
+
else if (parentFieldNames.has(field.Name)) {
|
|
2265
|
+
(sharedParentMirrors ??= {})[field.Name] = match.val;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
else {
|
|
2270
|
+
field.ClearAsNotLoaded();
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
if (sharedParentMirrors && this._parentEntity) {
|
|
2274
|
+
this._parentEntity.SetMany(sharedParentMirrors, true, true, true);
|
|
2275
|
+
}
|
|
2276
|
+
this.updateSavedStateFromPrimaryKeys();
|
|
2277
|
+
}
|
|
2278
|
+
static findOwnedFieldValueInSource(field, fieldData, getNormalizedMap) {
|
|
2279
|
+
if (BaseEntity.ownValuePresent(fieldData, field.Name)) {
|
|
2280
|
+
return { found: true, key: field.Name, val: fieldData[field.Name] };
|
|
2281
|
+
}
|
|
2282
|
+
if (field.CodeName !== field.Name && BaseEntity.ownValuePresent(fieldData, field.CodeName)) {
|
|
2283
|
+
return { found: true, key: field.CodeName, val: fieldData[field.CodeName] };
|
|
2284
|
+
}
|
|
2285
|
+
const normalizedMap = getNormalizedMap();
|
|
2286
|
+
const hit = normalizedMap.get(field.Name.trim().toLowerCase()) ??
|
|
2287
|
+
(field.CodeName !== field.Name
|
|
2288
|
+
? normalizedMap.get(field.CodeName.trim().toLowerCase())
|
|
2289
|
+
: undefined);
|
|
2290
|
+
if (hit !== undefined) {
|
|
2291
|
+
return { found: true, key: hit.key, val: hit.val };
|
|
2292
|
+
}
|
|
2293
|
+
return { found: false, key: field.Name, val: undefined };
|
|
2294
|
+
}
|
|
2295
|
+
static buildNormalizedHydrationMap(fieldData) {
|
|
2296
|
+
const normalizedMap = new Map();
|
|
2297
|
+
const keys = Object.keys(fieldData);
|
|
2298
|
+
for (let kIdx = 0; kIdx < keys.length; kIdx++) {
|
|
2299
|
+
const k = keys[kIdx];
|
|
2300
|
+
const v = fieldData[k];
|
|
2301
|
+
if (v !== undefined) {
|
|
2302
|
+
normalizedMap.set(k.trim().toLowerCase(), { key: k, val: v });
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
return normalizedMap;
|
|
2215
2306
|
}
|
|
2216
2307
|
/**
|
|
2217
2308
|
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
@@ -2906,6 +2997,7 @@ export class BaseEntity {
|
|
|
2906
2997
|
entity: level,
|
|
2907
2998
|
wasSaved: level._everSaved,
|
|
2908
2999
|
wasLoaded: level._recordLoaded,
|
|
3000
|
+
finalizeGeneration: level._finalizeGeneration,
|
|
2909
3001
|
resultHistory: [...level._resultHistory],
|
|
2910
3002
|
fields: level.Fields.map(field => ({ field, state: field.GetState() })),
|
|
2911
3003
|
});
|
|
@@ -2925,7 +3017,7 @@ export class BaseEntity {
|
|
|
2925
3017
|
* Puts this record back as a snapshot captured it, after the unit of work that finalized it
|
|
2926
3018
|
* rolled back.
|
|
2927
3019
|
*
|
|
2928
|
-
* A save's `finalizeSave()`
|
|
3020
|
+
* A save's `finalizeSave()` updates the record's fields from what its write returned, marks it
|
|
2929
3021
|
* saved and empties its result history. The database undid the write, so the record gets back
|
|
2930
3022
|
* its saved and loaded flags, its result history, and each field's value and tracking state. A
|
|
2931
3023
|
* field edited since then keeps the edit, and is compared with its captured baseline again, since
|
|
@@ -2935,14 +3027,16 @@ export class BaseEntity {
|
|
|
2935
3027
|
* The history matters to the caller: a save records its failure only when nothing else did,
|
|
2936
3028
|
* judged by the history's length when it started, which an emptied history would make wrong.
|
|
2937
3029
|
*
|
|
2938
|
-
* A record
|
|
2939
|
-
* changed and it is left alone.
|
|
3030
|
+
* A record whose `_finalizeGeneration` (and field object identities) still matches the snapshot
|
|
3031
|
+
* was never finalized, so nothing about it changed and it is left alone.
|
|
2940
3032
|
*/
|
|
2941
3033
|
restoreSnapshot(snapshot) {
|
|
2942
|
-
const finalized =
|
|
3034
|
+
const finalized = this._finalizeGeneration !== snapshot.finalizeGeneration ||
|
|
3035
|
+
snapshot.fields.some(({ field }) => this.GetFieldByName(field.Name) !== field);
|
|
2943
3036
|
if (!finalized) {
|
|
2944
3037
|
return;
|
|
2945
3038
|
}
|
|
3039
|
+
this._finalizeGeneration = snapshot.finalizeGeneration;
|
|
2946
3040
|
this._everSaved = snapshot.wasSaved;
|
|
2947
3041
|
this._recordLoaded = snapshot.wasLoaded;
|
|
2948
3042
|
this._resultHistory = snapshot.resultHistory;
|
|
@@ -4949,19 +5043,11 @@ export class BaseEntity {
|
|
|
4949
5043
|
// Record Changes payload from this entity's pre-save state, so narrowing the response
|
|
4950
5044
|
// here costs the audit nothing; it only keeps denied values out of this object.
|
|
4951
5045
|
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
|
-
}
|
|
5046
|
+
// Reset transient record state and update owned EntityField values in place so
|
|
5047
|
+
// hydrated EntityField instances and _fieldCache/_codeNameCache are preserved across saves.
|
|
5048
|
+
// Deliberately do NOT call discardJSONFieldObjects() here so JSONType references held
|
|
5049
|
+
// across Save() remain attached and live.
|
|
5050
|
+
this.resetAndApplyOwnedFieldsInPlace(fieldData);
|
|
4965
5051
|
this._everSaved = true; // Mark as saved after successful save
|
|
4966
5052
|
const result = this.LatestResult;
|
|
4967
5053
|
if (result)
|
|
@@ -5601,6 +5687,398 @@ export class BaseEntity {
|
|
|
5601
5687
|
await this.deserializeCompanionsFromData(data);
|
|
5602
5688
|
return true;
|
|
5603
5689
|
}
|
|
5690
|
+
/**
|
|
5691
|
+
* Framework-internal batch IS-A parent hydration entry point used by
|
|
5692
|
+
* `TransformSimpleObjectToEntityObject` when the provider supports batched `RunViews`.
|
|
5693
|
+
*
|
|
5694
|
+
* Collapses $O(2N)$ per-record child-discovery and child-load queries into at most
|
|
5695
|
+
* 1 batched discovery query + 1 batched `RunViews` call per hierarchy depth level
|
|
5696
|
+
* (and 0 discovery queries when a `SubtypeSelector` / `EntitySubtypeResolver` resolves all rows).
|
|
5697
|
+
*/
|
|
5698
|
+
static { this.DEFAULT_ISA_BATCH_CHUNK_SIZE = 500; }
|
|
5699
|
+
/**
|
|
5700
|
+
* Maximum number of primary keys included in a single `WHERE PK IN (...)` batch query
|
|
5701
|
+
* during IS-A child discovery or hydration.
|
|
5702
|
+
*/
|
|
5703
|
+
static get ISABatchChunkSize() {
|
|
5704
|
+
return BaseEntity.DEFAULT_ISA_BATCH_CHUNK_SIZE;
|
|
5705
|
+
}
|
|
5706
|
+
async LoadFromDataBatchAsISAParent(peers, md, contextUser) {
|
|
5707
|
+
const loadedParents = [];
|
|
5708
|
+
for (let i = 0; i < peers.length; i++) {
|
|
5709
|
+
const { entity, data } = peers[i];
|
|
5710
|
+
if (IsMemberOverridden(entity, 'LoadFromData', BaseEntity)) {
|
|
5711
|
+
await entity.LoadFromData(data);
|
|
5712
|
+
continue;
|
|
5713
|
+
}
|
|
5714
|
+
const allPksSet = await entity.hydrateSingleISAParentPeer(data);
|
|
5715
|
+
if (allPksSet) {
|
|
5716
|
+
loadedParents.push(entity);
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
if (loadedParents.length > 0) {
|
|
5720
|
+
await BaseEntity.hydrateISAChildrenForLevel(loadedParents, md, contextUser);
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5723
|
+
async hydrateSingleISAParentPeer(rawData) {
|
|
5724
|
+
this.discardJSONFieldObjects();
|
|
5725
|
+
const dataObj = rawData instanceof BaseEntity
|
|
5726
|
+
? rawData.GetAll()
|
|
5727
|
+
: rawData;
|
|
5728
|
+
const narrowed = this.narrowHydrationSource(dataObj);
|
|
5729
|
+
if (this._parentEntity && narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed)) {
|
|
5730
|
+
this._parentEntity.Hydrate(narrowed);
|
|
5731
|
+
}
|
|
5732
|
+
const isPlain = Boolean(narrowed && typeof narrowed === 'object' && !Array.isArray(narrowed) && !(narrowed instanceof Date));
|
|
5733
|
+
this.SetMany(narrowed, true, false, true);
|
|
5734
|
+
if (isPlain) {
|
|
5735
|
+
this.markFieldsOmittedBySourceAsNotLoaded(narrowed);
|
|
5736
|
+
}
|
|
5737
|
+
let allPksSet = false;
|
|
5738
|
+
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
5739
|
+
allPksSet = true;
|
|
5740
|
+
for (const pkey of this.PrimaryKeys) {
|
|
5741
|
+
if (pkey.Value === null || pkey.Value === undefined) {
|
|
5742
|
+
allPksSet = false;
|
|
5743
|
+
break;
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
this._recordLoaded = allPksSet;
|
|
5747
|
+
this._everSaved = allPksSet;
|
|
5748
|
+
if (allPksSet) {
|
|
5749
|
+
this.cacheRecordName();
|
|
5750
|
+
}
|
|
5751
|
+
}
|
|
5752
|
+
else {
|
|
5753
|
+
LogError(`BaseEntity.LoadFromDataBatchAsISAParent() called on ${this.EntityInfo.Name} with no primary keys defined. This is an error state and should not happen.`);
|
|
5754
|
+
this._recordLoaded = false;
|
|
5755
|
+
this._everSaved = false;
|
|
5756
|
+
}
|
|
5757
|
+
await this.deserializeCompanionsFromData(narrowed);
|
|
5758
|
+
return allPksSet;
|
|
5759
|
+
}
|
|
5760
|
+
static async hydrateISAChildrenForLevel(levelEntities, md, contextUser) {
|
|
5761
|
+
const activeParents = levelEntities.filter(e => e._recordLoaded && e.EntityInfo.IsParentType && !e._childEntityDiscoveryDone && e.EntityInfo.ChildEntities.length > 0);
|
|
5762
|
+
if (activeParents.length === 0) {
|
|
5763
|
+
return;
|
|
5764
|
+
}
|
|
5765
|
+
const groupsByEntityId = new Map();
|
|
5766
|
+
for (const p of activeParents) {
|
|
5767
|
+
p._childEntityDiscoveryDone = true;
|
|
5768
|
+
let group = groupsByEntityId.get(p.EntityInfo.ID);
|
|
5769
|
+
if (!group) {
|
|
5770
|
+
group = [];
|
|
5771
|
+
groupsByEntityId.set(p.EntityInfo.ID, group);
|
|
5772
|
+
}
|
|
5773
|
+
group.push(p);
|
|
5774
|
+
}
|
|
5775
|
+
const nextLevelChildren = [];
|
|
5776
|
+
for (const [, groupParents] of groupsByEntityId) {
|
|
5777
|
+
const sampleParent = groupParents[0];
|
|
5778
|
+
if (sampleParent.EntityInfo.AllowMultipleSubtypes) {
|
|
5779
|
+
await BaseEntity.hydrateOverlappingChildrenForGroup(groupParents, contextUser);
|
|
5780
|
+
}
|
|
5781
|
+
else {
|
|
5782
|
+
await BaseEntity.hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren);
|
|
5783
|
+
}
|
|
5784
|
+
}
|
|
5785
|
+
if (nextLevelChildren.some(c => c.EntityInfo.IsParentType)) {
|
|
5786
|
+
await BaseEntity.hydrateISAChildrenForLevel(nextLevelChildren, md, contextUser);
|
|
5787
|
+
}
|
|
5788
|
+
}
|
|
5789
|
+
static async hydrateOverlappingChildrenForGroup(groupParents, contextUser) {
|
|
5790
|
+
const sampleParent = groupParents[0];
|
|
5791
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
5792
|
+
const entityProvider = sampleParent.ProviderToUse;
|
|
5793
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5794
|
+
const pks = groupParents.map(p => String(p.PrimaryKey.Values()));
|
|
5795
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pks, contextUser ?? sampleParent._contextCurrentUser);
|
|
5796
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5797
|
+
for (const p of groupParents) {
|
|
5798
|
+
const rawPk = String(p.PrimaryKey.Values());
|
|
5799
|
+
const pkKey = pkField ? pkField.FormatCanonicalValue(rawPk) : CanonicalPrimaryKeyValue(rawPk, false);
|
|
5800
|
+
const matched = batchMap.get(pkKey) ?? [];
|
|
5801
|
+
p._childEntities = matched.map(name => ({ entityName: name }));
|
|
5802
|
+
}
|
|
5803
|
+
}
|
|
5804
|
+
else {
|
|
5805
|
+
await Promise.all(groupParents.map(p => p.discoverOverlappingChildren()));
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
static async hydrateDisjointChildrenForGroup(groupParents, md, contextUser, nextLevelChildren) {
|
|
5809
|
+
const sampleParent = groupParents[0];
|
|
5810
|
+
const parentInfo = sampleParent.EntityInfo;
|
|
5811
|
+
const entityProvider = sampleParent.ProviderToUse;
|
|
5812
|
+
const rvProvider = sampleParent.RunViewProviderToUse;
|
|
5813
|
+
const effectiveUser = contextUser ?? sampleParent._contextCurrentUser;
|
|
5814
|
+
// 1) Evaluate in-memory SubtypeSelector / EntitySubtypeResolver load hints
|
|
5815
|
+
const { hintedByChild, hintedNameByParent, needsDiscovery } = await BaseEntity.partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser);
|
|
5816
|
+
// 2) Batch-load hinted child rows in 1 RunViews call; any missing/failed row falls back to discovery
|
|
5817
|
+
if (hintedByChild.size > 0) {
|
|
5818
|
+
const unlinkedFromHints = await BaseEntity.batchLoadAndLinkDisjointChildren(hintedByChild, md, rvProvider, effectiveUser, nextLevelChildren);
|
|
5819
|
+
for (const unlinked of unlinkedFromHints) {
|
|
5820
|
+
// Same diagnostic the per-row path emits when a SubtypeSelector hint names a
|
|
5821
|
+
// child that has no row: operators need the signal that a selector is wrong.
|
|
5822
|
+
const hinted = hintedNameByParent.get(unlinked);
|
|
5823
|
+
if (hinted) {
|
|
5824
|
+
BaseEntity.logLoadFoundNoRow(hinted, unlinked.PrimaryKey);
|
|
5825
|
+
}
|
|
5826
|
+
needsDiscovery.push(unlinked);
|
|
5827
|
+
}
|
|
5828
|
+
}
|
|
5829
|
+
// 3) Batch-discover remaining unresolved parents in 1 query, then batch-load their child rows in 1 RunViews call
|
|
5830
|
+
if (needsDiscovery.length > 0) {
|
|
5831
|
+
const discoveredByChild = await BaseEntity.batchDiscoverDisjointChildren(parentInfo, needsDiscovery, entityProvider, rvProvider, effectiveUser);
|
|
5832
|
+
if (discoveredByChild.size > 0) {
|
|
5833
|
+
await BaseEntity.batchLoadAndLinkDisjointChildren(discoveredByChild, md, rvProvider, effectiveUser, nextLevelChildren);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
}
|
|
5837
|
+
static async partitionParentsBySubtypeLoadHint(groupParents, parentInfo, md, contextUser) {
|
|
5838
|
+
const hintedByChild = new Map();
|
|
5839
|
+
const hintedNameByParent = new Map();
|
|
5840
|
+
const needsDiscovery = [];
|
|
5841
|
+
for (const p of groupParents) {
|
|
5842
|
+
const hintedName = await p.resolveSubtypeLoadHint();
|
|
5843
|
+
if (hintedName) {
|
|
5844
|
+
const childInfo = md.EntityByName?.(hintedName) ??
|
|
5845
|
+
parentInfo.ChildEntities.find(c => sameEntityName(c.Name, hintedName));
|
|
5846
|
+
const userForPerm = contextUser ?? p._contextCurrentUser ?? md.CurrentUser;
|
|
5847
|
+
const canRead = childInfo && userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
5848
|
+
if (childInfo && canRead) {
|
|
5849
|
+
BaseEntity.appendParentToChildGroup(hintedByChild, childInfo.Name, p);
|
|
5850
|
+
hintedNameByParent.set(p, childInfo.Name);
|
|
5851
|
+
}
|
|
5852
|
+
else {
|
|
5853
|
+
needsDiscovery.push(p);
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
else {
|
|
5857
|
+
needsDiscovery.push(p);
|
|
5858
|
+
}
|
|
5859
|
+
}
|
|
5860
|
+
return { hintedByChild, hintedNameByParent, needsDiscovery };
|
|
5861
|
+
}
|
|
5862
|
+
static appendParentToChildGroup(grouped, childName, parent) {
|
|
5863
|
+
let bucket = grouped.get(childName);
|
|
5864
|
+
if (!bucket) {
|
|
5865
|
+
bucket = [];
|
|
5866
|
+
grouped.set(childName, bucket);
|
|
5867
|
+
}
|
|
5868
|
+
bucket.push(parent);
|
|
5869
|
+
}
|
|
5870
|
+
static buildChildPkInFilter(pkField, rawPkValues) {
|
|
5871
|
+
const formatted = rawPkValues.map(val => {
|
|
5872
|
+
if (!pkField.NeedsQuotes) {
|
|
5873
|
+
// Emit the digits as written: a bigint beyond 2^53 must not be rewritten by Number().
|
|
5874
|
+
const text = String(val).trim();
|
|
5875
|
+
if (/^-?\d+(\.\d+)?$/.test(text)) {
|
|
5876
|
+
return text;
|
|
5877
|
+
}
|
|
5878
|
+
}
|
|
5879
|
+
return `'${EscapeSQLString(String(val))}'`;
|
|
5880
|
+
});
|
|
5881
|
+
return `${pkField.Name} IN (${formatted.join(', ')})`;
|
|
5882
|
+
}
|
|
5883
|
+
static async batchDiscoverDisjointChildren(parentInfo, parents, entityProvider, rvProvider, contextUser) {
|
|
5884
|
+
const pkField = parentInfo.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5885
|
+
const pkIsUuid = Boolean(pkField?.IsUniqueIdentifier);
|
|
5886
|
+
const grouped = new Map();
|
|
5887
|
+
const pkValues = parents.map(p => String(p.PrimaryKey.Values()));
|
|
5888
|
+
if (typeof entityProvider?.FindISAChildEntitiesBatch === 'function') {
|
|
5889
|
+
const batchMap = await entityProvider.FindISAChildEntitiesBatch(parentInfo, pkValues, contextUser);
|
|
5890
|
+
for (const p of parents) {
|
|
5891
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
5892
|
+
const firstMatch = batchMap.get(pkKey)?.[0];
|
|
5893
|
+
if (firstMatch) {
|
|
5894
|
+
BaseEntity.appendParentToChildGroup(grouped, firstMatch, p);
|
|
5895
|
+
}
|
|
5896
|
+
}
|
|
5897
|
+
return grouped;
|
|
5898
|
+
}
|
|
5899
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
5900
|
+
return BaseEntity.discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser);
|
|
5901
|
+
}
|
|
5902
|
+
// Last-resort per-parent fallback when neither FindISAChildEntitiesBatch nor RunViews exists
|
|
5903
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
5904
|
+
for (const p of parents) {
|
|
5905
|
+
const found = await entityProvider.FindISAChildEntity(parentInfo, String(p.PrimaryKey.Values()), contextUser);
|
|
5906
|
+
if (found?.ChildEntityName) {
|
|
5907
|
+
BaseEntity.appendParentToChildGroup(grouped, found.ChildEntityName, p);
|
|
5908
|
+
}
|
|
5909
|
+
}
|
|
5910
|
+
}
|
|
5911
|
+
return grouped;
|
|
5912
|
+
}
|
|
5913
|
+
static async discoverDisjointChildrenViaRunViews(parentInfo, parents, pkValues, pkIsUuid, rvProvider, contextUser) {
|
|
5914
|
+
const grouped = new Map();
|
|
5915
|
+
const validChildren = parentInfo.ChildEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
5916
|
+
if (validChildren.length === 0)
|
|
5917
|
+
return grouped;
|
|
5918
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
5919
|
+
const winnerByPk = new Map();
|
|
5920
|
+
const rv = new RunView(rvProvider);
|
|
5921
|
+
for (let start = 0; start < pkValues.length; start += chunkSize) {
|
|
5922
|
+
const chunkPks = pkValues.slice(start, start + chunkSize);
|
|
5923
|
+
const probeParams = validChildren.map(child => {
|
|
5924
|
+
const childPk = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5925
|
+
return {
|
|
5926
|
+
EntityName: child.Name,
|
|
5927
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(childPk, chunkPks),
|
|
5928
|
+
Fields: [childPk.Name],
|
|
5929
|
+
ResultType: 'simple',
|
|
5930
|
+
// Internal probe: the pre-batch path (InnerLoad) never touched the RunView
|
|
5931
|
+
// cache. Every distinct IN-list would otherwise become its own filtered
|
|
5932
|
+
// cache slot that is invalidated (never maintained) on each child save.
|
|
5933
|
+
BypassCache: true,
|
|
5934
|
+
};
|
|
5935
|
+
});
|
|
5936
|
+
const probeResults = await rv.RunViews(probeParams, contextUser);
|
|
5937
|
+
for (let cIdx = 0; cIdx < validChildren.length; cIdx++) {
|
|
5938
|
+
const child = validChildren[cIdx];
|
|
5939
|
+
const pkName = child.FirstPrimaryKey.Name; // first-pk-ok: IS-A children share the parent's single key
|
|
5940
|
+
const res = probeResults[cIdx];
|
|
5941
|
+
if (!res?.Success || !Array.isArray(res.Results))
|
|
5942
|
+
continue;
|
|
5943
|
+
for (const row of res.Results) {
|
|
5944
|
+
const rawVal = row[pkName];
|
|
5945
|
+
if (rawVal != null) {
|
|
5946
|
+
const norm = CanonicalPrimaryKeyValue(String(rawVal), pkIsUuid);
|
|
5947
|
+
if (!winnerByPk.has(norm)) {
|
|
5948
|
+
winnerByPk.set(norm, child.Name);
|
|
5949
|
+
}
|
|
5950
|
+
}
|
|
5951
|
+
}
|
|
5952
|
+
}
|
|
5953
|
+
}
|
|
5954
|
+
for (const p of parents) {
|
|
5955
|
+
const norm = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), pkIsUuid);
|
|
5956
|
+
const childName = winnerByPk.get(norm);
|
|
5957
|
+
if (childName) {
|
|
5958
|
+
BaseEntity.appendParentToChildGroup(grouped, childName, p);
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
return grouped;
|
|
5962
|
+
}
|
|
5963
|
+
static async batchLoadAndLinkDisjointChildren(parentsByChildName, md, rvProvider, contextUser, outLinkedChildren) {
|
|
5964
|
+
const unlinkedParents = [];
|
|
5965
|
+
if (!rvProvider || typeof rvProvider.RunViews !== 'function') {
|
|
5966
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
5967
|
+
for (const p of parents) {
|
|
5968
|
+
const linked = await p.createAndLinkChildEntity(childName);
|
|
5969
|
+
if (!linked) {
|
|
5970
|
+
unlinkedParents.push(p);
|
|
5971
|
+
}
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
return unlinkedParents;
|
|
5975
|
+
}
|
|
5976
|
+
const deniedChildLogged = new Set();
|
|
5977
|
+
const slices = BaseEntity.buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged);
|
|
5978
|
+
if (slices.length === 0) {
|
|
5979
|
+
return unlinkedParents;
|
|
5980
|
+
}
|
|
5981
|
+
const rv = new RunView(rvProvider);
|
|
5982
|
+
const batchResults = await rv.RunViews(slices.map(s => s.param), contextUser);
|
|
5983
|
+
for (let sIdx = 0; sIdx < slices.length; sIdx++) {
|
|
5984
|
+
await BaseEntity.linkDisjointChildSliceResults(slices[sIdx], batchResults[sIdx], unlinkedParents, outLinkedChildren, deniedChildLogged);
|
|
5985
|
+
}
|
|
5986
|
+
return unlinkedParents;
|
|
5987
|
+
}
|
|
5988
|
+
static buildDisjointChildQuerySlices(parentsByChildName, md, contextUser, unlinkedParents, deniedChildLogged) {
|
|
5989
|
+
const chunkSize = BaseEntity.ISABatchChunkSize;
|
|
5990
|
+
const slices = [];
|
|
5991
|
+
for (const [childName, parents] of parentsByChildName) {
|
|
5992
|
+
const childInfo = md.EntityByName?.(childName) ??
|
|
5993
|
+
parents[0]?.EntityInfo.ChildEntities.find(c => sameEntityName(c.Name, childName));
|
|
5994
|
+
const pkField = childInfo?.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
5995
|
+
if (!childInfo || !pkField) {
|
|
5996
|
+
for (const p of parents)
|
|
5997
|
+
unlinkedParents.push(p);
|
|
5998
|
+
continue;
|
|
5999
|
+
}
|
|
6000
|
+
const userForPerm = contextUser ?? parents[0]?._contextCurrentUser ?? md.CurrentUser;
|
|
6001
|
+
const canRead = userForPerm ? childInfo.GetUserPermisions(userForPerm).CanRead : true;
|
|
6002
|
+
if (!canRead) {
|
|
6003
|
+
if (!deniedChildLogged.has(childInfo.Name)) {
|
|
6004
|
+
deniedChildLogged.add(childInfo.Name);
|
|
6005
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${childInfo.Name}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6006
|
+
}
|
|
6007
|
+
for (const p of parents)
|
|
6008
|
+
unlinkedParents.push(p);
|
|
6009
|
+
continue;
|
|
6010
|
+
}
|
|
6011
|
+
for (let start = 0; start < parents.length; start += chunkSize) {
|
|
6012
|
+
const sliceParents = parents.slice(start, start + chunkSize);
|
|
6013
|
+
const pks = sliceParents.map(p => String(p.PrimaryKey.Values()));
|
|
6014
|
+
slices.push({
|
|
6015
|
+
childName: childInfo.Name,
|
|
6016
|
+
pkFieldName: pkField.Name,
|
|
6017
|
+
pkIsUuid: Boolean(pkField.IsUniqueIdentifier),
|
|
6018
|
+
parents: sliceParents,
|
|
6019
|
+
param: {
|
|
6020
|
+
EntityName: childInfo.Name,
|
|
6021
|
+
ExtraFilter: BaseEntity.buildChildPkInFilter(pkField, pks),
|
|
6022
|
+
ResultType: 'simple',
|
|
6023
|
+
// Internal hydration read: see batchDiscoverDisjointChildren — keep these
|
|
6024
|
+
// per-batch IN-list reads out of the shared RunView cache.
|
|
6025
|
+
BypassCache: true,
|
|
6026
|
+
},
|
|
6027
|
+
});
|
|
6028
|
+
}
|
|
6029
|
+
}
|
|
6030
|
+
return slices;
|
|
6031
|
+
}
|
|
6032
|
+
static async linkDisjointChildSliceResults(slice, res, unlinkedParents, outLinkedChildren, deniedChildLogged) {
|
|
6033
|
+
if (!res?.Success || !Array.isArray(res.Results)) {
|
|
6034
|
+
// Not silent: the per-row path surfaced a failed child load; here the parents stay
|
|
6035
|
+
// unlinked (plain parent records) and the cause goes to the log.
|
|
6036
|
+
LogWarning(`BaseEntity: batched IS-A child load of '${slice.childName}' failed (${res?.ErrorMessage || 'no result'}); ${slice.parents.length} parent record(s) continue without their subtype`, 'IS-A');
|
|
6037
|
+
for (const p of slice.parents)
|
|
6038
|
+
unlinkedParents.push(p);
|
|
6039
|
+
return;
|
|
6040
|
+
}
|
|
6041
|
+
const rowByPk = new Map();
|
|
6042
|
+
for (const row of res.Results) {
|
|
6043
|
+
const pkVal = row[slice.pkFieldName];
|
|
6044
|
+
if (pkVal != null) {
|
|
6045
|
+
rowByPk.set(CanonicalPrimaryKeyValue(String(pkVal), slice.pkIsUuid), row);
|
|
6046
|
+
}
|
|
6047
|
+
}
|
|
6048
|
+
for (const p of slice.parents) {
|
|
6049
|
+
const pkKey = CanonicalPrimaryKeyValue(String(p.PrimaryKey.Values()), slice.pkIsUuid);
|
|
6050
|
+
const childRow = rowByPk.get(pkKey);
|
|
6051
|
+
if (!childRow) {
|
|
6052
|
+
unlinkedParents.push(p);
|
|
6053
|
+
continue;
|
|
6054
|
+
}
|
|
6055
|
+
const childEntity = await p.createChildEntityObject(slice.childName);
|
|
6056
|
+
if (!childEntity || !childEntity.CheckPermissions(EntityPermissionType.Read, false)) {
|
|
6057
|
+
// The per-row path threw here (InnerLoad → CheckPermissions(Read, true)). The batch
|
|
6058
|
+
// path degrades to a plain parent record instead, and says so once per slice.
|
|
6059
|
+
if (!deniedChildLogged.has(slice.childName)) {
|
|
6060
|
+
deniedChildLogged.add(slice.childName);
|
|
6061
|
+
LogWarning(`BaseEntity: current user cannot read IS-A child '${slice.childName}'; matching parent records are returned without their subtype`, 'IS-A');
|
|
6062
|
+
}
|
|
6063
|
+
unlinkedParents.push(p);
|
|
6064
|
+
continue;
|
|
6065
|
+
}
|
|
6066
|
+
await p.linkHydratedChildFromBatchRow(childEntity, childRow);
|
|
6067
|
+
outLinkedChildren.push(childEntity);
|
|
6068
|
+
}
|
|
6069
|
+
}
|
|
6070
|
+
async linkHydratedChildFromBatchRow(childEntity, childRow) {
|
|
6071
|
+
this.replaceChildParentChain(childEntity);
|
|
6072
|
+
this._childEntity = childEntity;
|
|
6073
|
+
childEntity.discardJSONFieldObjects();
|
|
6074
|
+
const narrowed = childEntity.narrowHydrationSource(childRow);
|
|
6075
|
+
childEntity.resetAndApplyOwnedFieldsInPlace(narrowed);
|
|
6076
|
+
childEntity._recordLoaded = true;
|
|
6077
|
+
childEntity._everSaved = true;
|
|
6078
|
+
childEntity._compositeKey = this.PrimaryKey;
|
|
6079
|
+
childEntity.cacheRecordName();
|
|
6080
|
+
await childEntity.deserializeCompanionsFromData(narrowed);
|
|
6081
|
+
}
|
|
5604
6082
|
/**
|
|
5605
6083
|
* Extracts and applies a companion payload carried inside a plain data object, if present.
|
|
5606
6084
|
*
|
|
@@ -6094,20 +6572,53 @@ export class BaseEntity {
|
|
|
6094
6572
|
if (childEntities.length === 0) {
|
|
6095
6573
|
return { HasChildren: false, ChildEntityName: '' };
|
|
6096
6574
|
}
|
|
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
6575
|
const pkValue = this.PrimaryKey.Values();
|
|
6101
|
-
|
|
6576
|
+
// Tier 1: Single UNION ALL SQL query on this entity's bound DatabaseProviderBase
|
|
6577
|
+
// (executes inside any active transaction on this.ProviderToUse in 1 round-trip instead of K).
|
|
6578
|
+
const entityProvider = this.ProviderToUse;
|
|
6579
|
+
if (typeof entityProvider?.FindISAChildEntity === 'function') {
|
|
6580
|
+
const found = await entityProvider.FindISAChildEntity(this.EntityInfo, String(pkValue), this._contextCurrentUser);
|
|
6581
|
+
if (found?.ChildEntityName) {
|
|
6582
|
+
return { HasChildren: true, ChildEntityName: found.ChildEntityName };
|
|
6583
|
+
}
|
|
6584
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6585
|
+
}
|
|
6586
|
+
// Tier 2: Batched RunViews on this instance's RunView provider (GraphQLDataProvider or test mocks)
|
|
6587
|
+
const rvProvider = this.RunViewProviderToUse;
|
|
6588
|
+
const rv = new RunView(rvProvider);
|
|
6589
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
6590
|
+
if (validChildren.length === 0) {
|
|
6591
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6592
|
+
}
|
|
6593
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function') {
|
|
6594
|
+
const viewParams = validChildren.map(childEntity => {
|
|
6595
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6596
|
+
return {
|
|
6597
|
+
EntityName: childEntity.Name,
|
|
6598
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6599
|
+
ResultType: 'simple',
|
|
6600
|
+
Fields: [pkField.Name],
|
|
6601
|
+
MaxRows: 1,
|
|
6602
|
+
};
|
|
6603
|
+
});
|
|
6604
|
+
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
6605
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
6606
|
+
const result = results[i];
|
|
6607
|
+
if (result?.Success && result.Results && result.Results.length > 0) {
|
|
6608
|
+
return { HasChildren: true, ChildEntityName: validChildren[i].Name };
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
return { HasChildren: false, ChildEntityName: '' };
|
|
6612
|
+
}
|
|
6613
|
+
for (const childEntity of validChildren) {
|
|
6102
6614
|
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6103
|
-
if (!pkField)
|
|
6104
|
-
continue;
|
|
6105
6615
|
const result = await rv.RunView({
|
|
6106
6616
|
EntityName: childEntity.Name,
|
|
6107
|
-
ExtraFilter: `${pkField.Name} = '${pkValue}'`,
|
|
6617
|
+
ExtraFilter: `${pkField.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6108
6618
|
ResultType: 'simple',
|
|
6109
6619
|
Fields: [pkField.Name],
|
|
6110
|
-
MaxRows: 1
|
|
6620
|
+
MaxRows: 1,
|
|
6621
|
+
SkipTotalRowCount: true
|
|
6111
6622
|
}, this._contextCurrentUser);
|
|
6112
6623
|
if (result && result.Success && result.Results && result.Results.length > 0) {
|
|
6113
6624
|
return { HasChildren: true, ChildEntityName: childEntity.Name };
|
|
@@ -6169,18 +6680,50 @@ export class BaseEntity {
|
|
|
6169
6680
|
if (childEntities.length === 0) {
|
|
6170
6681
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6171
6682
|
}
|
|
6172
|
-
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
6173
6683
|
const pkValue = primaryKey.Values();
|
|
6174
|
-
|
|
6684
|
+
const candidateEntityProvider = (provider ?? BaseEntity.Provider);
|
|
6685
|
+
if (typeof candidateEntityProvider?.FindISAChildEntity === 'function') {
|
|
6686
|
+
const found = await candidateEntityProvider.FindISAChildEntity(entityInfo, String(pkValue), contextUser);
|
|
6687
|
+
if (found?.ChildEntityName) {
|
|
6688
|
+
const matchedChild = childEntities.find(c => sameEntityName(c.Name, found.ChildEntityName));
|
|
6689
|
+
if (matchedChild) {
|
|
6690
|
+
return BaseEntity.resolveLeafEntityRecursive(matchedChild, primaryKey, contextUser, provider);
|
|
6691
|
+
}
|
|
6692
|
+
}
|
|
6693
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6694
|
+
}
|
|
6695
|
+
const rvProvider = (provider ?? BaseEntity.Provider);
|
|
6696
|
+
const rv = new RunView(rvProvider);
|
|
6697
|
+
const validChildren = childEntities.filter(c => c.FirstPrimaryKey != null); // first-pk-ok: IS-A children share the parent's single key
|
|
6698
|
+
if (rvProvider && typeof rvProvider.RunViews === 'function' && validChildren.length > 0) {
|
|
6699
|
+
const viewParams = validChildren.map(child => {
|
|
6700
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6701
|
+
return {
|
|
6702
|
+
EntityName: child.Name,
|
|
6703
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6704
|
+
ResultType: 'simple',
|
|
6705
|
+
Fields: [childPK.Name],
|
|
6706
|
+
MaxRows: 1,
|
|
6707
|
+
};
|
|
6708
|
+
});
|
|
6709
|
+
const results = await rv.RunViews(viewParams, contextUser);
|
|
6710
|
+
for (let i = 0; i < validChildren.length; i++) {
|
|
6711
|
+
const result = results[i];
|
|
6712
|
+
if (result?.Success && result.Results?.length > 0) {
|
|
6713
|
+
return BaseEntity.resolveLeafEntityRecursive(validChildren[i], primaryKey, contextUser, provider);
|
|
6714
|
+
}
|
|
6715
|
+
}
|
|
6716
|
+
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
6717
|
+
}
|
|
6718
|
+
for (const child of validChildren) {
|
|
6175
6719
|
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
6176
|
-
if (!childPK)
|
|
6177
|
-
continue;
|
|
6178
6720
|
const result = await rv.RunView({
|
|
6179
6721
|
EntityName: child.Name,
|
|
6180
|
-
ExtraFilter: `${childPK.Name} = '${pkValue}'`,
|
|
6722
|
+
ExtraFilter: `${childPK.Name} = '${EscapeSQLString(String(pkValue))}'`,
|
|
6181
6723
|
ResultType: 'simple',
|
|
6182
6724
|
Fields: [childPK.Name],
|
|
6183
|
-
MaxRows: 1
|
|
6725
|
+
MaxRows: 1,
|
|
6726
|
+
SkipTotalRowCount: true
|
|
6184
6727
|
}, contextUser);
|
|
6185
6728
|
if (result?.Success && result.Results?.length > 0) {
|
|
6186
6729
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
@@ -6227,10 +6770,11 @@ export class BaseEntity {
|
|
|
6227
6770
|
return;
|
|
6228
6771
|
const viewParams = validSiblings.map(sibling => ({
|
|
6229
6772
|
EntityName: sibling.Name,
|
|
6230
|
-
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
6773
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${EscapeSQLString(String(pkValue))}'`, // first-pk-ok: IS-A shared key
|
|
6231
6774
|
ResultType: 'simple',
|
|
6232
6775
|
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
6233
|
-
MaxRows: 1
|
|
6776
|
+
MaxRows: 1,
|
|
6777
|
+
SkipTotalRowCount: true
|
|
6234
6778
|
}));
|
|
6235
6779
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
6236
6780
|
for (let i = 0; i < results.length; i++) {
|
|
@@ -6434,7 +6978,8 @@ export class BaseEntity {
|
|
|
6434
6978
|
}
|
|
6435
6979
|
/**
|
|
6436
6980
|
* Generates a vector embedding for a single text field using AI engine.
|
|
6437
|
-
* Only generates embeddings for new records
|
|
6981
|
+
* Only generates embeddings for new records, when the source field has changed, or when
|
|
6982
|
+
* {@link IsEmbeddingStale} reports the stored vector came from another model.
|
|
6438
6983
|
* Stores the vector embedding, the model ID used to generate it and, when the entity has one, the
|
|
6439
6984
|
* vector's binary companion.
|
|
6440
6985
|
*
|
|
@@ -6452,33 +6997,46 @@ export class BaseEntity {
|
|
|
6452
6997
|
* @returns Promise that resolves to true if embedding was generated successfully, false otherwise
|
|
6453
6998
|
*/
|
|
6454
6999
|
async GenerateEmbedding(field, vectorField, modelField, binaryVectorField) {
|
|
7000
|
+
const clearVector = () => {
|
|
7001
|
+
vectorField.Value = null;
|
|
7002
|
+
if (binaryVectorField)
|
|
7003
|
+
binaryVectorField.Value = null;
|
|
7004
|
+
if (modelField)
|
|
7005
|
+
modelField.Value = null;
|
|
7006
|
+
};
|
|
7007
|
+
// After an edit to a saved record, its stored vector describes the old text, so a failed
|
|
7008
|
+
// re-embed clears it rather than leaving it searchable.
|
|
7009
|
+
const edited = this.IsSaved && field.Dirty;
|
|
6455
7010
|
try {
|
|
6456
7011
|
if (this._skipEmbeddings)
|
|
6457
7012
|
return true;
|
|
6458
|
-
|
|
7013
|
+
const stale = vectorField.Value != null && await this.IsEmbeddingStale(modelField?.Value ?? null);
|
|
7014
|
+
if (!this.IsSaved || field.Dirty || stale) {
|
|
6459
7015
|
if (field.Value?.trim().length > 0) {
|
|
6460
7016
|
// recalc vector
|
|
6461
7017
|
const e = await this.EmbedTextLocal(field.Value);
|
|
6462
|
-
|
|
7018
|
+
// An empty vector is a failed embed (the BaseEmbeddings convention); don't persist it.
|
|
7019
|
+
if (e?.vector?.length) {
|
|
6463
7020
|
vectorField.Value = JSON.stringify(e.vector);
|
|
6464
7021
|
if (binaryVectorField)
|
|
6465
7022
|
binaryVectorField.Value = Float32VectorToBase64(e.vector);
|
|
6466
7023
|
if (modelField)
|
|
6467
7024
|
modelField.Value = e.modelID;
|
|
6468
7025
|
}
|
|
7026
|
+
else if (edited) {
|
|
7027
|
+
clearVector();
|
|
7028
|
+
}
|
|
6469
7029
|
}
|
|
6470
7030
|
else {
|
|
6471
|
-
|
|
6472
|
-
if (binaryVectorField)
|
|
6473
|
-
binaryVectorField.Value = null;
|
|
6474
|
-
if (modelField)
|
|
6475
|
-
modelField.Value = null;
|
|
7031
|
+
clearVector();
|
|
6476
7032
|
}
|
|
6477
7033
|
}
|
|
6478
7034
|
return true;
|
|
6479
7035
|
}
|
|
6480
7036
|
catch (e) {
|
|
6481
7037
|
console.error("Error generating embedding:", e);
|
|
7038
|
+
if (edited)
|
|
7039
|
+
clearVector();
|
|
6482
7040
|
return false;
|
|
6483
7041
|
}
|
|
6484
7042
|
}
|
|
@@ -6492,6 +7050,18 @@ export class BaseEntity {
|
|
|
6492
7050
|
async EmbedTextLocal(textToEmbed) {
|
|
6493
7051
|
throw new Error("EmbedTextLocal not implemented in BaseEntity, sub-classes must implement this functionality to use it");
|
|
6494
7052
|
}
|
|
7053
|
+
/**
|
|
7054
|
+
* Whether a stored vector is stale: it came from a model other than the one this entity embeds
|
|
7055
|
+
* with now, or from an unrecorded one. Asked only for a field that holds a vector. A stale vector
|
|
7056
|
+
* is regenerated on the next save even when its text is unchanged, which is how stored vectors
|
|
7057
|
+
* follow a change of embedding model. The base implementation returns false (only new or changed
|
|
7058
|
+
* text is embedded); server-side sub-classes that embed with the platform's default local model
|
|
7059
|
+
* override it.
|
|
7060
|
+
* @param embeddingModelID - The ID of the AI model that produced the stored vector, or null
|
|
7061
|
+
*/
|
|
7062
|
+
async IsEmbeddingStale(embeddingModelID) {
|
|
7063
|
+
return false;
|
|
7064
|
+
}
|
|
6495
7065
|
/**
|
|
6496
7066
|
* Specifies if the current object supports the @see EmbedTextLocal method or not - useful to know before calling it for conditional
|
|
6497
7067
|
* code that has fallbacks as needed. BaseEntity does not implement this method but server-side sub-classes often do, but it is not mandatory for
|