@memberjunction/core 6.1.0-edge.5 → 6.1.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/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 +52 -1
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +95 -7
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +292 -0
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +878 -98
- 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 +550 -8
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +695 -20
- 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 +16 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +34 -3
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +53 -4
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/providerBase.d.ts +282 -5
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +754 -93
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.d.ts +164 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.js +279 -0
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -0
- 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/saveEntityGraphOperation.d.ts +10 -0
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +2 -1
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +70 -0
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -0
- package/dist/generic/wellKnownUserSource.js +82 -0
- package/dist/generic/wellKnownUserSource.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -8,14 +8,17 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
var EntityField_1;
|
|
11
|
-
import { IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
11
|
+
import { DeserializeValidationErrors, IsMemberOverridden, MJEventType, MJGlobal, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
|
-
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
13
|
+
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, 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';
|
|
@@ -49,6 +52,57 @@ let EntityField = class EntityField {
|
|
|
49
52
|
"real": { min: -3.402823466e+38, max: 3.402823466e+38 },
|
|
50
53
|
"money": { min: -922337203685477.5808, max: 922337203685477.5807 },
|
|
51
54
|
}; }
|
|
55
|
+
/**
|
|
56
|
+
* True when the source this entity was hydrated from OMITTED this field's key — most
|
|
57
|
+
* commonly because field-level security stripped it before the payload reached us, but
|
|
58
|
+
* equally for any partial hydration. The field's in-memory state (its metadata default,
|
|
59
|
+
* else null) is a construction artifact, not data: the save path skips not-loaded fields
|
|
60
|
+
* entirely (the generated procs' `ISNULL(@p, [Col])` merge then preserves the stored
|
|
61
|
+
* value), {@link Dirty} always reports false for them, and {@link Validate} exempts them
|
|
62
|
+
* from the required/null check.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately DISTINCT from `_NeverSet`, which means "no set since construction," exists
|
|
65
|
+
* to permit the one-time write to ReadOnly fields on load, and is re-armed wholesale by
|
|
66
|
+
* `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
|
|
67
|
+
* by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
|
|
68
|
+
* cleared by ANY explicit set (an intentional blind write to a read-denied field is a
|
|
69
|
+
* legitimate write-only update and must save), and is never present on new (unhydrated)
|
|
70
|
+
* entities — their fields legitimately hold metadata defaults for INSERT.
|
|
71
|
+
*/
|
|
72
|
+
get NotLoaded() {
|
|
73
|
+
return this._NotLoaded;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Framework-internal: hydration paths call this for each field whose key the hydration
|
|
77
|
+
* source omitted. Application code should never need it — an explicit {@link Value} set
|
|
78
|
+
* clears the flag.
|
|
79
|
+
*/
|
|
80
|
+
MarkNotLoaded() {
|
|
81
|
+
this._NotLoaded = true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True when field-level security bars this user from supplying the field's value on INSERT,
|
|
85
|
+
* so the save path must omit it and let the column take its database default.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately DISTINCT from {@link NotLoaded}, even though both end in "leave this
|
|
88
|
+
* parameter out of the SP call". `NotLoaded` means "the hydration source omitted this key"
|
|
89
|
+
* and carries consequences this must not: it suppresses the `_Clear` companion, exempts the
|
|
90
|
+
* field from required/null validation, and forces {@link Dirty} to false. A create-suppressed
|
|
91
|
+
* field, by contrast, holds a perfectly real value the user typed — it is simply not one they
|
|
92
|
+
* are permitted to supply. Conflating them would silently disarm validation on fields a user
|
|
93
|
+
* IS allowed to create.
|
|
94
|
+
*
|
|
95
|
+
* Set per-save by the create-path gate and cleared at the start of every save, because the
|
|
96
|
+
* answer depends on the acting user and the same entity object can be saved by different
|
|
97
|
+
* users over its lifetime.
|
|
98
|
+
*/
|
|
99
|
+
get CreateSuppressed() {
|
|
100
|
+
return this._CreateSuppressed;
|
|
101
|
+
}
|
|
102
|
+
/** Framework-internal: set by the save path's field-security create gate. */
|
|
103
|
+
SetCreateSuppressed(suppressed) {
|
|
104
|
+
this._CreateSuppressed = suppressed;
|
|
105
|
+
}
|
|
52
106
|
get Name() {
|
|
53
107
|
return this._entityFieldInfo.Name;
|
|
54
108
|
}
|
|
@@ -123,8 +177,16 @@ let EntityField = class EntityField {
|
|
|
123
177
|
value = value.replace(/ +$/, '');
|
|
124
178
|
}
|
|
125
179
|
this._Value = value;
|
|
180
|
+
// Any explicit set means the field now holds REAL data — including a blind write to
|
|
181
|
+
// a read-denied field (the write-only case), which must flow to the save. Captured
|
|
182
|
+
// BEFORE clearing so the OldValue branch below can see it: a set onto a not-loaded
|
|
183
|
+
// field is an EDIT (must become dirty and reach the save), never the "initial value
|
|
184
|
+
// set" of record setup — the record's setup moment was the hydration that omitted
|
|
185
|
+
// this field.
|
|
186
|
+
const wasNotLoaded = this._NotLoaded;
|
|
187
|
+
this._NotLoaded = false;
|
|
126
188
|
// in the below, we set the OldValue, but only if (a) we have never set the value before, or (b) the value or the old value is not null - which means that we are in a record setup scenario
|
|
127
|
-
if (this._NeverSet &&
|
|
189
|
+
if (this._NeverSet && !wasNotLoaded &&
|
|
128
190
|
(value !== null || this._OldValue !== null)) {
|
|
129
191
|
// initial value set
|
|
130
192
|
this._OldValue = value;
|
|
@@ -142,9 +204,11 @@ let EntityField = class EntityField {
|
|
|
142
204
|
}
|
|
143
205
|
/**
|
|
144
206
|
* Returns true if the field is dirty, false otherwise. A field is considered dirty if the value is different from the old value. If the field is read only, it is never dirty.
|
|
207
|
+
* A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
|
|
208
|
+
* artifact, not data, and nothing that was never loaded can have been changed.
|
|
145
209
|
*/
|
|
146
210
|
get Dirty() {
|
|
147
|
-
if (this.ReadOnly)
|
|
211
|
+
if (this.ReadOnly || this._NotLoaded)
|
|
148
212
|
return false;
|
|
149
213
|
else {
|
|
150
214
|
const oldNull = this._OldValue === null || this.OldValue === undefined || Number.isNaN(this.OldValue); // check for NaN because sometimes we have old values that are NaN and we need to account for that
|
|
@@ -278,7 +342,13 @@ let EntityField = class EntityField {
|
|
|
278
342
|
// no longer false-warns or throws here.
|
|
279
343
|
if (!ef.ReadOnly && !ef.SkipValidation) {
|
|
280
344
|
// only do validation on updatable fields and skip the special case fields defined inside the SkipValidation property (like ID/CreatedAt/UpdatedAt)
|
|
281
|
-
|
|
345
|
+
// NotLoaded exemption: a field the hydration source omitted holds null/default by
|
|
346
|
+
// construction and is SKIPPED by the save SQL, so the stored value is untouched —
|
|
347
|
+
// failing the required check here would make every partially hydrated record
|
|
348
|
+
// unsaveable for unrelated edits (the exact NOT-NULL breakage mode the not-loaded
|
|
349
|
+
// design exists to remove). Length/type checks below still apply to whatever the
|
|
350
|
+
// constructor state is; only the required/null check is meaningless for it.
|
|
351
|
+
if (!ef.AllowsNull && !this._NotLoaded && (this.Value === null || this.Value === undefined)) {
|
|
282
352
|
// make sure this isn't a field that has a default value and we are inside a new record
|
|
283
353
|
if (ef.DefaultValue === null || ef.DefaultValue === undefined || ef.DefaultValue.trim().length === 0) {
|
|
284
354
|
// we have no default value, so this is an error
|
|
@@ -327,11 +397,42 @@ let EntityField = class EntityField {
|
|
|
327
397
|
const nullNote = ef.AllowsNull ? ' (or null)' : '';
|
|
328
398
|
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be one of: ${ef.ValueListValuesForDisplay}${nullNote}. Current value is '${this.Value}'`, this.Value));
|
|
329
399
|
}
|
|
400
|
+
// ExtendedType semantic checks (Image / Color / JSON). Empty values are handled by
|
|
401
|
+
// the AllowsNull rung above — only non-empty strings are inspected here.
|
|
402
|
+
if (ef.TSType === EntityFieldTSType.String && this.Value != null && this.Value !== '') {
|
|
403
|
+
const text = String(this.Value);
|
|
404
|
+
switch (ef.ExtendedType) {
|
|
405
|
+
case 'JSON': {
|
|
406
|
+
const parsed = TryParseJsonText(text);
|
|
407
|
+
if (parsed.ok === false) {
|
|
408
|
+
result.Success = false;
|
|
409
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be valid JSON. ${parsed.message}`, this.Value));
|
|
410
|
+
}
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case 'Color': {
|
|
414
|
+
if (!IsValidCssColor(text)) {
|
|
415
|
+
result.Success = false;
|
|
416
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be a CSS color (hex, rgb, or hsl)`, this.Value));
|
|
417
|
+
}
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'Image': {
|
|
421
|
+
if (!IsPermittedImageFieldValue(text)) {
|
|
422
|
+
result.Success = false;
|
|
423
|
+
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} must be an image URL or inline image (data URI / base64)`, this.Value));
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
330
429
|
}
|
|
331
430
|
return result;
|
|
332
431
|
}
|
|
333
432
|
constructor(fieldInfo, Value) {
|
|
334
433
|
this._NeverSet = true;
|
|
434
|
+
this._NotLoaded = false;
|
|
435
|
+
this._CreateSuppressed = false;
|
|
335
436
|
// NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
|
|
336
437
|
// column is physically part of the entity and the instance must exist. Active-status is
|
|
337
438
|
// asserted only when code accesses the field by name via BaseEntity.Get/Set/SetMany, never
|
|
@@ -489,6 +590,21 @@ export class BaseEntityAIActionParams {
|
|
|
489
590
|
*/
|
|
490
591
|
export class BaseEntityResult {
|
|
491
592
|
constructor(success, message, type) {
|
|
593
|
+
/**
|
|
594
|
+
* Set by a producer whose `Message` ALREADY renders every entry of `Errors`, so that
|
|
595
|
+
* {@link CompleteMessage} does not say the same thing twice.
|
|
596
|
+
*
|
|
597
|
+
* Two kinds of producer build `Message` out of `Errors`: the client-side providers, which copy
|
|
598
|
+
* the SERVER's `CompleteMessage` (= its errors, joined) into `Message` and then rehydrate the same
|
|
599
|
+
* entries into `Errors` so a form can paint the fields; and the IS-A parent-failure paths, which
|
|
600
|
+
* write "Failed to save parent entity … : <errors joined>". Without this flag every one of them
|
|
601
|
+
* read twice in `CompleteMessage`. A substring dedupe was tried and reverted (it is lossy in ways
|
|
602
|
+
* a reader cannot detect — see `CompleteMessage`); a producer stating the fact is exact.
|
|
603
|
+
*
|
|
604
|
+
* Only honoured when `Message` actually has text: a producer that set the flag and left `Message`
|
|
605
|
+
* empty is contradicting itself, and the errors are still rendered rather than lost.
|
|
606
|
+
*/
|
|
607
|
+
this.MessageIncludesErrors = false;
|
|
492
608
|
/**
|
|
493
609
|
* A copy of the values of the entity object BEFORE the operation was performed
|
|
494
610
|
*/
|
|
@@ -505,6 +621,56 @@ export class BaseEntityResult {
|
|
|
505
621
|
this.StartedAt = new Date();
|
|
506
622
|
this.EndedAt = new Date();
|
|
507
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Renders ONE entry of the {@link Errors} array as human-readable text.
|
|
626
|
+
*
|
|
627
|
+
* `Errors` is typed `any[]`, and two shapes land in it from different places:
|
|
628
|
+
*
|
|
629
|
+
* - **`ValidationErrorInfo`** — carries **`Message`** (capital M), plus `Source`, `Value` and
|
|
630
|
+
* `Type`. This is what `_InnerSave` puts there when validation refuses a save: it throws the
|
|
631
|
+
* `ValidationResult`, and the catch block assigns `newResult.Errors = e.Errors`.
|
|
632
|
+
* - **`Error`** (and anything error-like) — carries lowercase **`message`**.
|
|
633
|
+
*
|
|
634
|
+
* This used to read `err.message` ONLY, so every `ValidationErrorInfo` fell through to
|
|
635
|
+
* `JSON.stringify(err)`. That is not a cosmetic difference: `CompleteMessage` is the string the
|
|
636
|
+
* server hands the client on a failed save — every write-refusal throw in `ResolverBase`
|
|
637
|
+
* (`CreateRecord`/`UpdateRecord`/`DeleteRecord`) puts it in the `GraphQLError`, and
|
|
638
|
+
* `SaveEntityGraphOperation` puts it in `ErrorMessage` — so the whole point of writing a careful,
|
|
639
|
+
* field-named refusal in a subclass's `ValidateAsync()` was defeated at the last step, and the
|
|
640
|
+
* user saw
|
|
641
|
+
* `{"Source":"ParentContractID","Message":"…","Value":null,"Type":"Failure"}` in a toast.
|
|
642
|
+
*
|
|
643
|
+
* Nothing catches this at compile time because `Errors` is `any[]`; nothing catches it at runtime
|
|
644
|
+
* because `JSON.stringify` always succeeds. It is only visible by reading the message a user got.
|
|
645
|
+
*
|
|
646
|
+
* The parameter is `unknown` rather than `any` — per `.claude/rules/typescript-style.md` — because
|
|
647
|
+
* not knowing the shape is the whole reason this helper exists, and `unknown` forces the narrowing
|
|
648
|
+
* that makes each shape's handling explicit. Callers pass `any` (the `Errors` array and the `Error`
|
|
649
|
+
* property are both legacy `any`), which is assignable, so no call site changes.
|
|
650
|
+
*
|
|
651
|
+
* `Message` is preferred over `message` because a `ValidationErrorInfo` has only the former,
|
|
652
|
+
* while an `Error` has only the latter — so the order matters solely for an object carrying both,
|
|
653
|
+
* where the MJ-native field is the better answer.
|
|
654
|
+
*
|
|
655
|
+
* @param err - One entry from the `Errors` array.
|
|
656
|
+
* @returns The entry's human-readable text, falling back to JSON for a shape with neither field.
|
|
657
|
+
*/
|
|
658
|
+
static ErrorText(err) {
|
|
659
|
+
if (err === null || err === undefined) {
|
|
660
|
+
return '';
|
|
661
|
+
}
|
|
662
|
+
if (typeof err === 'string') {
|
|
663
|
+
return err;
|
|
664
|
+
}
|
|
665
|
+
if (typeof err === 'object') {
|
|
666
|
+
const shaped = err;
|
|
667
|
+
const text = shaped.Message ?? shaped.message;
|
|
668
|
+
if (typeof text === 'string' && text.trim().length > 0) {
|
|
669
|
+
return text;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return JSON.stringify(err);
|
|
673
|
+
}
|
|
508
674
|
/**
|
|
509
675
|
* Returns a complete message that includes the Message property (if present), the Error property (if present), and any Errors array items (if present).
|
|
510
676
|
*/
|
|
@@ -514,23 +680,30 @@ export class BaseEntityResult {
|
|
|
514
680
|
if (this.Message && this.Message.trim().length > 0) {
|
|
515
681
|
msg = this.Message;
|
|
516
682
|
}
|
|
517
|
-
// now check the simple Error property
|
|
683
|
+
// now check the simple Error property. Same shape problem as the Errors array below, so the
|
|
684
|
+
// same helper answers it: a string, an Error (lowercase `message`), or an MJ
|
|
685
|
+
// ValidationErrorInfo (capital `Message`) all render as their text rather than as JSON.
|
|
518
686
|
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
|
-
}
|
|
687
|
+
msg = (msg ? msg + '\n' : '') + BaseEntityResult.ErrorText(this.Error);
|
|
529
688
|
}
|
|
530
|
-
// now check the Errors array
|
|
531
|
-
|
|
689
|
+
// now check the Errors array.
|
|
690
|
+
//
|
|
691
|
+
// NOT de-duplicated, deliberately. Some producers set BOTH `Message` and `Errors` and build
|
|
692
|
+
// the former out of the latter — `_InnerSave`/`_InnerDelete` do on an IS-A parent failure —
|
|
693
|
+
// so their text does appear twice here. Suppressing a repeat was tried and reverted: any
|
|
694
|
+
// containment test is lossy in ways a reader cannot detect. Three fields failing with the
|
|
695
|
+
// same sentence collapse to one line; an entry whose text is a substring of another is kept
|
|
696
|
+
// or dropped depending on ARRAY ORDER; and a distinct error vanishes when its text happens to
|
|
697
|
+
// appear inside the summary. Saying something twice is ugly. Silently reporting one problem
|
|
698
|
+
// when there were three is the failure this whole class of bug is about, so the duplication
|
|
699
|
+
// stays UNLESS the producer states, via `MessageIncludesErrors`, that `Message` already
|
|
700
|
+
// renders every entry — an exact fact, not a guess, and the only producer-side fix that
|
|
701
|
+
// removes the repeat at the source.
|
|
702
|
+
const messageHasText = !!this.Message && this.Message.trim().length > 0;
|
|
703
|
+
const errorsAlreadyInMessage = this.MessageIncludesErrors && messageHasText;
|
|
704
|
+
if (this.Errors && this.Errors.length > 0 && !errorsAlreadyInMessage) {
|
|
532
705
|
// append
|
|
533
|
-
msg = (msg ? msg + '\n' : '') + this.Errors.map(err =>
|
|
706
|
+
msg = (msg ? msg + '\n' : '') + this.Errors.map(err => BaseEntityResult.ErrorText(err)).join('\n');
|
|
534
707
|
}
|
|
535
708
|
return msg;
|
|
536
709
|
}
|
|
@@ -816,6 +989,16 @@ export class BaseEntity {
|
|
|
816
989
|
* pay nothing for the feature, not even an empty Map per instance.
|
|
817
990
|
*/
|
|
818
991
|
this._companions = null;
|
|
992
|
+
/**
|
|
993
|
+
* 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.
|
|
994
|
+
*
|
|
995
|
+
* @remarks
|
|
996
|
+
* Dirtiness rolls up **companions** as well as fields. Without that rollup, a clean parent with
|
|
997
|
+
* three brand-new children reports `Dirty === false`, `_InnerSave` takes its not-dirty early
|
|
998
|
+
* return, and the children are silently never persisted — the save reports success and writes
|
|
999
|
+
* nothing. See {@link EntityCompanion.Dirty}.
|
|
1000
|
+
*/
|
|
1001
|
+
this._isCheckingDirty = false;
|
|
819
1002
|
this._compositeKey = null;
|
|
820
1003
|
// ────────────────────────────────────────────────────────────────────
|
|
821
1004
|
// Restore context — populated by callers immediately before Save() to
|
|
@@ -833,6 +1016,13 @@ export class BaseEntity {
|
|
|
833
1016
|
this._restoreContext = null;
|
|
834
1017
|
// Holds the current pending save observable (if any)
|
|
835
1018
|
this._pendingSave$ = null;
|
|
1019
|
+
/**
|
|
1020
|
+
* 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
|
|
1021
|
+
* override or wrap this base class method to add other logic for validation.
|
|
1022
|
+
*
|
|
1023
|
+
* @returns ValidationResult The validation result
|
|
1024
|
+
*/
|
|
1025
|
+
this._isValidating = false;
|
|
836
1026
|
/**
|
|
837
1027
|
* private storage for vectors that might be used for this entity, typically in association with individual fields
|
|
838
1028
|
* 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 +1042,42 @@ export class BaseEntity {
|
|
|
852
1042
|
this._provider = Provider;
|
|
853
1043
|
this.init();
|
|
854
1044
|
}
|
|
1045
|
+
/**
|
|
1046
|
+
* The provider actually stored on this instance, or `null` if none was bound.
|
|
1047
|
+
* Unlike {@link ProviderToUse}, this does **not** fall back to the process-wide
|
|
1048
|
+
* {@link BaseEntity.Provider}. Use it to detect a dropped constructor argument:
|
|
1049
|
+
* `GetEntityObject(graphProvider)` must yield `BoundProvider === graphProvider`.
|
|
1050
|
+
*/
|
|
1051
|
+
get BoundProvider() {
|
|
1052
|
+
return this._provider;
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Bind this instance to a provider after construction.
|
|
1056
|
+
*
|
|
1057
|
+
* **Rule (ORM, not just metadata-sync):** every DB read and write on this
|
|
1058
|
+
* instance — Save, Load, Delete, RunView, GetEntityObject of children/embeds,
|
|
1059
|
+
* lookups, RecordGeoCode — MUST use this provider. Mixing another provider
|
|
1060
|
+
* (especially the process-wide host) into the same record graph is a deadlock:
|
|
1061
|
+
* a child FK waits on an uncommitted parent on another connection.
|
|
1062
|
+
*
|
|
1063
|
+
* {@link ProviderBase.GetEntityObject} always calls this so a subclass that
|
|
1064
|
+
* declares `constructor(Entity: EntityInfo)` and drops the second ClassFactory
|
|
1065
|
+
* argument cannot silently run on the global host.
|
|
1066
|
+
*/
|
|
1067
|
+
BindProvider(provider) {
|
|
1068
|
+
this._provider = provider;
|
|
1069
|
+
if (this._parentEntity && this._parentEntity.BoundProvider !== provider) {
|
|
1070
|
+
this._parentEntity.BindProvider(provider);
|
|
1071
|
+
}
|
|
1072
|
+
if (this._childEntity && this._childEntity.BoundProvider !== provider) {
|
|
1073
|
+
this._childEntity.BindProvider(provider);
|
|
1074
|
+
}
|
|
1075
|
+
if (this._companions) {
|
|
1076
|
+
for (const companion of this._companions.values()) {
|
|
1077
|
+
companion.BindProvider(provider);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
855
1081
|
/**
|
|
856
1082
|
* Initializes the IS-A parent entity composition chain. For child type entities,
|
|
857
1083
|
* this creates the parent entity instance (and recursively its parent, etc.) and
|
|
@@ -1062,6 +1288,249 @@ export class BaseEntity {
|
|
|
1062
1288
|
// InitializeChildEntity is idempotent via _childEntityDiscoveryDone flag
|
|
1063
1289
|
await childEntity.InitializeChildEntity();
|
|
1064
1290
|
}
|
|
1291
|
+
static { this._subtypeLookupCache = new Map(); }
|
|
1292
|
+
/**
|
|
1293
|
+
* Clears the static memoization cache used by SubtypeSelector path evaluation.
|
|
1294
|
+
*/
|
|
1295
|
+
static ClearSubtypeLookupCache() {
|
|
1296
|
+
BaseEntity._subtypeLookupCache.clear();
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Prospective counterpart to FindISAChildEntity.
|
|
1300
|
+
* Evaluates which IsA child subtype entity this record should have based on:
|
|
1301
|
+
* 1. Registered EntitySubtypeResolver (ClassFactory key = entity name)
|
|
1302
|
+
* 2. Entity.SubtypeSelector declarative FK traversal path
|
|
1303
|
+
* 3. Unconditional single-child IsA fallback (ChildEntities.length === 1)
|
|
1304
|
+
* 4. Otherwise null (no subtype)
|
|
1305
|
+
*
|
|
1306
|
+
* @see plans/sync-composition-axes.md
|
|
1307
|
+
*/
|
|
1308
|
+
async ResolveSubtypeEntityName() {
|
|
1309
|
+
if (!this.EntityInfo.IsParentType || !this.EntityInfo.ChildEntities || this.EntityInfo.ChildEntities.length === 0) {
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
// 1. Registered resolver override
|
|
1313
|
+
const reg = MJGlobal.Instance.ClassFactory.GetRegistration(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1314
|
+
if (reg) {
|
|
1315
|
+
const resolution = MJGlobal.Instance.ClassFactory.TryCreateInstance(EntitySubtypeResolver, this.EntityInfo.Name);
|
|
1316
|
+
if (resolution.Resolved && resolution.Instance) {
|
|
1317
|
+
const raw = resolution.Instance.Resolve(this);
|
|
1318
|
+
const candidate = raw instanceof Promise ? await raw : raw;
|
|
1319
|
+
if (candidate != null && candidate.trim() !== '') {
|
|
1320
|
+
const trimmed = candidate.trim();
|
|
1321
|
+
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1322
|
+
if (!match) {
|
|
1323
|
+
throw new Error(`EntitySubtypeResolver for '${this.EntityInfo.Name}' returned '${candidate}', which is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1324
|
+
}
|
|
1325
|
+
return match.Name;
|
|
1326
|
+
}
|
|
1327
|
+
return null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// 2. Entity.SubtypeSelector declarative path
|
|
1331
|
+
const selectorConfig = this.EntityInfo.SubtypeSelectorConfig;
|
|
1332
|
+
if (selectorConfig && selectorConfig.Path && selectorConfig.Path.trim() !== '') {
|
|
1333
|
+
const pathResult = await this.evaluateSubtypeSelectorPath(selectorConfig.Path.trim());
|
|
1334
|
+
if (pathResult != null && pathResult.trim() !== '') {
|
|
1335
|
+
const trimmed = pathResult.trim();
|
|
1336
|
+
const match = this.EntityInfo.ChildEntities.find(c => c.Name.trim().toLowerCase() === trimmed.toLowerCase());
|
|
1337
|
+
if (!match) {
|
|
1338
|
+
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}'.`);
|
|
1339
|
+
}
|
|
1340
|
+
return match.Name;
|
|
1341
|
+
}
|
|
1342
|
+
return null;
|
|
1343
|
+
}
|
|
1344
|
+
// 3. Exactly one child, unconditional IsA
|
|
1345
|
+
if (!this.EntityInfo.AllowMultipleSubtypes && this.EntityInfo.ChildEntities.length === 1) {
|
|
1346
|
+
return this.EntityInfo.ChildEntities[0].Name;
|
|
1347
|
+
}
|
|
1348
|
+
// 4. Otherwise null
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Create-safe prospective counterpart to InitializeChildEntity.
|
|
1353
|
+
* Unlike createAndLinkChildEntity, does NOT unlink when InnerLoad finds no row —
|
|
1354
|
+
* that is the create case. Idempotent. Defaults to ResolveSubtypeEntityName()
|
|
1355
|
+
* when no name is passed.
|
|
1356
|
+
*
|
|
1357
|
+
* @param entityName Optional explicit child entity name. If omitted, resolved via ResolveSubtypeEntityName().
|
|
1358
|
+
* @returns The linked child BaseEntity, or null if no subtype applies.
|
|
1359
|
+
*/
|
|
1360
|
+
async EnsureISAChild(entityName) {
|
|
1361
|
+
if (!entityName) {
|
|
1362
|
+
entityName = await this.ResolveSubtypeEntityName();
|
|
1363
|
+
}
|
|
1364
|
+
if (!entityName) {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
const matchedChild = this.EntityInfo.ChildEntities?.find(c => c.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
|
|
1368
|
+
if (!matchedChild) {
|
|
1369
|
+
throw new Error(`'${entityName}' is not a declared IsA child entity of '${this.EntityInfo.Name}'.`);
|
|
1370
|
+
}
|
|
1371
|
+
const resolvedName = matchedChild.Name;
|
|
1372
|
+
if (!this.EntityInfo.AllowMultipleSubtypes) {
|
|
1373
|
+
// Disjoint hierarchy
|
|
1374
|
+
if (this._childEntity) {
|
|
1375
|
+
if (this._childEntity.EntityInfo.Name.trim().toLowerCase() === resolvedName.trim().toLowerCase()) {
|
|
1376
|
+
return this._childEntity; // Idempotent
|
|
1377
|
+
}
|
|
1378
|
+
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).`);
|
|
1379
|
+
}
|
|
1380
|
+
const childProvider = this.ProviderToUse;
|
|
1381
|
+
const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
|
|
1382
|
+
// Wire up shared instance chain
|
|
1383
|
+
this.replaceChildParentChain(childEntity);
|
|
1384
|
+
this._childEntity = childEntity;
|
|
1385
|
+
const dirtySnapshots = this.captureChainDirtyState();
|
|
1386
|
+
if (this.PrimaryKey && this.PrimaryKey.HasValue) {
|
|
1387
|
+
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1388
|
+
if (!loaded) {
|
|
1389
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1394
|
+
}
|
|
1395
|
+
this.restoreChainDirtyState(dirtySnapshots);
|
|
1396
|
+
// Recursively discover grandchildren if the child is also a parent type
|
|
1397
|
+
if (childEntity.EntityInfo.IsParentType) {
|
|
1398
|
+
await childEntity.EnsureISAChild();
|
|
1399
|
+
}
|
|
1400
|
+
return childEntity;
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
// Overlapping hierarchy (AllowMultipleSubtypes = true)
|
|
1404
|
+
if (!this._childEntities) {
|
|
1405
|
+
this._childEntities = [];
|
|
1406
|
+
}
|
|
1407
|
+
if (!this._childEntities.some(c => c.entityName.trim().toLowerCase() === resolvedName.trim().toLowerCase())) {
|
|
1408
|
+
this._childEntities.push({ entityName: resolvedName });
|
|
1409
|
+
}
|
|
1410
|
+
const childProvider = this.ProviderToUse;
|
|
1411
|
+
const childEntity = await childProvider.GetEntityObject(resolvedName, this._contextCurrentUser);
|
|
1412
|
+
this.replaceChildParentChain(childEntity);
|
|
1413
|
+
const dirtySnapshots = this.captureChainDirtyState();
|
|
1414
|
+
if (this.PrimaryKey && this.PrimaryKey.HasValue) {
|
|
1415
|
+
const loaded = await childEntity.InnerLoad(this.PrimaryKey);
|
|
1416
|
+
if (!loaded) {
|
|
1417
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
else {
|
|
1421
|
+
this.mirrorSharedKeysToChild(childEntity);
|
|
1422
|
+
}
|
|
1423
|
+
this.restoreChainDirtyState(dirtySnapshots);
|
|
1424
|
+
if (childEntity.EntityInfo.IsParentType) {
|
|
1425
|
+
await childEntity.EnsureISAChild();
|
|
1426
|
+
}
|
|
1427
|
+
return childEntity;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
mirrorSharedKeysToChild(childEntity) {
|
|
1431
|
+
const parentPks = this.EntityInfo.PrimaryKeys;
|
|
1432
|
+
if (!parentPks || parentPks.length === 0)
|
|
1433
|
+
return;
|
|
1434
|
+
for (const pk of parentPks) {
|
|
1435
|
+
const val = this.Get(pk.Name);
|
|
1436
|
+
if (val != null) {
|
|
1437
|
+
childEntity.mirrorSharedKey(pk.Name, val);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
async evaluateSubtypeSelectorPath(path) {
|
|
1442
|
+
const segments = path.split('.').map(s => s.trim()).filter(Boolean);
|
|
1443
|
+
if (segments.length === 0)
|
|
1444
|
+
return null;
|
|
1445
|
+
let currentEntity = this;
|
|
1446
|
+
let currentEntityInfo = this.EntityInfo;
|
|
1447
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1448
|
+
const fieldName = segments[i];
|
|
1449
|
+
const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
|
|
1450
|
+
if (!fieldInfo) {
|
|
1451
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1452
|
+
}
|
|
1453
|
+
const fkValue = currentEntity.Get(fieldInfo.Name);
|
|
1454
|
+
if (fkValue == null || fkValue === '') {
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
const relatedEntityName = fieldInfo.RelatedEntity;
|
|
1458
|
+
if (!relatedEntityName) {
|
|
1459
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' on entity '${currentEntityInfo.Name}' is not a foreign key relationship.`);
|
|
1460
|
+
}
|
|
1461
|
+
const targetEntity = await this.getSubtypePathTargetEntity(relatedEntityName, fkValue);
|
|
1462
|
+
if (!targetEntity) {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
currentEntity = targetEntity;
|
|
1466
|
+
currentEntityInfo = targetEntity.EntityInfo;
|
|
1467
|
+
}
|
|
1468
|
+
const terminalSegment = segments[segments.length - 1];
|
|
1469
|
+
const terminalField = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === terminalSegment.toLowerCase());
|
|
1470
|
+
if (!terminalField) {
|
|
1471
|
+
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1472
|
+
}
|
|
1473
|
+
const terminalValue = currentEntity.Get(terminalField.Name);
|
|
1474
|
+
if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
|
|
1475
|
+
return null;
|
|
1476
|
+
}
|
|
1477
|
+
return terminalValue.trim();
|
|
1478
|
+
}
|
|
1479
|
+
async getSubtypePathTargetEntity(entityName, pkValue) {
|
|
1480
|
+
const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
|
|
1481
|
+
if (BaseEntity._subtypeLookupCache.has(cacheKey)) {
|
|
1482
|
+
return BaseEntity._subtypeLookupCache.get(cacheKey) ?? null;
|
|
1483
|
+
}
|
|
1484
|
+
// 1. Check BaseEngineRegistry for loaded cached entities
|
|
1485
|
+
const cachedMatches = BaseEngineRegistry.Instance.FindCachedEntity(entityName);
|
|
1486
|
+
if (cachedMatches && cachedMatches.length > 0) {
|
|
1487
|
+
for (const match of cachedMatches) {
|
|
1488
|
+
const found = match.records.find(r => {
|
|
1489
|
+
const firstPK = r.FirstPrimaryKey; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1490
|
+
if (firstPK) {
|
|
1491
|
+
return String(firstPK.Value).trim().toLowerCase() === String(pkValue).trim().toLowerCase();
|
|
1492
|
+
}
|
|
1493
|
+
return false;
|
|
1494
|
+
});
|
|
1495
|
+
if (found) {
|
|
1496
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, found);
|
|
1497
|
+
return found;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
// 2. Fall back to loading via provider
|
|
1502
|
+
const provider = this.ProviderToUse;
|
|
1503
|
+
if (!provider?.GetEntityObject) {
|
|
1504
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1505
|
+
return null;
|
|
1506
|
+
}
|
|
1507
|
+
try {
|
|
1508
|
+
const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
|
|
1509
|
+
if (!targetObj) {
|
|
1510
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
let key;
|
|
1514
|
+
if (pkValue instanceof CompositeKey) {
|
|
1515
|
+
key = pkValue;
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
key = new CompositeKey();
|
|
1519
|
+
const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1520
|
+
key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
|
|
1521
|
+
}
|
|
1522
|
+
const loaded = await targetObj.InnerLoad(key);
|
|
1523
|
+
if (loaded) {
|
|
1524
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
|
|
1525
|
+
return targetObj;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
catch {
|
|
1529
|
+
// On load failure, return null
|
|
1530
|
+
}
|
|
1531
|
+
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1532
|
+
return null;
|
|
1533
|
+
}
|
|
1065
1534
|
captureChainDirtyState() {
|
|
1066
1535
|
const snapshots = [];
|
|
1067
1536
|
let curr = this;
|
|
@@ -1102,6 +1571,10 @@ export class BaseEntity {
|
|
|
1102
1571
|
// Replace it with `this`, then walk upward replacing each parent.
|
|
1103
1572
|
let childParent = childEntity._parentEntity;
|
|
1104
1573
|
let ourInstance = this;
|
|
1574
|
+
if (!childParent) {
|
|
1575
|
+
childEntity._parentEntity = this;
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1105
1578
|
while (childParent && ourInstance) {
|
|
1106
1579
|
// Replace the child's parent reference with our shared instance
|
|
1107
1580
|
if (childParent === childEntity._parentEntity) {
|
|
@@ -1193,6 +1666,8 @@ export class BaseEntity {
|
|
|
1193
1666
|
// ignoreNonExistentFields=true remains as a safety net; ownedFieldsFrom already
|
|
1194
1667
|
// dropped columns that belong to another level of the IS-A chain.
|
|
1195
1668
|
this.SetMany(this.ownedFieldsFrom(data), true, true, true);
|
|
1669
|
+
// Hydrate is a hydration entry point: keys the source omitted are not-loaded.
|
|
1670
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
1196
1671
|
}
|
|
1197
1672
|
/**
|
|
1198
1673
|
* Columns on `data` that this entity actually defines (by field name or CodeName).
|
|
@@ -1346,6 +1821,8 @@ export class BaseEntity {
|
|
|
1346
1821
|
throw new Error(`BaseEntity.ConstructUninitializedEntity: ClassFactory could not construct '${entityName}'. ` +
|
|
1347
1822
|
`Ensure the entity class is registered.`);
|
|
1348
1823
|
}
|
|
1824
|
+
// Same rebind as GetEntityObject — 1-arg subclasses drop the ClassFactory provider.
|
|
1825
|
+
instance.BindProvider(provider);
|
|
1349
1826
|
await instance.Config(this.ContextCurrentUser);
|
|
1350
1827
|
await instance.InitializeParentEntity();
|
|
1351
1828
|
// Recurse so a *new* peer's own embeds are constructed (required nested
|
|
@@ -1434,7 +1911,10 @@ export class BaseEntity {
|
|
|
1434
1911
|
}, { provider: this.ProviderToUse, user: this.ActiveUser });
|
|
1435
1912
|
if (!result.Success || !result.Output?.Success) {
|
|
1436
1913
|
const detail = result.ErrorMessage ?? result.Output?.ErrorMessage ?? 'unknown error';
|
|
1437
|
-
|
|
1914
|
+
// The structured refusal rides alongside the prose so a form can paint the fields a
|
|
1915
|
+
// server-side ValidateAsync named — the same thing a plain save gets from the
|
|
1916
|
+
// GraphQL error's `extensions.validationErrors`.
|
|
1917
|
+
this.registerGraphFailure(detail, 'save', result.Output?.ValidationErrors);
|
|
1438
1918
|
this.RaiseEvent('graph_save', { Success: false, NodeCount: plan.NodeCount, Error: detail });
|
|
1439
1919
|
return false;
|
|
1440
1920
|
}
|
|
@@ -1541,7 +2021,7 @@ export class BaseEntity {
|
|
|
1541
2021
|
// One `RunViews` for all remaining collections — N declared collections cost one round trip,
|
|
1542
2022
|
// not N. Params are built per collection so each keeps its own filter and ordering. The key
|
|
1543
2023
|
// is escaped exactly as RelatedRecordCollection.Load() and the batch loader escape it.
|
|
1544
|
-
const parentKeyLiteral = String(this.FirstPrimaryKey?.Value).replace(/'/g, "''");
|
|
2024
|
+
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
2025
|
const rv = new RunView(this.ProviderToUse);
|
|
1546
2026
|
const results = await rv.RunViews(needsDatabase.map(c => ({
|
|
1547
2027
|
EntityName: c.RelatedEntityName,
|
|
@@ -1570,7 +2050,7 @@ export class BaseEntity {
|
|
|
1570
2050
|
seedEmbedLoadVisited() {
|
|
1571
2051
|
const seeded = new Set();
|
|
1572
2052
|
const name = this.EntityInfo?.Name;
|
|
1573
|
-
const pk = this.FirstPrimaryKey?.Value;
|
|
2053
|
+
const pk = this.FirstPrimaryKey?.Value; // first-pk-ok: must equal EmbeddedRecord.LoadEager's `entity:fk` cycle token, and an FK holds one column
|
|
1574
2054
|
if (name && pk !== null && pk !== undefined && pk !== '') {
|
|
1575
2055
|
seeded.add(`${name}:${String(pk)}`);
|
|
1576
2056
|
}
|
|
@@ -1853,11 +2333,16 @@ export class BaseEntity {
|
|
|
1853
2333
|
*
|
|
1854
2334
|
* @param message - The failure detail.
|
|
1855
2335
|
*/
|
|
1856
|
-
registerGraphFailure(message, operation = 'save') {
|
|
2336
|
+
registerGraphFailure(message, operation = 'save', validationErrors) {
|
|
1857
2337
|
const result = new BaseEntityResult();
|
|
1858
2338
|
result.Success = false;
|
|
1859
2339
|
result.Type = operation === 'delete' ? 'delete' : this.IsSaved ? 'update' : 'create';
|
|
1860
2340
|
result.Message = message ?? 'Entity graph operation failed';
|
|
2341
|
+
// Rehydrated into real ValidationErrorInfo instances so `LatestResult.Errors` reads exactly as
|
|
2342
|
+
// it does after a local `Validate()` refusal; `[]` when the server sent none.
|
|
2343
|
+
result.Errors = DeserializeValidationErrors(validationErrors);
|
|
2344
|
+
// `message` is the server's CompleteMessage — the same errors, already joined — so say so.
|
|
2345
|
+
result.MessageIncludesErrors = result.Errors.length > 0 && !!message;
|
|
1861
2346
|
result.StartedAt = new Date();
|
|
1862
2347
|
result.EndedAt = new Date();
|
|
1863
2348
|
result.OriginalValues = this.Fields.map(f => ({ FieldName: f.CodeName, Value: f.OldValue }));
|
|
@@ -2164,6 +2649,46 @@ export class BaseEntity {
|
|
|
2164
2649
|
}
|
|
2165
2650
|
return this._fieldCache.get(lcase) || null;
|
|
2166
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* True when any of the named fields exists on this entity and its current value
|
|
2654
|
+
* differs from the last loaded or saved value.
|
|
2655
|
+
*
|
|
2656
|
+
* This is the boolean form of `GetFieldByName(name)?.Dirty === true`. Prefer it at
|
|
2657
|
+
* call sites that only care whether a column has been edited — pricing, validation,
|
|
2658
|
+
* and "did the user type this" gates — so they do not repeat the optional-chain and
|
|
2659
|
+
* do not treat a missing field as a distinct third state.
|
|
2660
|
+
*
|
|
2661
|
+
* Semantics:
|
|
2662
|
+
* - **Unknown or blank names return `false`.** They are not dirty; they are absent.
|
|
2663
|
+
* Callers that must distinguish "no such field" from "field is clean" should use
|
|
2664
|
+
* {@link GetFieldByName} and inspect the result.
|
|
2665
|
+
* - **Names are case-insensitive and trimmed**, matching {@link GetFieldByName}.
|
|
2666
|
+
* - **Read-only fields are never dirty**, even if their value was overwritten internally.
|
|
2667
|
+
* - **Multiple names are OR'd.** `FieldIsDirty('UnitPrice', 'ProductPriceID')` is true
|
|
2668
|
+
* if either field has been edited. An empty rest list is a single-field check.
|
|
2669
|
+
*
|
|
2670
|
+
* @param fieldName First field to test. A missing/blank name contributes `false`.
|
|
2671
|
+
* @param more Additional field names, each OR'd with the first.
|
|
2672
|
+
* @returns `true` if at least one named field exists and is dirty; otherwise `false`.
|
|
2673
|
+
*
|
|
2674
|
+
* @example
|
|
2675
|
+
* ```ts
|
|
2676
|
+
* // Single field
|
|
2677
|
+
* if (line.FieldIsDirty('UnitPrice')) { ... }
|
|
2678
|
+
*
|
|
2679
|
+
* // Either money column was edited
|
|
2680
|
+
* if (line.FieldIsDirty('UnitPrice', 'ProductPriceID')) { ... }
|
|
2681
|
+
* ```
|
|
2682
|
+
*/
|
|
2683
|
+
FieldIsDirty(fieldName, ...more) {
|
|
2684
|
+
const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
|
|
2685
|
+
for (const name of names) {
|
|
2686
|
+
if (this.GetFieldByName(name)?.Dirty === true) {
|
|
2687
|
+
return true;
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
return false;
|
|
2691
|
+
}
|
|
2167
2692
|
/**
|
|
2168
2693
|
* 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
2694
|
* @param codeName
|
|
@@ -2188,26 +2713,25 @@ export class BaseEntity {
|
|
|
2188
2713
|
}
|
|
2189
2714
|
return this._codeNameCache.get(lcase) || null;
|
|
2190
2715
|
}
|
|
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
2716
|
get Dirty() {
|
|
2201
|
-
if (
|
|
2202
|
-
return
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2717
|
+
if (this._isCheckingDirty)
|
|
2718
|
+
return false;
|
|
2719
|
+
this._isCheckingDirty = true;
|
|
2720
|
+
try {
|
|
2721
|
+
if (!this.IsSaved)
|
|
2722
|
+
return true;
|
|
2723
|
+
if (this.companionsDirty)
|
|
2724
|
+
return true;
|
|
2725
|
+
// Raw mode means LoadFromData populated us but no mutation has happened — nothing can be
|
|
2726
|
+
// dirty. Avoid hydrating just to check.
|
|
2727
|
+
if (!this._fieldsHydrated)
|
|
2728
|
+
return this._parentEntity?.Dirty ?? false;
|
|
2729
|
+
return this._Fields.some(f => f.Dirty) ||
|
|
2730
|
+
(this._parentEntity?.Dirty ?? false);
|
|
2731
|
+
}
|
|
2732
|
+
finally {
|
|
2733
|
+
this._isCheckingDirty = false;
|
|
2734
|
+
}
|
|
2211
2735
|
}
|
|
2212
2736
|
/**
|
|
2213
2737
|
* Whether any registered companion holds unsaved work.
|
|
@@ -2242,7 +2766,7 @@ export class BaseEntity {
|
|
|
2242
2766
|
* Helper method to return just the first Primary Key
|
|
2243
2767
|
*/
|
|
2244
2768
|
get FirstPrimaryKey() {
|
|
2245
|
-
return this.PrimaryKeys[0];
|
|
2769
|
+
return this.PrimaryKeys[0]; // first-pk-ok: the accessor itself
|
|
2246
2770
|
}
|
|
2247
2771
|
/**
|
|
2248
2772
|
* 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.
|
|
@@ -2276,6 +2800,7 @@ export class BaseEntity {
|
|
|
2276
2800
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2277
2801
|
this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Set');
|
|
2278
2802
|
}
|
|
2803
|
+
this.AssertFieldReadable(FieldName);
|
|
2279
2804
|
this.SetLocal(FieldName, Value);
|
|
2280
2805
|
}
|
|
2281
2806
|
}
|
|
@@ -2295,6 +2820,33 @@ export class BaseEntity {
|
|
|
2295
2820
|
EntityFieldInfo.AssertEntityFieldActiveStatus(fi, caller);
|
|
2296
2821
|
}
|
|
2297
2822
|
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Field-level security choke point for the strongly-typed accessor path. Throws when the
|
|
2825
|
+
* acting user may not READ the field.
|
|
2826
|
+
*
|
|
2827
|
+
* Called by `Get()` and `Set()` — every generated typed accessor
|
|
2828
|
+
* (`get Salary() { return this.Get('Salary'); }`) routes through them, so these two sites
|
|
2829
|
+
* cover the whole typed surface. Gated on both READ, because a field a user cannot see is
|
|
2830
|
+
* one they cannot meaningfully address by name at all; update and create denials are
|
|
2831
|
+
* enforced on the write path, where a rejection can name a save rather than a keystroke.
|
|
2832
|
+
*
|
|
2833
|
+
* **Deliberately NOT called by `SetMany`.** That is the hydration and resolver-apply path —
|
|
2834
|
+
* throwing there would break loading a record that merely CONTAINS a restricted column.
|
|
2835
|
+
*
|
|
2836
|
+
* **Fails open when no user resolves.** `ActiveUser` is legitimately null in plenty of
|
|
2837
|
+
* server paths, and a gate that threw there would break unrelated code in ways that look
|
|
2838
|
+
* nothing like field security.
|
|
2839
|
+
*
|
|
2840
|
+
* Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
|
|
2841
|
+
* build) reads `EntityField.Value` directly and never routes through here — that exemption
|
|
2842
|
+
* is load-bearing, not an oversight. Do not "fix" it.
|
|
2843
|
+
*/
|
|
2844
|
+
AssertFieldReadable(fieldName) {
|
|
2845
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
2846
|
+
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
2847
|
+
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2298
2850
|
/**
|
|
2299
2851
|
* Internal helper that sets a field value directly on THIS entity's own Fields array
|
|
2300
2852
|
* without IS-A routing. Used by Set() for own-fields and for mirroring parent field values.
|
|
@@ -2362,6 +2914,10 @@ export class BaseEntity {
|
|
|
2362
2914
|
if (this.EntityInfo?.HasInactiveFields) {
|
|
2363
2915
|
this.AssertFieldActiveStatus(FieldName, 'BaseEntity.Get');
|
|
2364
2916
|
}
|
|
2917
|
+
// Field security sits BEFORE the raw-mode fast path below, not after: an entity whose
|
|
2918
|
+
// fields are not yet hydrated would otherwise return denied values straight out of _raw
|
|
2919
|
+
// without the gate ever running.
|
|
2920
|
+
this.AssertFieldReadable(FieldName);
|
|
2365
2921
|
// Raw mode fast path: read directly from the cached data without building EntityField
|
|
2366
2922
|
// instances. This is the dominant cost in engine warm-loads — generated typed getters
|
|
2367
2923
|
// (e.g. `get Name() { return this.Get('Name'); }`) flow through here, so a consumer that
|
|
@@ -2518,6 +3074,13 @@ export class BaseEntity {
|
|
|
2518
3074
|
GetAll(oldValues = false, onlyDirtyFields = false) {
|
|
2519
3075
|
let obj = {};
|
|
2520
3076
|
for (let field of this.Fields) {
|
|
3077
|
+
// NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
|
|
3078
|
+
// construction artifact, and serializing it would launder a default/null into
|
|
3079
|
+
// something downstream code treats as data — the exact masquerade the flag exists
|
|
3080
|
+
// to prevent. Key-absence also propagates the flag naturally: hydrating another
|
|
3081
|
+
// entity from this output re-marks the same fields not-loaded.
|
|
3082
|
+
if (field.NotLoaded)
|
|
3083
|
+
continue;
|
|
2521
3084
|
if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
|
|
2522
3085
|
// Reads field.Value directly — serialization is framework-internal, so it does not
|
|
2523
3086
|
// (and must not) assert active status. No suppression toggle needed: the assertion no
|
|
@@ -2635,7 +3198,7 @@ export class BaseEntity {
|
|
|
2635
3198
|
async GetRelatedEntityDataExt(re, filter = null, maxRecords = null) {
|
|
2636
3199
|
// we need to query the database to get related entity info
|
|
2637
3200
|
const params = EntityInfo.BuildRelationshipViewParams(this, re, filter, maxRecords);
|
|
2638
|
-
const rv = new RunView();
|
|
3201
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
2639
3202
|
const result = await rv.RunView(params, this._contextCurrentUser);
|
|
2640
3203
|
if (result && result.Success) {
|
|
2641
3204
|
return {
|
|
@@ -2709,6 +3272,9 @@ export class BaseEntity {
|
|
|
2709
3272
|
}
|
|
2710
3273
|
}
|
|
2711
3274
|
}
|
|
3275
|
+
// _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
|
|
3276
|
+
// are not-loaded, not defaulted. See EntityField.NotLoaded.
|
|
3277
|
+
this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
|
|
2712
3278
|
// Raw data has been promoted into Fields — release the reference so we don't carry
|
|
2713
3279
|
// duplicate state. Fields hold their own copies, so a frozen source no longer
|
|
2714
3280
|
// constrains anything from here on.
|
|
@@ -2716,6 +3282,22 @@ export class BaseEntity {
|
|
|
2716
3282
|
this._rawConverted = null;
|
|
2717
3283
|
}
|
|
2718
3284
|
}
|
|
3285
|
+
/**
|
|
3286
|
+
* Marks every field whose key the given HYDRATION SOURCE omitted as {@link EntityField.NotLoaded}.
|
|
3287
|
+
* Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
|
|
3288
|
+
* {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
|
|
3289
|
+
* where omitting a field means "leave it alone," not "this field was never loaded."
|
|
3290
|
+
* Both the field Name and CodeName are checked, matching SetMany's key acceptance.
|
|
3291
|
+
*/
|
|
3292
|
+
markFieldsOmittedBySourceAsNotLoaded(source) {
|
|
3293
|
+
if (!source || typeof source !== 'object')
|
|
3294
|
+
return;
|
|
3295
|
+
for (const field of this.Fields) {
|
|
3296
|
+
if (source[field.Name] === undefined && source[field.CodeName] === undefined) {
|
|
3297
|
+
field.MarkNotLoaded();
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
2719
3301
|
/**
|
|
2720
3302
|
* This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
|
|
2721
3303
|
* This method will ONLY copy values for fields that exist in the current entity object. If the other object has fields that don't exist in the current object, they will be ignored.
|
|
@@ -2729,7 +3311,9 @@ export class BaseEntity {
|
|
|
2729
3311
|
for (let field of this.Fields) {
|
|
2730
3312
|
if (!field.IsPrimaryKey || includePrimaryKeys) {
|
|
2731
3313
|
const otherField = other.GetFieldByName(field.Name);
|
|
2732
|
-
|
|
3314
|
+
// Skip fields the SOURCE never loaded (D-3): copying their construction
|
|
3315
|
+
// state would masquerade a default/null as real data on this entity.
|
|
3316
|
+
if (otherField && !otherField.NotLoaded) {
|
|
2733
3317
|
this.Set(field.Name, otherField.Value);
|
|
2734
3318
|
if (replaceOldValues) {
|
|
2735
3319
|
field.ResetOldValue();
|
|
@@ -2800,7 +3384,7 @@ export class BaseEntity {
|
|
|
2800
3384
|
// Root of an IS-A chain, or a standalone (non-IS-A) entity: generate
|
|
2801
3385
|
// a single GUID/UUID PK here (SQL Server `uniqueidentifier` /
|
|
2802
3386
|
// PostgreSQL `uuid`).
|
|
2803
|
-
const pk = this.EntityInfo.PrimaryKeys
|
|
3387
|
+
const pk = this.EntityInfo.FirstPrimaryKey; // first-pk-ok: guarded by PrimaryKeys.length === 1 above
|
|
2804
3388
|
if (!pk.AutoIncrement &&
|
|
2805
3389
|
pk.IsUniqueIdentifier &&
|
|
2806
3390
|
!this.Get(pk.Name)) {
|
|
@@ -3050,6 +3634,9 @@ export class BaseEntity {
|
|
|
3050
3634
|
`Failed to save parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
|
|
3051
3635
|
// Surface the parent's field-level errors so the caller can act on them.
|
|
3052
3636
|
newResult.Errors = parentErrors;
|
|
3637
|
+
// When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
|
|
3638
|
+
// them — say so, or CompleteMessage repeats every one.
|
|
3639
|
+
newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
|
|
3053
3640
|
newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
|
|
3054
3641
|
newResult.EndedAt = new Date();
|
|
3055
3642
|
this.RegisterResultHistoryEntry(newResult);
|
|
@@ -3060,6 +3647,8 @@ export class BaseEntity {
|
|
|
3060
3647
|
const type = this.IsSaved ? EntityPermissionType.Update : EntityPermissionType.Create;
|
|
3061
3648
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
3062
3649
|
this.CheckPermissions(type, true); // this will throw an error and exit out if we don't have permission
|
|
3650
|
+
this.CheckFieldLevelUpdatePermissions(); // field-level security — throws if a dirty field is not updatable by this user
|
|
3651
|
+
this.ApplyFieldLevelCreateSuppression(); // field-level security on INSERT — omits fields, never rejects
|
|
3063
3652
|
// IS-A disjoint subtype enforcement: on CREATE, ensure parent record
|
|
3064
3653
|
// isn't already claimed by another child type (e.g., can't create Meeting
|
|
3065
3654
|
// if a Publication already exists with the same Product ID).
|
|
@@ -3279,6 +3868,14 @@ export class BaseEntity {
|
|
|
3279
3868
|
// does not own (e.g. OrderHeader on Event Order Line). Keep only columns
|
|
3280
3869
|
// this entity defines, and ignore anything leftover.
|
|
3281
3870
|
this.SetMany(this.ownedFieldsFrom(fieldData), true, true, true);
|
|
3871
|
+
// finalizeSave re-hydrates from the save RESPONSE — a hydration source. Keys it
|
|
3872
|
+
// omitted (e.g. fields the server stripped for field security) must be marked
|
|
3873
|
+
// not-loaded, or the defaults init() just produced would masquerade as confirmed
|
|
3874
|
+
// values and be resent on the NEXT save (the create/update-response corner: a
|
|
3875
|
+
// trigger/system adjustment or concurrent change would be silently stomped).
|
|
3876
|
+
if (typeof fieldData === 'object' && !Array.isArray(fieldData)) {
|
|
3877
|
+
this.markFieldsOmittedBySourceAsNotLoaded(fieldData);
|
|
3878
|
+
}
|
|
3282
3879
|
this._everSaved = true; // Mark as saved after successful save
|
|
3283
3880
|
const result = this.LatestResult;
|
|
3284
3881
|
if (result)
|
|
@@ -3398,6 +3995,158 @@ export class BaseEntity {
|
|
|
3398
3995
|
else
|
|
3399
3996
|
return bAllowed;
|
|
3400
3997
|
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Field-level security on the write path: rejects a save that modifies a field this user
|
|
4000
|
+
* has no update permission on.
|
|
4001
|
+
*
|
|
4002
|
+
* ENFORCEMENT LAYER — read this before treating it as the security boundary. `BaseEntity`
|
|
4003
|
+
* also runs in the browser, where this guard is trivially bypassable. The AUTHORITATIVE
|
|
4004
|
+
* check is the server-side execution of this same code: the MJServer mutation resolver
|
|
4005
|
+
* re-instantiates the entity and re-runs Save on the server, where the client cannot reach
|
|
4006
|
+
* it. The client-side occurrence is UX and defense-in-depth — fail fast with a clear
|
|
4007
|
+
* message before a network round-trip — and must never be relied on alone.
|
|
4008
|
+
*
|
|
4009
|
+
* UPDATE rejects; CREATE does not — see {@link ApplyFieldLevelCreateSuppression}.
|
|
4010
|
+
*
|
|
4011
|
+
* Note this checks DIRTY fields only. CLIENT-side that is safe on its own: nothing ever
|
|
4012
|
+
* nulls a restricted value in memory, so a field the user cannot see was never loaded as
|
|
4013
|
+
* null, is not dirty, and an unrelated edit saves cleanly with the restricted column
|
|
4014
|
+
* keeping its stored value.
|
|
4015
|
+
*
|
|
4016
|
+
* SERVER-side, dirty-only is safe only because `ResolverBase.UpdateRecord` guarantees the
|
|
4017
|
+
* entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
|
|
4018
|
+
* carry that premise, and BOTH are load-bearing:
|
|
4019
|
+
*
|
|
4020
|
+
* 1. `StripDeniedReadFieldsFromClientInput` removes client-sent values for fields the caller
|
|
4021
|
+
* cannot READ, which `SetMany` would otherwise make genuinely dirty with fabricated data.
|
|
4022
|
+
* 2. `entityInfo.EnableFieldLevelSecurity` forces the truth-load branch, so the entity's
|
|
4023
|
+
* non-dirty baseline is the real stored row rather than the client's `OldValues___`.
|
|
4024
|
+
*
|
|
4025
|
+
* (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
|
|
4026
|
+
* EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
|
|
4027
|
+
* never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
|
|
4028
|
+
* `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
|
|
4029
|
+
* canonical FLS configuration, and one that leaves (1) with nothing to strip — could write an
|
|
4030
|
+
* update-denied field just by pinning its value in `OldValues___` and never naming it in the
|
|
4031
|
+
* mutation. If you are considering relaxing that branch condition, this check is what breaks.
|
|
4032
|
+
*
|
|
4033
|
+
* The refusal names the missing permission when the caller can READ the field, and falls back
|
|
4034
|
+
* to the ambiguous "does not exist or you do not have access" wording when they cannot. See
|
|
4035
|
+
* {@link FieldSecurityWriteDenialMessage} for why that split discloses nothing.
|
|
4036
|
+
*/
|
|
4037
|
+
CheckFieldLevelUpdatePermissions() {
|
|
4038
|
+
if (!this.IsSaved) {
|
|
4039
|
+
return; // INSERT — handled by ApplyFieldLevelCreateSuppression, which never rejects
|
|
4040
|
+
}
|
|
4041
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedUpdateFields(u));
|
|
4042
|
+
if (!denied) {
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
// Resolved once, and only if we are actually going to reject: the wording depends on
|
|
4046
|
+
// whether the caller can READ the field they were refused a write on.
|
|
4047
|
+
let deniedRead;
|
|
4048
|
+
for (const field of this.Fields) {
|
|
4049
|
+
const key = field.Name.trim().toLowerCase();
|
|
4050
|
+
if (field.Dirty && denied.has(key)) {
|
|
4051
|
+
LogDebug(`[FieldSecurity] Rejected save on '${this.EntityInfo.Name}': ` +
|
|
4052
|
+
`field '${field.Name}' is not updatable by this user`);
|
|
4053
|
+
// A field the caller can READ gets the real reason. Both facts the ambiguous
|
|
4054
|
+
// wording protects — that the column exists, and that it is restricted for them —
|
|
4055
|
+
// are already theirs, so withholding the reason only tells someone a field whose
|
|
4056
|
+
// values they are looking at might not exist.
|
|
4057
|
+
//
|
|
4058
|
+
// A field they CANNOT read keeps the ambiguous wording. Not hypothetical: SetMany
|
|
4059
|
+
// deliberately skips the readability assertion (hydration / resolver-apply path),
|
|
4060
|
+
// so server-side code can dirty a read-denied field and land here.
|
|
4061
|
+
if (deniedRead === undefined) {
|
|
4062
|
+
deniedRead = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
4063
|
+
}
|
|
4064
|
+
throw deniedRead?.has(key)
|
|
4065
|
+
? new FieldSecurityError(field.Name, this.EntityInfo.Name)
|
|
4066
|
+
: FieldSecurityError.WriteDenial(field.Name, this.EntityInfo.Name);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
/**
|
|
4071
|
+
* Field-level security on the INSERT path: marks the fields this user may not supply so the
|
|
4072
|
+
* save omits them and each column takes its database default.
|
|
4073
|
+
*
|
|
4074
|
+
* **This never rejects, and that is deliberate.** Rejecting would be inconsistent with the
|
|
4075
|
+
* read path (a denied field is simply absent, not an error) and would leak information — an
|
|
4076
|
+
* error naming `Salary` confirms the field exists and is restricted, which the ambiguous
|
|
4077
|
+
* denial wording exists to prevent. Silently defaulting is also what an unrestricted user
|
|
4078
|
+
* gets by leaving the field blank, so a restricted user creating a record ends up with the
|
|
4079
|
+
* same record SHAPE rather than a failure.
|
|
4080
|
+
*
|
|
4081
|
+
* The cost is that a user who supplies a value for a create-denied field gets no feedback
|
|
4082
|
+
* that it was dropped, which is why the drop is logged and why the admin UI should not
|
|
4083
|
+
* render the field at all.
|
|
4084
|
+
*
|
|
4085
|
+
* Runs on every save (clearing prior marks first) because the answer depends on the acting
|
|
4086
|
+
* user, and one entity object can be saved by different users over its lifetime.
|
|
4087
|
+
*/
|
|
4088
|
+
ApplyFieldLevelCreateSuppression() {
|
|
4089
|
+
for (const field of this.Fields) {
|
|
4090
|
+
field.SetCreateSuppressed(false);
|
|
4091
|
+
}
|
|
4092
|
+
if (this.IsSaved) {
|
|
4093
|
+
return; // UPDATE — CheckFieldLevelUpdatePermissions owns that path
|
|
4094
|
+
}
|
|
4095
|
+
const denied = this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedCreateFields(u));
|
|
4096
|
+
if (!denied) {
|
|
4097
|
+
return;
|
|
4098
|
+
}
|
|
4099
|
+
const suppressed = [];
|
|
4100
|
+
for (const field of this.Fields) {
|
|
4101
|
+
if (denied.has(field.Name.trim().toLowerCase())) {
|
|
4102
|
+
field.SetCreateSuppressed(true);
|
|
4103
|
+
suppressed.push(field.Name);
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
if (suppressed.length > 0) {
|
|
4107
|
+
LogDebug(`[FieldSecurity] Create on '${this.EntityInfo.Name}': ` +
|
|
4108
|
+
`omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
/**
|
|
4112
|
+
* The denied-field set for the acting user, or null when field security does not apply —
|
|
4113
|
+
* the entity has it switched off, no user resolves, or the user is denied nothing.
|
|
4114
|
+
*
|
|
4115
|
+
* Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
|
|
4116
|
+
* the three cheap short-circuits in one place instead of repeated at each gate.
|
|
4117
|
+
*/
|
|
4118
|
+
deniedFieldsForActiveUser(select) {
|
|
4119
|
+
if (!this.EntityInfo?.EnableFieldLevelSecurity) {
|
|
4120
|
+
return null; // one boolean for the overwhelming majority of entities
|
|
4121
|
+
}
|
|
4122
|
+
const u = this.resolveActiveUserOrNull();
|
|
4123
|
+
if (!u) {
|
|
4124
|
+
return null; // no user resolves — fail open, see AssertFieldReadable
|
|
4125
|
+
}
|
|
4126
|
+
const denied = select(u);
|
|
4127
|
+
return denied.size > 0 ? denied : null;
|
|
4128
|
+
}
|
|
4129
|
+
/**
|
|
4130
|
+
* {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
|
|
4131
|
+
* one from.
|
|
4132
|
+
*
|
|
4133
|
+
* `ActiveUser` ends in `Metadata.Provider.CurrentUser`, which throws a TypeError when there
|
|
4134
|
+
* is no global provider — during early boot, in tests, or in any context that never
|
|
4135
|
+
* configured one. That was harmless while only the save path consulted it, but `Get()` and
|
|
4136
|
+
* `Set()` now do on every access to an FLS-enabled entity, so an unresolvable provider would
|
|
4137
|
+
* turn an ordinary read into a crash.
|
|
4138
|
+
*
|
|
4139
|
+
* "No provider to ask" is the same answer as "no user" for this purpose, and field security
|
|
4140
|
+
* fails open on both.
|
|
4141
|
+
*/
|
|
4142
|
+
resolveActiveUserOrNull() {
|
|
4143
|
+
try {
|
|
4144
|
+
return this.ActiveUser ?? null;
|
|
4145
|
+
}
|
|
4146
|
+
catch {
|
|
4147
|
+
return null;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
3401
4150
|
ThrowPermissionError(u, type, additionalInfoMessage) {
|
|
3402
4151
|
throw new Error(`User: ${u.Name} (ID: ${u.ID}, Email: ${u.Email})
|
|
3403
4152
|
Does NOT have permission to ${EntityPermissionType[type]} ${this.EntityInfo.Name} records.
|
|
@@ -3465,6 +4214,10 @@ export class BaseEntity {
|
|
|
3465
4214
|
f.ResetNeverSetFlag();
|
|
3466
4215
|
}
|
|
3467
4216
|
this.SetMany(data, false, true, true); // don't ignore non-existent fields, but DO replace old values
|
|
4217
|
+
// InnerLoad is a hydration entry point: any field the provider's row omitted (e.g.
|
|
4218
|
+
// a client-side load whose server response stripped read-denied fields) is
|
|
4219
|
+
// not-loaded, so its constructor state never masquerades as data on the next save.
|
|
4220
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
3468
4221
|
if (EntityRelationshipsToLoad) {
|
|
3469
4222
|
for (let relationship of EntityRelationshipsToLoad) {
|
|
3470
4223
|
if (data[relationship]) {
|
|
@@ -3626,6 +4379,10 @@ export class BaseEntity {
|
|
|
3626
4379
|
// Hits when: subsequent LoadFromData call on an already-loaded instance, IS-A entity
|
|
3627
4380
|
// (parent or child), or non-plain-object input. Preserves original semantics exactly.
|
|
3628
4381
|
this.SetMany(data, true, _replaceOldValues, true); // ignore non-existent fields, but DO replace old values based on the provided param
|
|
4382
|
+
if (isPlainObject) {
|
|
4383
|
+
// LoadFromData is a hydration entry point: keys the source omitted are not-loaded.
|
|
4384
|
+
this.markFieldsOmittedBySourceAsNotLoaded(data);
|
|
4385
|
+
}
|
|
3629
4386
|
// now, check to see if we have the primary key set, if so, we should consider ourselves
|
|
3630
4387
|
// loaded from the database and set the _recordLoaded flag to true along with the _everSaved flag
|
|
3631
4388
|
if (this.PrimaryKeys && this.PrimaryKeys.length > 0) {
|
|
@@ -3671,40 +4428,45 @@ export class BaseEntity {
|
|
|
3671
4428
|
await this.DeserializeCompanions(payloads);
|
|
3672
4429
|
}
|
|
3673
4430
|
}
|
|
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
4431
|
Validate() {
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
4432
|
+
if (this._isValidating) {
|
|
4433
|
+
const emptyResult = new ValidationResult();
|
|
4434
|
+
emptyResult.Success = true;
|
|
4435
|
+
return emptyResult;
|
|
4436
|
+
}
|
|
4437
|
+
this._isValidating = true;
|
|
4438
|
+
try {
|
|
4439
|
+
const result = new ValidationResult();
|
|
4440
|
+
result.Success = true; // start off with assumption of success, if any field fails, we'll set this to false
|
|
4441
|
+
// IS-A composition: validate parent entity first to collect all chain errors
|
|
4442
|
+
if (this._parentEntity) {
|
|
4443
|
+
const parentResult = this._parentEntity.Validate();
|
|
4444
|
+
if (!parentResult.Success) {
|
|
4445
|
+
result.Success = false;
|
|
4446
|
+
parentResult.Errors.forEach(err => result.Errors.push(err));
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
// Validate own fields — for IS-A entities, skip parent field mirrors since
|
|
4450
|
+
// those are validated via _parentEntity above
|
|
4451
|
+
for (let field of this.Fields) {
|
|
4452
|
+
if (this._parentEntityFieldNames?.has(field.Name))
|
|
4453
|
+
continue; // skip parent field mirrors — authoritative validation is on _parentEntity
|
|
4454
|
+
const err = field.Validate();
|
|
4455
|
+
err.Errors.forEach(element => {
|
|
4456
|
+
result.Errors.push(element);
|
|
4457
|
+
});
|
|
4458
|
+
result.Success = result.Success && err.Success; // if any field fails, we fail, but keep going to get all of the validation messages
|
|
3689
4459
|
}
|
|
4460
|
+
// Companions validate LAST but still BEFORE any write, over their complete state including
|
|
4461
|
+
// pending removals. That ordering is what lets a cross-child invariant — "debits must equal
|
|
4462
|
+
// credits", "a confirmed order must have lines" — be enforced against the whole graph rather
|
|
4463
|
+
// than discovered halfway through persisting it.
|
|
4464
|
+
this.validateCompanions(result);
|
|
4465
|
+
return result;
|
|
3690
4466
|
}
|
|
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
|
|
4467
|
+
finally {
|
|
4468
|
+
this._isValidating = false;
|
|
3701
4469
|
}
|
|
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
4470
|
}
|
|
3709
4471
|
/**
|
|
3710
4472
|
* Fans synchronous validation out to every registered companion.
|
|
@@ -3930,6 +4692,9 @@ export class BaseEntity {
|
|
|
3930
4692
|
`Failed to delete parent entity '${this._parentEntity.EntityInfo?.Name}': ${detail}`;
|
|
3931
4693
|
// Surface the parent's field-level errors so the caller can act on them.
|
|
3932
4694
|
newResult.Errors = parentErrors;
|
|
4695
|
+
// When `detail` was built from `parentErrors` (no parent Message), `Message` already renders
|
|
4696
|
+
// them — say so, or CompleteMessage repeats every one.
|
|
4697
|
+
newResult.MessageIncludesErrors = !parentLatest?.Message && parentErrors.length > 0;
|
|
3933
4698
|
newResult.OriginalValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.OldValue }; });
|
|
3934
4699
|
newResult.EndedAt = new Date();
|
|
3935
4700
|
this.RegisterResultHistoryEntry(newResult);
|
|
@@ -4020,11 +4785,12 @@ export class BaseEntity {
|
|
|
4020
4785
|
if (childEntities.length === 0) {
|
|
4021
4786
|
return { HasChildren: false, ChildEntityName: '' };
|
|
4022
4787
|
}
|
|
4023
|
-
// Use RunView
|
|
4024
|
-
|
|
4788
|
+
// Use RunView on this instance's provider — a host RunView cannot see
|
|
4789
|
+
// uncommitted child rows on a graph-scoped connection.
|
|
4790
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4025
4791
|
const pkValue = this.PrimaryKey.Values();
|
|
4026
4792
|
for (const childEntity of childEntities) {
|
|
4027
|
-
const pkField = childEntity.
|
|
4793
|
+
const pkField = childEntity.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
4028
4794
|
if (!pkField)
|
|
4029
4795
|
continue;
|
|
4030
4796
|
const result = await rv.RunView({
|
|
@@ -4083,21 +4849,21 @@ export class BaseEntity {
|
|
|
4083
4849
|
if (!entityInfo) {
|
|
4084
4850
|
return { LeafEntityName: entityName, IsLeaf: true };
|
|
4085
4851
|
}
|
|
4086
|
-
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser);
|
|
4852
|
+
return BaseEntity.ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, md);
|
|
4087
4853
|
}
|
|
4088
4854
|
/**
|
|
4089
4855
|
* Internal recursive helper for leaf entity resolution.
|
|
4090
4856
|
* Walks down the child hierarchy until no more children are found.
|
|
4091
4857
|
*/
|
|
4092
|
-
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser) {
|
|
4858
|
+
static async ResolveLeafEntityRecursive(entityInfo, primaryKey, contextUser, provider) {
|
|
4093
4859
|
const childEntities = entityInfo.ChildEntities;
|
|
4094
4860
|
if (childEntities.length === 0) {
|
|
4095
4861
|
return { LeafEntityName: entityInfo.Name, IsLeaf: true };
|
|
4096
4862
|
}
|
|
4097
|
-
const rv = new RunView();
|
|
4863
|
+
const rv = new RunView((provider ?? BaseEntity.Provider));
|
|
4098
4864
|
const pkValue = primaryKey.Values();
|
|
4099
4865
|
for (const child of childEntities) {
|
|
4100
|
-
const childPK = child.
|
|
4866
|
+
const childPK = child.FirstPrimaryKey; // first-pk-ok: IS-A children share the parent's single key
|
|
4101
4867
|
if (!childPK)
|
|
4102
4868
|
continue;
|
|
4103
4869
|
const result = await rv.RunView({
|
|
@@ -4109,7 +4875,7 @@ export class BaseEntity {
|
|
|
4109
4875
|
}, contextUser);
|
|
4110
4876
|
if (result?.Success && result.Results?.length > 0) {
|
|
4111
4877
|
// Found a child — recurse to see if there's an even more specific leaf
|
|
4112
|
-
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser);
|
|
4878
|
+
return BaseEntity.ResolveLeafEntityRecursive(child, primaryKey, contextUser, provider);
|
|
4113
4879
|
}
|
|
4114
4880
|
}
|
|
4115
4881
|
// No child found — this entity IS the leaf
|
|
@@ -4142,16 +4908,19 @@ export class BaseEntity {
|
|
|
4142
4908
|
const pkValue = this.PrimaryKey.Values();
|
|
4143
4909
|
if (!pkValue)
|
|
4144
4910
|
return;
|
|
4145
|
-
// Build all sibling queries and execute them in a single batch
|
|
4146
|
-
|
|
4147
|
-
|
|
4911
|
+
// Build all sibling queries and execute them in a single batch on
|
|
4912
|
+
// this instance's provider so an uncommitted sibling on the same
|
|
4913
|
+
// graph connection is visible (host RunView would miss it).
|
|
4914
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4915
|
+
// first-pk-ok: IS-A siblings share the parent's single key
|
|
4916
|
+
const validSiblings = siblingChildEntities.filter(s => s.FirstPrimaryKey); // first-pk-ok: IS-A siblings share the parent's single key
|
|
4148
4917
|
if (validSiblings.length === 0)
|
|
4149
4918
|
return;
|
|
4150
4919
|
const viewParams = validSiblings.map(sibling => ({
|
|
4151
4920
|
EntityName: sibling.Name,
|
|
4152
|
-
ExtraFilter: `${sibling.
|
|
4921
|
+
ExtraFilter: `${sibling.FirstPrimaryKey.Name} = '${pkValue}'`, // first-pk-ok: IS-A shared key
|
|
4153
4922
|
ResultType: 'simple',
|
|
4154
|
-
Fields: [sibling.
|
|
4923
|
+
Fields: [sibling.FirstPrimaryKey.Name], // first-pk-ok: IS-A shared key
|
|
4155
4924
|
MaxRows: 1
|
|
4156
4925
|
}));
|
|
4157
4926
|
const results = await rv.RunViews(viewParams, this._contextCurrentUser);
|
|
@@ -4218,9 +4987,20 @@ export class BaseEntity {
|
|
|
4218
4987
|
if (!f) {
|
|
4219
4988
|
return null;
|
|
4220
4989
|
}
|
|
4221
|
-
|
|
4222
|
-
|
|
4990
|
+
// Field security: the name field is an ordinary field and can be denied like any other.
|
|
4991
|
+
// `Get()` THROWS for a denied field, and this method runs AUTOMATICALLY after every
|
|
4992
|
+
// Load / LoadFromData / Save via CacheRecordName — so an unguarded read here does not
|
|
4993
|
+
// hide a name, it makes the record fail to load at all, with a message about the name
|
|
4994
|
+
// field that reads like the record itself is broken.
|
|
4995
|
+
//
|
|
4996
|
+
// Returning null is the same answer callers already handle for "this entity has no name
|
|
4997
|
+
// field", and every one of them degrades to the primary key. It also keeps a denied name
|
|
4998
|
+
// OUT of the provider's record-name cache, which is keyed by entity + primary key and NOT
|
|
4999
|
+
// by user — caching it would leak it to the next caller.
|
|
5000
|
+
if (!this.EntityInfo.IsFieldReadableByUser(f.Name, this.ActiveUser)) {
|
|
5001
|
+
return null;
|
|
4223
5002
|
}
|
|
5003
|
+
return this.Get(f.Name);
|
|
4224
5004
|
}
|
|
4225
5005
|
/**
|
|
4226
5006
|
* Static Utility method to get RecordChanges for a given entityName/KeyValuePair combination
|
|
@@ -4444,7 +5224,7 @@ export class BaseEntity {
|
|
|
4444
5224
|
LogError(`BaseEntity.GetDescendants(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4445
5225
|
return [];
|
|
4446
5226
|
}
|
|
4447
|
-
const pkName = this.FirstPrimaryKey
|
|
5227
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4448
5228
|
const rootId = this.Get(pkName);
|
|
4449
5229
|
if (!rootId)
|
|
4450
5230
|
return [];
|
|
@@ -4453,7 +5233,7 @@ export class BaseEntity {
|
|
|
4453
5233
|
const filter = maxDepth != null
|
|
4454
5234
|
? `${rootFieldName} = '${rootId}' AND ${depthFieldName} <= ${maxDepth}`
|
|
4455
5235
|
: `${rootFieldName} = '${rootId}'`;
|
|
4456
|
-
const rv = new RunView();
|
|
5236
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4457
5237
|
const result = await rv.RunView({
|
|
4458
5238
|
EntityName: this.EntityInfo.Name,
|
|
4459
5239
|
ExtraFilter: filter,
|
|
@@ -4472,7 +5252,7 @@ export class BaseEntity {
|
|
|
4472
5252
|
LogError(`BaseEntity.GetAncestors(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4473
5253
|
return [];
|
|
4474
5254
|
}
|
|
4475
|
-
const pkName = this.FirstPrimaryKey
|
|
5255
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4476
5256
|
const currentId = this.Get(pkName);
|
|
4477
5257
|
const pathFieldName = `${fkField.Name}Path`;
|
|
4478
5258
|
const depthFieldName = `${fkField.Name}Depth`;
|
|
@@ -4482,7 +5262,7 @@ export class BaseEntity {
|
|
|
4482
5262
|
const rawIds = path.split('/').filter(id => id.length > 0 && id !== currentId);
|
|
4483
5263
|
if (rawIds.length === 0)
|
|
4484
5264
|
return [];
|
|
4485
|
-
const rv = new RunView();
|
|
5265
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4486
5266
|
const idList = rawIds.map(id => `'${id}'`).join(',');
|
|
4487
5267
|
const result = await rv.RunView({
|
|
4488
5268
|
EntityName: this.EntityInfo.Name,
|
|
@@ -4502,11 +5282,11 @@ export class BaseEntity {
|
|
|
4502
5282
|
LogError(`BaseEntity.GetChildren(): No recursive foreign key field found on entity ${this.EntityInfo?.Name}`);
|
|
4503
5283
|
return [];
|
|
4504
5284
|
}
|
|
4505
|
-
const pkName = this.FirstPrimaryKey
|
|
5285
|
+
const pkName = this.FirstPrimaryKey.Name; // first-pk-ok: getRecursiveForeignKeyField already gates hierarchy traversal to single-column keys
|
|
4506
5286
|
const currentId = this.Get(pkName);
|
|
4507
5287
|
if (!currentId)
|
|
4508
5288
|
return [];
|
|
4509
|
-
const rv = new RunView();
|
|
5289
|
+
const rv = new RunView(this.RunViewProviderToUse);
|
|
4510
5290
|
const result = await rv.RunView({
|
|
4511
5291
|
EntityName: this.EntityInfo.Name,
|
|
4512
5292
|
ExtraFilter: `${fkField.Name} = '${currentId}'`,
|