@memberjunction/core 6.2.0-edge.4 → 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 +22 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +43 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.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 +82 -14
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +311 -46
- 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 +194 -14
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +638 -218
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.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 +103 -13
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +321 -77
- 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/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +306 -12
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +894 -92
- 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/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +39 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +318 -51
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +675 -109
- 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/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- 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 +370 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1085 -284
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +23 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +73 -33
- 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/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +17 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +25 -10
- 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/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -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
- package/readme.md +29 -4
|
@@ -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;
|
|
@@ -92,6 +94,18 @@ let EntityField = class EntityField {
|
|
|
92
94
|
*/
|
|
93
95
|
MarkNotLoaded() {
|
|
94
96
|
this._notLoaded = true;
|
|
97
|
+
this._writtenAfterNotLoaded = false;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Framework-internal, for the hydration paths: the source omitted this field, so it is
|
|
101
|
+
* not-loaded AND holds nothing. Unlike {@link MarkNotLoaded}, which only flags the field and keeps
|
|
102
|
+
* its in-memory value, this drops a default or a previous record's value that would otherwise
|
|
103
|
+
* read back through Get() as if it had been loaded.
|
|
104
|
+
*/
|
|
105
|
+
ClearAsNotLoaded() {
|
|
106
|
+
this._value = null;
|
|
107
|
+
this._oldValue = null;
|
|
108
|
+
this.MarkNotLoaded();
|
|
95
109
|
}
|
|
96
110
|
/**
|
|
97
111
|
* True when field-level security bars this user from supplying the field's value on INSERT,
|
|
@@ -171,7 +185,18 @@ let EntityField = class EntityField {
|
|
|
171
185
|
return this._entityFieldInfo.HasDefaultValue;
|
|
172
186
|
}
|
|
173
187
|
/**
|
|
174
|
-
* 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.
|
|
175
200
|
*/
|
|
176
201
|
set Value(value) {
|
|
177
202
|
// No active-status assertion here — see the Value getter note. Deprecation/disabled checks
|
|
@@ -198,6 +223,9 @@ let EntityField = class EntityField {
|
|
|
198
223
|
// this field.
|
|
199
224
|
const wasNotLoaded = this._notLoaded;
|
|
200
225
|
this._notLoaded = false;
|
|
226
|
+
if (wasNotLoaded) {
|
|
227
|
+
this._writtenAfterNotLoaded = true;
|
|
228
|
+
}
|
|
201
229
|
// 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
|
|
202
230
|
if (this._neverSet && !wasNotLoaded &&
|
|
203
231
|
(value !== null || this._oldValue !== null)) {
|
|
@@ -215,6 +243,12 @@ let EntityField = class EntityField {
|
|
|
215
243
|
ResetNeverSetFlag() {
|
|
216
244
|
this._neverSet = true;
|
|
217
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* True when the field has had no explicit write since construction or since {@link ResetNeverSetFlag}.
|
|
248
|
+
*/
|
|
249
|
+
get NeverSet() {
|
|
250
|
+
return this._neverSet;
|
|
251
|
+
}
|
|
218
252
|
/**
|
|
219
253
|
* 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.
|
|
220
254
|
* A {@link NotLoaded} field is never dirty either — its in-memory state is a construction
|
|
@@ -223,9 +257,11 @@ let EntityField = class EntityField {
|
|
|
223
257
|
get Dirty() {
|
|
224
258
|
if (this.ReadOnly || this._notLoaded)
|
|
225
259
|
return false;
|
|
260
|
+
else if (this._writtenAfterNotLoaded)
|
|
261
|
+
return true; // a write onto a value the record never loaded must reach the save, even null
|
|
226
262
|
else {
|
|
227
263
|
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
|
|
228
|
-
const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.
|
|
264
|
+
const curNull = this.Value === null || this.Value === undefined || Number.isNaN(this.Value);
|
|
229
265
|
if (oldNull && curNull)
|
|
230
266
|
return false; // BOTH are null or undefined, not dirty
|
|
231
267
|
else {
|
|
@@ -239,16 +275,16 @@ let EntityField = class EntityField {
|
|
|
239
275
|
// and sometimes the new values are date objects, convert to UTC timestamp for comparison
|
|
240
276
|
newCompare = this.Value.getTime();
|
|
241
277
|
}
|
|
278
|
+
const tsType = this._entityFieldInfo.TSType;
|
|
242
279
|
// Special handling for bit/Boolean types - treat truthy values as equivalent
|
|
243
|
-
if (
|
|
244
|
-
this._entityFieldInfo.Type.toLowerCase() === 'bit') {
|
|
280
|
+
if (tsType === EntityFieldTSType.Boolean) {
|
|
245
281
|
// Convert both values to boolean for comparison
|
|
246
282
|
const oldBool = this.convertToBoolean(oldCompare);
|
|
247
283
|
const newBool = this.convertToBoolean(newCompare);
|
|
248
284
|
return oldBool !== newBool;
|
|
249
285
|
}
|
|
250
286
|
// Special handling for numeric types - treat numeric strings that convert to same value as equivalent
|
|
251
|
-
if (
|
|
287
|
+
if (tsType === EntityFieldTSType.Number) {
|
|
252
288
|
const oldNum = this.convertToNumber(oldCompare);
|
|
253
289
|
const newNum = this.convertToNumber(newCompare);
|
|
254
290
|
// Handle NaN cases - if both are NaN, they're equivalent
|
|
@@ -262,7 +298,7 @@ let EntityField = class EntityField {
|
|
|
262
298
|
return oldNum !== newNum;
|
|
263
299
|
}
|
|
264
300
|
// for string types where the comparisons are not both strings
|
|
265
|
-
if (
|
|
301
|
+
if (tsType === EntityFieldTSType.String) {
|
|
266
302
|
if (typeof oldCompare === 'object') {
|
|
267
303
|
// need to convert the object to a string for comparison
|
|
268
304
|
oldCompare = JSON.stringify(oldCompare);
|
|
@@ -370,7 +406,7 @@ let EntityField = class EntityField {
|
|
|
370
406
|
}
|
|
371
407
|
else {
|
|
372
408
|
// we do have a default value, but our current value is null. If we are in an EXISTING record, this is an error, check the OldValue to determine this
|
|
373
|
-
if (this._oldValue !== null && this._oldValue !== undefined) {
|
|
409
|
+
if ((this._oldValue !== null && this._oldValue !== undefined) || this._writtenAfterNotLoaded) {
|
|
374
410
|
result.Success = false;
|
|
375
411
|
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be null`, null));
|
|
376
412
|
}
|
|
@@ -465,9 +501,16 @@ let EntityField = class EntityField {
|
|
|
465
501
|
result.Errors.push(new ValidationErrorInfo(ef.Name, `${ef.DisplayNameOrName} cannot be longer than ${ef.MaxByteLength} bytes. Current value is ${byteLength} bytes`, this.Value));
|
|
466
502
|
}
|
|
467
503
|
}
|
|
468
|
-
constructor(fieldInfo, Value) {
|
|
504
|
+
constructor(fieldInfo, Value, skipDefaultValue = false) {
|
|
469
505
|
this._neverSet = true;
|
|
470
506
|
this._notLoaded = false;
|
|
507
|
+
/**
|
|
508
|
+
* True once the field is explicitly set after being not-loaded: a write onto a value the record
|
|
509
|
+
* never loaded. The field is then dirty whatever it was set to — even null, which equals its
|
|
510
|
+
* cleared old value — so the write reaches the save, and Validate treats it as an existing
|
|
511
|
+
* record's value. Cleared when the old value is reset (load, save) or the field is not-loaded again.
|
|
512
|
+
*/
|
|
513
|
+
this._writtenAfterNotLoaded = false;
|
|
471
514
|
this._createSuppressed = false;
|
|
472
515
|
// NOTE: constructing an EntityField for a deprecated/disabled field is always allowed — the
|
|
473
516
|
// column is physically part of the entity and the instance must exist. Active-status is
|
|
@@ -477,102 +520,51 @@ let EntityField = class EntityField {
|
|
|
477
520
|
if (Value) {
|
|
478
521
|
this.Value = Value;
|
|
479
522
|
}
|
|
480
|
-
else if (fieldInfo.DefaultValue) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
this.Value = false;
|
|
487
|
-
}
|
|
488
|
-
else if (fieldInfo.TSType === EntityFieldTSType.Number) {
|
|
489
|
-
// special handling for numbers as we don't want a string passed into a value for a numeric field
|
|
490
|
-
if (!isNaN(Number(fieldInfo.DefaultValue))) {
|
|
491
|
-
this.Value = Number(fieldInfo.DefaultValue);
|
|
492
|
-
}
|
|
493
|
-
else if (fieldInfo.DefaultValue.trim().toLowerCase() === "null") {
|
|
494
|
-
this.Value = null;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
else if (fieldInfo.IsUniqueIdentifier) {
|
|
498
|
-
// special handling for GUIDs (SQL Server `uniqueidentifier` / PostgreSQL `uuid`),
|
|
499
|
-
// we don't want to populate anything here because the server always sets the value, leave blank
|
|
500
|
-
this.Value = null;
|
|
501
|
-
}
|
|
502
|
-
else if (fieldInfo.TSType === EntityFieldTSType.Date) {
|
|
503
|
-
if (fieldInfo.DefaultValue.trim().length > 0) {
|
|
504
|
-
// special handling for dates as we don't want to use getdate() type defaults as is, we want to convert them to a JS Date object
|
|
505
|
-
try {
|
|
506
|
-
// attempt to convert the default value to a date
|
|
507
|
-
const temp = new Date(fieldInfo.DefaultValue);
|
|
508
|
-
// if we get here, that means it worked, but we could have an invalid date, so check for that
|
|
509
|
-
if (isNaN(temp.getTime())) {
|
|
510
|
-
// this is an invalid date, so throw an error
|
|
511
|
-
throw new Error(); // blank error because we just end up going to the catch block anyway and in there we have logic to handle this properly
|
|
512
|
-
}
|
|
513
|
-
else
|
|
514
|
-
this.Value = temp;
|
|
515
|
-
}
|
|
516
|
-
catch (e) {
|
|
517
|
-
// if we get here, that means the default value is not a valid date, so we need to check to see if it's a SQL current date function
|
|
518
|
-
if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(fieldInfo.DefaultValue)) {
|
|
519
|
-
// we have a SQL current date function default, leave the field alone if its a special date field as the server (i.e. database) will handle
|
|
520
|
-
//setting the value, otherwise set the value to the current date
|
|
521
|
-
if (fieldInfo.IsSpecialDateField) {
|
|
522
|
-
this.Value = null;
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
this.Value = new Date();
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
else {
|
|
529
|
-
// we have a default value that is not a valid date and not a getdate() type default, so we need to throw an error
|
|
530
|
-
throw new Error(`Invalid default value for ${fieldInfo.Entity}.${fieldInfo.Name} of ${fieldInfo.DefaultValue}. Default values for date fields must be a valid date or a getdate() type default`);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
523
|
+
else if (!skipDefaultValue && fieldInfo.DefaultValue) {
|
|
524
|
+
const parsed = typeof fieldInfo.GetParsedDefaultValue === 'function'
|
|
525
|
+
? fieldInfo.GetParsedDefaultValue()
|
|
526
|
+
: EntityFieldInfo.ParseDefaultValue(fieldInfo);
|
|
527
|
+
if (parsed.isCurrentDateFunction) {
|
|
528
|
+
this.Value = new Date();
|
|
534
529
|
}
|
|
535
|
-
else {
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
// PG's pg_get_expr() (used by vwSQLColumnsAndEntityFields →
|
|
539
|
-
// EntityField.DefaultValue) renders a string default like:
|
|
540
|
-
// 'Single'::character varying
|
|
541
|
-
// 'pending'::text
|
|
542
|
-
// SQL Server stores the same default as just `'Single'` or
|
|
543
|
-
// `'pending'`. If we set the field value to the raw PG form,
|
|
544
|
-
// the value is `'Single'::character varying` (27 chars), and
|
|
545
|
-
// a MaxLength=20 constraint immediately fails validation —
|
|
546
|
-
// even though the actual content is `Single` (6 chars).
|
|
547
|
-
//
|
|
548
|
-
// Unwrap a leading single-quoted string followed by a `::type`
|
|
549
|
-
// suffix. Function-call defaults (`nextval('...')`,
|
|
550
|
-
// `now() AT TIME ZONE 'UTC'`) deliberately don't match this
|
|
551
|
-
// shape and are left untouched — the database evaluates them
|
|
552
|
-
// server-side at INSERT time.
|
|
553
|
-
const dv = fieldInfo.DefaultValue.trim();
|
|
554
|
-
const pgTypedLiteral = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/;
|
|
555
|
-
const m = dv.match(pgTypedLiteral);
|
|
556
|
-
if (m) {
|
|
557
|
-
// Replace the SQL-escaped doubled quotes back to a single quote.
|
|
558
|
-
this.Value = m[1].replace(/''/g, "'");
|
|
559
|
-
}
|
|
560
|
-
else {
|
|
561
|
-
this.Value = fieldInfo.DefaultValue;
|
|
562
|
-
}
|
|
530
|
+
else if (parsed.hasValue) {
|
|
531
|
+
this.Value = parsed.value instanceof Date ? new Date(parsed.value.getTime()) : parsed.value;
|
|
563
532
|
}
|
|
564
533
|
this._neverSet = true; // set this back to true because we are setting the default value and we want to be able to set this ONCE from BaseEntity when we load
|
|
565
534
|
}
|
|
566
535
|
else {
|
|
567
|
-
this.
|
|
536
|
+
this._value = null; // we need to set the value to null instead of being undefined as the value is defined, it is NULL
|
|
537
|
+
this._oldValue = null;
|
|
568
538
|
this._neverSet = true; // set this back to true because we are setting the value to null;
|
|
569
539
|
}
|
|
570
540
|
}
|
|
571
541
|
/**
|
|
572
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.
|
|
573
548
|
*/
|
|
574
549
|
ResetOldValue() {
|
|
575
550
|
this._oldValue = this.Value;
|
|
551
|
+
this._writtenAfterNotLoaded = false;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Framework-internal, for {@link BaseEntity.Revert}: puts the field back to its last loaded or
|
|
555
|
+
* saved state. A not-loaded field has nothing to revert and stays not-loaded; a field written
|
|
556
|
+
* after being not-loaded goes back to not-loaded, so the revert neither turns an unloaded field
|
|
557
|
+
* into "loaded" data a later update would write, nor leaves the write's dirty flag behind.
|
|
558
|
+
*/
|
|
559
|
+
RevertToOldValue() {
|
|
560
|
+
if (this._notLoaded) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (this._writtenAfterNotLoaded) {
|
|
564
|
+
this.ClearAsNotLoaded();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
this.Value = this.OldValue;
|
|
576
568
|
}
|
|
577
569
|
/**
|
|
578
570
|
* Restores the dirty-tracking baseline to a previously captured value.
|
|
@@ -585,7 +577,13 @@ let EntityField = class EntityField {
|
|
|
585
577
|
* back can put them back with {@link RestoreState}.
|
|
586
578
|
*/
|
|
587
579
|
GetState() {
|
|
588
|
-
return {
|
|
580
|
+
return {
|
|
581
|
+
Value: this._value,
|
|
582
|
+
OldValue: this._oldValue,
|
|
583
|
+
NotLoaded: this._notLoaded,
|
|
584
|
+
NeverSet: this._neverSet,
|
|
585
|
+
WrittenAfterNotLoaded: this._writtenAfterNotLoaded,
|
|
586
|
+
};
|
|
589
587
|
}
|
|
590
588
|
/**
|
|
591
589
|
* Framework-internal: puts back a state {@link GetState} captured. It writes the state as it was,
|
|
@@ -596,6 +594,7 @@ let EntityField = class EntityField {
|
|
|
596
594
|
this._oldValue = state.OldValue;
|
|
597
595
|
this._notLoaded = state.NotLoaded;
|
|
598
596
|
this._neverSet = state.NeverSet;
|
|
597
|
+
this._writtenAfterNotLoaded = state.WrittenAfterNotLoaded ?? false;
|
|
599
598
|
}
|
|
600
599
|
/**
|
|
601
600
|
* @deprecated No-op as of the active-status relocation. Active-status (deprecated/disabled)
|
|
@@ -620,7 +619,7 @@ let EntityField = class EntityField {
|
|
|
620
619
|
};
|
|
621
620
|
EntityField = EntityField_1 = __decorate([
|
|
622
621
|
OptionalKeyedSpecialization(),
|
|
623
|
-
__metadata("design:paramtypes", [EntityFieldInfo, Object])
|
|
622
|
+
__metadata("design:paramtypes", [EntityFieldInfo, Object, Boolean])
|
|
624
623
|
], EntityField);
|
|
625
624
|
export { EntityField };
|
|
626
625
|
/**
|
|
@@ -905,6 +904,12 @@ export class BaseEntity {
|
|
|
905
904
|
* commits atomically.
|
|
906
905
|
*/
|
|
907
906
|
this._transactionGroup = null;
|
|
907
|
+
/**
|
|
908
|
+
* RxJS `Subject` that fires `BaseEntityEvent`s (save, delete, field-change,
|
|
909
|
+
* remote-invalidate). Exposed publicly via `Event$` so engines and UI components can react
|
|
910
|
+
* to entity lifecycle events.
|
|
911
|
+
*/
|
|
912
|
+
this._eventSubject = null;
|
|
908
913
|
/**
|
|
909
914
|
* Bounded ring of `BaseEntityResult` objects from each Save/Delete attempt. The most
|
|
910
915
|
* recent entry is exposed via `LatestResult` for error inspection after a failure.
|
|
@@ -1112,7 +1117,7 @@ export class BaseEntity {
|
|
|
1112
1117
|
* however it is possible for the string in the map to be any unique key relative to the object so you could have vectors
|
|
1113
1118
|
* that embed multiple fields if desired.
|
|
1114
1119
|
*/
|
|
1115
|
-
this._vectors =
|
|
1120
|
+
this._vectors = null;
|
|
1116
1121
|
/**
|
|
1117
1122
|
* When true, GenerateEmbedding() returns immediately without computing vectors.
|
|
1118
1123
|
* Set this before calling Save() in batch/sync contexts where embedding computation
|
|
@@ -1120,7 +1125,6 @@ export class BaseEntity {
|
|
|
1120
1125
|
* per-process is expensive and vectors can be computed later by the API server).
|
|
1121
1126
|
*/
|
|
1122
1127
|
this._skipEmbeddings = false;
|
|
1123
|
-
this._eventSubject = new Subject();
|
|
1124
1128
|
this._entityInfo = Entity;
|
|
1125
1129
|
EntityInfo.AssertEntityActiveStatus(Entity, 'BaseEntity::constructor');
|
|
1126
1130
|
this._provider = Provider;
|
|
@@ -1662,7 +1666,39 @@ export class BaseEntity {
|
|
|
1662
1666
|
static errorText(e) {
|
|
1663
1667
|
return e instanceof Error ? e.message : String(e);
|
|
1664
1668
|
}
|
|
1665
|
-
|
|
1669
|
+
/**
|
|
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.
|
|
1676
|
+
*/
|
|
1677
|
+
static { this._subtypeLookupCache = new WeakMap(); }
|
|
1678
|
+
/** Partition key for lookups made by an entity that has no provider at all. */
|
|
1679
|
+
static { this._noProviderSubtypeKey = {}; }
|
|
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
|
+
}
|
|
1689
|
+
const partitionKey = provider ?? BaseEntity._noProviderSubtypeKey;
|
|
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);
|
|
1696
|
+
if (!cache) {
|
|
1697
|
+
cache = new Map();
|
|
1698
|
+
byUser.set(userKey, cache);
|
|
1699
|
+
}
|
|
1700
|
+
return cache;
|
|
1701
|
+
}
|
|
1666
1702
|
/** Per entity (lowercased name): the resolver asked for load hints, decided once per registration. */
|
|
1667
1703
|
static { this._subtypeLoadHintResolvers = new Map(); }
|
|
1668
1704
|
/** Indexes over engine-cached arrays, keyed by the live array itself; see {@link findEngineCachedRow}. */
|
|
@@ -1674,7 +1710,7 @@ export class BaseEntity {
|
|
|
1674
1710
|
* reported again.
|
|
1675
1711
|
*/
|
|
1676
1712
|
static ClearSubtypeLookupCache() {
|
|
1677
|
-
BaseEntity._subtypeLookupCache
|
|
1713
|
+
BaseEntity._subtypeLookupCache = new WeakMap();
|
|
1678
1714
|
BaseEntity._subtypeLoadHintResolvers.clear();
|
|
1679
1715
|
BaseEntity._engineCachedRowIndexes = new WeakMap();
|
|
1680
1716
|
BaseEntity._loggedSubtypeLoadHintMessages.clear();
|
|
@@ -1863,12 +1899,16 @@ export class BaseEntity {
|
|
|
1863
1899
|
return null;
|
|
1864
1900
|
let currentEntity = this;
|
|
1865
1901
|
let currentEntityInfo = this.EntityInfo;
|
|
1902
|
+
const actingUser = this.resolveActiveUserOrNull();
|
|
1866
1903
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
1867
1904
|
const fieldName = segments[i];
|
|
1868
1905
|
const fieldInfo = currentEntityInfo.Fields.find(f => f.Name.trim().toLowerCase() === fieldName.toLowerCase());
|
|
1869
1906
|
if (!fieldInfo) {
|
|
1870
1907
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': field '${fieldName}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1871
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
|
+
}
|
|
1872
1912
|
const fkValue = currentEntity.Get(fieldInfo.Name);
|
|
1873
1913
|
if (fkValue == null || fkValue === '') {
|
|
1874
1914
|
return null;
|
|
@@ -1889,33 +1929,54 @@ export class BaseEntity {
|
|
|
1889
1929
|
if (!terminalField) {
|
|
1890
1930
|
throw new Error(`Invalid SubtypeSelector path '${path}' on '${this.EntityInfo.Name}': terminal field '${terminalSegment}' was not found on entity '${currentEntityInfo.Name}'.`);
|
|
1891
1931
|
}
|
|
1932
|
+
if (!this.subtypeSelectorMayRead(currentEntity, terminalField.Name, actingUser)) {
|
|
1933
|
+
return null;
|
|
1934
|
+
}
|
|
1892
1935
|
const terminalValue = currentEntity.Get(terminalField.Name);
|
|
1893
1936
|
if (terminalValue == null || typeof terminalValue !== 'string' || terminalValue.trim() === '') {
|
|
1894
1937
|
return null;
|
|
1895
1938
|
}
|
|
1896
1939
|
return terminalValue.trim();
|
|
1897
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
|
+
}
|
|
1898
1953
|
async getSubtypePathTargetEntity(entityName, pkValue) {
|
|
1899
1954
|
const cacheKey = `${entityName.trim().toLowerCase()}|${String(pkValue).trim().toLowerCase()}`;
|
|
1900
|
-
|
|
1901
|
-
|
|
1955
|
+
const provider = this.ProviderToUse;
|
|
1956
|
+
const lookupCache = BaseEntity.subtypeLookupCacheFor(provider, this.resolveActiveUserOrNull());
|
|
1957
|
+
if (lookupCache?.has(cacheKey)) {
|
|
1958
|
+
return lookupCache.get(cacheKey) ?? null;
|
|
1902
1959
|
}
|
|
1903
|
-
|
|
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) {
|
|
1904
1970
|
const cached = BaseEntity.findSubtypePathTargetInEngineCache(entityName, pkValue);
|
|
1905
1971
|
if (cached) {
|
|
1906
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, cached);
|
|
1907
1972
|
return cached;
|
|
1908
1973
|
}
|
|
1909
|
-
// 2. Fall back to loading via provider
|
|
1910
|
-
const provider = this.ProviderToUse;
|
|
1911
1974
|
if (!provider?.GetEntityObject) {
|
|
1912
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1913
1975
|
return null;
|
|
1914
1976
|
}
|
|
1915
1977
|
try {
|
|
1916
1978
|
const targetObj = await provider.GetEntityObject(entityName, this._contextCurrentUser);
|
|
1917
1979
|
if (!targetObj) {
|
|
1918
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1919
1980
|
return null;
|
|
1920
1981
|
}
|
|
1921
1982
|
let key;
|
|
@@ -1927,17 +1988,11 @@ export class BaseEntity {
|
|
|
1927
1988
|
const pkName = targetObj.FirstPrimaryKey.Name; // first-pk-ok: FK target — pkValue is one SubtypeSelector FK column's value
|
|
1928
1989
|
key.KeyValuePairs.push(new KeyValuePair(pkName, pkValue));
|
|
1929
1990
|
}
|
|
1930
|
-
|
|
1931
|
-
if (loaded) {
|
|
1932
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, targetObj);
|
|
1933
|
-
return targetObj;
|
|
1934
|
-
}
|
|
1991
|
+
return (await targetObj.InnerLoad(key)) ? targetObj : null;
|
|
1935
1992
|
}
|
|
1936
1993
|
catch {
|
|
1937
|
-
//
|
|
1994
|
+
return null; // on load failure, no target
|
|
1938
1995
|
}
|
|
1939
|
-
BaseEntity._subtypeLookupCache.set(cacheKey, null);
|
|
1940
|
-
return null;
|
|
1941
1996
|
}
|
|
1942
1997
|
/**
|
|
1943
1998
|
* Finds the record a `SubtypeSelector` hop points at among the entity objects that loaded
|
|
@@ -2142,6 +2197,8 @@ export class BaseEntity {
|
|
|
2142
2197
|
// _everSaved, and recreates all EntityField instances with _NeverSet = true
|
|
2143
2198
|
this.init();
|
|
2144
2199
|
this.discardJSONFieldObjects();
|
|
2200
|
+
// Field security: denied values never enter this object (see narrowHydrationSource).
|
|
2201
|
+
data = this.narrowHydrationSource(data);
|
|
2145
2202
|
// Recursively hydrate parent entities first (deepest ancestor resets first).
|
|
2146
2203
|
// After init(), parent fields have fresh _NeverSet=true, so SetMany can set PKs.
|
|
2147
2204
|
if (this._parentEntity) {
|
|
@@ -2406,7 +2463,9 @@ export class BaseEntity {
|
|
|
2406
2463
|
}
|
|
2407
2464
|
const result = await operation.Execute({
|
|
2408
2465
|
EntityName: this.EntityInfo.Name,
|
|
2409
|
-
|
|
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()),
|
|
2410
2469
|
Companions: await this.SerializeCompanions(),
|
|
2411
2470
|
IsExistingRecord: this.IsSaved,
|
|
2412
2471
|
}, { provider: this.ProviderToUse, user: this.ActiveUser });
|
|
@@ -2445,9 +2504,14 @@ export class BaseEntity {
|
|
|
2445
2504
|
// 'save' event both carry it; hardcoding 'update' here made every remote graph CREATE
|
|
2446
2505
|
// report (and notify subscribers) as an update.
|
|
2447
2506
|
const saveSubType = this.IsSaved ? 'update' : 'create';
|
|
2507
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
2448
2508
|
// Root: rebase field values and dirty state onto what the database now holds.
|
|
2449
2509
|
this.init();
|
|
2450
|
-
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);
|
|
2451
2515
|
this._recordLoaded = true;
|
|
2452
2516
|
this._everSaved = true;
|
|
2453
2517
|
// Children: adopt server-assigned primary keys and computed values. 'result' mode adopts the
|
|
@@ -2461,7 +2525,28 @@ export class BaseEntity {
|
|
|
2461
2525
|
result.StartedAt = new Date();
|
|
2462
2526
|
result.EndedAt = new Date();
|
|
2463
2527
|
this.RegisterResultHistoryEntry(result);
|
|
2464
|
-
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();
|
|
2465
2550
|
}
|
|
2466
2551
|
/**
|
|
2467
2552
|
* Gives every eager companion a chance to populate itself.
|
|
@@ -3039,6 +3124,9 @@ export class BaseEntity {
|
|
|
3039
3124
|
* @returns
|
|
3040
3125
|
*/
|
|
3041
3126
|
RegisterEventHandler(callback) {
|
|
3127
|
+
if (!this._eventSubject) {
|
|
3128
|
+
this._eventSubject = new Subject();
|
|
3129
|
+
}
|
|
3042
3130
|
return this._eventSubject.asObservable().subscribe(callback);
|
|
3043
3131
|
}
|
|
3044
3132
|
/**
|
|
@@ -3060,6 +3148,9 @@ export class BaseEntity {
|
|
|
3060
3148
|
this.RaiseEvent('transaction_ready', null);
|
|
3061
3149
|
}
|
|
3062
3150
|
static { this._baseEventCode = 'BaseEntityEvent'; }
|
|
3151
|
+
static { this._globalEventTypes = new Set([
|
|
3152
|
+
'save', 'delete', 'load_complete', 'save_started', 'delete_started', 'load_started'
|
|
3153
|
+
]); }
|
|
3063
3154
|
/**
|
|
3064
3155
|
* When a BaseEntity class raises an event with MJGlobal, the eventCode property is set to this value. This is used to identify events that are raised by BaseEntity objects.
|
|
3065
3156
|
* Any MJGlobal event that is raised by a BaseEntity class will use a BaseEntityEvent type as the args parameter
|
|
@@ -3070,7 +3161,7 @@ export class BaseEntity {
|
|
|
3070
3161
|
/**
|
|
3071
3162
|
* Used for raising events within the BaseEntity and can be used by sub-classes to raise events that are specific to the entity.
|
|
3072
3163
|
*/
|
|
3073
|
-
RaiseEvent(type, payload, saveSubType = undefined) {
|
|
3164
|
+
RaiseEvent(type, payload, saveSubType = undefined, changedFieldNames) {
|
|
3074
3165
|
// Resolve the entity's bound provider once so every consumer (local subscribers
|
|
3075
3166
|
// AND MJGlobal listeners) receives consistent provider context. This matters in
|
|
3076
3167
|
// multi-provider scenarios — listeners like LocalCacheManager / BaseEngine need to
|
|
@@ -3081,17 +3172,17 @@ export class BaseEntity {
|
|
|
3081
3172
|
const provider = this.ProviderToUse;
|
|
3082
3173
|
// this is the local event handler that is specific to THIS instance of the entity object
|
|
3083
3174
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call this._eventSubject.next()`);
|
|
3084
|
-
this._eventSubject
|
|
3175
|
+
this._eventSubject?.next({ type: type, payload: payload, saveSubType: saveSubType, baseEntity: this, provider, ChangedFieldNames: changedFieldNames });
|
|
3085
3176
|
// this next call is to MJGlobal to let everyone who cares knows that we had an event on an entity object
|
|
3086
3177
|
// we broadcast save/delete/load events and their _started counterparts
|
|
3087
|
-
|
|
3088
|
-
if (globalEventTypes.includes(type)) {
|
|
3178
|
+
if (BaseEntity._globalEventTypes.has(type)) {
|
|
3089
3179
|
const event = new BaseEntityEvent();
|
|
3090
3180
|
event.baseEntity = this;
|
|
3091
3181
|
event.payload = payload;
|
|
3092
3182
|
event.type = type;
|
|
3093
3183
|
event.saveSubType = saveSubType;
|
|
3094
3184
|
event.provider = provider;
|
|
3185
|
+
event.ChangedFieldNames = changedFieldNames;
|
|
3095
3186
|
LogDebug(`BaseEntity.RaiseEvent() - ${type === 'save' ? 'save:' + saveSubType : type} event raised for ${this.EntityInfo.Name}, about to call MJGlobal.RaiseEvent()`);
|
|
3096
3187
|
MJGlobal.Instance.RaiseEvent({
|
|
3097
3188
|
component: this,
|
|
@@ -3232,6 +3323,7 @@ export class BaseEntity {
|
|
|
3232
3323
|
get TransactionGroup() {
|
|
3233
3324
|
return this._transactionGroup;
|
|
3234
3325
|
}
|
|
3326
|
+
/** Set `null` to detach the entity from a group, e.g. once the group has been submitted. */
|
|
3235
3327
|
set TransactionGroup(group) {
|
|
3236
3328
|
this._transactionGroup = group;
|
|
3237
3329
|
}
|
|
@@ -3272,6 +3364,10 @@ export class BaseEntity {
|
|
|
3272
3364
|
}
|
|
3273
3365
|
// Lookup needs an EntityField instance to return — hydrate.
|
|
3274
3366
|
this.hydrateFieldsIfNeeded();
|
|
3367
|
+
if (this._entityInfo && typeof this._entityInfo.FieldIndexByName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._fieldCache === null) {
|
|
3368
|
+
const idx = this._entityInfo.FieldIndexByName(fieldName);
|
|
3369
|
+
return idx >= 0 ? (this._fields[idx] ?? null) : null;
|
|
3370
|
+
}
|
|
3275
3371
|
const lcase = fieldName.trim().toLowerCase(); // do this once as we will use it multiple times
|
|
3276
3372
|
if (this._fieldCache === null) {
|
|
3277
3373
|
this._fieldCache = new Map();
|
|
@@ -3315,6 +3411,9 @@ export class BaseEntity {
|
|
|
3315
3411
|
* ```
|
|
3316
3412
|
*/
|
|
3317
3413
|
FieldIsDirty(fieldName, ...more) {
|
|
3414
|
+
if (!this._fieldsHydrated && this._raw !== null && !this._parentEntity) {
|
|
3415
|
+
return false;
|
|
3416
|
+
}
|
|
3318
3417
|
const names = more.length === 0 ? [fieldName] : [fieldName, ...more];
|
|
3319
3418
|
for (const name of names) {
|
|
3320
3419
|
if (this.GetFieldByName(name)?.Dirty === true) {
|
|
@@ -3334,6 +3433,10 @@ export class BaseEntity {
|
|
|
3334
3433
|
}
|
|
3335
3434
|
// Lookup needs an EntityField instance to return — hydrate.
|
|
3336
3435
|
this.hydrateFieldsIfNeeded();
|
|
3436
|
+
if (this._entityInfo && typeof this._entityInfo.FieldIndexByCodeName === 'function' && this._fields.length === this._entityInfo.Fields.length && this._codeNameCache === null) {
|
|
3437
|
+
const idx = this._entityInfo.FieldIndexByCodeName(codeName);
|
|
3438
|
+
return idx >= 0 ? (this._fields[idx] ?? null) : null;
|
|
3439
|
+
}
|
|
3337
3440
|
const lcase = codeName.trim().toLowerCase();
|
|
3338
3441
|
if (this._codeNameCache === null) {
|
|
3339
3442
|
this._codeNameCache = new Map();
|
|
@@ -3472,11 +3575,14 @@ export class BaseEntity {
|
|
|
3472
3575
|
* nothing like field security.
|
|
3473
3576
|
*
|
|
3474
3577
|
* Framework-internal value machinery (`Dirty`, `Validate`, `GetAll`, hydration, save-SQL
|
|
3475
|
-
* build) reads `EntityField.Value` directly and never routes through here
|
|
3476
|
-
*
|
|
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.
|
|
3477
3583
|
*/
|
|
3478
3584
|
assertFieldReadable(fieldName) {
|
|
3479
|
-
const denied = this.
|
|
3585
|
+
const denied = this.deniedReadFieldsForActiveUser();
|
|
3480
3586
|
if (denied?.has(fieldName?.trim().toLowerCase())) {
|
|
3481
3587
|
throw new FieldSecurityError(fieldName, this.EntityInfo.Name);
|
|
3482
3588
|
}
|
|
@@ -3488,41 +3594,47 @@ export class BaseEntity {
|
|
|
3488
3594
|
setLocal(FieldName, Value) {
|
|
3489
3595
|
const field = this.GetFieldByName(FieldName);
|
|
3490
3596
|
if (field != null) {
|
|
3491
|
-
|
|
3492
|
-
|
|
3597
|
+
this.setFieldValue(field, Value);
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
setFieldValue(field, Value) {
|
|
3601
|
+
const tsType = field.EntityFieldInfo.TSType;
|
|
3602
|
+
if (tsType === EntityFieldTSType.Date && (typeof Value === 'string' || typeof Value === 'number')) {
|
|
3603
|
+
field.Value = new Date(Value);
|
|
3604
|
+
}
|
|
3605
|
+
else if (tsType === EntityFieldTSType.Number && typeof Value === 'string' && Value !== null && Value !== undefined) {
|
|
3606
|
+
const numericValue = Number(Value);
|
|
3607
|
+
if (!isNaN(numericValue)) {
|
|
3608
|
+
field.Value = numericValue;
|
|
3493
3609
|
}
|
|
3494
|
-
else
|
|
3495
|
-
|
|
3496
|
-
if (!isNaN(numericValue)) {
|
|
3497
|
-
field.Value = numericValue;
|
|
3498
|
-
}
|
|
3499
|
-
else {
|
|
3500
|
-
field.Value = Value;
|
|
3501
|
-
}
|
|
3610
|
+
else {
|
|
3611
|
+
field.Value = Value;
|
|
3502
3612
|
}
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
}
|
|
3511
|
-
else {
|
|
3512
|
-
field.Value = Value;
|
|
3513
|
-
}
|
|
3613
|
+
}
|
|
3614
|
+
else if (tsType === EntityFieldTSType.Boolean && Value !== null && Value !== undefined) {
|
|
3615
|
+
if (typeof Value === 'string') {
|
|
3616
|
+
const trimmed = Value.trim();
|
|
3617
|
+
const lower = trimmed.toLowerCase();
|
|
3618
|
+
if (trimmed === '1' || lower === 'true') {
|
|
3619
|
+
field.Value = true;
|
|
3514
3620
|
}
|
|
3515
|
-
else if (
|
|
3516
|
-
field.Value =
|
|
3621
|
+
else if (trimmed === '0' || lower === 'false') {
|
|
3622
|
+
field.Value = false;
|
|
3517
3623
|
}
|
|
3518
3624
|
else {
|
|
3519
3625
|
field.Value = Value;
|
|
3520
3626
|
}
|
|
3521
3627
|
}
|
|
3628
|
+
else if (typeof Value === 'number') {
|
|
3629
|
+
field.Value = Value !== 0;
|
|
3630
|
+
}
|
|
3522
3631
|
else {
|
|
3523
3632
|
field.Value = Value;
|
|
3524
3633
|
}
|
|
3525
3634
|
}
|
|
3635
|
+
else {
|
|
3636
|
+
field.Value = Value;
|
|
3637
|
+
}
|
|
3526
3638
|
}
|
|
3527
3639
|
/**
|
|
3528
3640
|
* Returns the value of the field with the given name. If the field is a date, and the value is a string, it will be converted to a date object.
|
|
@@ -3533,6 +3645,15 @@ export class BaseEntity {
|
|
|
3533
3645
|
* @param FieldName
|
|
3534
3646
|
* @returns
|
|
3535
3647
|
*/
|
|
3648
|
+
/**
|
|
3649
|
+
* A raw-mode row's value for a field: under its Name, or else its CodeName. Hydration accepts
|
|
3650
|
+
* both keys (as SetMany does), so every raw-mode read must too, or a CodeName-keyed row reads
|
|
3651
|
+
* back empty. `undefined` means the row omitted the field.
|
|
3652
|
+
*/
|
|
3653
|
+
rawValueFor(raw, field) {
|
|
3654
|
+
const byName = raw[field.Name];
|
|
3655
|
+
return byName !== undefined || field.CodeName === field.Name ? byName : raw[field.CodeName];
|
|
3656
|
+
}
|
|
3536
3657
|
/** Records a value derived from `_raw` so later reads skip the conversion. See {@link _rawConverted}. */
|
|
3537
3658
|
memoizeRawConversion(fieldName, value) {
|
|
3538
3659
|
(this._rawConverted ??= new Map()).set(fieldName, value);
|
|
@@ -3559,28 +3680,31 @@ export class BaseEntity {
|
|
|
3559
3680
|
// hydration. Cache data uses exact SQL column names so no case-insensitive scan needed.
|
|
3560
3681
|
if (!this._fieldsHydrated && this._raw) {
|
|
3561
3682
|
let value = this._raw[FieldName];
|
|
3562
|
-
if (value === undefined)
|
|
3563
|
-
return null;
|
|
3564
3683
|
// Conversions mirror the hydrated path, and memoize into `_rawConverted` rather than
|
|
3565
3684
|
// back into `_raw` — the row may be shared, frozen cache state. Fields needing no
|
|
3566
3685
|
// conversion (the vast majority) never touch the memo at all, so the fast path stays
|
|
3567
3686
|
// a single property read.
|
|
3568
3687
|
const fi = this._entityInfo?.FieldByName(FieldName);
|
|
3688
|
+
if (value === undefined && fi)
|
|
3689
|
+
value = this.rawValueFor(this._raw, fi);
|
|
3690
|
+
if (value === undefined)
|
|
3691
|
+
return null;
|
|
3692
|
+
const memoKey = fi ? fi.Name : FieldName;
|
|
3569
3693
|
if (fi?.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
|
|
3570
|
-
const memo = this._rawConverted?.get(
|
|
3694
|
+
const memo = this._rawConverted?.get(memoKey);
|
|
3571
3695
|
if (memo !== undefined)
|
|
3572
3696
|
return memo;
|
|
3573
3697
|
const d = new Date(value);
|
|
3574
|
-
this.memoizeRawConversion(
|
|
3698
|
+
this.memoizeRawConversion(memoKey, d);
|
|
3575
3699
|
return d;
|
|
3576
3700
|
}
|
|
3577
3701
|
// Mirror the EntityField.Value setter: rtrim padding for fixed-width string columns.
|
|
3578
3702
|
if (typeof value === 'string' && fi?.FixedWidthColumn) {
|
|
3579
|
-
const memo = this._rawConverted?.get(
|
|
3703
|
+
const memo = this._rawConverted?.get(memoKey);
|
|
3580
3704
|
if (memo !== undefined)
|
|
3581
3705
|
return memo;
|
|
3582
3706
|
value = trimTrailingSpaces(value);
|
|
3583
|
-
this.memoizeRawConversion(
|
|
3707
|
+
this.memoizeRawConversion(memoKey, value);
|
|
3584
3708
|
}
|
|
3585
3709
|
return value;
|
|
3586
3710
|
}
|
|
@@ -3624,8 +3748,8 @@ export class BaseEntity {
|
|
|
3624
3748
|
if (checkStatus) {
|
|
3625
3749
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
3626
3750
|
}
|
|
3627
|
-
// Use
|
|
3628
|
-
this.
|
|
3751
|
+
// Use setFieldValue directly so we don't repeat GetFieldByName(key)
|
|
3752
|
+
this.setFieldValue(field, object[key]);
|
|
3629
3753
|
if (replaceOldValues) {
|
|
3630
3754
|
field.ResetOldValue();
|
|
3631
3755
|
}
|
|
@@ -3638,8 +3762,8 @@ export class BaseEntity {
|
|
|
3638
3762
|
if (checkStatus) {
|
|
3639
3763
|
EntityFieldInfo.AssertEntityFieldActiveStatus(field.EntityFieldInfo, 'BaseEntity.SetMany');
|
|
3640
3764
|
}
|
|
3641
|
-
// Use
|
|
3642
|
-
this.
|
|
3765
|
+
// Use setFieldValue directly so we don't repeat GetFieldByName(field.Name)
|
|
3766
|
+
this.setFieldValue(field, object[key]);
|
|
3643
3767
|
if (replaceOldValues) {
|
|
3644
3768
|
field.ResetOldValue();
|
|
3645
3769
|
}
|
|
@@ -3707,21 +3831,51 @@ export class BaseEntity {
|
|
|
3707
3831
|
*/
|
|
3708
3832
|
GetAll(oldValues = false, onlyDirtyFields = false) {
|
|
3709
3833
|
let obj = {};
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3834
|
+
if (!this._fieldsHydrated && this._raw !== null) {
|
|
3835
|
+
if (!onlyDirtyFields) {
|
|
3836
|
+
for (const ef of this.EntityInfo.Fields) {
|
|
3837
|
+
const name = ef.Name;
|
|
3838
|
+
const rawVal = this.rawValueFor(this._raw, ef);
|
|
3839
|
+
// Key absence in _raw means NotLoaded (D-3 decision): omit from serialized output
|
|
3840
|
+
if (rawVal === undefined)
|
|
3841
|
+
continue;
|
|
3842
|
+
let val;
|
|
3843
|
+
const memo = this._rawConverted?.get(name);
|
|
3844
|
+
if (memo !== undefined) {
|
|
3845
|
+
val = memo;
|
|
3846
|
+
}
|
|
3847
|
+
else if (ef.TSType === EntityFieldTSType.Date && (typeof rawVal === 'string' || typeof rawVal === 'number')) {
|
|
3848
|
+
val = new Date(rawVal);
|
|
3849
|
+
this.memoizeRawConversion(name, val);
|
|
3850
|
+
}
|
|
3851
|
+
else if (typeof rawVal === 'string' && ef.FixedWidthColumn) {
|
|
3852
|
+
val = trimTrailingSpaces(rawVal);
|
|
3853
|
+
this.memoizeRawConversion(name, val);
|
|
3854
|
+
}
|
|
3855
|
+
else {
|
|
3856
|
+
val = rawVal;
|
|
3857
|
+
}
|
|
3858
|
+
obj[name] = val;
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
else {
|
|
3863
|
+
for (let field of this.Fields) {
|
|
3864
|
+
// NotLoaded fields are OMITTED (D-3 decision): their in-memory state is a
|
|
3865
|
+
// construction artifact, and serializing it would launder a default/null into
|
|
3866
|
+
// something downstream code treats as data — the exact masquerade the flag exists
|
|
3867
|
+
// to prevent. Key-absence also propagates the flag naturally: hydrating another
|
|
3868
|
+
// entity from this output re-marks the same fields not-loaded.
|
|
3869
|
+
if (field.NotLoaded)
|
|
3870
|
+
continue;
|
|
3871
|
+
if (!onlyDirtyFields || (onlyDirtyFields && field.Dirty)) {
|
|
3872
|
+
// Reads field.Value directly — serialization is framework-internal, so it does not
|
|
3873
|
+
// (and must not) assert active status. No suppression toggle needed: the assertion no
|
|
3874
|
+
// longer lives on EntityField.Value (it moved to BaseEntity.Get/Set/SetMany).
|
|
3875
|
+
obj[field.Name] = oldValues ? field.OldValue : field.Value;
|
|
3876
|
+
if (field.EntityFieldInfo.TSType == EntityFieldTSType.Date && obj[field.Name] && !(obj[field.Name] instanceof Date)) {
|
|
3877
|
+
obj[field.Name] = new Date(obj[field.Name]); // a timestamp, convert to JS Date Object
|
|
3878
|
+
}
|
|
3725
3879
|
}
|
|
3726
3880
|
}
|
|
3727
3881
|
}
|
|
@@ -3744,6 +3898,53 @@ export class BaseEntity {
|
|
|
3744
3898
|
}
|
|
3745
3899
|
return obj;
|
|
3746
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
|
+
}
|
|
3747
3948
|
/**
|
|
3748
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.
|
|
3749
3950
|
* It is also helpful for quickly finding the fields that are "dirty".
|
|
@@ -3921,26 +4122,33 @@ export class BaseEntity {
|
|
|
3921
4122
|
this._fieldsHydrated = true;
|
|
3922
4123
|
if (!this.EntityInfo)
|
|
3923
4124
|
return;
|
|
3924
|
-
// 1. Allocate one EntityField per column.
|
|
3925
|
-
//
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
4125
|
+
// 1. Allocate one EntityField per column. Check once whether any custom EntityField
|
|
4126
|
+
// subclasses are registered in ClassFactory; in standard deployments none are, so we
|
|
4127
|
+
// can instantiate EntityField directly without N string concatenations + factory lookups.
|
|
4128
|
+
const hasCustomEntityFieldRegistrations = MJGlobal.Instance.ClassFactory.GetAllRegistrations(EntityField).length > 0;
|
|
4129
|
+
const entityFields = this.EntityInfo.Fields;
|
|
4130
|
+
const skipDefaultValue = this._raw !== null;
|
|
4131
|
+
if (hasCustomEntityFieldRegistrations) {
|
|
4132
|
+
const prefix = this.EntityInfo.Name + '.';
|
|
4133
|
+
for (let i = 0; i < entityFields.length; i++) {
|
|
4134
|
+
const rawField = entityFields[i];
|
|
4135
|
+
const newField = MJGlobal.Instance.ClassFactory.CreateInstance(EntityField, prefix + rawField.Name, rawField, undefined, skipDefaultValue);
|
|
4136
|
+
this._fields.push(newField);
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
4139
|
+
else {
|
|
4140
|
+
for (let i = 0; i < entityFields.length; i++) {
|
|
4141
|
+
this._fields.push(new EntityField(entityFields[i], undefined, skipDefaultValue));
|
|
4142
|
+
}
|
|
4143
|
+
}
|
|
4144
|
+
// 2. If we have raw data from LoadFromData, populate Fields and mark omitted fields as
|
|
4145
|
+
// NotLoaded in a single pass.
|
|
3935
4146
|
if (this._raw) {
|
|
3936
|
-
|
|
3937
|
-
|
|
4147
|
+
const raw = this._raw;
|
|
4148
|
+
for (let i = 0; i < this._fields.length; i++) {
|
|
4149
|
+
const field = this._fields[i];
|
|
4150
|
+
const value = this.rawValueFor(raw, field.EntityFieldInfo);
|
|
3938
4151
|
if (value !== undefined) {
|
|
3939
|
-
// Populating fields from the loaded row writes EntityField.Value directly, which does
|
|
3940
|
-
// not assert active status — so a deprecated column still present in the table emits
|
|
3941
|
-
// no warning on load (hydration is not user use of the field). No suppression toggle
|
|
3942
|
-
// is needed now that the assertion lives at BaseEntity.Get/Set/SetMany.
|
|
3943
|
-
// Date conversion mirrors what SetLocal / Get does for raw string/number dates.
|
|
3944
4152
|
if (field.EntityFieldInfo.TSType === EntityFieldTSType.Date && (typeof value === 'string' || typeof value === 'number')) {
|
|
3945
4153
|
field.Value = new Date(value);
|
|
3946
4154
|
}
|
|
@@ -3948,10 +4156,10 @@ export class BaseEntity {
|
|
|
3948
4156
|
field.Value = value;
|
|
3949
4157
|
}
|
|
3950
4158
|
}
|
|
4159
|
+
else {
|
|
4160
|
+
field.MarkNotLoaded();
|
|
4161
|
+
}
|
|
3951
4162
|
}
|
|
3952
|
-
// _raw IS a hydration source (LoadFromData fast path) — fields whose key it omitted
|
|
3953
|
-
// are not-loaded, not defaulted. See EntityField.NotLoaded.
|
|
3954
|
-
this.markFieldsOmittedBySourceAsNotLoaded(this._raw);
|
|
3955
4163
|
// Raw data has been promoted into Fields — release the reference so we don't carry
|
|
3956
4164
|
// duplicate state. Fields hold their own copies, so a frozen source no longer
|
|
3957
4165
|
// constrains anything from here on.
|
|
@@ -3964,16 +4172,42 @@ export class BaseEntity {
|
|
|
3964
4172
|
* Called only by the hydration entry points ({@link LoadFromData} both modes, {@link Hydrate},
|
|
3965
4173
|
* {@link InnerLoad}) — never by plain {@link SetMany}, which is an incremental mutation API
|
|
3966
4174
|
* where omitting a field means "leave it alone," not "this field was never loaded."
|
|
3967
|
-
* 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.
|
|
3968
4179
|
*/
|
|
3969
4180
|
markFieldsOmittedBySourceAsNotLoaded(source) {
|
|
3970
4181
|
if (!source || typeof source !== 'object')
|
|
3971
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;
|
|
3972
4186
|
for (const field of this.Fields) {
|
|
3973
|
-
if (source
|
|
3974
|
-
|
|
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())) {
|
|
4192
|
+
// Cleared, not just flagged: otherwise a default from construction, or the previous
|
|
4193
|
+
// record's value on a reload, reads back through Get() as if it had been loaded.
|
|
4194
|
+
field.ClearAsNotLoaded();
|
|
4195
|
+
}
|
|
4196
|
+
}
|
|
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());
|
|
3975
4208
|
}
|
|
3976
4209
|
}
|
|
4210
|
+
return keys;
|
|
3977
4211
|
}
|
|
3978
4212
|
/**
|
|
3979
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.
|
|
@@ -3984,8 +4218,15 @@ export class BaseEntity {
|
|
|
3984
4218
|
*/
|
|
3985
4219
|
CopyFrom(other, includePrimaryKeys = false, replaceOldValues = false) {
|
|
3986
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();
|
|
3987
4225
|
// iterate through all of OUR fields and set them to the value of the other object, if they exist in the other object
|
|
3988
4226
|
for (let field of this.Fields) {
|
|
4227
|
+
if (denied?.has(field.Name.trim().toLowerCase())) {
|
|
4228
|
+
continue;
|
|
4229
|
+
}
|
|
3989
4230
|
if (!field.IsPrimaryKey || includePrimaryKeys) {
|
|
3990
4231
|
const otherField = other.GetFieldByName(field.Name);
|
|
3991
4232
|
// Skip fields the SOURCE never loaded (D-3): copying their construction
|
|
@@ -4009,9 +4250,27 @@ export class BaseEntity {
|
|
|
4009
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,
|
|
4010
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
|
|
4011
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.
|
|
4012
4263
|
*/
|
|
4013
4264
|
set ContextCurrentUser(user) {
|
|
4265
|
+
if (user === this._contextCurrentUser) {
|
|
4266
|
+
return;
|
|
4267
|
+
}
|
|
4014
4268
|
this._contextCurrentUser = user;
|
|
4269
|
+
this.narrowHeldValuesForActiveUser(false);
|
|
4270
|
+
if (this._parentEntity) {
|
|
4271
|
+
this._parentEntity.ContextCurrentUser = user;
|
|
4272
|
+
}
|
|
4273
|
+
this.notifyCompanionsContextUserChanged(user);
|
|
4015
4274
|
}
|
|
4016
4275
|
get ContextCurrentUser() {
|
|
4017
4276
|
return this._contextCurrentUser;
|
|
@@ -4078,6 +4337,11 @@ export class BaseEntity {
|
|
|
4078
4337
|
}
|
|
4079
4338
|
}
|
|
4080
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);
|
|
4081
4345
|
// Apply caller-supplied values LAST so an explicit PK (or any routed
|
|
4082
4346
|
// parent field) wins over the generated/adopted value instead of being
|
|
4083
4347
|
// clobbered by the parent's NewRecord().
|
|
@@ -4104,6 +4368,53 @@ export class BaseEntity {
|
|
|
4104
4368
|
}
|
|
4105
4369
|
}
|
|
4106
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
|
+
}
|
|
4107
4418
|
/**
|
|
4108
4419
|
* Returns the active restore context for the next save, if any.
|
|
4109
4420
|
*
|
|
@@ -4479,7 +4790,7 @@ export class BaseEntity {
|
|
|
4479
4790
|
const data = await this.ProviderToUse.Save(this, this.ActiveUser, _options);
|
|
4480
4791
|
if (!this.TransactionGroup) {
|
|
4481
4792
|
// no transaction group, so we have our results here
|
|
4482
|
-
const result = this.finalizeSave(data, saveSubType);
|
|
4793
|
+
const result = this.finalizeSave(data, saveSubType, _options);
|
|
4483
4794
|
// Settle the scope this entity opened, if any (only the initiator holds
|
|
4484
4795
|
// one). The provider can fail by RETURNING falsy data rather than
|
|
4485
4796
|
// throwing — a validate-type entity action rejecting the save does
|
|
@@ -4500,7 +4811,7 @@ export class BaseEntity {
|
|
|
4500
4811
|
this.TransactionGroup.TransactionNotifications$.subscribe(({ success, results, error }) => {
|
|
4501
4812
|
const transItem = success && results ? results.find(r => r.Transaction.BaseEntity === this) : undefined;
|
|
4502
4813
|
if (transItem) {
|
|
4503
|
-
this.finalizeSave(transItem.Result, saveSubType); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
|
|
4814
|
+
this.finalizeSave(transItem.Result, saveSubType, _options); // we get the resulting data from the transaction result, not data above as that will be blank when in a TG
|
|
4504
4815
|
}
|
|
4505
4816
|
else {
|
|
4506
4817
|
// The transaction group failed / rolled back, OR (should never happen) reported success
|
|
@@ -4602,10 +4913,43 @@ export class BaseEntity {
|
|
|
4602
4913
|
// If no remaining children, safe to delete parent
|
|
4603
4914
|
return remainingChildren.length === 0;
|
|
4604
4915
|
}
|
|
4605
|
-
finalizeSave(data, saveSubType) {
|
|
4916
|
+
finalizeSave(data, saveSubType, options) {
|
|
4606
4917
|
if (data) {
|
|
4918
|
+
// The 'save' event names the fields this save wrote; init() below clears every dirty flag.
|
|
4919
|
+
const changedFieldNames = this.dirtyFieldNames();
|
|
4920
|
+
const effectiveReturnMode = options?.SaveReturnMode ?? this.EntityInfo.SaveReturnMode;
|
|
4921
|
+
const hasGetAll = (val) => val instanceof BaseEntity || typeof val.GetAll === 'function';
|
|
4922
|
+
const rawFieldData = hasGetAll(data) ? data.GetAll() : data;
|
|
4923
|
+
// In 'datestamps_only' mode, the database only returned PK + __mj_CreatedAt / __mj_UpdatedAt.
|
|
4924
|
+
// Merge those returned values onto the current in-memory values BEFORE init() wipes Fields,
|
|
4925
|
+
// so caller-set fields remain loaded and intact without a post-save BaseView SELECT.
|
|
4926
|
+
// When DatabaseProviderBase.Save already merged its pre-save snapshot (`__mj_preMerged`),
|
|
4927
|
+
// reuse `rawFieldData` directly to avoid a redundant second `GetAll()` call.
|
|
4928
|
+
let fieldData;
|
|
4929
|
+
if (effectiveReturnMode === 'datestamps_only' && typeof rawFieldData === 'object' && !Array.isArray(rawFieldData)) {
|
|
4930
|
+
if (rawFieldData.__mj_preMerged) {
|
|
4931
|
+
fieldData = rawFieldData;
|
|
4932
|
+
}
|
|
4933
|
+
else {
|
|
4934
|
+
const currentValues = this.GetAll();
|
|
4935
|
+
if (saveSubType === 'create' || !this.IsSaved) {
|
|
4936
|
+
for (const f of this.EntityInfo.Fields) {
|
|
4937
|
+
if (f.IsVirtual && !f.IsPrimaryKey && !(f.Name in rawFieldData) && (currentValues[f.Name] === null || currentValues[f.Name] === undefined)) {
|
|
4938
|
+
delete currentValues[f.Name];
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
fieldData = { ...currentValues, ...rawFieldData };
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
else {
|
|
4946
|
+
fieldData = rawFieldData;
|
|
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);
|
|
4607
4952
|
this.init(); // wipe out the current data to flush out the DIRTY flags, load the ID as part of this too
|
|
4608
|
-
const fieldData = (data instanceof BaseEntity || (data && typeof data.GetAll === 'function')) ? data.GetAll() : data;
|
|
4609
4953
|
// IS-A GetAll() merges the parent, including parent virtuals this entity
|
|
4610
4954
|
// does not own (e.g. OrderHeader on Event Order Line). Keep only columns
|
|
4611
4955
|
// this entity defines, and ignore anything leftover.
|
|
@@ -4624,7 +4968,7 @@ export class BaseEntity {
|
|
|
4624
4968
|
result.NewValues = this.Fields.map(f => { return { FieldName: f.CodeName, Value: f.Value }; }); // set the latest values here
|
|
4625
4969
|
// Cache the record name for faster lookups (updates cache with potentially new name)
|
|
4626
4970
|
this.cacheRecordName();
|
|
4627
|
-
this.RaiseEvent('save', null, saveSubType);
|
|
4971
|
+
this.RaiseEvent('save', null, saveSubType, changedFieldNames);
|
|
4628
4972
|
return true;
|
|
4629
4973
|
}
|
|
4630
4974
|
else {
|
|
@@ -4760,16 +5104,17 @@ export class BaseEntity {
|
|
|
4760
5104
|
* entity was hydrated FROM THE DATABASE on every FLS entity. Two distinct resolver behaviours
|
|
4761
5105
|
* carry that premise, and BOTH are load-bearing:
|
|
4762
5106
|
*
|
|
4763
|
-
* 1. `
|
|
4764
|
-
* cannot READ,
|
|
4765
|
-
*
|
|
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
|
|
4766
5111
|
* non-dirty baseline is the real stored row rather than the client's `OldValues___`.
|
|
4767
5112
|
*
|
|
4768
5113
|
* (2) is not redundant with (1). A value arriving through `LoadFromData` is recorded by the
|
|
4769
5114
|
* EntityField setter as the field's INITIAL value, so it is not dirty — and this check would
|
|
4770
5115
|
* never see it, while `GenerateSaveSQL` sends it anyway (it filters on `NotLoaded`, never on
|
|
4771
5116
|
* `Dirty`). Without the forced truth-load, a caller with Read Allow + Update Deny — the
|
|
4772
|
-
* 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
|
|
4773
5118
|
* update-denied field just by pinning its value in `OldValues___` and never naming it in the
|
|
4774
5119
|
* mutation. If you are considering relaxing that branch condition, this check is what breaks.
|
|
4775
5120
|
*
|
|
@@ -4802,7 +5147,7 @@ export class BaseEntity {
|
|
|
4802
5147
|
// deliberately skips the readability assertion (hydration / resolver-apply path),
|
|
4803
5148
|
// so server-side code can dirty a read-denied field and land here.
|
|
4804
5149
|
if (deniedRead === undefined) {
|
|
4805
|
-
deniedRead = this.
|
|
5150
|
+
deniedRead = this.deniedReadFieldsForActiveUser();
|
|
4806
5151
|
}
|
|
4807
5152
|
throw deniedRead?.has(key)
|
|
4808
5153
|
? new FieldSecurityError(field.Name, this.EntityInfo.Name)
|
|
@@ -4851,15 +5196,32 @@ export class BaseEntity {
|
|
|
4851
5196
|
`omitted field(s) ${suppressed.join(', ')}; each column takes its default`);
|
|
4852
5197
|
}
|
|
4853
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
|
+
}
|
|
4854
5216
|
/**
|
|
4855
5217
|
* The denied-field set for the acting user, or null when field security does not apply —
|
|
4856
|
-
* the entity
|
|
5218
|
+
* it is off on the entity and every IS-A ancestor, no user resolves, or the user is denied nothing.
|
|
4857
5219
|
*
|
|
4858
5220
|
* Returning null rather than an empty Set lets callers skip their loop entirely, and keeps
|
|
4859
5221
|
* the three cheap short-circuits in one place instead of repeated at each gate.
|
|
4860
5222
|
*/
|
|
4861
5223
|
deniedFieldsForActiveUser(select) {
|
|
4862
|
-
if (!this.EntityInfo?.
|
|
5224
|
+
if (!this.EntityInfo?.FieldLevelSecurityApplies) {
|
|
4863
5225
|
return null; // one boolean for the overwhelming majority of entities
|
|
4864
5226
|
}
|
|
4865
5227
|
const u = this.resolveActiveUserOrNull();
|
|
@@ -4869,6 +5231,34 @@ export class BaseEntity {
|
|
|
4869
5231
|
const denied = select(u);
|
|
4870
5232
|
return denied.size > 0 ? denied : null;
|
|
4871
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
|
+
}
|
|
4872
5262
|
/**
|
|
4873
5263
|
* {@link ActiveUser}, but null instead of throwing when no provider is configured to resolve
|
|
4874
5264
|
* one from.
|
|
@@ -4903,7 +5293,7 @@ export class BaseEntity {
|
|
|
4903
5293
|
this.discardJSONFieldObjects(); // unconditional: an un-flushed edit through a stale reference must go too
|
|
4904
5294
|
if (this.Dirty) {
|
|
4905
5295
|
for (let field of this.Fields) {
|
|
4906
|
-
field.
|
|
5296
|
+
field.RevertToOldValue();
|
|
4907
5297
|
}
|
|
4908
5298
|
// IS-A composition: revert parent entity chain as well
|
|
4909
5299
|
if (this._parentEntity) {
|
|
@@ -4936,7 +5326,7 @@ export class BaseEntity {
|
|
|
4936
5326
|
if (!this.IsSaved) {
|
|
4937
5327
|
this.init(); // wipe out current data if we're loading on top of existing record
|
|
4938
5328
|
}
|
|
4939
|
-
|
|
5329
|
+
let data = await this.readRowForLoad(CompositeKey, EntityRelationshipsToLoad);
|
|
4940
5330
|
this.discardJSONFieldObjects(); // a reload replaces the record's data
|
|
4941
5331
|
if (!data) {
|
|
4942
5332
|
// A subtype-hint probe asks whether this row exists, so "no" is an answer, not an error
|
|
@@ -4945,6 +5335,9 @@ export class BaseEntity {
|
|
|
4945
5335
|
}
|
|
4946
5336
|
return false; // no data loaded, return false
|
|
4947
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);
|
|
4948
5341
|
// IS-A: hydrate parent chain from loaded data before populating self.
|
|
4949
5342
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
4950
5343
|
// then populates from data. This must happen before self's SetMany so parents
|
|
@@ -5095,6 +5488,16 @@ export class BaseEntity {
|
|
|
5095
5488
|
*/
|
|
5096
5489
|
async LoadFromData(data, _replaceOldValues = false) {
|
|
5097
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);
|
|
5098
5501
|
// IS-A: hydrate parent chain from data before populating self.
|
|
5099
5502
|
// Hydrate resets each parent via init() (giving fresh _NeverSet=true on PK fields)
|
|
5100
5503
|
// then populates from data, ensuring correct PK and saved state on parents.
|
|
@@ -5108,12 +5511,26 @@ export class BaseEntity {
|
|
|
5108
5511
|
// This is the dominant warm-load case (cache restore in TransformSimpleObjectToEntityObject
|
|
5109
5512
|
// populating engine arrays of read-only data) and was previously the per-row bottleneck.
|
|
5110
5513
|
const isPlainObject = data && typeof data === 'object' && !Array.isArray(data) && !(data instanceof Date);
|
|
5514
|
+
const isFirstOrUnhydratedLoad = this._raw === null &&
|
|
5515
|
+
!this._recordLoaded &&
|
|
5516
|
+
!this._everSaved &&
|
|
5517
|
+
((!this._fieldsHydrated && this._fields.length === 0) || this._fields.every(f => f.NeverSet));
|
|
5518
|
+
const hasCompanionPayload = this.HasCompanions &&
|
|
5519
|
+
isPlainObject &&
|
|
5520
|
+
Array.isArray(data[COMPANION_PAYLOAD_KEY]);
|
|
5111
5521
|
const canTakeFastPath = isPlainObject &&
|
|
5112
|
-
|
|
5113
|
-
this._fields.length === 0 &&
|
|
5522
|
+
isFirstOrUnhydratedLoad &&
|
|
5114
5523
|
!this._parentEntity &&
|
|
5115
|
-
!this.EntityInfo?.IsParentType
|
|
5524
|
+
!this.EntityInfo?.IsParentType &&
|
|
5525
|
+
!hasCompanionPayload;
|
|
5116
5526
|
if (canTakeFastPath) {
|
|
5527
|
+
if (this._fieldsHydrated || this._fields.length > 0) {
|
|
5528
|
+
this._fields = [];
|
|
5529
|
+
this._fieldsHydrated = false;
|
|
5530
|
+
this._fieldCache = null;
|
|
5531
|
+
this._codeNameCache = null;
|
|
5532
|
+
}
|
|
5533
|
+
this._compositeKey = null;
|
|
5117
5534
|
this._raw = data;
|
|
5118
5535
|
// Drop any conversions memoized from a previously-loaded row — they describe the old
|
|
5119
5536
|
// `_raw`, not this one. No isFrozen probe is needed: `Get()` never writes to `_raw`,
|
|
@@ -5124,7 +5541,7 @@ export class BaseEntity {
|
|
|
5124
5541
|
const pks = this.EntityInfo?.PrimaryKeys ?? [];
|
|
5125
5542
|
let allPksPresent = pks.length > 0;
|
|
5126
5543
|
for (const pkInfo of pks) {
|
|
5127
|
-
const v = this._raw
|
|
5544
|
+
const v = this.rawValueFor(this._raw, pkInfo);
|
|
5128
5545
|
if (v === null || v === undefined) {
|
|
5129
5546
|
allPksPresent = false;
|
|
5130
5547
|
break;
|
|
@@ -5887,7 +6304,7 @@ export class BaseEntity {
|
|
|
5887
6304
|
//
|
|
5888
6305
|
// Returning null is the same answer callers already handle for "this entity has no name
|
|
5889
6306
|
// field", and every one of them degrades to the primary key.
|
|
5890
|
-
if (!this.
|
|
6307
|
+
if (!this.IsFieldReadableByActiveUser(f.Name)) {
|
|
5891
6308
|
return null;
|
|
5892
6309
|
}
|
|
5893
6310
|
return this.Get(f.Name);
|
|
@@ -6091,13 +6508,16 @@ export class BaseEntity {
|
|
|
6091
6508
|
* use this to track vectors associated with
|
|
6092
6509
|
*/
|
|
6093
6510
|
get Vectors() {
|
|
6511
|
+
if (!this._vectors) {
|
|
6512
|
+
this._vectors = new Map();
|
|
6513
|
+
}
|
|
6094
6514
|
return this._vectors;
|
|
6095
6515
|
}
|
|
6096
6516
|
/**
|
|
6097
6517
|
* Resets the vector embeddings for this entity to an empty state.
|
|
6098
6518
|
*/
|
|
6099
6519
|
ResetVectors() {
|
|
6100
|
-
this._vectors
|
|
6520
|
+
this._vectors?.clear();
|
|
6101
6521
|
}
|
|
6102
6522
|
/**
|
|
6103
6523
|
* Resolves the recursive foreign key field for this entity. If `parentFieldName` is provided,
|