@memberjunction/core 6.1.0-edge.4 → 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 +172 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +622 -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 +53 -2
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +92 -13
- 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 +136 -7
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +204 -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 +36 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js +25 -0
- 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/metadata.d.ts +11 -0
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +29 -0
- package/dist/generic/metadata.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/generic/schemaHydration.d.ts +54 -0
- package/dist/generic/schemaHydration.d.ts.map +1 -0
- package/dist/generic/schemaHydration.js +75 -0
- package/dist/generic/schemaHydration.js.map +1 -0
- package/dist/generic/transactionGroup.d.ts +18 -0
- package/dist/generic/transactionGroup.d.ts.map +1 -1
- package/dist/generic/transactionGroup.js +22 -0
- package/dist/generic/transactionGroup.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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
|
|
@@ -874,6 +1012,106 @@ export class BaseEntity {
|
|
|
874
1012
|
// Cache the parent field names for O(1) routing lookups
|
|
875
1013
|
this._parentEntityFieldNames = this.EntityInfo.ParentEntityFieldNames;
|
|
876
1014
|
}
|
|
1015
|
+
/**
|
|
1016
|
+
* IS-A PROMOTION (#3825): binds this NEW child record to an EXISTING parent row, so saving it
|
|
1017
|
+
* ADDS a subtype to a person/org/product that already exists instead of trying to create a
|
|
1018
|
+
* duplicate parent.
|
|
1019
|
+
*
|
|
1020
|
+
* Before this existed the operation was impossible: `NewRecord()` always starts a fresh parent
|
|
1021
|
+
* chain, so "this existing Person is now also an Applicant" INSERTed a second Person and
|
|
1022
|
+
* collided with the existing primary key (or, with parent fields unset, failed the parent's
|
|
1023
|
+
* NOT NULL validation as if it were brand new). Discovery ran the other way only — a loaded
|
|
1024
|
+
* parent finds its existing child — and promotion is the normal case in a multi-app install,
|
|
1025
|
+
* where a shared entity like Person accumulates subtypes owned by different applications.
|
|
1026
|
+
*
|
|
1027
|
+
* What it does, in the existing machinery rather than beside it:
|
|
1028
|
+
* 1. LOADS the parent chain by the supplied key (`InnerLoad`, which also hydrates any
|
|
1029
|
+
* grandparents from the same row). A loaded parent saves as an UPDATE, which is the whole
|
|
1030
|
+
* trick — the chain save that already runs parent-first now updates the existing row and
|
|
1031
|
+
* INSERTs only this child.
|
|
1032
|
+
* 2. Mirrors the shared primary key into this child's local fields, restoring `_NeverSet`
|
|
1033
|
+
* exactly as `NewRecord()`'s adoption path does, so the ReadOnly mirror stays writable for
|
|
1034
|
+
* the rest of the lifecycle.
|
|
1035
|
+
*
|
|
1036
|
+
* Everything else is deliberately UNTOUCHED: field routing still sends parent-held values to
|
|
1037
|
+
* the (now loaded) parent, permissions and validation run at every level, and
|
|
1038
|
+
* `EnforceDisjointSubtype` still refuses a second subtype where the parent forbids overlap.
|
|
1039
|
+
* If loading the parent discovers an existing child of ANOTHER subtype, the chain save is
|
|
1040
|
+
* unaffected — parent saves run with `IsParentEntitySave`, which bypasses leaf delegation.
|
|
1041
|
+
*
|
|
1042
|
+
* Call AFTER `NewRecord()` and BEFORE `Save()`:
|
|
1043
|
+
* ```typescript
|
|
1044
|
+
* const applicant = await md.GetEntityObject<ApplicantEntity>('Applicants', contextUser);
|
|
1045
|
+
* applicant.NewRecord();
|
|
1046
|
+
* if (!await applicant.AttachToParent(CompositeKey.FromID(personId))) {
|
|
1047
|
+
* // no such parent row — decide whether to create a fresh chain instead
|
|
1048
|
+
* }
|
|
1049
|
+
* applicant.Set('CompanyID', companyId); // child-held fields as usual
|
|
1050
|
+
* await applicant.Save(); // Person UPDATEd, Applicant INSERTed, one transaction
|
|
1051
|
+
* ```
|
|
1052
|
+
*
|
|
1053
|
+
* @param parentKey Primary key of the EXISTING parent row to promote.
|
|
1054
|
+
* @returns `true` when the parent loaded and this record is now bound to it; `false` when no
|
|
1055
|
+
* parent row exists under that key (this record is left exactly as it was — still a fresh
|
|
1056
|
+
* chain — so the caller can choose to save it as one).
|
|
1057
|
+
* @throws When this entity is not an IS-A child type, or has already been saved — promotion
|
|
1058
|
+
* is a decision about what a NEW record IS, not an edit to an existing one.
|
|
1059
|
+
*/
|
|
1060
|
+
async AttachToParent(parentKey) {
|
|
1061
|
+
if (!this.EntityInfo.IsChildType || !this._parentEntity) {
|
|
1062
|
+
throw new Error(`AttachToParent: '${this.EntityInfo.Name}' is not an IS-A child type — there is no parent to attach to.`);
|
|
1063
|
+
}
|
|
1064
|
+
if (this.IsSaved) {
|
|
1065
|
+
throw new Error(`AttachToParent: '${this.EntityInfo.Name}' record is already saved. Promotion binds a NEW record to an existing parent; it cannot re-parent a saved one.`);
|
|
1066
|
+
}
|
|
1067
|
+
// The PARENT's primary keys drive everything here, not the child's. The shared-PK mirror
|
|
1068
|
+
// is a same-name convention, and a child EntityInfo is not obliged to re-declare the key as
|
|
1069
|
+
// its own PrimaryKey — iterating the child's list silently does nothing on such a schema,
|
|
1070
|
+
// which is exactly the empty-loop bug the first draft of this method had.
|
|
1071
|
+
const parentPks = this._parentEntity.EntityInfo.PrimaryKeys;
|
|
1072
|
+
// Captured BEFORE the load: InnerLoad wipes the parent chain's state before it reads, so a
|
|
1073
|
+
// MISSING parent row would otherwise leave the fresh chain gutted — root PK nulled, record
|
|
1074
|
+
// unsaveable — when the contract is "left exactly as it was, so the caller can still save
|
|
1075
|
+
// it as a fresh chain".
|
|
1076
|
+
const freshPkValues = parentPks.map(pk => ({
|
|
1077
|
+
name: pk.Name,
|
|
1078
|
+
value: this._parentEntity.Get(pk.Name),
|
|
1079
|
+
}));
|
|
1080
|
+
const loaded = await this._parentEntity.InnerLoad(parentKey);
|
|
1081
|
+
if (!loaded) {
|
|
1082
|
+
// Restore the fresh chain the failed load destroyed: re-seed the parent chain, then put
|
|
1083
|
+
// the ORIGINAL minted key back (Set routes to the root), so the record the caller holds
|
|
1084
|
+
// is bit-for-bit the fresh record they built.
|
|
1085
|
+
this._parentEntity.NewRecord();
|
|
1086
|
+
for (const pk of freshPkValues) {
|
|
1087
|
+
if (pk.value != null) {
|
|
1088
|
+
this._parentEntity.Set(pk.name, pk.value);
|
|
1089
|
+
this.mirrorSharedKey(pk.name, pk.value);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
for (const pk of parentPks) {
|
|
1095
|
+
const parentValue = this._parentEntity.Get(pk.Name);
|
|
1096
|
+
if (parentValue != null) {
|
|
1097
|
+
this.mirrorSharedKey(pk.Name, parentValue);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Writes one shared-key value onto this child's LOCAL mirror, when a same-named field exists —
|
|
1104
|
+
* a schema that leaves the shared key entirely to routing has no mirror to maintain, and that
|
|
1105
|
+
* is fine. Restores `_NeverSet` exactly as `NewRecord()`'s adoption path does, so the ReadOnly
|
|
1106
|
+
* mirror stays writable for the rest of the lifecycle.
|
|
1107
|
+
*/
|
|
1108
|
+
mirrorSharedKey(name, value) {
|
|
1109
|
+
if (!this.GetFieldByName(name)) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.SetLocal(name, value);
|
|
1113
|
+
this.GetFieldByName(name)?.ResetNeverSetFlag();
|
|
1114
|
+
}
|
|
877
1115
|
/**
|
|
878
1116
|
* Discovers and initializes the IS-A child entity for a loaded record.
|
|
879
1117
|
*
|
|
@@ -962,6 +1200,249 @@ export class BaseEntity {
|
|
|
962
1200
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
963
1201
|
await childEntity.InitializeChildEntity();
|
|
964
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
|
+
}
|
|
965
1446
|
captureChainDirtyState() {
|
|
966
1447
|
const snapshots = [];
|
|
967
1448
|
let curr = this;
|
|
@@ -1002,6 +1483,10 @@ export class BaseEntity {
|
|
|
1002
1483
|
// Replace it with `this`, then walk upward replacing each parent.
|
|
1003
1484
|
let childParent = childEntity._parentEntity;
|
|
1004
1485
|
let ourInstance = this;
|
|
1486
|
+
if (!childParent) {
|
|
1487
|
+
childEntity._parentEntity = this;
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1005
1490
|
while (childParent && ourInstance) {
|
|
1006
1491
|
// Replace the child's parent reference with our shared instance
|
|
1007
1492
|
if (childParent === childEntity._parentEntity) {
|
|
@@ -1246,6 +1731,8 @@ export class BaseEntity {
|
|
|
1246
1731
|
throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
|
|
1247
1732
|
`Ensure the entity class is registered.`);
|
|
1248
1733
|
}
|
|
1734
|
+
// Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
|
|
1735
|
+
instance.BindProvider(provider);
|
|
1249
1736
|
await instance.Config(this.ContextCurrentUser);
|
|
1250
1737
|
await instance.InitializeParentEntity();
|
|
1251
1738
|
// Recurse so a *new* peer's own embeds are constructed (required nested
|
|
@@ -1441,7 +1928,7 @@ export class BaseEntity {
|
|
|
1441
1928
|
// One `RunViews` for all remaining collections — N declared collections cost one round trip,
|
|
1442
1929
|
// not N. Params are built per collection so each keeps its own filter and ordering. The key
|
|
1443
1930
|
// is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
|
|
1444
|
-
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
|
|
1445
1932
|
const rv = new RunView(this.ProviderToUse);
|
|
1446
1933
|
const results = await rv.RunViews(needsDatabase.map(c => ({
|
|
1447
1934
|
EntityName: c.RelatedEntityName,
|
|
@@ -1470,7 +1957,7 @@ export class BaseEntity {
|
|
|
1470
1957
|
seedEmbedLoadVisited() {
|
|
1471
1958
|
const seeded = new Set();
|
|
1472
1959
|
const name = this.EntityInfo?.Name;
|
|
1473
|
-
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
|
|
1474
1961
|
if (name && pk !== null && pk !== undefined && pk !== '') {
|
|
1475
1962
|
seeded.add(`${name}:${String(pk)}`);
|
|
1476
1963
|
}
|
|
@@ -2064,6 +2551,46 @@ export class BaseEntity {
|
|
|
2064
2551
|
}
|
|
2065
2552
|
return this._fieldCache.get(lcase) || null;
|
|
2066
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
|
+
}
|
|
2067
2594
|
/**
|
|
2068
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.
|
|
2069
2596
|
* @param codeName
|
|
@@ -2088,26 +2615,25 @@ export class BaseEntity {
|
|
|
2088
2615
|
}
|
|
2089
2616
|
return this._codeNameCache.get(lcase) || null;
|
|
2090
2617
|
}
|
|
2091
|
-
/**
|
|
2092
|
-
* 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.
|
|
2093
|
-
*
|
|
2094
|
-
* @remarks
|
|
2095
|
-
* Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
|
|
2096
|
-
* three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
|
|
2097
|
-
* return, and the children are silently never persisted — the save reports success and writes
|
|
2098
|
-
* nothing. See {@link EntityCompanion.Dirty}.
|
|
2099
|
-
*/
|
|
2100
2618
|
get Dirty() {
|
|
2101
|
-
if (
|
|
2102
|
-
return
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
+
}
|
|
2111
2637
|
}
|
|
2112
2638
|
/**
|
|
2113
2639
|
* Whether any registered companion holds unsaved work.
|
|
@@ -2142,7 +2668,7 @@ export class BaseEntity {
|
|
|
2142
2668
|
* Helper method to return just the first Primary Key
|
|
2143
2669
|
*/
|
|
2144
2670
|
get FirstPrimaryKey() {
|
|
2145
|
-
return this.PrimaryKeys[0];
|
|
2671
|
+
return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
|
|
2146
2672
|
}
|
|
2147
2673
|
/**
|
|
2148
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.
|
|
@@ -2535,7 +3061,7 @@ export class BaseEntity {
|
|
|
2535
3061
|
async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
|
|
2536
3062
|
// we need to query the database to get related entity info
|
|
2537
3063
|
const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
|
|
2538
|
-
const rv = new RunView();
|
|
3064
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
2539
3065
|
const result = await rv.RunView(params, this._contextCurrentUser);
|
|
2540
3066
|
if (result && result.Success) {
|
|
2541
3067
|
return {
|
|
@@ -2700,7 +3226,7 @@ export class BaseEntity {
|
|
|
2700
3226
|
// Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
|
|
2701
3227
|
// a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
|
|
2702
3228
|
// PostgreSQL `uuid`).
|
|
2703
|
-
const pk = this.EntityInfo.PrimaryKeys
|
|
3229
|
+
const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
|
|
2704
3230
|
if (!pk.AutoIncrement &&
|
|
2705
3231
|
pk.IsUniqueIdentifier &&
|
|
2706
3232
|
!this.Get(pk.Name)) {
|
|
@@ -3571,40 +4097,45 @@ export class BaseEntity {
|
|
|
3571
4097
|
await this.DeserializeCompanions(payloads);
|
|
3572
4098
|
}
|
|
3573
4099
|
}
|
|
3574
|
-
/**
|
|
3575
|
-
* 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
|
|
3576
|
-
* override or wrap this base class method to add other logic for validation.
|
|
3577
|
-
*
|
|
3578
|
-
* @returns ValidationResult The validation result
|
|
3579
|
-
*/
|
|
3580
4100
|
Validate() {
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
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
|
|
3589
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;
|
|
3590
4135
|
}
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
for (let field of this.Fields) {
|
|
3594
|
-
if (this._parentEntityFieldNames?.has(field.Name))
|
|
3595
|
-
continue; // skip parent field mirrors — authoritative validation is on _parentEntity
|
|
3596
|
-
const err = field.Validate();
|
|
3597
|
-
err.Errors.forEach(element => {
|
|
3598
|
-
result.Errors.push(element);
|
|
3599
|
-
});
|
|
3600
|
-
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;
|
|
3601
4138
|
}
|
|
3602
|
-
// Companions validate LAST but still BEFORE any write, over their complete state including
|
|
3603
|
-
// pending removals. That ordering is what lets a cross-child invariant — "debits must equal
|
|
3604
|
-
// credits", "a confirmed order must have lines" — be enforced against the whole graph rather
|
|
3605
|
-
// than discovered halfway through persisting it.
|
|
3606
|
-
this.validateCompanions(result);
|
|
3607
|
-
return result;
|
|
3608
4139
|
}
|
|
3609
4140
|
/**
|
|
3610
4141
|
* Fans synchronous validation out to every registered companion.
|
|
@@ -3920,11 +4451,12 @@ export class BaseEntity {
|
|
|
3920
4451
|
if (childEntities.length === 0) {
|
|
3921
4452
|
return { HasChildren: false, ChildEntityName: '' };
|
|
3922
4453
|
}
|
|
3923
|
-
// Use RunView
|
|
3924
|
-
|
|
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);
|
|
3925
4457
|
const pkValue = this.PrimaryKey.Values();
|
|
3926
4458
|
for (const childEntity of childEntities) {
|
|
3927
|
-
const pkField = childEntity.
|
|
4459
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
3928
4460
|
if (!pkField)
|
|
3929
4461
|
continue;
|
|
3930
4462
|
const result = await rv.RunView({
|
|
@@ -3983,21 +4515,21 @@ export class BaseEntity {
|
|
|
3983
4515
|
if (!entityInfo) {
|
|
3984
4516
|
return { LeafEntityName: entityName, IsLeaf: true };
|
|
3985
4517
|
}
|
|
3986
|
-
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
|
|
4518
|
+
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
|
|
3987
4519
|
}
|
|
3988
4520
|
/**
|
|
3989
4521
|
* Internal recursive helper for leaf entity resolution.
|
|
3990
4522
|
* Walks down the child hierarchy until no more children are found.
|
|
3991
4523
|
*/
|
|
3992
|
-
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
|
|
4524
|
+
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
|
|
3993
4525
|
const childEntities = entityInfo.ChildEntities;
|
|
3994
4526
|
if (childEntities.length === 0) {
|
|
3995
4527
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
3996
4528
|
}
|
|
3997
|
-
const rv = new RunView();
|
|
4529
|
+
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
3998
4530
|
const pkValue = primaryKey.Values();
|
|
3999
4531
|
for (const child of childEntities) {
|
|
4000
|
-
const childPK = child.
|
|
4532
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
4001
4533
|
if (!childPK)
|
|
4002
4534
|
continue;
|
|
4003
4535
|
const result = await rv.RunView({
|
|
@@ -4009,7 +4541,7 @@ export class BaseEntity {
|
|
|
4009
4541
|
}, contextUser);
|
|
4010
4542
|
if (result?.Success && result.Results?.length > 0) {
|
|
4011
4543
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
4012
|
-
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
|
|
4544
|
+
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
|
|
4013
4545
|
}
|
|
4014
4546
|
}
|
|
4015
4547
|
// No child found — this entity IS the leaf
|
|
@@ -4042,16 +4574,19 @@ export class BaseEntity {
|
|
|
4042
4574
|
const pkValue = this.PrimaryKey.Values();
|
|
4043
4575
|
if (!pkValue)
|
|
4044
4576
|
return;
|
|
4045
|
-
// Build all sibling queries and execute them in a single batch
|
|
4046
|
-
|
|
4047
|
-
|
|
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
|
|
4048
4583
|
if (validSiblings.length === 0)
|
|
4049
4584
|
return;
|
|
4050
4585
|
const viewParams = validSiblings.map(sibling => ({
|
|
4051
4586
|
EntityName: sibling.Name,
|
|
4052
|
-
ExtraFilter: `${sibling.
|
|
4587
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
4053
4588
|
ResultType: 'simple',
|
|
4054
|
-
Fields: [sibling.
|
|
4589
|
+
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
4055
4590
|
MaxRows: 1
|
|
4056
4591
|
}));
|
|
4057
4592
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
@@ -4344,7 +4879,7 @@ export class BaseEntity {
|
|
|
4344
4879
|
LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4345
4880
|
return [];
|
|
4346
4881
|
}
|
|
4347
|
-
const pkName = this.FirstPrimaryKey
|
|
4882
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4348
4883
|
const rootId = this.Get(pkName);
|
|
4349
4884
|
if (!rootId)
|
|
4350
4885
|
return [];
|
|
@@ -4353,7 +4888,7 @@ export class BaseEntity {
|
|
|
4353
4888
|
const filter = maxDepth != null
|
|
4354
4889
|
? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
|
|
4355
4890
|
: `${rootFieldName} = '${rootId}'`;
|
|
4356
|
-
const rv = new RunView();
|
|
4891
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4357
4892
|
const result = await rv.RunView({
|
|
4358
4893
|
EntityName: this.EntityInfo.Name,
|
|
4359
4894
|
ExtraFilter: filter,
|
|
@@ -4372,7 +4907,7 @@ export class BaseEntity {
|
|
|
4372
4907
|
LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4373
4908
|
return [];
|
|
4374
4909
|
}
|
|
4375
|
-
const pkName = this.FirstPrimaryKey
|
|
4910
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4376
4911
|
const currentId = this.Get(pkName);
|
|
4377
4912
|
const pathFieldName = `${fkField.Name}Path`;
|
|
4378
4913
|
const depthFieldName = `${fkField.Name}Depth`;
|
|
@@ -4382,7 +4917,7 @@ export class BaseEntity {
|
|
|
4382
4917
|
const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
|
|
4383
4918
|
if (rawIds.length === 0)
|
|
4384
4919
|
return [];
|
|
4385
|
-
const rv = new RunView();
|
|
4920
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4386
4921
|
const idList = rawIds.map(id => `'${id}'`).join(',');
|
|
4387
4922
|
const result = await rv.RunView({
|
|
4388
4923
|
EntityName: this.EntityInfo.Name,
|
|
@@ -4402,11 +4937,11 @@ export class BaseEntity {
|
|
|
4402
4937
|
LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4403
4938
|
return [];
|
|
4404
4939
|
}
|
|
4405
|
-
const pkName = this.FirstPrimaryKey
|
|
4940
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4406
4941
|
const currentId = this.Get(pkName);
|
|
4407
4942
|
if (!currentId)
|
|
4408
4943
|
return [];
|
|
4409
|
-
const rv = new RunView();
|
|
4944
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4410
4945
|
const result = await rv.RunView({
|
|
4411
4946
|
EntityName: this.EntityInfo.Name,
|
|
4412
4947
|
ExtraFilter: `${fkField.Name} = '${currentId}'`,
|