@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts +27 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.d.ts.map +1 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js +6 -0
- package/dist/generic/JSONType-interfaces/IEntitySubtypeSelectorConfig.js.map +1 -0
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +2 -3
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +119 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +522 -87
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +6 -0
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +19 -1
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/compositeKey.d.ts +108 -0
- package/dist/generic/compositeKey.d.ts.map +1 -1
- package/dist/generic/compositeKey.js +225 -10
- package/dist/generic/compositeKey.js.map +1 -1
- package/dist/generic/databaseProviderBase.d.ts +49 -0
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +90 -11
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +2 -1
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +9 -2
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +6 -1
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +7 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +89 -7
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +127 -14
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entitySubtypeResolver.d.ts +19 -0
- package/dist/generic/entitySubtypeResolver.d.ts.map +1 -0
- package/dist/generic/entitySubtypeResolver.js +24 -0
- package/dist/generic/entitySubtypeResolver.js.map +1 -0
- package/dist/generic/entityTransactionScope.d.ts +6 -3
- package/dist/generic/entityTransactionScope.d.ts.map +1 -1
- package/dist/generic/entityTransactionScope.js +6 -3
- package/dist/generic/entityTransactionScope.js.map +1 -1
- package/dist/generic/extendedTypeValue.d.ts +47 -0
- package/dist/generic/extendedTypeValue.d.ts.map +1 -0
- package/dist/generic/extendedTypeValue.js +152 -0
- package/dist/generic/extendedTypeValue.js.map +1 -0
- package/dist/generic/filters/compositeFilter.d.ts +77 -0
- package/dist/generic/filters/compositeFilter.d.ts.map +1 -0
- package/dist/generic/filters/compositeFilter.js +324 -0
- package/dist/generic/filters/compositeFilter.js.map +1 -0
- package/dist/generic/filters/filter.types.d.ts +29 -0
- package/dist/generic/filters/filter.types.d.ts.map +1 -0
- package/dist/generic/filters/filter.types.js +28 -0
- package/dist/generic/filters/filter.types.js.map +1 -0
- package/dist/generic/filters/index.d.ts +3 -0
- package/dist/generic/filters/index.d.ts.map +1 -0
- package/dist/generic/filters/index.js +3 -0
- package/dist/generic/filters/index.js.map +1 -0
- package/dist/generic/geoFields.d.ts +64 -0
- package/dist/generic/geoFields.d.ts.map +1 -0
- package/dist/generic/geoFields.js +108 -0
- package/dist/generic/geoFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +11 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +4 -2
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +5 -3
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +118 -3
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +356 -70
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/relatedRecordBatchLoader.js +2 -2
- package/dist/generic/relatedRecordBatchLoader.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +2 -1
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +16 -4
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -11,11 +11,14 @@ var EntityField_1;
|
|
|
11
11
|
import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
13
|
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
14
|
+
import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
|
|
15
|
+
import { BaseEngineRegistry } from './baseEngineRegistry.js';
|
|
16
|
+
import { IsPermittedImageFieldValue, IsValidCssColor, TryParseJsonText } from './extendedTypeValue.js';
|
|
14
17
|
import { EntityDeleteOptions, EntitySaveOptions, ProviderType } from './interfaces.js';
|
|
15
18
|
import { Metadata } from './metadata.js';
|
|
16
19
|
import { RunView } from '../views/runView.js';
|
|
17
20
|
import { LogDebug, LogError } from './logging.js';
|
|
18
|
-
import { CompositeKey } from './compositeKey.js';
|
|
21
|
+
import { CompositeKey, KeyValuePair } from './compositeKey.js';
|
|
19
22
|
import { RelatedRecordCollection } from './relatedRecordCollection.js';
|
|
20
23
|
import { COMPANION_PAYLOAD_KEY } from './entityCompanion.js';
|
|
21
24
|
import { EmbeddedRecord } from './embeddedRecord.js';
|
|
@@ -327,6 +330,35 @@ let EntityField = class EntityField {
|
|
|
327
330
|
const nullNote = ef.AllowsNull ? ' (or null)' : '';
|
|
328
331
|
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
|
|
329
332
|
}
|
|
333
|
+
// ExtendedType semantic checks (Image / Color / JSON). Empty values are handled by
|
|
334
|
+
// the AllowsNull rung above — only non-empty strings are inspected here.
|
|
335
|
+
if (ef.TSType === EntityFieldTSType.String && this.Value != null && this.Value !== '') {
|
|
336
|
+
const text = String(this.Value);
|
|
337
|
+
switch (ef.ExtendedType) {
|
|
338
|
+
case 'JSON': {
|
|
339
|
+
const parsed = TryParseJsonText(text);
|
|
340
|
+
if (parsed.ok === false) {
|
|
341
|
+
result.Success = false;
|
|
342
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be valid JSON. ${parsed.message}`, this.Value));
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case 'Color': {
|
|
347
|
+
if (!IsValidCssColor(text)) {
|
|
348
|
+
result.Success = false;
|
|
349
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be a CSS color (hex, rgb, or hsl)`, this.Value));
|
|
350
|
+
}
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'Image': {
|
|
354
|
+
if (!IsPermittedImageFieldValue(text)) {
|
|
355
|
+
result.Success = false;
|
|
356
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be an image URL or inline image (data URI / base64)`, this.Value));
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
330
362
|
}
|
|
331
363
|
return result;
|
|
332
364
|
}
|
|
@@ -505,6 +537,56 @@ export class BaseEntityResult {
|
|
|
505
537
|
this.StartedAt = new Date();
|
|
506
538
|
this.EndedAt = new Date();
|
|
507
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* Renders ONE entry of the {@link Errors} array as human-readable text.
|
|
542
|
+
*
|
|
543
|
+
* `Errors` is typed `any[]`, and two shapes land in it from different places:
|
|
544
|
+
*
|
|
545
|
+
* - **`ValidationErrorInfo`** — carries **`Message`** (capital M), plus `Source`, `Value` and
|
|
546
|
+
* `Type`. This is what `_InnerSave` puts there when validation refuses a save: it throws the
|
|
547
|
+
* `ValidationResult`, and the catch block assigns `newResult.Errors = e.Errors`.
|
|
548
|
+
* - **`Error`** (and anything error-like) — carries lowercase **`message`**.
|
|
549
|
+
*
|
|
550
|
+
* This used to read `err.message` ONLY, so every `ValidationErrorInfo` fell through to
|
|
551
|
+
* `JSON.stringify(err)`. That is not a cosmetic difference: `CompleteMessage` is the string the
|
|
552
|
+
* server hands the client on a failed save — every write-refusal throw in `ResolverBase`
|
|
553
|
+
* (`CreateRecord`/`UpdateRecord`/`DeleteRecord`) puts it in the `GraphQLError`, and
|
|
554
|
+
* `SaveEntityGraphOperation` puts it in `ErrorMessage` — so the whole point of writing a careful,
|
|
555
|
+
* field-named refusal in a subclass's `ValidateAsync()` was defeated at the last step, and the
|
|
556
|
+
* user saw
|
|
557
|
+
* `{"Source":"ParentContractID","Message":"…","Value":null,"Type":"Failure"}` in a toast.
|
|
558
|
+
*
|
|
559
|
+
* Nothing catches this at compile time because `Errors` is `any[]`; nothing catches it at runtime
|
|
560
|
+
* because `JSON.stringify` always succeeds. It is only visible by reading the message a user got.
|
|
561
|
+
*
|
|
562
|
+
* The parameter is `unknown` rather than `any` — per `.claude/rules/typescript-style.md` — because
|
|
563
|
+
* not knowing the shape is the whole reason this helper exists, and `unknown` forces the narrowing
|
|
564
|
+
* that makes each shape's handling explicit. Callers pass `any` (the `Errors` array and the `Error`
|
|
565
|
+
* property are both legacy `any`), which is assignable, so no call site changes.
|
|
566
|
+
*
|
|
567
|
+
* `Message` is preferred over `message` because a `ValidationErrorInfo` has only the former,
|
|
568
|
+
* while an `Error` has only the latter — so the order matters solely for an object carrying both,
|
|
569
|
+
* where the MJ-native field is the better answer.
|
|
570
|
+
*
|
|
571
|
+
* @param err - One entry from the `Errors` array.
|
|
572
|
+
* @returns The entry's human-readable text, falling back to JSON for a shape with neither field.
|
|
573
|
+
*/
|
|
574
|
+
static ErrorText(err) {
|
|
575
|
+
if (err === null || err === undefined) {
|
|
576
|
+
return '';
|
|
577
|
+
}
|
|
578
|
+
if (typeof err === 'string') {
|
|
579
|
+
return err;
|
|
580
|
+
}
|
|
581
|
+
if (typeof err === 'object') {
|
|
582
|
+
const shaped = err;
|
|
583
|
+
const text = shaped.Message ?? shaped.message;
|
|
584
|
+
if (typeof text === 'string' && text.trim().length > 0) {
|
|
585
|
+
return text;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return JSON.stringify(err);
|
|
589
|
+
}
|
|
508
590
|
/**
|
|
509
591
|
* Returns a complete message that includes the Message property (if present), the Error property (if present), and any Errors array items (if present).
|
|
510
592
|
*/
|
|
@@ -514,23 +596,26 @@ export class BaseEntityResult {
|
|
|
514
596
|
if (this.Message && this.Message.trim().length > 0) {
|
|
515
597
|
msg = this.Message;
|
|
516
598
|
}
|
|
517
|
-
// now check the simple Error property
|
|
599
|
+
// now check the simple Error property. Same shape problem as the Errors array below, so the
|
|
600
|
+
// same helper answers it: a string, an Error (lowercase `message`), or an MJ
|
|
601
|
+
// ValidationErrorInfo (capital `Message`) all render as their text rather than as JSON.
|
|
518
602
|
if (this.Error) {
|
|
519
|
-
msg = (msg ? msg + '\n' : '');
|
|
520
|
-
if (typeof this.Error === 'string') {
|
|
521
|
-
msg += this.Error;
|
|
522
|
-
}
|
|
523
|
-
else if (this.Error.message) {
|
|
524
|
-
msg += this.Error.message;
|
|
525
|
-
}
|
|
526
|
-
else {
|
|
527
|
-
msg += JSON.stringify(this.Error);
|
|
528
|
-
}
|
|
603
|
+
msg = (msg ? msg + '\n' : '') + BaseEntityResult.ErrorText(this.Error);
|
|
529
604
|
}
|
|
530
|
-
// now check the Errors array
|
|
605
|
+
// now check the Errors array.
|
|
606
|
+
//
|
|
607
|
+
// NOT de-duplicated, deliberately. Some producers set BOTH `Message` and `Errors` and build
|
|
608
|
+
// the former out of the latter — `_InnerSave`/`_InnerDelete` do on an IS-A parent failure —
|
|
609
|
+
// so their text does appear twice here. Suppressing a repeat was tried and reverted: any
|
|
610
|
+
// containment test is lossy in ways a reader cannot detect. Three fields failing with the
|
|
611
|
+
// same sentence collapse to one line; an entry whose text is a substring of another is kept
|
|
612
|
+
// or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
|
|
613
|
+
// appear inside the summary. Saying something twice is ugly. Silently reporting one problem
|
|
614
|
+
// when there were three is the failure this whole class of bug is about, so the duplication
|
|
615
|
+
// stays until a producer-side fix removes it at the source.
|
|
531
616
|
if (this.Errors && this.Errors.length > 0) {
|
|
532
617
|
// append
|
|
533
|
-
msg = (msg ? msg + '\n' : '') + this.Errors.map(err =>
|
|
618
|
+
msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
|
|
534
619
|
}
|
|
535
620
|
return msg;
|
|
536
621
|
}
|
|
@@ -816,6 +901,16 @@ export class BaseEntity {
|
|
|
816
901
|
* pay nothing for the feature, not even an empty Map per instance.
|
|
817
902
|
*/
|
|
818
903
|
this._companions = null;
|
|
904
|
+
/**
|
|
905
|
+
* 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.
|
|
906
|
+
*
|
|
907
|
+
* @remarks
|
|
908
|
+
* Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
|
|
909
|
+
* three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
|
|
910
|
+
* return, and the children are silently never persisted — the save reports success and writes
|
|
911
|
+
* nothing. See {@link EntityCompanion.Dirty}.
|
|
912
|
+
*/
|
|
913
|
+
this._isCheckingDirty = false;
|
|
819
914
|
this._compositeKey = null;
|
|
820
915
|
// ────────────────────────────────────────────────────────────────────
|
|
821
916
|
// Restore context — populated by callers immediately before Save() to
|
|
@@ -833,6 +928,13 @@ export class BaseEntity {
|
|
|
833
928
|
this._restoreContext = null;
|
|
834
929
|
// Holds the current pending save observable (if any)
|
|
835
930
|
this._pendingSave$ = null;
|
|
931
|
+
/**
|
|
932
|
+
* This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
|
|
933
|
+
* override or wrap this base class method to add other logic for validation.
|
|
934
|
+
*
|
|
935
|
+
* @returns ValidationResult The validation result
|
|
936
|
+
*/
|
|
937
|
+
this._isValidating = false;
|
|
836
938
|
/**
|
|
837
939
|
* private storage for vectors that might be used for this entity, typically in association with individual fields
|
|
838
940
|
* however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
|
|
@@ -852,6 +954,42 @@ export class BaseEntity {
|
|
|
852
954
|
this._provider = Provider;
|
|
853
955
|
this.init();
|
|
854
956
|
}
|
|
957
|
+
/**
|
|
958
|
+
* The provider actually stored on this instance, or `null` if none was bound.
|
|
959
|
+
* Unlike {@link ProviderToUse}, this does **not** fall back to the process-wide
|
|
960
|
+
* {@link BaseEntity.Provider}. Use it to detect a dropped constructor argument:
|
|
961
|
+
* `GetEntityObject(graphProvider)` must yield `BoundProvider === graphProvider`.
|
|
962
|
+
*/
|
|
963
|
+
get BoundProvider() {
|
|
964
|
+
return this._provider;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Bind this instance to a provider after construction.
|
|
968
|
+
*
|
|
969
|
+
* **Rule (ORM, not just metadata-sync):** every DB read and write on this
|
|
970
|
+
* instance — Save, Load, Delete, RunView, GetEntityObject of children/embeds,
|
|
971
|
+
* lookups, RecordGeoCode — MUST use this provider. Mixing another provider
|
|
972
|
+
* (especially the process-wide host) into the same record graph is a deadlock:
|
|
973
|
+
* a child FK waits on an uncommitted parent on another connection.
|
|
974
|
+
*
|
|
975
|
+
* {@link ProviderBase.GetEntityObject} always calls this so a subclass that
|
|
976
|
+
* declares `constructor(Entity: EntityInfo)` and drops the second ClassFactory
|
|
977
|
+
* argument cannot silently run on the global host.
|
|
978
|
+
*/
|
|
979
|
+
BindProvider(provider) {
|
|
980
|
+
this._provider = provider;
|
|
981
|
+
if (this._parentEntity && this._parentEntity.BoundProvider !== provider) {
|
|
982
|
+
this._parentEntity.BindProvider(provider);
|
|
983
|
+
}
|
|
984
|
+
if (this._childEntity && this._childEntity.BoundProvider !== provider) {
|
|
985
|
+
this._childEntity.BindProvider(provider);
|
|
986
|
+
}
|
|
987
|
+
if (this._companions) {
|
|
988
|
+
for (const companion of this._companions.values()) {
|
|
989
|
+
companion.BindProvider(provider);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
855
993
|
/**
|
|
856
994
|
* Initializes the IS-A parent entity composition chain. For child type entities,
|
|
857
995
|
* this creates the parent entity instance (and recursively its parent, etc.) and
|
|
@@ -1062,6 +1200,249 @@ export class BaseEntity {
|
|
|
1062
1200
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
1063
1201
|
await childEntity.InitializeChildEntity();
|
|
1064
1202
|
}
|
|
1203
|
+
static { this._subtypeLookupCache = new Map(); }
|
|
1204
|
+
/**
|
|
1205
|
+
* Clears the static memoization cache used by SubtypeSelector path evaluation.
|
|
1206
|
+
*/
|
|
1207
|
+
static ClearSubtypeLookupCache() {
|
|
1208
|
+
BaseEntity._subtypeLookupCache.clear();
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Prospective counterpart to FindISAChildEntity.
|
|
1212
|
+
* Evaluates which IsA child subtype entity this record should have based on:
|
|
1213
|
+
* 1. Registered EntitySubtypeResolver (ClassFactory key = entity name)
|
|
1214
|
+
* 2. Entity.SubtypeSelector declarative FK traversal path
|
|
1215
|
+
* 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
|
|
1216
|
+
* 4. Otherwise null (no subtype)
|
|
1217
|
+
*
|
|
1218
|
+
* @see plans/sync-composition-axes.md
|
|
1219
|
+
*/
|
|
1220
|
+
async ResolveSubtypeEntityName() {
|
|
1221
|
+
if (!this.EntityInfo.IsParentType || !this.EntityInfo.ChildEntities || this.EntityInfo.ChildEntities.length === 0) {
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
// 1. Registered resolver override
|
|
1225
|
+
const reg = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1226
|
+
if (reg) {
|
|
1227
|
+
const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1228
|
+
if (resolution.Resolved && resolution.Instance) {
|
|
1229
|
+
const raw = resolution.Instance.Resolve(this);
|
|
1230
|
+
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1231
|
+
if (candidate != null && candidate.trim() !== '') {
|
|
1232
|
+
const trimmed = candidate.trim();
|
|
1233
|
+
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1234
|
+
if (!match) {
|
|
1235
|
+
throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1236
|
+
}
|
|
1237
|
+
return match.Name;
|
|
1238
|
+
}
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
// 2. Entity.SubtypeSelector declarative path
|
|
1243
|
+
const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
|
|
1244
|
+
if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
|
|
1245
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
|
|
1246
|
+
if (pathResult != null && pathResult.trim() !== '') {
|
|
1247
|
+
const trimmed = pathResult.trim();
|
|
1248
|
+
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1249
|
+
if (!match) {
|
|
1250
|
+
throw new Error(`SubtypeSelector path '${selectorConfig.Path}' on '${this.EntityInfo.Name}' resolved to '${pathResult}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1251
|
+
}
|
|
1252
|
+
return match.Name;
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
// 3. Exactly one child, unconditional IsA
|
|
1257
|
+
if (!this.EntityInfo.AllowMultipleSubtypes && this.EntityInfo.ChildEntities.length === 1) {
|
|
1258
|
+
return this.EntityInfo.ChildEntities[0].Name;
|
|
1259
|
+
}
|
|
1260
|
+
// 4. Otherwise null
|
|
1261
|
+
return null;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Create-safe prospective counterpart to InitializeChildEntity.
|
|
1265
|
+
* Unlike createAndLinkChildEntity, does NOT unlink when InnerLoad finds no row —
|
|
1266
|
+
* that is the create case. Idempotent. Defaults to ResolveSubtypeEntityName()
|
|
1267
|
+
* when no name is passed.
|
|
1268
|
+
*
|
|
1269
|
+
* @param entityName Optional explicit child entity name. If omitted, resolved via ResolveSubtypeEntityName().
|
|
1270
|
+
* @returns The linked child BaseEntity, or null if no subtype applies.
|
|
1271
|
+
*/
|
|
1272
|
+
async EnsureISAChild(entityName) {
|
|
1273
|
+
if (!entityName) {
|
|
1274
|
+
entityName = await this.ResolveSubtypeEntityName();
|
|
1275
|
+
}
|
|
1276
|
+
if (!entityName) {
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
|
|
1280
|
+
if (!matchedChild) {
|
|
1281
|
+
throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1282
|
+
}
|
|
1283
|
+
const resolvedName = matchedChild.Name;
|
|
1284
|
+
if (!this.EntityInfo.AllowMultipleSubtypes) {
|
|
1285
|
+
// Disjoint hierarchy
|
|
1286
|
+
if (this._childEntity) {
|
|
1287
|
+
if (this._childEntity.EntityInfo.Name.trim().toLowerCase() === resolvedName.trim().toLowerCase()) {
|
|
1288
|
+
return this._childEntity; // Idempotent
|
|
1289
|
+
}
|
|
1290
|
+
throw new Error(`Entity '${this.EntityInfo.Name}' already has an attached child entity of type '${this._childEntity.EntityInfo.Name}', cannot attach '${resolvedName}' (AllowMultipleSubtypes is false).`);
|
|
1291
|
+
}
|
|
1292
|
+
const childProvider = this.ProviderToUse;
|
|
1293
|
+
const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
|
|
1294
|
+
// Wire up shared instance chain
|
|
1295
|
+
this.replaceChildParentChain(childEntity);
|
|
1296
|
+
this._childEntity = childEntity;
|
|
1297
|
+
const dirtySnapshots = this.captureChainDirtyState();
|
|
1298
|
+
if (this.PrimaryKey && this.PrimaryKey.HasValue) {
|
|
1299
|
+
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1300
|
+
if (!loaded) {
|
|
1301
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1306
|
+
}
|
|
1307
|
+
this.restoreChainDirtyState(dirtySnapshots);
|
|
1308
|
+
// Recursively discover grandchildren if the child is also a parent type
|
|
1309
|
+
if (childEntity.EntityInfo.IsParentType) {
|
|
1310
|
+
await childEntity.EnsureISAChild();
|
|
1311
|
+
}
|
|
1312
|
+
return childEntity;
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
// Overlapping hierarchy (AllowMultipleSubtypes = true)
|
|
1316
|
+
if (!this._childEntities) {
|
|
1317
|
+
this._childEntities = [];
|
|
1318
|
+
}
|
|
1319
|
+
if (!this._childEntities.some(c => c.entityName.trim().toLowerCase() === resolvedName.trim().toLowerCase())) {
|
|
1320
|
+
this._childEntities.push({ entityName: resolvedName });
|
|
1321
|
+
}
|
|
1322
|
+
const childProvider = this.ProviderToUse;
|
|
1323
|
+
const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
|
|
1324
|
+
this.replaceChildParentChain(childEntity);
|
|
1325
|
+
const dirtySnapshots = this.captureChainDirtyState();
|
|
1326
|
+
if (this.PrimaryKey && this.PrimaryKey.HasValue) {
|
|
1327
|
+
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1328
|
+
if (!loaded) {
|
|
1329
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
else {
|
|
1333
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1334
|
+
}
|
|
1335
|
+
this.restoreChainDirtyState(dirtySnapshots);
|
|
1336
|
+
if (childEntity.EntityInfo.IsParentType) {
|
|
1337
|
+
await childEntity.EnsureISAChild();
|
|
1338
|
+
}
|
|
1339
|
+
return childEntity;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
mirrorSharedKeysToChild(childEntity) {
|
|
1343
|
+
const parentPks = this.EntityInfo.PrimaryKeys;
|
|
1344
|
+
if (!parentPks || parentPks.length === 0)
|
|
1345
|
+
return;
|
|
1346
|
+
for (const pk of parentPks) {
|
|
1347
|
+
const val = this.Get(pk.Name);
|
|
1348
|
+
if (val != null) {
|
|
1349
|
+
childEntity.mirrorSharedKey(pk.Name, val);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async evaluateSubtypeSelectorPath(path) {
|
|
1354
|
+
const segments = path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1355
|
+
if (segments.length === 0)
|
|
1356
|
+
return null;
|
|
1357
|
+
let currentEntity = this;
|
|
1358
|
+
let currentEntityInfo = this.EntityInfo;
|
|
1359
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1360
|
+
const fieldName = segments[i];
|
|
1361
|
+
const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
|
|
1362
|
+
if (!fieldInfo) {
|
|
1363
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1364
|
+
}
|
|
1365
|
+
const fkValue = currentEntity.Get(fieldInfo.Name);
|
|
1366
|
+
if (fkValue == null || fkValue === '') {
|
|
1367
|
+
return null;
|
|
1368
|
+
}
|
|
1369
|
+
const relatedEntityName = fieldInfo.RelatedEntity;
|
|
1370
|
+
if (!relatedEntityName) {
|
|
1371
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
|
|
1372
|
+
}
|
|
1373
|
+
const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
|
|
1374
|
+
if (!targetEntity) {
|
|
1375
|
+
return null;
|
|
1376
|
+
}
|
|
1377
|
+
currentEntity = targetEntity;
|
|
1378
|
+
currentEntityInfo = targetEntity.EntityInfo;
|
|
1379
|
+
}
|
|
1380
|
+
const terminalSegment = segments[segments.length - 1];
|
|
1381
|
+
const terminalField = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === terminalSegment.toLowerCase());
|
|
1382
|
+
if (!terminalField) {
|
|
1383
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1384
|
+
}
|
|
1385
|
+
const terminalValue = currentEntity.Get(terminalField.Name);
|
|
1386
|
+
if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
return terminalValue.trim();
|
|
1390
|
+
}
|
|
1391
|
+
async getSubtypePathTargetEntity(entityName, pkValue) {
|
|
1392
|
+
const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
|
|
1393
|
+
if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
|
|
1394
|
+
return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
|
|
1395
|
+
}
|
|
1396
|
+
// 1. Check BaseEngineRegistry for loaded cached entities
|
|
1397
|
+
const cachedMatches = BaseEngineRegistry.Instance.FindCachedEntity(entityName);
|
|
1398
|
+
if (cachedMatches && cachedMatches.length > 0) {
|
|
1399
|
+
for (const match of cachedMatches) {
|
|
1400
|
+
const found = match.records.find(r => {
|
|
1401
|
+
const firstPK = r.FirstPrimaryKey; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1402
|
+
if (firstPK) {
|
|
1403
|
+
return String(firstPK.Value).trim().toLowerCase() === String(pkValue).trim().toLowerCase();
|
|
1404
|
+
}
|
|
1405
|
+
return false;
|
|
1406
|
+
});
|
|
1407
|
+
if (found) {
|
|
1408
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, found);
|
|
1409
|
+
return found;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
// 2. Fall back to loading via provider
|
|
1414
|
+
const provider = this.ProviderToUse;
|
|
1415
|
+
if (!provider?.GetEntityObject) {
|
|
1416
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
try {
|
|
1420
|
+
const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
|
|
1421
|
+
if (!targetObj) {
|
|
1422
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1423
|
+
return null;
|
|
1424
|
+
}
|
|
1425
|
+
let key;
|
|
1426
|
+
if (pkValue instanceof CompositeKey) {
|
|
1427
|
+
key = pkValue;
|
|
1428
|
+
}
|
|
1429
|
+
else {
|
|
1430
|
+
key = new CompositeKey();
|
|
1431
|
+
const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1432
|
+
key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
|
|
1433
|
+
}
|
|
1434
|
+
const loaded = await targetObj.InnerLoad(key);
|
|
1435
|
+
if (loaded) {
|
|
1436
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
|
|
1437
|
+
return targetObj;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
// On load failure, return null
|
|
1442
|
+
}
|
|
1443
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1065
1446
|
captureChainDirtyState() {
|
|
1066
1447
|
const snapshots = [];
|
|
1067
1448
|
let curr = this;
|
|
@@ -1102,6 +1483,10 @@ export class BaseEntity {
|
|
|
1102
1483
|
// Replace it with `this`, then walk upward replacing each parent.
|
|
1103
1484
|
let childParent = childEntity._parentEntity;
|
|
1104
1485
|
let ourInstance = this;
|
|
1486
|
+
if (!childParent) {
|
|
1487
|
+
childEntity._parentEntity = this;
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1105
1490
|
while (childParent && ourInstance) {
|
|
1106
1491
|
// Replace the child's parent reference with our shared instance
|
|
1107
1492
|
if (childParent === childEntity._parentEntity) {
|
|
@@ -1346,6 +1731,8 @@ export class BaseEntity {
|
|
|
1346
1731
|
throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
|
|
1347
1732
|
`Ensure the entity class is registered.`);
|
|
1348
1733
|
}
|
|
1734
|
+
// Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
|
|
1735
|
+
instance.BindProvider(provider);
|
|
1349
1736
|
await instance.Config(this.ContextCurrentUser);
|
|
1350
1737
|
await instance.InitializeParentEntity();
|
|
1351
1738
|
// Recurse so a *new* peer's own embeds are constructed (required nested
|
|
@@ -1541,7 +1928,7 @@ export class BaseEntity {
|
|
|
1541
1928
|
// One `RunViews` for all remaining collections — N declared collections cost one round trip,
|
|
1542
1929
|
// not N. Params are built per collection so each keeps its own filter and ordering. The key
|
|
1543
1930
|
// is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
|
|
1544
|
-
const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
|
|
1931
|
+
const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''"); // first-pk-ok: RelatedEntityJoinField is one FK column, so the parent key it holds is single-column by design
|
|
1545
1932
|
const rv = new RunView(this.ProviderToUse);
|
|
1546
1933
|
const results = await rv.RunViews(needsDatabase.map(c => ({
|
|
1547
1934
|
EntityName: c.RelatedEntityName,
|
|
@@ -1570,7 +1957,7 @@ export class BaseEntity {
|
|
|
1570
1957
|
seedEmbedLoadVisited() {
|
|
1571
1958
|
const seeded = new Set();
|
|
1572
1959
|
const name = this.EntityInfo?.Name;
|
|
1573
|
-
const pk = this.FirstPrimaryKey?.Value;
|
|
1960
|
+
const pk = this.FirstPrimaryKey?.Value; // first-pk-ok: must equal EmbeddedRecord.LoadEager's `entity:fk` cycle token, and an FK holds one column
|
|
1574
1961
|
if (name && pk !== null && pk !== undefined && pk !== '') {
|
|
1575
1962
|
seeded.add(`${name}:${String(pk)}`);
|
|
1576
1963
|
}
|
|
@@ -2164,6 +2551,46 @@ export class BaseEntity {
|
|
|
2164
2551
|
}
|
|
2165
2552
|
return this._fieldCache.get(lcase) || null;
|
|
2166
2553
|
}
|
|
2554
|
+
/**
|
|
2555
|
+
* True when any of the named fields exists on this entity and its current value
|
|
2556
|
+
* differs from the last loaded or saved value.
|
|
2557
|
+
*
|
|
2558
|
+
* This is the boolean form of `GetFieldByName(name)?.Dirty === true`. Prefer it at
|
|
2559
|
+
* call sites that only care whether a column has been edited — pricing, validation,
|
|
2560
|
+
* and "did the user type this" gates — so they do not repeat the optional-chain and
|
|
2561
|
+
* do not treat a missing field as a distinct third state.
|
|
2562
|
+
*
|
|
2563
|
+
* Semantics:
|
|
2564
|
+
* - **Unknown or blank names return `false`.** They are not dirty; they are absent.
|
|
2565
|
+
* Callers that must distinguish "no such field" from "field is clean" should use
|
|
2566
|
+
* {@link GetFieldByName} and inspect the result.
|
|
2567
|
+
* - **Names are case-insensitive and trimmed**, matching {@link GetFieldByName}.
|
|
2568
|
+
* - **Read-only fields are never dirty**, even if their value was overwritten internally.
|
|
2569
|
+
* - **Multiple names are OR'd.** `FieldIsDirty('UnitPrice', 'ProductPriceID')` is true
|
|
2570
|
+
* if either field has been edited. An empty rest list is a single-field check.
|
|
2571
|
+
*
|
|
2572
|
+
* @param fieldName First field to test. A missing/blank name contributes `false`.
|
|
2573
|
+
* @param more Additional field names, each OR'd with the first.
|
|
2574
|
+
* @returns `true` if at least one named field exists and is dirty; otherwise `false`.
|
|
2575
|
+
*
|
|
2576
|
+
* @example
|
|
2577
|
+
* ```ts
|
|
2578
|
+
* // Single field
|
|
2579
|
+
* if (line.FieldIsDirty('UnitPrice')) { ... }
|
|
2580
|
+
*
|
|
2581
|
+
* // Either money column was edited
|
|
2582
|
+
* if (line.FieldIsDirty('UnitPrice', 'ProductPriceID')) { ... }
|
|
2583
|
+
* ```
|
|
2584
|
+
*/
|
|
2585
|
+
FieldIsDirty(fieldName, ...more) {
|
|
2586
|
+
const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
|
|
2587
|
+
for (const name of names) {
|
|
2588
|
+
if (this.GetFieldByName(name)?.Dirty === true) {
|
|
2589
|
+
return true;
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
return false;
|
|
2593
|
+
}
|
|
2167
2594
|
/**
|
|
2168
2595
|
* Convenience method to access a field by code name. This method is case-insensitive and will return null if the field is not found.
|
|
2169
2596
|
* @param codeName
|
|
@@ -2188,26 +2615,25 @@ export class BaseEntity {
|
|
|
2188
2615
|
}
|
|
2189
2616
|
return this._codeNameCache.get(lcase) || null;
|
|
2190
2617
|
}
|
|
2191
|
-
/**
|
|
2192
|
-
* 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.
|
|
2193
|
-
*
|
|
2194
|
-
* @remarks
|
|
2195
|
-
* Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
|
|
2196
|
-
* three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
|
|
2197
|
-
* return, and the children are silently never persisted — the save reports success and writes
|
|
2198
|
-
* nothing. See {@link EntityCompanion.Dirty}.
|
|
2199
|
-
*/
|
|
2200
2618
|
get Dirty() {
|
|
2201
|
-
if (
|
|
2202
|
-
return
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2619
|
+
if (this._isCheckingDirty)
|
|
2620
|
+
return false;
|
|
2621
|
+
this._isCheckingDirty = true;
|
|
2622
|
+
try {
|
|
2623
|
+
if (!this.IsSaved)
|
|
2624
|
+
return true;
|
|
2625
|
+
if (this.companionsDirty)
|
|
2626
|
+
return true;
|
|
2627
|
+
// Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
|
|
2628
|
+
// dirty. Avoid hydrating just to check.
|
|
2629
|
+
if (!this._fieldsHydrated)
|
|
2630
|
+
return this._parentEntity?.Dirty ?? false;
|
|
2631
|
+
return this._Fields.some(f => f.Dirty) ||
|
|
2632
|
+
(this._parentEntity?.Dirty ?? false);
|
|
2633
|
+
}
|
|
2634
|
+
finally {
|
|
2635
|
+
this._isCheckingDirty = false;
|
|
2636
|
+
}
|
|
2211
2637
|
}
|
|
2212
2638
|
/**
|
|
2213
2639
|
* Whether any registered companion holds unsaved work.
|
|
@@ -2242,7 +2668,7 @@ export class BaseEntity {
|
|
|
2242
2668
|
* Helper method to return just the first Primary Key
|
|
2243
2669
|
*/
|
|
2244
2670
|
get FirstPrimaryKey() {
|
|
2245
|
-
return this.PrimaryKeys[0];
|
|
2671
|
+
return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
|
|
2246
2672
|
}
|
|
2247
2673
|
/**
|
|
2248
2674
|
* Returns true if the record has been loaded from the database, false otherwise. This is useful to check to see if the record is in a "New Record" state or not.
|
|
@@ -2635,7 +3061,7 @@ export class BaseEntity {
|
|
|
2635
3061
|
async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
|
|
2636
3062
|
// we need to query the database to get related entity info
|
|
2637
3063
|
const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
|
|
2638
|
-
const rv = new RunView();
|
|
3064
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
2639
3065
|
const result = await rv.RunView(params, this._contextCurrentUser);
|
|
2640
3066
|
if (result && result.Success) {
|
|
2641
3067
|
return {
|
|
@@ -2800,7 +3226,7 @@ export class BaseEntity {
|
|
|
2800
3226
|
// Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
|
|
2801
3227
|
// a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
|
|
2802
3228
|
// PostgreSQL `uuid`).
|
|
2803
|
-
const pk = this.EntityInfo.PrimaryKeys
|
|
3229
|
+
const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
|
|
2804
3230
|
if (!pk.AutoIncrement &&
|
|
2805
3231
|
pk.IsUniqueIdentifier &&
|
|
2806
3232
|
!this.Get(pk.Name)) {
|
|
@@ -3671,40 +4097,45 @@ export class BaseEntity {
|
|
|
3671
4097
|
await this.DeserializeCompanions(payloads);
|
|
3672
4098
|
}
|
|
3673
4099
|
}
|
|
3674
|
-
/**
|
|
3675
|
-
* This method is used automatically within Save() and is used to determine if the state of the object is valid relative to the validation rules that are defined in metadata. In addition, sub-classes can
|
|
3676
|
-
* override or wrap this base class method to add other logic for validation.
|
|
3677
|
-
*
|
|
3678
|
-
* @returns ValidationResult The validation result
|
|
3679
|
-
*/
|
|
3680
4100
|
Validate() {
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
4101
|
+
if (this._isValidating) {
|
|
4102
|
+
const emptyResult = new ValidationResult();
|
|
4103
|
+
emptyResult.Success = true;
|
|
4104
|
+
return emptyResult;
|
|
4105
|
+
}
|
|
4106
|
+
this._isValidating = true;
|
|
4107
|
+
try {
|
|
4108
|
+
const result = new ValidationResult();
|
|
4109
|
+
result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
|
|
4110
|
+
// IS-A composition: validate parent entity first to collect all chain errors
|
|
4111
|
+
if (this._parentEntity) {
|
|
4112
|
+
const parentResult = this._parentEntity.Validate();
|
|
4113
|
+
if (!parentResult.Success) {
|
|
4114
|
+
result.Success = false;
|
|
4115
|
+
parentResult.Errors.forEach(err => result.Errors.push(err));
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
// Validate own fields — for IS-A entities, skip parent field mirrors since
|
|
4119
|
+
// those are validated via _parentEntity above
|
|
4120
|
+
for (let field of this.Fields) {
|
|
4121
|
+
if (this._parentEntityFieldNames?.has(field.Name))
|
|
4122
|
+
continue; // skip parent field mirrors — authoritative validation is on _parentEntity
|
|
4123
|
+
const err = field.Validate();
|
|
4124
|
+
err.Errors.forEach(element => {
|
|
4125
|
+
result.Errors.push(element);
|
|
4126
|
+
});
|
|
4127
|
+
result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
|
|
3689
4128
|
}
|
|
4129
|
+
// Companions validate LAST but still BEFORE any write, over their complete state including
|
|
4130
|
+
// pending removals. That ordering is what lets a cross-child invariant — "debits must equal
|
|
4131
|
+
// credits", "a confirmed order must have lines" — be enforced against the whole graph rather
|
|
4132
|
+
// than discovered halfway through persisting it.
|
|
4133
|
+
this.validateCompanions(result);
|
|
4134
|
+
return result;
|
|
3690
4135
|
}
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
for (let field of this.Fields) {
|
|
3694
|
-
if (this._parentEntityFieldNames?.has(field.Name))
|
|
3695
|
-
continue; // skip parent field mirrors — authoritative validation is on _parentEntity
|
|
3696
|
-
const err = field.Validate();
|
|
3697
|
-
err.Errors.forEach(element => {
|
|
3698
|
-
result.Errors.push(element);
|
|
3699
|
-
});
|
|
3700
|
-
result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
|
|
4136
|
+
finally {
|
|
4137
|
+
this._isValidating = false;
|
|
3701
4138
|
}
|
|
3702
|
-
// Companions validate LAST but still BEFORE any write, over their complete state including
|
|
3703
|
-
// pending removals. That ordering is what lets a cross-child invariant — "debits must equal
|
|
3704
|
-
// credits", "a confirmed order must have lines" — be enforced against the whole graph rather
|
|
3705
|
-
// than discovered halfway through persisting it.
|
|
3706
|
-
this.validateCompanions(result);
|
|
3707
|
-
return result;
|
|
3708
4139
|
}
|
|
3709
4140
|
/**
|
|
3710
4141
|
* Fans synchronous validation out to every registered companion.
|
|
@@ -4020,11 +4451,12 @@ export class BaseEntity {
|
|
|
4020
4451
|
if (childEntities.length === 0) {
|
|
4021
4452
|
return { HasChildren: false, ChildEntityName: '' };
|
|
4022
4453
|
}
|
|
4023
|
-
// Use RunView
|
|
4024
|
-
|
|
4454
|
+
// Use RunView on this instance's provider — a host RunView cannot see
|
|
4455
|
+
// uncommitted child rows on a graph-scoped connection.
|
|
4456
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4025
4457
|
const pkValue = this.PrimaryKey.Values();
|
|
4026
4458
|
for (const childEntity of childEntities) {
|
|
4027
|
-
const pkField = childEntity.
|
|
4459
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
4028
4460
|
if (!pkField)
|
|
4029
4461
|
continue;
|
|
4030
4462
|
const result = await rv.RunView({
|
|
@@ -4083,21 +4515,21 @@ export class BaseEntity {
|
|
|
4083
4515
|
if (!entityInfo) {
|
|
4084
4516
|
return { LeafEntityName: entityName, IsLeaf: true };
|
|
4085
4517
|
}
|
|
4086
|
-
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
|
|
4518
|
+
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
|
|
4087
4519
|
}
|
|
4088
4520
|
/**
|
|
4089
4521
|
* Internal recursive helper for leaf entity resolution.
|
|
4090
4522
|
* Walks down the child hierarchy until no more children are found.
|
|
4091
4523
|
*/
|
|
4092
|
-
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
|
|
4524
|
+
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
|
|
4093
4525
|
const childEntities = entityInfo.ChildEntities;
|
|
4094
4526
|
if (childEntities.length === 0) {
|
|
4095
4527
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
4096
4528
|
}
|
|
4097
|
-
const rv = new RunView();
|
|
4529
|
+
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
4098
4530
|
const pkValue = primaryKey.Values();
|
|
4099
4531
|
for (const child of childEntities) {
|
|
4100
|
-
const childPK = child.
|
|
4532
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
4101
4533
|
if (!childPK)
|
|
4102
4534
|
continue;
|
|
4103
4535
|
const result = await rv.RunView({
|
|
@@ -4109,7 +4541,7 @@ export class BaseEntity {
|
|
|
4109
4541
|
}, contextUser);
|
|
4110
4542
|
if (result?.Success && result.Results?.length > 0) {
|
|
4111
4543
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
4112
|
-
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
|
|
4544
|
+
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
|
|
4113
4545
|
}
|
|
4114
4546
|
}
|
|
4115
4547
|
// No child found — this entity IS the leaf
|
|
@@ -4142,16 +4574,19 @@ export class BaseEntity {
|
|
|
4142
4574
|
const pkValue = this.PrimaryKey.Values();
|
|
4143
4575
|
if (!pkValue)
|
|
4144
4576
|
return;
|
|
4145
|
-
// Build all sibling queries and execute them in a single batch
|
|
4146
|
-
|
|
4147
|
-
|
|
4577
|
+
// Build all sibling queries and execute them in a single batch on
|
|
4578
|
+
// this instance's provider so an uncommitted sibling on the same
|
|
4579
|
+
// graph connection is visible (host RunView would miss it).
|
|
4580
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4581
|
+
// first-pk-ok: IS-A siblings share the parent's single key
|
|
4582
|
+
const validSiblings = siblingChildEntities.filter(s => s.FirstPrimaryKey); // first-pk-ok: IS-A siblings share the parent's single key
|
|
4148
4583
|
if (validSiblings.length === 0)
|
|
4149
4584
|
return;
|
|
4150
4585
|
const viewParams = validSiblings.map(sibling => ({
|
|
4151
4586
|
EntityName: sibling.Name,
|
|
4152
|
-
ExtraFilter: `${sibling.
|
|
4587
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
4153
4588
|
ResultType: 'simple',
|
|
4154
|
-
Fields: [sibling.
|
|
4589
|
+
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
4155
4590
|
MaxRows: 1
|
|
4156
4591
|
}));
|
|
4157
4592
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
@@ -4444,7 +4879,7 @@ export class BaseEntity {
|
|
|
4444
4879
|
LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4445
4880
|
return [];
|
|
4446
4881
|
}
|
|
4447
|
-
const pkName = this.FirstPrimaryKey
|
|
4882
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4448
4883
|
const rootId = this.Get(pkName);
|
|
4449
4884
|
if (!rootId)
|
|
4450
4885
|
return [];
|
|
@@ -4453,7 +4888,7 @@ export class BaseEntity {
|
|
|
4453
4888
|
const filter = maxDepth != null
|
|
4454
4889
|
? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
|
|
4455
4890
|
: `${rootFieldName} = '${rootId}'`;
|
|
4456
|
-
const rv = new RunView();
|
|
4891
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4457
4892
|
const result = await rv.RunView({
|
|
4458
4893
|
EntityName: this.EntityInfo.Name,
|
|
4459
4894
|
ExtraFilter: filter,
|
|
@@ -4472,7 +4907,7 @@ export class BaseEntity {
|
|
|
4472
4907
|
LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4473
4908
|
return [];
|
|
4474
4909
|
}
|
|
4475
|
-
const pkName = this.FirstPrimaryKey
|
|
4910
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4476
4911
|
const currentId = this.Get(pkName);
|
|
4477
4912
|
const pathFieldName = `${fkField.Name}Path`;
|
|
4478
4913
|
const depthFieldName = `${fkField.Name}Depth`;
|
|
@@ -4482,7 +4917,7 @@ export class BaseEntity {
|
|
|
4482
4917
|
const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
|
|
4483
4918
|
if (rawIds.length === 0)
|
|
4484
4919
|
return [];
|
|
4485
|
-
const rv = new RunView();
|
|
4920
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4486
4921
|
const idList = rawIds.map(id => `'${id}'`).join(',');
|
|
4487
4922
|
const result = await rv.RunView({
|
|
4488
4923
|
EntityName: this.EntityInfo.Name,
|
|
@@ -4502,11 +4937,11 @@ export class BaseEntity {
|
|
|
4502
4937
|
LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4503
4938
|
return [];
|
|
4504
4939
|
}
|
|
4505
|
-
const pkName = this.FirstPrimaryKey
|
|
4940
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4506
4941
|
const currentId = this.Get(pkName);
|
|
4507
4942
|
if (!currentId)
|
|
4508
4943
|
return [];
|
|
4509
|
-
const rv = new RunView();
|
|
4944
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4510
4945
|
const result = await rv.RunView({
|
|
4511
4946
|
EntityName: this.EntityInfo.Name,
|
|
4512
4947
|
ExtraFilter: `${fkField.Name} = '${currentId}'`,
|