@memberjunction/core 6.2.0-edge.5 → 6.2.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/BaseEntitySaveQueue.d.ts +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +53 -9
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +194 -35
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +153 -17
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +345 -46
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +66 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +159 -12
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +228 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +574 -65
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +14 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +80 -11
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +209 -38
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +254 -56
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +698 -195
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- 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/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
|
@@ -8,7 +8,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
8
8
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
9
9
|
};
|
|
10
10
|
var EntityField_1;
|
|
11
|
-
import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
|
|
11
|
+
import { Base64DecodedByteLength, DeserializeValidationErrors, Float32VectorToBase64, IsValidBase64, IsMemberOverridden, MJEventType, MJGlobal, NormalizeUUID, OptionalKeyedSpecialization, uuidv4, UUIDsEqual, UserCacheKeySegment, WarningManager, ComputeContentHashAsync } from '@memberjunction/global';
|
|
12
12
|
import { GetDataHooks } from './dataHooks.js';
|
|
13
13
|
import { EntityFieldInfo, EntityInfo, EntityFieldTSType, EntityPermissionType, FieldSecurityError, RecordChange, ValidationErrorInfo, ValidationResult } from './entityInfo.js';
|
|
14
14
|
import { EntitySubtypeResolver } from './entitySubtypeResolver.js';
|
|
@@ -25,6 +25,7 @@ import { EmbeddedRecord } from './embeddedRecord.js';
|
|
|
25
25
|
import { EntitySavePlan, ExecuteEntitySavePlan } from './entitySavePlan.js';
|
|
26
26
|
import { JSONFieldBinding, ValidateJSONFieldValue } from './jsonFieldBinding.js';
|
|
27
27
|
import { BaseRemotableOperation } from './baseRemotableOperation.js';
|
|
28
|
+
import { OmitDeniedFieldKeys, OmitFieldsUnreadableByActiveUser } from './entityValueFieldSecurity.js';
|
|
28
29
|
import { SAVE_ENTITY_GRAPH_OPERATION_KEY, } from './saveEntityGraphOperation.js';
|
|
29
30
|
import { finalize, firstValueFrom, from, of, shareReplay, Subject, switchMap } from 'rxjs';
|
|
30
31
|
/**
|
|
@@ -79,8 +80,9 @@ let EntityField = class EntityField {
|
|
|
79
80
|
* `InnerLoad` — reusing it would conflate defaults with omissions. This flag is set only
|
|
80
81
|
* by the hydration paths (via {@link MarkNotLoaded}) when a source omits the key, is
|
|
81
82
|
* cleared by ANY explicit set (an intentional blind write to a read-denied field is a
|
|
82
|
-
* legitimate write-only update and must save)
|
|
83
|
-
*
|
|
83
|
+
* legitimate write-only update and must save). On a new record it marks only the fields the
|
|
84
|
+
* record's user may not read; every other field legitimately holds its metadata default for
|
|
85
|
+
* INSERT.
|
|
84
86
|
*/
|
|
85
87
|
get NotLoaded() {
|
|
86
88
|
return this._notLoaded;
|
|
@@ -183,7 +185,18 @@ let EntityField = class EntityField {
|
|
|
183
185
|
return this._entityFieldInfo.HasDefaultValue;
|
|
184
186
|
}
|
|
185
187
|
/**
|
|
186
|
-
* Sets the value of the field.
|
|
188
|
+
* Sets the value of the field.
|
|
189
|
+
*
|
|
190
|
+
* - A read-only field accepts ONE set after construction or {@link ResetNeverSetFlag} (how a
|
|
191
|
+
* load populates it); later sets are ignored.
|
|
192
|
+
* - Dirty state is not stored here: {@link Dirty} compares the value with {@link OldValue} when
|
|
193
|
+
* it is read. A set during record setup (the first set of a never-set field) also becomes
|
|
194
|
+
* the old value, so it is not dirty.
|
|
195
|
+
* - A set onto a {@link NotLoaded} field clears that flag and is always dirty, whatever the
|
|
196
|
+
* value, even one equal to the field's construction state. The write therefore reaches the
|
|
197
|
+
* save and its update-permission check.
|
|
198
|
+
* - No field-level security or active-status check happens here. Those live at
|
|
199
|
+
* `BaseEntity.Get`/`Set` and at the save; this accessor is framework-internal.
|
|
187
200
|
*/
|
|
188
201
|
set Value(value) {
|
|
189
202
|
// No active-status assertion here — see the Value getter note. Deprecation/disabled checks
|
|
@@ -527,6 +540,11 @@ let EntityField = class EntityField {
|
|
|
527
540
|
}
|
|
528
541
|
/**
|
|
529
542
|
* This method will set the internal Old Value which is used to track dirty state, to the current value of the field. This effectively resets the dirty state of the field to false. Use this method sparingly.
|
|
543
|
+
*
|
|
544
|
+
* It also clears the written-after-not-loaded flag: the current value becomes the field's
|
|
545
|
+
* loaded baseline. The hydration paths rely on that (a reload that supplies the field must not
|
|
546
|
+
* leave it dirty), so code outside hydration must not call this to "accept" a write onto a
|
|
547
|
+
* not-loaded field, or the write would be saved without being seen as a change.
|
|
530
548
|
*/
|
|
531
549
|
ResetOldValue() {
|
|
532
550
|
this._oldValue = this.Value;
|
|
@@ -1649,20 +1667,35 @@ export class BaseEntity {
|
|
|
1649
1667
|
return e instanceof Error ? e.message : String(e);
|
|
1650
1668
|
}
|
|
1651
1669
|
/**
|
|
1652
|
-
* The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup
|
|
1653
|
-
* shared across providers would hand provider B a row loaded
|
|
1654
|
-
* another
|
|
1655
|
-
*
|
|
1670
|
+
* The create path's SubtypeSelector lookup memo, partitioned by the provider that ran the lookup and
|
|
1671
|
+
* then by the user it ran for. A memo shared across providers would hand provider B a row loaded
|
|
1672
|
+
* through provider A (another connection) or a `null` miss recorded against A's database. A memo
|
|
1673
|
+
* shared across users would hand one user an entity object shaped to another: an object holds only
|
|
1674
|
+
* the fields its own user may read, so a record loaded for an administrator carries values a
|
|
1675
|
+
* restricted user is denied. Keyed weakly so a request-scoped provider's memo is collected with it.
|
|
1656
1676
|
*/
|
|
1657
1677
|
static { this._subtypeLookupCache = new WeakMap(); }
|
|
1658
1678
|
/** Partition key for lookups made by an entity that has no provider at all. */
|
|
1659
1679
|
static { this._noProviderSubtypeKey = {}; }
|
|
1660
|
-
|
|
1680
|
+
/**
|
|
1681
|
+
* The memo for one provider and user, or null when no user resolves: an entry keyed without a
|
|
1682
|
+
* user would be shared by every caller, so such a lookup is not memoized at all.
|
|
1683
|
+
*/
|
|
1684
|
+
static subtypeLookupCacheFor(provider, user) {
|
|
1685
|
+
const userKey = UserCacheKeySegment(user?.ID);
|
|
1686
|
+
if (userKey === null) {
|
|
1687
|
+
return null;
|
|
1688
|
+
}
|
|
1661
1689
|
const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
|
|
1662
|
-
let
|
|
1690
|
+
let byUser = BaseEntity._subtypeLookupCache.get(partitionKey);
|
|
1691
|
+
if (!byUser) {
|
|
1692
|
+
byUser = new Map();
|
|
1693
|
+
BaseEntity._subtypeLookupCache.set(partitionKey, byUser);
|
|
1694
|
+
}
|
|
1695
|
+
let cache = byUser.get(userKey);
|
|
1663
1696
|
if (!cache) {
|
|
1664
1697
|
cache = new Map();
|
|
1665
|
-
|
|
1698
|
+
byUser.set(userKey, cache);
|
|
1666
1699
|
}
|
|
1667
1700
|
return cache;
|
|
1668
1701
|
}
|
|
@@ -1866,12 +1899,16 @@ export class BaseEntity {
|
|
|
1866
1899
|
return null;
|
|
1867
1900
|
let currentEntity = this;
|
|
1868
1901
|
let currentEntityInfo = this.EntityInfo;
|
|
1902
|
+
const actingUser = this.resolveActiveUserOrNull();
|
|
1869
1903
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
1870
1904
|
const fieldName = segments[i];
|
|
1871
1905
|
const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
|
|
1872
1906
|
if (!fieldInfo) {
|
|
1873
1907
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1874
1908
|
}
|
|
1909
|
+
if (!this.subtypeSelectorMayRead(currentEntity, fieldInfo.Name, actingUser)) {
|
|
1910
|
+
return null; // a hop the user may not read gives no subtype, as a denied name field gives no name
|
|
1911
|
+
}
|
|
1875
1912
|
const fkValue = currentEntity.Get(fieldInfo.Name);
|
|
1876
1913
|
if (fkValue == null || fkValue === '') {
|
|
1877
1914
|
return null;
|
|
@@ -1892,34 +1929,54 @@ export class BaseEntity {
|
|
|
1892
1929
|
if (!terminalField) {
|
|
1893
1930
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1894
1931
|
}
|
|
1932
|
+
if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1895
1935
|
const terminalValue = currentEntity.Get(terminalField.Name);
|
|
1896
1936
|
if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
|
|
1897
1937
|
return null;
|
|
1898
1938
|
}
|
|
1899
1939
|
return terminalValue.trim();
|
|
1900
1940
|
}
|
|
1941
|
+
/**
|
|
1942
|
+
* Whether a `SubtypeSelector` hop may read `fieldName` on `record`, which is this record or
|
|
1943
|
+
* one the path reached. Both the record's own user and the user resolving the subtype must be
|
|
1944
|
+
* allowed: a record reached through an engine cache was loaded as the system user and holds
|
|
1945
|
+
* every column, so its own answer says nothing about the acting user.
|
|
1946
|
+
*/
|
|
1947
|
+
subtypeSelectorMayRead(record, fieldName, actingUser) {
|
|
1948
|
+
if (!record.IsFieldReadableByActiveUser(fieldName)) {
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
return record === this || !actingUser || record.EntityInfo.IsFieldReadableByUser(fieldName, actingUser);
|
|
1952
|
+
}
|
|
1901
1953
|
async getSubtypePathTargetEntity(entityName, pkValue) {
|
|
1902
1954
|
const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
|
|
1903
1955
|
const provider = this.ProviderToUse;
|
|
1904
|
-
const lookupCache = BaseEntity.subtypeLookupCacheFor(provider);
|
|
1905
|
-
if (lookupCache
|
|
1956
|
+
const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
|
|
1957
|
+
if (lookupCache?.has(cacheKey)) {
|
|
1906
1958
|
return lookupCache.get(cacheKey) ?? null;
|
|
1907
1959
|
}
|
|
1908
|
-
|
|
1960
|
+
const found = await this.findSubtypePathTarget(provider, entityName, pkValue);
|
|
1961
|
+
lookupCache?.set(cacheKey, found);
|
|
1962
|
+
return found;
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Finds the record a create-time `SubtypeSelector` hop points at: in a loaded engine cache
|
|
1966
|
+
* first, then by loading it through the provider for this record's user. Null when neither
|
|
1967
|
+
* has it, or the load fails.
|
|
1968
|
+
*/
|
|
1969
|
+
async findSubtypePathTarget(provider, entityName, pkValue) {
|
|
1909
1970
|
const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
|
|
1910
1971
|
if (cached) {
|
|
1911
|
-
lookupCache.set(cacheKey, cached);
|
|
1912
1972
|
return cached;
|
|
1913
1973
|
}
|
|
1914
|
-
// 2. Fall back to loading via provider
|
|
1915
1974
|
if (!provider?.GetEntityObject) {
|
|
1916
|
-
lookupCache.set(cacheKey, null);
|
|
1917
1975
|
return null;
|
|
1918
1976
|
}
|
|
1919
1977
|
try {
|
|
1920
1978
|
const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
|
|
1921
1979
|
if (!targetObj) {
|
|
1922
|
-
lookupCache.set(cacheKey, null);
|
|
1923
1980
|
return null;
|
|
1924
1981
|
}
|
|
1925
1982
|
let key;
|
|
@@ -1931,17 +1988,11 @@ export class BaseEntity {
|
|
|
1931
1988
|
const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1932
1989
|
key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
|
|
1933
1990
|
}
|
|
1934
|
-
|
|
1935
|
-
if (loaded) {
|
|
1936
|
-
lookupCache.set(cacheKey, targetObj);
|
|
1937
|
-
return targetObj;
|
|
1938
|
-
}
|
|
1991
|
+
return (await targetObj.InnerLoad(key)) ? targetObj : null;
|
|
1939
1992
|
}
|
|
1940
1993
|
catch {
|
|
1941
|
-
//
|
|
1994
|
+
return null; // on load failure, no target
|
|
1942
1995
|
}
|
|
1943
|
-
lookupCache.set(cacheKey, null);
|
|
1944
|
-
return null;
|
|
1945
1996
|
}
|
|
1946
1997
|
/**
|
|
1947
1998
|
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
@@ -2146,6 +2197,8 @@ export class BaseEntity {
|
|
|
2146
2197
|
// _everSaved, and recreates all EntityField instances with _NeverSet = true
|
|
2147
2198
|
this.init();
|
|
2148
2199
|
this.discardJSONFieldObjects();
|
|
2200
|
+
// Field security: denied values never enter this object (see narrowHydrationSource).
|
|
2201
|
+
data = this.narrowHydrationSource(data);
|
|
2149
2202
|
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
2150
2203
|
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
2151
2204
|
if (this._parentEntity) {
|
|
@@ -2410,7 +2463,9 @@ export class BaseEntity {
|
|
|
2410
2463
|
}
|
|
2411
2464
|
const result = await operation.Execute({
|
|
2412
2465
|
EntityName: this.EntityInfo.Name,
|
|
2413
|
-
|
|
2466
|
+
// Without the fields this user may not read: the server refuses a restricted
|
|
2467
|
+
// caller's input that names one, as the transaction-group serializer knows.
|
|
2468
|
+
Fields: OmitFieldsUnreadableByActiveUser(this, this.GetAll()),
|
|
2414
2469
|
Companions: await this.SerializeCompanions(),
|
|
2415
2470
|
IsExistingRecord: this.IsSaved,
|
|
2416
2471
|
}, { provider: this.ProviderToUse, user: this.ActiveUser });
|
|
@@ -2449,9 +2504,14 @@ export class BaseEntity {
|
|
|
2449
2504
|
// 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
|
|
2450
2505
|
// report (and notify subscribers) as an update.
|
|
2451
2506
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
2507
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
2452
2508
|
// Root: rebase field values and dirty state onto what the database now holds.
|
|
2453
2509
|
this.init();
|
|
2454
|
-
this.
|
|
2510
|
+
const fields = this.narrowHydrationSource(output.Fields);
|
|
2511
|
+
this.SetMany(fields, false, true, true);
|
|
2512
|
+
// A hydration source like any other: a field the result omitted (the server withheld it, or
|
|
2513
|
+
// this user may not hold it) is not loaded, so its construction state is never resent as data.
|
|
2514
|
+
this.markFieldsOmittedBySourceAsNotLoaded(fields);
|
|
2455
2515
|
this._recordLoaded = true;
|
|
2456
2516
|
this._everSaved = true;
|
|
2457
2517
|
// Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
|
|
@@ -2465,7 +2525,28 @@ export class BaseEntity {
|
|
|
2465
2525
|
result.StartedAt = new Date();
|
|
2466
2526
|
result.EndedAt = new Date();
|
|
2467
2527
|
this.RegisterResultHistoryEntry(result);
|
|
2468
|
-
this.RaiseEvent('save', null, saveSubType);
|
|
2528
|
+
this.RaiseEvent('save', null, saveSubType, changedFieldNames);
|
|
2529
|
+
}
|
|
2530
|
+
/**
|
|
2531
|
+
* The names of the fields that are dirty right now. Cheaper than walking {@link Fields}: an
|
|
2532
|
+
* object still in raw mode (loaded and never written to) cannot be dirty, so it answers `[]`
|
|
2533
|
+
* without building its field objects.
|
|
2534
|
+
*/
|
|
2535
|
+
GetDirtyFieldNames() {
|
|
2536
|
+
if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
|
|
2537
|
+
return [];
|
|
2538
|
+
}
|
|
2539
|
+
const names = [];
|
|
2540
|
+
for (const f of this.Fields) {
|
|
2541
|
+
if (f.Dirty) {
|
|
2542
|
+
names.push(f.Name);
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
return names;
|
|
2546
|
+
}
|
|
2547
|
+
/** The names of the fields that are dirty right now, for the `save` event's {@link BaseEntityEvent.ChangedFieldNames}. */
|
|
2548
|
+
dirtyFieldNames() {
|
|
2549
|
+
return this.GetDirtyFieldNames();
|
|
2469
2550
|
}
|
|
2470
2551
|
/**
|
|
2471
2552
|
* Gives every eager companion a chance to populate itself.
|
|
@@ -3080,7 +3161,7 @@ export class BaseEntity {
|
|
|
3080
3161
|
/**
|
|
3081
3162
|
* Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
|
|
3082
3163
|
*/
|
|
3083
|
-
RaiseEvent(type, payload, saveSubType = undefined) {
|
|
3164
|
+
RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
|
|
3084
3165
|
// Resolve the entity's bound provider once so every consumer (local subscribers
|
|
3085
3166
|
// AND MJGlobal listeners) receives consistent provider context. This matters in
|
|
3086
3167
|
// multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
|
|
@@ -3091,7 +3172,7 @@ export class BaseEntity {
|
|
|
3091
3172
|
const provider = this.ProviderToUse;
|
|
3092
3173
|
// this is the local event handler that is specific to THIS instance of the entity object
|
|
3093
3174
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
|
|
3094
|
-
this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider });
|
|
3175
|
+
this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider, ChangedFieldNames: changedFieldNames });
|
|
3095
3176
|
// this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
|
|
3096
3177
|
// we broadcast save/delete/load events and their _started counterparts
|
|
3097
3178
|
if (BaseEntity._globalEventTypes.has(type)) {
|
|
@@ -3101,6 +3182,7 @@ export class BaseEntity {
|
|
|
3101
3182
|
event.type = type;
|
|
3102
3183
|
event.saveSubType = saveSubType;
|
|
3103
3184
|
event.provider = provider;
|
|
3185
|
+
event.ChangedFieldNames = changedFieldNames;
|
|
3104
3186
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
|
|
3105
3187
|
MJGlobal.Instance.RaiseEvent({
|
|
3106
3188
|
component: this,
|
|
@@ -3241,6 +3323,7 @@ export class BaseEntity {
|
|
|
3241
3323
|
get TransactionGroup() {
|
|
3242
3324
|
return this._transactionGroup;
|
|
3243
3325
|
}
|
|
3326
|
+
/** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
|
|
3244
3327
|
set TransactionGroup(group) {
|
|
3245
3328
|
this._transactionGroup = group;
|
|
3246
3329
|
}
|
|
@@ -3492,11 +3575,14 @@ export class BaseEntity {
|
|
|
3492
3575
|
* nothing like field security.
|
|
3493
3576
|
*
|
|
3494
3577
|
* Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
|
|
3495
|
-
* build) reads `EntityField.Value` directly and never routes through here
|
|
3496
|
-
*
|
|
3578
|
+
* build) reads `EntityField.Value` directly and never routes through here. It needs no gate
|
|
3579
|
+
* of its own: every hydration point leaves a denied field not-loaded and empty for a
|
|
3580
|
+
* restricted user (see {@link narrowHydrationSource}), so there is no denied value for that
|
|
3581
|
+
* machinery to read. This assertion is what turns asking for one into an error rather than a
|
|
3582
|
+
* silent null.
|
|
3497
3583
|
*/
|
|
3498
3584
|
assertFieldReadable(fieldName) {
|
|
3499
|
-
const denied = this.
|
|
3585
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
3500
3586
|
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
3501
3587
|
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
3502
3588
|
}
|
|
@@ -3812,6 +3898,53 @@ export class BaseEntity {
|
|
|
3812
3898
|
}
|
|
3813
3899
|
return obj;
|
|
3814
3900
|
}
|
|
3901
|
+
/**
|
|
3902
|
+
* Whether {@link GetAll} would carry a key for every non-binary field of this entity: the
|
|
3903
|
+
* object holds a value for each one (none is not loaded, or withheld from its user), here or,
|
|
3904
|
+
* for an IS-A child, on an ancestor. Answers the question
|
|
3905
|
+
* `LocalCacheManager.RowHasEveryField(this.EntityInfo, this.GetAll())` answers, without
|
|
3906
|
+
* building the row, and without building field objects for an object in raw mode.
|
|
3907
|
+
*/
|
|
3908
|
+
HoldsEveryField() {
|
|
3909
|
+
const infos = this.EntityInfo?.Fields;
|
|
3910
|
+
if (!Array.isArray(infos) || infos.length === 0) {
|
|
3911
|
+
return true;
|
|
3912
|
+
}
|
|
3913
|
+
if (!this._fieldsHydrated && this._raw !== null) {
|
|
3914
|
+
for (const fi of infos) {
|
|
3915
|
+
if (!fi.IsBinaryFieldType && this.rawValueFor(this._raw, fi) === undefined && !this.ancestorHoldsField(fi.Name)) {
|
|
3916
|
+
return false;
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
return true;
|
|
3920
|
+
}
|
|
3921
|
+
for (const field of this.Fields) {
|
|
3922
|
+
if (!field.EntityFieldInfo.IsBinaryFieldType && field.NotLoaded && !this.ancestorHoldsField(field.Name)) {
|
|
3923
|
+
return false;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
return true;
|
|
3927
|
+
}
|
|
3928
|
+
/** Whether an IS-A ancestor's {@link GetAll} would carry the key `name` (an exact field name). */
|
|
3929
|
+
ancestorHoldsField(name) {
|
|
3930
|
+
return this._parentEntity?.holdsFieldNamed(name) ?? false;
|
|
3931
|
+
}
|
|
3932
|
+
/** Whether this object's {@link GetAll} would carry the key `name`, counting its own ancestors. */
|
|
3933
|
+
holdsFieldNamed(name) {
|
|
3934
|
+
if (!this._fieldsHydrated && this._raw !== null) {
|
|
3935
|
+
const fi = this.EntityInfo?.FieldByName(name);
|
|
3936
|
+
if (fi && fi.Name === name && this.rawValueFor(this._raw, fi) !== undefined) {
|
|
3937
|
+
return true;
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
else {
|
|
3941
|
+
const field = this.GetFieldByName(name);
|
|
3942
|
+
if (field && field.Name === name && !field.NotLoaded) {
|
|
3943
|
+
return true;
|
|
3944
|
+
}
|
|
3945
|
+
}
|
|
3946
|
+
return this.ancestorHoldsField(name);
|
|
3947
|
+
}
|
|
3815
3948
|
/**
|
|
3816
3949
|
* Returns a partial object that contains only the fields that have changed since the last time the record was saved. This is useful for scenarios where you want to send only the changes to the server or to a client.
|
|
3817
3950
|
* It is also helpful for quickly finding the fields that are "dirty".
|
|
@@ -4039,19 +4172,43 @@ export class BaseEntity {
|
|
|
4039
4172
|
* Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
|
|
4040
4173
|
* {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
|
|
4041
4174
|
* where omitting a field means "leave it alone," not "this field was never loaded."
|
|
4042
|
-
* Both the field Name and CodeName are checked, matching SetMany's key
|
|
4175
|
+
* Both the field Name and CodeName are checked, case-insensitively, matching SetMany's key
|
|
4176
|
+
* acceptance: SetMany applies `{name: 'b'}` to a field called `Name`, so an exact-case lookup
|
|
4177
|
+
* here would mark the value it just applied as not loaded, and GetAll and the save would then
|
|
4178
|
+
* drop it.
|
|
4043
4179
|
*/
|
|
4044
4180
|
markFieldsOmittedBySourceAsNotLoaded(source) {
|
|
4045
4181
|
if (!source || typeof source !== 'object')
|
|
4046
4182
|
return;
|
|
4183
|
+
// Exact keys first: a source keyed by the field names, the usual case, never builds the
|
|
4184
|
+
// case-insensitive key set. It is built once, on the first field the exact check misses.
|
|
4185
|
+
let presentKeys = null;
|
|
4047
4186
|
for (const field of this.Fields) {
|
|
4048
|
-
if (source
|
|
4187
|
+
if (BaseEntity.ownValuePresent(source, field.Name) || BaseEntity.ownValuePresent(source, field.CodeName)) {
|
|
4188
|
+
continue;
|
|
4189
|
+
}
|
|
4190
|
+
presentKeys ??= BaseEntity.normalizedPresentKeys(source);
|
|
4191
|
+
if (!presentKeys.has(field.Name.trim().toLowerCase()) && !presentKeys.has(field.CodeName.trim().toLowerCase())) {
|
|
4049
4192
|
// Cleared, not just flagged: otherwise a default from construction, or the previous
|
|
4050
4193
|
// record's value on a reload, reads back through Get() as if it had been loaded.
|
|
4051
4194
|
field.ClearAsNotLoaded();
|
|
4052
4195
|
}
|
|
4053
4196
|
}
|
|
4054
4197
|
}
|
|
4198
|
+
/** Whether `source` holds `key` as its own property with a value (an inherited `constructor` does not count). */
|
|
4199
|
+
static ownValuePresent(source, key) {
|
|
4200
|
+
return source[key] !== undefined && Object.prototype.hasOwnProperty.call(source, key);
|
|
4201
|
+
}
|
|
4202
|
+
/** The keys of `source` that carry a value, trimmed and lowercased. */
|
|
4203
|
+
static normalizedPresentKeys(source) {
|
|
4204
|
+
const keys = new Set();
|
|
4205
|
+
for (const key of Object.keys(source)) {
|
|
4206
|
+
if (source[key] !== undefined) {
|
|
4207
|
+
keys.add(key.trim().toLowerCase());
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
return keys;
|
|
4211
|
+
}
|
|
4055
4212
|
/**
|
|
4056
4213
|
* This method will copy the values from the other entity object into the current one. This is useful for things like cloning a record.
|
|
4057
4214
|
* 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.
|
|
@@ -4061,8 +4218,15 @@ export class BaseEntity {
|
|
|
4061
4218
|
*/
|
|
4062
4219
|
CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
|
|
4063
4220
|
try {
|
|
4221
|
+
// Field security: a field THIS object's user may not read is skipped, not copied. The
|
|
4222
|
+
// source can be full width (another user's object, or a server-internal one), and
|
|
4223
|
+
// Set() refuses a denied field, which used to end the copy at the first one.
|
|
4224
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
4064
4225
|
// iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
|
|
4065
4226
|
for (let field of this.Fields) {
|
|
4227
|
+
if (denied?.has(field.Name.trim().toLowerCase())) {
|
|
4228
|
+
continue;
|
|
4229
|
+
}
|
|
4066
4230
|
if (!field.IsPrimaryKey || includePrimaryKeys) {
|
|
4067
4231
|
const otherField = other.GetFieldByName(field.Name);
|
|
4068
4232
|
// Skip fields the SOURCE never loaded (D-3): copying their construction
|
|
@@ -4086,9 +4250,27 @@ export class BaseEntity {
|
|
|
4086
4250
|
* The ContextCurrentUser is a property used to manually set the "current" user for scenarios, primarily on the server side, where the user changes per request. For situations where there is no global CurrentUser in the Metadata.Provider,
|
|
4087
4251
|
* you MUST set this property to the user you want to use for the current operation. If you used Metadata.GetEntityObject() to get the entity object, this property will be set automatically for you as that method has a parameter that can
|
|
4088
4252
|
* be provided for the ContextCurrentUser.
|
|
4253
|
+
*
|
|
4254
|
+
* **Field security.** An object holds only what its user may read, so assigning a different
|
|
4255
|
+
* user to an object that already holds a record narrows it to the new user: values the new
|
|
4256
|
+
* user may not read are dropped (the fields become not loaded) from this object, its IS-A
|
|
4257
|
+
* parent, its embedded records and the loaded children of its related-record collections,
|
|
4258
|
+
* which are given the same user. A read-only cache collection's children are the engine's own
|
|
4259
|
+
* instances and are left as they are. Nothing is widened: a field
|
|
4260
|
+
* the previous user could not read stays not loaded for a user who can, because its value was
|
|
4261
|
+
* never loaded. Set the user before loading wherever possible, so the record is loaded at the
|
|
4262
|
+
* right width in the first place.
|
|
4089
4263
|
*/
|
|
4090
4264
|
set ContextCurrentUser(user) {
|
|
4265
|
+
if (user === this._contextCurrentUser) {
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4091
4268
|
this._contextCurrentUser = user;
|
|
4269
|
+
this.narrowHeldValuesForActiveUser(false);
|
|
4270
|
+
if (this._parentEntity) {
|
|
4271
|
+
this._parentEntity.ContextCurrentUser = user;
|
|
4272
|
+
}
|
|
4273
|
+
this.notifyCompanionsContextUserChanged(user);
|
|
4092
4274
|
}
|
|
4093
4275
|
get ContextCurrentUser() {
|
|
4094
4276
|
return this._contextCurrentUser;
|
|
@@ -4155,6 +4337,11 @@ export class BaseEntity {
|
|
|
4155
4337
|
}
|
|
4156
4338
|
}
|
|
4157
4339
|
}
|
|
4340
|
+
// Field security: a new record holds no default for a field its user may not read. The
|
|
4341
|
+
// field is not loaded instead, so GetAll() and every payload built from it leave it out
|
|
4342
|
+
// (the server refuses a restricted caller's input that names it), and the save omits it,
|
|
4343
|
+
// so the column takes its database default.
|
|
4344
|
+
this.narrowHeldValuesForActiveUser(true);
|
|
4158
4345
|
// Apply caller-supplied values LAST so an explicit PK (or any routed
|
|
4159
4346
|
// parent field) wins over the generated/adopted value instead of being
|
|
4160
4347
|
// clobbered by the parent's NewRecord().
|
|
@@ -4181,6 +4368,53 @@ export class BaseEntity {
|
|
|
4181
4368
|
}
|
|
4182
4369
|
}
|
|
4183
4370
|
}
|
|
4371
|
+
/**
|
|
4372
|
+
* Gives every companion this object's new user: embedded records and the loaded children of
|
|
4373
|
+
* related-record collections take it. See {@link ContextCurrentUser}.
|
|
4374
|
+
*/
|
|
4375
|
+
notifyCompanionsContextUserChanged(user) {
|
|
4376
|
+
if (!this.HasCompanions) {
|
|
4377
|
+
return;
|
|
4378
|
+
}
|
|
4379
|
+
for (const companion of this.Companions) {
|
|
4380
|
+
companion.OnOwnerContextUserChanged(user);
|
|
4381
|
+
}
|
|
4382
|
+
}
|
|
4383
|
+
/**
|
|
4384
|
+
* Drops every value this object holds that its user may not read: the field is left not
|
|
4385
|
+
* loaded and empty, or, before the fields are built, the key leaves the raw row (as a copy;
|
|
4386
|
+
* the row can be shared). A no-op when field security does not apply or the user is denied
|
|
4387
|
+
* nothing, which is the one boolean most entities pay.
|
|
4388
|
+
*
|
|
4389
|
+
* For the points where the user or the record changes WITHOUT a hydration source to narrow:
|
|
4390
|
+
* a user assigned to an object that already holds a record, and a new record's defaults.
|
|
4391
|
+
*
|
|
4392
|
+
* @param buildFields true to build the fields first when they are not built yet, as a new
|
|
4393
|
+
* record needs: its defaults live in fields that are only created on first use.
|
|
4394
|
+
*/
|
|
4395
|
+
narrowHeldValuesForActiveUser(buildFields) {
|
|
4396
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
4397
|
+
if (!denied) {
|
|
4398
|
+
return;
|
|
4399
|
+
}
|
|
4400
|
+
if (!this._fieldsHydrated && !buildFields) {
|
|
4401
|
+
if (this._raw) {
|
|
4402
|
+
this._raw = OmitDeniedFieldKeys(this._raw, denied);
|
|
4403
|
+
this._rawConverted = null;
|
|
4404
|
+
}
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
let cleared = false;
|
|
4408
|
+
for (const field of this.Fields) {
|
|
4409
|
+
if (denied.has(field.Name.trim().toLowerCase()) && (!field.NotLoaded || field.Value !== null)) {
|
|
4410
|
+
field.ClearAsNotLoaded();
|
|
4411
|
+
cleared = true;
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
if (cleared) {
|
|
4415
|
+
this.discardJSONFieldObjects(); // a parsed JSON object can hold a value just dropped
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4184
4418
|
/**
|
|
4185
4419
|
* Returns the active restore context for the next save, if any.
|
|
4186
4420
|
*
|
|
@@ -4681,6 +4915,8 @@ export class BaseEntity {
|
|
|
4681
4915
|
}
|
|
4682
4916
|
finalizeSave(data, saveSubType, options) {
|
|
4683
4917
|
if (data) {
|
|
4918
|
+
// The 'save' event names the fields this save wrote; init() below clears every dirty flag.
|
|
4919
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
4684
4920
|
const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
|
|
4685
4921
|
const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
|
|
4686
4922
|
const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
|
|
@@ -4709,6 +4945,10 @@ export class BaseEntity {
|
|
|
4709
4945
|
else {
|
|
4710
4946
|
fieldData = rawFieldData;
|
|
4711
4947
|
}
|
|
4948
|
+
// The save returns the whole row, whoever saved it. The provider has already built the
|
|
4949
|
+
// Record Changes payload from this entity's pre-save state, so narrowing the response
|
|
4950
|
+
// here costs the audit nothing; it only keeps denied values out of this object.
|
|
4951
|
+
fieldData = this.narrowHydrationSource(fieldData);
|
|
4712
4952
|
this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
|
|
4713
4953
|
// IS-A GetAll() merges the parent, including parent virtuals this entity
|
|
4714
4954
|
// does not own (e.g. OrderHeader on Event Order Line). Keep only columns
|
|
@@ -4728,7 +4968,7 @@ export class BaseEntity {
|
|
|
4728
4968
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
4729
4969
|
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
4730
4970
|
this.cacheRecordName();
|
|
4731
|
-
this.RaiseEvent('save', null, saveSubType);
|
|
4971
|
+
this.RaiseEvent('save', null, saveSubType, changedFieldNames);
|
|
4732
4972
|
return true;
|
|
4733
4973
|
}
|
|
4734
4974
|
else {
|
|
@@ -4864,16 +5104,17 @@ export class BaseEntity {
|
|
|
4864
5104
|
* entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
|
|
4865
5105
|
* carry that premise, and BOTH are load-bearing:
|
|
4866
5106
|
*
|
|
4867
|
-
* 1. `
|
|
4868
|
-
* cannot READ,
|
|
4869
|
-
*
|
|
5107
|
+
* 1. `AssertClientInputFieldSecurity` (through `AssertClientInputWritableForUser`) rejects
|
|
5108
|
+
* client input naming a field the caller cannot READ, or no field at all, which `SetMany`
|
|
5109
|
+
* would otherwise make genuinely dirty with fabricated data.
|
|
5110
|
+
* 2. `entityInfo.FieldLevelSecurityApplies` forces the truth-load branch, so the entity's
|
|
4870
5111
|
* non-dirty baseline is the real stored row rather than the client's `OldValues___`.
|
|
4871
5112
|
*
|
|
4872
5113
|
* (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
|
|
4873
5114
|
* EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
|
|
4874
5115
|
* never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
|
|
4875
5116
|
* `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
|
|
4876
|
-
* canonical FLS configuration, and one that leaves (1) with nothing to
|
|
5117
|
+
* canonical FLS configuration, and one that leaves (1) with nothing to reject — could write an
|
|
4877
5118
|
* update-denied field just by pinning its value in `OldValues___` and never naming it in the
|
|
4878
5119
|
* mutation. If you are considering relaxing that branch condition, this check is what breaks.
|
|
4879
5120
|
*
|
|
@@ -4906,7 +5147,7 @@ export class BaseEntity {
|
|
|
4906
5147
|
// deliberately skips the readability assertion (hydration / resolver-apply path),
|
|
4907
5148
|
// so server-side code can dirty a read-denied field and land here.
|
|
4908
5149
|
if (deniedRead === undefined) {
|
|
4909
|
-
deniedRead = this.
|
|
5150
|
+
deniedRead = this.deniedReadFieldsForActiveUser();
|
|
4910
5151
|
}
|
|
4911
5152
|
throw deniedRead?.has(key)
|
|
4912
5153
|
? new FieldSecurityError(field.Name, this.EntityInfo.Name)
|
|
@@ -4955,15 +5196,32 @@ export class BaseEntity {
|
|
|
4955
5196
|
`omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
|
|
4956
5197
|
}
|
|
4957
5198
|
}
|
|
5199
|
+
/**
|
|
5200
|
+
* Whether this object's user may READ the named field: the question `Get()` asks before it
|
|
5201
|
+
* throws a `FieldSecurityError`, answered without throwing.
|
|
5202
|
+
*
|
|
5203
|
+
* For code that reads a field it did not choose, such as a configured path or a display
|
|
5204
|
+
* column, and must treat a denied field as having no value rather than fail. Fails open the
|
|
5205
|
+
* way `Get()` does: true when field security does not apply to this entity (or any IS-A
|
|
5206
|
+
* ancestor) or no user resolves.
|
|
5207
|
+
*
|
|
5208
|
+
* @param fieldName the field's Name, matched case-insensitively
|
|
5209
|
+
*/
|
|
5210
|
+
IsFieldReadableByActiveUser(fieldName) {
|
|
5211
|
+
if (!fieldName) {
|
|
5212
|
+
return true;
|
|
5213
|
+
}
|
|
5214
|
+
return !this.deniedReadFieldsForActiveUser()?.has(fieldName.trim().toLowerCase());
|
|
5215
|
+
}
|
|
4958
5216
|
/**
|
|
4959
5217
|
* The denied-field set for the acting user, or null when field security does not apply —
|
|
4960
|
-
* the entity
|
|
5218
|
+
* it is off on the entity and every IS-A ancestor, no user resolves, or the user is denied nothing.
|
|
4961
5219
|
*
|
|
4962
5220
|
* Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
|
|
4963
5221
|
* the three cheap short-circuits in one place instead of repeated at each gate.
|
|
4964
5222
|
*/
|
|
4965
5223
|
deniedFieldsForActiveUser(select) {
|
|
4966
|
-
if (!this.EntityInfo?.
|
|
5224
|
+
if (!this.EntityInfo?.FieldLevelSecurityApplies) {
|
|
4967
5225
|
return null; // one boolean for the overwhelming majority of entities
|
|
4968
5226
|
}
|
|
4969
5227
|
const u = this.resolveActiveUserOrNull();
|
|
@@ -4973,6 +5231,34 @@ export class BaseEntity {
|
|
|
4973
5231
|
const denied = select(u);
|
|
4974
5232
|
return denied.size > 0 ? denied : null;
|
|
4975
5233
|
}
|
|
5234
|
+
/**
|
|
5235
|
+
* The fields this object's user may not READ, lowercased by Name and CodeName, or null when
|
|
5236
|
+
* field security does not apply. Every readability question this class asks, at hydration,
|
|
5237
|
+
* at the typed accessors and at the save gates, goes through here.
|
|
5238
|
+
*/
|
|
5239
|
+
deniedReadFieldsForActiveUser() {
|
|
5240
|
+
return this.deniedFieldsForActiveUser(u => this.EntityInfo.GetDeniedReadFields(u));
|
|
5241
|
+
}
|
|
5242
|
+
/**
|
|
5243
|
+
* A hydration source reduced to what this object's user may hold: the same object when the
|
|
5244
|
+
* user is denied nothing (or field security does not apply), otherwise a shallow copy without
|
|
5245
|
+
* the denied keys, matched case-insensitively under Name and CodeName.
|
|
5246
|
+
*
|
|
5247
|
+
* Every point that fills this object from a row passes the row through here first, which is
|
|
5248
|
+
* what makes the object's width a property of its user rather than of the path that built it.
|
|
5249
|
+
* A key absent from a hydration source already means "not loaded" everywhere (`GetAll`, field
|
|
5250
|
+
* building, the save), so omitting a denied key is all it takes for the value never to enter
|
|
5251
|
+
* the object, and for every accessor and serializer to be correct without a check of its own.
|
|
5252
|
+
*
|
|
5253
|
+
* The caller's row is never modified, because it can be a frozen row shared through a cache,
|
|
5254
|
+
* and an unrestricted user's row is returned by reference, so the raw load path stays zero-copy.
|
|
5255
|
+
*/
|
|
5256
|
+
narrowHydrationSource(source) {
|
|
5257
|
+
if (!source || typeof source !== 'object' || Array.isArray(source) || source instanceof Date) {
|
|
5258
|
+
return source;
|
|
5259
|
+
}
|
|
5260
|
+
return OmitDeniedFieldKeys(source, this.deniedReadFieldsForActiveUser());
|
|
5261
|
+
}
|
|
4976
5262
|
/**
|
|
4977
5263
|
* {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
|
|
4978
5264
|
* one from.
|
|
@@ -5040,7 +5326,7 @@ export class BaseEntity {
|
|
|
5040
5326
|
if (!this.IsSaved) {
|
|
5041
5327
|
this.init(); // wipe out current data if we're loading on top of existing record
|
|
5042
5328
|
}
|
|
5043
|
-
|
|
5329
|
+
let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
|
|
5044
5330
|
this.discardJSONFieldObjects(); // a reload replaces the record's data
|
|
5045
5331
|
if (!data) {
|
|
5046
5332
|
// A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
|
|
@@ -5049,6 +5335,9 @@ export class BaseEntity {
|
|
|
5049
5335
|
}
|
|
5050
5336
|
return false; // no data loaded, return false
|
|
5051
5337
|
}
|
|
5338
|
+
// Field security: denied values never enter this object, even from a provider that
|
|
5339
|
+
// did not narrow its SELECT (see narrowHydrationSource).
|
|
5340
|
+
data = this.narrowHydrationSource(data);
|
|
5052
5341
|
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
5053
5342
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
5054
5343
|
// then populates from data. This must happen before self's SetMany so parents
|
|
@@ -5199,6 +5488,16 @@ export class BaseEntity {
|
|
|
5199
5488
|
*/
|
|
5200
5489
|
async LoadFromData(data, _replaceOldValues = false) {
|
|
5201
5490
|
this.discardJSONFieldObjects(); // the record's data is being replaced: parsed JSON objects are stale
|
|
5491
|
+
// An entity object passed as the row is read through its own GetAll(): what ITS user may
|
|
5492
|
+
// read, as a plain row. Kept as the row itself, it would be read through its typed getters,
|
|
5493
|
+
// which answer for the source's user and so return values this object's user is denied
|
|
5494
|
+
// (narrowing below sees only an object's own keys, and an entity's values are not those).
|
|
5495
|
+
if (data instanceof BaseEntity) {
|
|
5496
|
+
data = data.GetAll();
|
|
5497
|
+
}
|
|
5498
|
+
// Field security: a restricted user's object holds a copy of the row without the keys they
|
|
5499
|
+
// are denied; anyone else's keeps the caller's row by reference (see narrowHydrationSource).
|
|
5500
|
+
data = this.narrowHydrationSource(data);
|
|
5202
5501
|
// IS-A: hydrate parent chain from data before populating self.
|
|
5203
5502
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
5204
5503
|
// then populates from data, ensuring correct PK and saved state on parents.
|
|
@@ -6005,7 +6304,7 @@ export class BaseEntity {
|
|
|
6005
6304
|
//
|
|
6006
6305
|
// Returning null is the same answer callers already handle for "this entity has no name
|
|
6007
6306
|
// field", and every one of them degrades to the primary key.
|
|
6008
|
-
if (!this.
|
|
6307
|
+
if (!this.IsFieldReadableByActiveUser(f.Name)) {
|
|
6009
6308
|
return null;
|
|
6010
6309
|
}
|
|
6011
6310
|
return this.Get(f.Name);
|