@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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { BaseInfo } from "./baseInfo.js";
|
|
2
2
|
import { Metadata } from "./metadata.js";
|
|
3
|
+
import { MetadataProviderFor } from "./infoProviderResolution.js";
|
|
3
4
|
import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
|
|
4
5
|
import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
|
|
5
6
|
import { LogError } from "./logging.js";
|
|
6
|
-
import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
|
|
7
|
+
import { WarningManager, SafeJSONParse, UUIDsEqual, NormalizeUUID, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
|
|
7
8
|
import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
|
|
8
9
|
/**
|
|
9
10
|
* Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
|
|
@@ -305,6 +306,186 @@ export class EntityOrganicKeyRelatedEntityInfo extends BaseInfo {
|
|
|
305
306
|
}
|
|
306
307
|
}
|
|
307
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Metadata describing a governed alternate SQL view on an entity that maintains 100% column
|
|
311
|
+
* parity with the entity's canonical `BaseView` while applying a curated canonical filter
|
|
312
|
+
* or join (for example, `Active` members or `Open` orders).
|
|
313
|
+
*
|
|
314
|
+
* Alternate base views can form a same-entity derivation hierarchy via {@link ParentID}
|
|
315
|
+
* (for example, `ActiveAutoRenew` selecting from `vwActiveMembers`).
|
|
316
|
+
*
|
|
317
|
+
* Maps to the `MJ: Entity Alternate Base Views` metadata entity.
|
|
318
|
+
*/
|
|
319
|
+
export class EntityAlternateBaseViewInfo extends BaseInfo {
|
|
320
|
+
/**
|
|
321
|
+
* The parent alternate base view on the same entity when {@link ParentID} is set,
|
|
322
|
+
* or `undefined` for a top-level alternate base view.
|
|
323
|
+
*/
|
|
324
|
+
get ParentView() {
|
|
325
|
+
return this._resolvedParentViewRef;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Direct child alternate base views on the same entity that derive from this view via {@link ParentID}.
|
|
329
|
+
*/
|
|
330
|
+
get ChildViews() {
|
|
331
|
+
return this._resolvedChildViewsList;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Effective database schema name for this alternate base view: uses {@link SchemaName}
|
|
335
|
+
* when explicitly populated, falling back to the parent entity's `SchemaName`.
|
|
336
|
+
*/
|
|
337
|
+
get EffectiveSchemaName() {
|
|
338
|
+
const explicit = this.SchemaName?.trim();
|
|
339
|
+
if (explicit && explicit.length > 0) {
|
|
340
|
+
return explicit;
|
|
341
|
+
}
|
|
342
|
+
return this._entitySchemaName ?? '';
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Human-friendly label for UI surfaces: returns {@link DisplayName} when set,
|
|
346
|
+
* falling back to {@link Name}.
|
|
347
|
+
*/
|
|
348
|
+
get EffectiveDisplayName() {
|
|
349
|
+
const display = this.DisplayName?.trim();
|
|
350
|
+
if (display && display.length > 0) {
|
|
351
|
+
return display;
|
|
352
|
+
}
|
|
353
|
+
return this.Name;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* True when this alternate base view (and every ancestor in its {@link ParentView} chain)
|
|
357
|
+
* has `Status` equal to `'Active'` or `'Deprecated'`. Disabled views (or views derived
|
|
358
|
+
* from a disabled or missing ancestor) return `false`.
|
|
359
|
+
*/
|
|
360
|
+
get IsUsable() {
|
|
361
|
+
return this.checkIsUsable(new Set());
|
|
362
|
+
}
|
|
363
|
+
checkIsUsable(visited) {
|
|
364
|
+
if (this.Status !== 'Active' && this.Status !== 'Deprecated') {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
if (!this.ParentID) {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
if (!this._resolvedParentViewRef) {
|
|
371
|
+
return false; // ParentID is set but parent view was not found on the entity
|
|
372
|
+
}
|
|
373
|
+
const key = NormalizeUUID(this.ID);
|
|
374
|
+
if (key && visited.has(key)) {
|
|
375
|
+
return false; // Defensive cycle guard
|
|
376
|
+
}
|
|
377
|
+
if (key) {
|
|
378
|
+
visited.add(key);
|
|
379
|
+
}
|
|
380
|
+
return this._resolvedParentViewRef.checkIsUsable(visited);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* True when this view (or any ancestor in its {@link ParentView} chain) has
|
|
384
|
+
* `Status === 'Deprecated'`. Deprecated alternate base views remain usable at runtime
|
|
385
|
+
* so existing views and queries do not break, but emit a deprecation warning.
|
|
386
|
+
*/
|
|
387
|
+
get IsDeprecated() {
|
|
388
|
+
return this.checkIsDeprecated(new Set());
|
|
389
|
+
}
|
|
390
|
+
checkIsDeprecated(visited) {
|
|
391
|
+
if (this.Status === 'Deprecated') {
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
if (!this._resolvedParentViewRef) {
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
const key = NormalizeUUID(this.ID);
|
|
398
|
+
if (key && visited.has(key)) {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
if (key) {
|
|
402
|
+
visited.add(key);
|
|
403
|
+
}
|
|
404
|
+
return this._resolvedParentViewRef.checkIsDeprecated(visited);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* True when `CachePolicy === 'Bypass'` on this view or any ancestor in its {@link ParentView}
|
|
408
|
+
* chain, instructing `ProviderBase` to skip local/server result caching for reads through
|
|
409
|
+
* this alternate base view (used when the view reads the clock or joins other tables whose
|
|
410
|
+
* updates do not fire `BaseEntity` events on this entity).
|
|
411
|
+
*/
|
|
412
|
+
get IsBypassCache() {
|
|
413
|
+
return this.checkIsBypassCache(new Set());
|
|
414
|
+
}
|
|
415
|
+
checkIsBypassCache(visited) {
|
|
416
|
+
if (this.CachePolicy === 'Bypass') {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
if (!this._resolvedParentViewRef) {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
const key = NormalizeUUID(this.ID);
|
|
423
|
+
if (key && visited.has(key)) {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
if (key) {
|
|
427
|
+
visited.add(key);
|
|
428
|
+
}
|
|
429
|
+
return this._resolvedParentViewRef.checkIsBypassCache(visited);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Links `ParentView` and `ChildViews` references across the alternate base views belonging
|
|
433
|
+
* to a single entity and stamps the owning entity's `SchemaName` fallback.
|
|
434
|
+
*/
|
|
435
|
+
static LinkHierarchy(views, entitySchemaName) {
|
|
436
|
+
const byId = new Map();
|
|
437
|
+
for (const v of views) {
|
|
438
|
+
if (entitySchemaName !== undefined) {
|
|
439
|
+
v._entitySchemaName = entitySchemaName;
|
|
440
|
+
}
|
|
441
|
+
v._resolvedParentViewRef = undefined;
|
|
442
|
+
v._resolvedChildViewsList = [];
|
|
443
|
+
if (v.ID) {
|
|
444
|
+
byId.set(NormalizeUUID(v.ID), v);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
for (const v of views) {
|
|
448
|
+
if (v.ParentID && !UUIDsEqual(v.ParentID, v.ID)) {
|
|
449
|
+
const parent = byId.get(NormalizeUUID(v.ParentID));
|
|
450
|
+
if (parent) {
|
|
451
|
+
v._resolvedParentViewRef = parent;
|
|
452
|
+
parent._resolvedChildViewsList.push(v);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
constructor(initData = null, entitySchemaName) {
|
|
458
|
+
super();
|
|
459
|
+
this.ID = null;
|
|
460
|
+
this.EntityID = null;
|
|
461
|
+
this.ParentID = null;
|
|
462
|
+
this.Name = null;
|
|
463
|
+
this.DisplayName = null;
|
|
464
|
+
this.Description = null;
|
|
465
|
+
this.SchemaName = null;
|
|
466
|
+
this.ViewName = null;
|
|
467
|
+
this.CachePolicy = 'Default';
|
|
468
|
+
this.Status = 'Active';
|
|
469
|
+
this.Sequence = 0;
|
|
470
|
+
this.__mj_CreatedAt = null;
|
|
471
|
+
this.__mj_UpdatedAt = null;
|
|
472
|
+
// Virtual fields from vwEntityAlternateBaseViews
|
|
473
|
+
this.Entity = null;
|
|
474
|
+
this.Parent = null;
|
|
475
|
+
this.RootParentID = null;
|
|
476
|
+
this._entitySchemaName = null;
|
|
477
|
+
// Named without matching `ParentView` / `ChildViews` getter names so `BaseInfo.toJSON()`
|
|
478
|
+
// does not serialize parent<->child object references into a circular JSON graph.
|
|
479
|
+
this._resolvedParentViewRef = undefined;
|
|
480
|
+
this._resolvedChildViewsList = [];
|
|
481
|
+
if (entitySchemaName !== undefined) {
|
|
482
|
+
this._entitySchemaName = entitySchemaName;
|
|
483
|
+
}
|
|
484
|
+
if (initData) {
|
|
485
|
+
this.copyInitData(initData);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
308
489
|
export const EntityPermissionType = {
|
|
309
490
|
Read: 'Read',
|
|
310
491
|
Create: 'Create',
|
|
@@ -369,9 +550,13 @@ export class EntityPermissionInfo extends BaseInfo {
|
|
|
369
550
|
break;
|
|
370
551
|
}
|
|
371
552
|
if (fID && fID.length > 0)
|
|
372
|
-
return
|
|
553
|
+
return MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, fID));
|
|
373
554
|
}
|
|
374
|
-
|
|
555
|
+
/**
|
|
556
|
+
* @param initData - the raw permission row
|
|
557
|
+
* @param md - the provider that owns this metadata; RLS filters resolve through it (see {@link BaseInfo.OwningProvider})
|
|
558
|
+
*/
|
|
559
|
+
constructor(initData, md) {
|
|
375
560
|
super();
|
|
376
561
|
this.ID = null;
|
|
377
562
|
this.EntityID = null;
|
|
@@ -401,6 +586,7 @@ export class EntityPermissionInfo extends BaseInfo {
|
|
|
401
586
|
this.CreateRLSFilter = null;
|
|
402
587
|
this.UpdateRLSFilter = null;
|
|
403
588
|
this.DeleteRLSFilter = null;
|
|
589
|
+
this.SetOwningProvider(md);
|
|
404
590
|
this.copyInitData(initData);
|
|
405
591
|
}
|
|
406
592
|
}
|
|
@@ -457,9 +643,16 @@ export const ReadableFieldsTransportKey = 'ReadableFields___';
|
|
|
457
643
|
* set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
|
|
458
644
|
*/
|
|
459
645
|
export function IsRestrictingFieldRule(rule) {
|
|
460
|
-
return (rule?.ReadAccess
|
|
461
|
-
|
|
462
|
-
|
|
646
|
+
return isDenyAccess(rule?.ReadAccess) || isDenyAccess(rule?.UpdateAccess) || isDenyAccess(rule?.CreateAccess);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Whether one Access value means `Deny`, compared the way the aggregation compares: ignoring case
|
|
650
|
+
* and surrounding whitespace. The column's CHECK constraint runs under a case-insensitive
|
|
651
|
+
* collation, so `'deny'` stores, and the aggregation enforces it as a Deny; a strict `===` here
|
|
652
|
+
* would call that same rule harmless.
|
|
653
|
+
*/
|
|
654
|
+
function isDenyAccess(access) {
|
|
655
|
+
return (access ?? '').trim().toLowerCase() === 'deny';
|
|
463
656
|
}
|
|
464
657
|
/**
|
|
465
658
|
* The single wording for "you cannot use this field", modelled on SQL Server's posture of never
|
|
@@ -927,14 +1120,16 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
927
1120
|
static AggregateFieldRulesForUser(rules, user) {
|
|
928
1121
|
const allow = { CanRead: false, CanUpdate: false, CanCreate: false };
|
|
929
1122
|
const deny = { CanRead: false, CanUpdate: false, CanCreate: false };
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1123
|
+
const userRoleIds = getUserRoleIdSet(user);
|
|
1124
|
+
if (userRoleIds.size > 0) {
|
|
1125
|
+
for (const fp of rules) {
|
|
1126
|
+
if (!fp.RoleID || !userRoleIds.has(fp.RoleID.trim().toLowerCase())) {
|
|
1127
|
+
continue; // user does not hold this role
|
|
1128
|
+
}
|
|
1129
|
+
EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
|
|
1130
|
+
EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
|
|
1131
|
+
EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
|
|
934
1132
|
}
|
|
935
|
-
EntityFieldInfo.applyAccessToBuckets(fp.ReadAccess, allow, deny, 'CanRead');
|
|
936
|
-
EntityFieldInfo.applyAccessToBuckets(fp.UpdateAccess, allow, deny, 'CanUpdate');
|
|
937
|
-
EntityFieldInfo.applyAccessToBuckets(fp.CreateAccess, allow, deny, 'CanCreate');
|
|
938
1133
|
}
|
|
939
1134
|
const effective = {
|
|
940
1135
|
CanRead: allow.CanRead && !deny.CanRead,
|
|
@@ -971,17 +1166,23 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
971
1166
|
* Returns the ValueListType using the EntityFieldValueListType enum.
|
|
972
1167
|
*/
|
|
973
1168
|
get ValueListTypeEnum() {
|
|
974
|
-
if (this.
|
|
975
|
-
return
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1169
|
+
if (this._valueListTypeEnumCache !== undefined) {
|
|
1170
|
+
return this._valueListTypeEnumCache;
|
|
1171
|
+
}
|
|
1172
|
+
if (this.ValueListType == null) {
|
|
1173
|
+
this._valueListTypeEnumCache = EntityFieldValueListType.None;
|
|
1174
|
+
return this._valueListTypeEnumCache;
|
|
1175
|
+
}
|
|
1176
|
+
const target = this.ValueListType.toLowerCase().trim();
|
|
1177
|
+
for (const enumMember in EntityFieldValueListType) {
|
|
1178
|
+
if (typeof EntityFieldValueListType[enumMember] === 'string' &&
|
|
1179
|
+
enumMember.toLowerCase().trim() === target) {
|
|
1180
|
+
this._valueListTypeEnumCache = EntityFieldValueListType[enumMember];
|
|
1181
|
+
return this._valueListTypeEnumCache;
|
|
983
1182
|
}
|
|
984
1183
|
}
|
|
1184
|
+
this._valueListTypeEnumCache = EntityFieldValueListType.None;
|
|
1185
|
+
return this._valueListTypeEnumCache;
|
|
985
1186
|
}
|
|
986
1187
|
/**
|
|
987
1188
|
* Upper bound on how many legal values a validation message enumerates before it truncates.
|
|
@@ -1458,19 +1659,28 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1458
1659
|
* Returns true if the field is the CreatedAt field, a special field that is used to track the creation date of a record. This is only used when the entity has TrackRecordChanges=1
|
|
1459
1660
|
*/
|
|
1460
1661
|
get IsCreatedAtField() {
|
|
1461
|
-
|
|
1662
|
+
if (this._isCreatedAtFieldCache !== undefined)
|
|
1663
|
+
return this._isCreatedAtFieldCache;
|
|
1664
|
+
this._isCreatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.CreatedAtFieldName.trim().toLowerCase();
|
|
1665
|
+
return this._isCreatedAtFieldCache;
|
|
1462
1666
|
}
|
|
1463
1667
|
/**
|
|
1464
1668
|
* Returns true if the field is the UpdatedAt field, a special field that is used to track the last update date of a record. This is only used when the entity has TrackRecordChanges=1
|
|
1465
1669
|
*/
|
|
1466
1670
|
get IsUpdatedAtField() {
|
|
1467
|
-
|
|
1671
|
+
if (this._isUpdatedAtFieldCache !== undefined)
|
|
1672
|
+
return this._isUpdatedAtFieldCache;
|
|
1673
|
+
this._isUpdatedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.UpdatedAtFieldName.trim().toLowerCase();
|
|
1674
|
+
return this._isUpdatedAtFieldCache;
|
|
1468
1675
|
}
|
|
1469
1676
|
/**
|
|
1470
1677
|
* Returns true if the field is the DeletedAt field, a special field that is used to track the deletion date of a record. This is only used when the entity has DeleteType=Soft
|
|
1471
1678
|
*/
|
|
1472
1679
|
get IsDeletedAtField() {
|
|
1473
|
-
|
|
1680
|
+
if (this._isDeletedAtFieldCache !== undefined)
|
|
1681
|
+
return this._isDeletedAtFieldCache;
|
|
1682
|
+
this._isDeletedAtFieldCache = this.Name.trim().toLowerCase() === EntityInfo.DeletedAtFieldName.trim().toLowerCase();
|
|
1683
|
+
return this._isDeletedAtFieldCache;
|
|
1474
1684
|
}
|
|
1475
1685
|
/**
|
|
1476
1686
|
* Returns true if the field is a GUID/UUID column in the database.
|
|
@@ -1479,8 +1689,11 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1479
1689
|
* `uuid`, so a `uniqueidentifier`-only check would miss every UUID column.
|
|
1480
1690
|
*/
|
|
1481
1691
|
get IsUniqueIdentifier() {
|
|
1692
|
+
if (this._isUniqueIdentifierCache !== undefined)
|
|
1693
|
+
return this._isUniqueIdentifierCache;
|
|
1482
1694
|
const t = this.Type.trim().toLowerCase();
|
|
1483
|
-
|
|
1695
|
+
this._isUniqueIdentifierCache = t === 'uniqueidentifier' || t === 'uuid';
|
|
1696
|
+
return this._isUniqueIdentifierCache;
|
|
1484
1697
|
}
|
|
1485
1698
|
/**
|
|
1486
1699
|
* Returns true if the field's SQL type is **fixed-width / space-padded**
|
|
@@ -1493,7 +1706,10 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1493
1706
|
* (un-padded) form of the value.
|
|
1494
1707
|
*/
|
|
1495
1708
|
get FixedWidthColumn() {
|
|
1496
|
-
|
|
1709
|
+
if (this._fixedWidthColumnCache !== undefined)
|
|
1710
|
+
return this._fixedWidthColumnCache;
|
|
1711
|
+
this._fixedWidthColumnCache = IsFixedWidthStringSQLType(this.Type);
|
|
1712
|
+
return this._fixedWidthColumnCache;
|
|
1497
1713
|
}
|
|
1498
1714
|
/**
|
|
1499
1715
|
* Returns true if the field has a default value set
|
|
@@ -1623,6 +1839,10 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1623
1839
|
FormatValue(value, decimals = 2, currency = 'USD', maxLength = 0, trailingChars = "...") {
|
|
1624
1840
|
return FormatValue(this.Type, value, decimals, currency, maxLength, trailingChars);
|
|
1625
1841
|
}
|
|
1842
|
+
static { this.CHILD_INIT_KEYS = new Set([
|
|
1843
|
+
'EntityFieldValues', '_EntityFieldValues',
|
|
1844
|
+
'EntityFieldPermissions', '_FieldPermissions', 'FieldPermissions',
|
|
1845
|
+
]); }
|
|
1626
1846
|
constructor(initData = null) {
|
|
1627
1847
|
super();
|
|
1628
1848
|
this.ID = null;
|
|
@@ -2007,6 +2227,7 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
2007
2227
|
*/
|
|
2008
2228
|
this._relatedEntityJoinFieldsFailedParsing = false;
|
|
2009
2229
|
this._fieldPermissions = [];
|
|
2230
|
+
this._valueListTypeEnumCache = undefined;
|
|
2010
2231
|
/** Memoized {@link TSType} — `Type` is immutable after metadata load, so the classification never changes. */
|
|
2011
2232
|
this._tsType = undefined;
|
|
2012
2233
|
/**
|
|
@@ -2014,8 +2235,14 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
2014
2235
|
* This property returns a consistent CodeName you can use everywhere to refer to the field when generated variable names
|
|
2015
2236
|
*/
|
|
2016
2237
|
this._codeName = null;
|
|
2238
|
+
this._isCreatedAtFieldCache = undefined;
|
|
2239
|
+
this._isUpdatedAtFieldCache = undefined;
|
|
2240
|
+
this._isDeletedAtFieldCache = undefined;
|
|
2241
|
+
this._isUniqueIdentifierCache = undefined;
|
|
2242
|
+
this._fixedWidthColumnCache = undefined;
|
|
2243
|
+
this._parsedDefaultValueCache = undefined;
|
|
2017
2244
|
if (initData) {
|
|
2018
|
-
this.copyInitData(initData);
|
|
2245
|
+
this.copyInitData(initData, EntityFieldInfo.CHILD_INIT_KEYS);
|
|
2019
2246
|
// Lazy-construct EntityFieldValueInfo objects — stash raw data now,
|
|
2020
2247
|
// construct typed instances on first access via the getter.
|
|
2021
2248
|
// This eliminates ~36,000 object constructions during startup deserialization.
|
|
@@ -2096,7 +2323,157 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
2096
2323
|
// Using includes() because the value might be wrapped in parentheses like (getdate())
|
|
2097
2324
|
return EntityFieldInfo.SQL_CURRENT_DATE_FUNCTIONS.some(func => normalizedValue.includes(func));
|
|
2098
2325
|
}
|
|
2326
|
+
static { this.PG_TYPED_LITERAL_REGEX = /^'((?:[^']|'')*)'::[A-Za-z][\w "()\[\],]*$/; }
|
|
2327
|
+
/**
|
|
2328
|
+
* Parses a field's SQL DefaultValue into its runtime JS representation.
|
|
2329
|
+
* Exposed as a static helper so unit tests passing plain-object mocks for EntityFieldInfo
|
|
2330
|
+
* work identically to real EntityFieldInfo instances.
|
|
2331
|
+
*/
|
|
2332
|
+
static ParseDefaultValue(field) {
|
|
2333
|
+
const rawDefault = field.DefaultValue;
|
|
2334
|
+
if (!rawDefault) {
|
|
2335
|
+
return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
|
|
2336
|
+
}
|
|
2337
|
+
if (field.TSType === EntityFieldTSType.Boolean) {
|
|
2338
|
+
const val = typeof rawDefault === 'string' &&
|
|
2339
|
+
(rawDefault.trim() === '1' || rawDefault.trim().toLowerCase() === 'true');
|
|
2340
|
+
return { source: rawDefault, hasValue: true, value: val, isCurrentDateFunction: false };
|
|
2341
|
+
}
|
|
2342
|
+
if (field.TSType === EntityFieldTSType.Number) {
|
|
2343
|
+
const num = Number(rawDefault);
|
|
2344
|
+
if (!isNaN(num)) {
|
|
2345
|
+
return { source: rawDefault, hasValue: true, value: num, isCurrentDateFunction: false };
|
|
2346
|
+
}
|
|
2347
|
+
if (typeof rawDefault === 'string' && rawDefault.trim().toLowerCase() === 'null') {
|
|
2348
|
+
return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
|
|
2349
|
+
}
|
|
2350
|
+
return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
|
|
2351
|
+
}
|
|
2352
|
+
const isUniqueId = field.IsUniqueIdentifier !== undefined
|
|
2353
|
+
? field.IsUniqueIdentifier
|
|
2354
|
+
: (typeof field.Type === 'string' && (field.Type.trim().toLowerCase() === 'uniqueidentifier' || field.Type.trim().toLowerCase() === 'uuid'));
|
|
2355
|
+
if (isUniqueId) {
|
|
2356
|
+
return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
|
|
2357
|
+
}
|
|
2358
|
+
if (field.TSType === EntityFieldTSType.Date) {
|
|
2359
|
+
if (typeof rawDefault === 'string' && rawDefault.trim().length > 0) {
|
|
2360
|
+
const temp = new Date(rawDefault);
|
|
2361
|
+
if (!isNaN(temp.getTime())) {
|
|
2362
|
+
return { source: rawDefault, hasValue: true, value: temp, isCurrentDateFunction: false };
|
|
2363
|
+
}
|
|
2364
|
+
if (EntityFieldInfo.IsDefaultValueSQLCurrentDateFunction(rawDefault)) {
|
|
2365
|
+
if (field.IsSpecialDateField) {
|
|
2366
|
+
return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: false };
|
|
2367
|
+
}
|
|
2368
|
+
return { source: rawDefault, hasValue: true, value: null, isCurrentDateFunction: true };
|
|
2369
|
+
}
|
|
2370
|
+
throw new Error(`Invalid default value for ${field.Entity}.${field.Name} of ${rawDefault}. Default values for date fields must be a valid date or a getdate() type default`);
|
|
2371
|
+
}
|
|
2372
|
+
return { source: rawDefault, hasValue: false, value: undefined, isCurrentDateFunction: false };
|
|
2373
|
+
}
|
|
2374
|
+
const dv = typeof rawDefault === 'string' ? rawDefault.trim() : String(rawDefault);
|
|
2375
|
+
const m = dv.match(EntityFieldInfo.PG_TYPED_LITERAL_REGEX);
|
|
2376
|
+
const strVal = m ? m[1].replace(/''/g, "'") : rawDefault;
|
|
2377
|
+
return { source: rawDefault, hasValue: true, value: strVal, isCurrentDateFunction: false };
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* Parses and memoizes the SQL DefaultValue into its runtime JS representation once per
|
|
2381
|
+
* EntityFieldInfo instead of re-parsing (trimming, regex matching, Number/Date parsing)
|
|
2382
|
+
* on every EntityField instantiation.
|
|
2383
|
+
*/
|
|
2384
|
+
GetParsedDefaultValue() {
|
|
2385
|
+
const rawDefault = this.DefaultValue;
|
|
2386
|
+
if (this._parsedDefaultValueCache !== undefined && this._parsedDefaultValueCache.source === rawDefault) {
|
|
2387
|
+
return this._parsedDefaultValueCache;
|
|
2388
|
+
}
|
|
2389
|
+
this._parsedDefaultValueCache = EntityFieldInfo.ParseDefaultValue(this);
|
|
2390
|
+
return this._parsedDefaultValueCache;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
const _emptyStringSet = new Set();
|
|
2394
|
+
const _userRoleIdSetCache = new WeakMap();
|
|
2395
|
+
function getUserRoleIdSet(user) {
|
|
2396
|
+
if (!user?.UserRoles || user.UserRoles.length === 0) {
|
|
2397
|
+
return _emptyStringSet;
|
|
2398
|
+
}
|
|
2399
|
+
const roles = user.UserRoles;
|
|
2400
|
+
const len = roles.length;
|
|
2401
|
+
const firstId = roles[0]?.RoleID;
|
|
2402
|
+
const cached = _userRoleIdSetCache.get(user);
|
|
2403
|
+
if (cached && cached.rolesRef === roles && cached.rolesLen === len && cached.firstRoleId === firstId) {
|
|
2404
|
+
return cached.roleIds;
|
|
2405
|
+
}
|
|
2406
|
+
const roleIds = new Set();
|
|
2407
|
+
for (let i = 0; i < len; i++) {
|
|
2408
|
+
const rid = roles[i]?.RoleID;
|
|
2409
|
+
if (rid) {
|
|
2410
|
+
roleIds.add(rid.trim().toLowerCase());
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
_userRoleIdSetCache.set(user, { rolesRef: roles, rolesLen: len, firstRoleId: firstId, roleIds });
|
|
2414
|
+
return roleIds;
|
|
2415
|
+
}
|
|
2416
|
+
const _entityFieldIndexCache = new WeakMap();
|
|
2417
|
+
const _entityDeniedFieldsCache = new WeakMap();
|
|
2418
|
+
/**
|
|
2419
|
+
* The field-security generation: one process-wide number that moves whenever an answer derived
|
|
2420
|
+
* from field-security configuration may have changed. It moves when an `EntityInfo` is built (a
|
|
2421
|
+
* metadata refresh rebuilds every entity), when an entity's {@link EntityInfo.EnableFieldLevelSecurity}
|
|
2422
|
+
* flag is assigned, and when {@link EntityInfo.InvalidateFieldSecurityCache} is called. Every memo
|
|
2423
|
+
* of a field-security answer records the number it was filled under and is discarded once the
|
|
2424
|
+
* number has moved, so a memo is never served across any of those changes. Numbers only grow.
|
|
2425
|
+
*/
|
|
2426
|
+
let _fieldSecurityGeneration = 0;
|
|
2427
|
+
/** Marks every memoized field-security answer stale (see {@link CurrentFieldSecurityGeneration}). */
|
|
2428
|
+
function advanceFieldSecurityGeneration() {
|
|
2429
|
+
_fieldSecurityGeneration++;
|
|
2099
2430
|
}
|
|
2431
|
+
/**
|
|
2432
|
+
* The current field-security generation. A memo of anything derived from field-security
|
|
2433
|
+
* configuration (denied sets, whether field security applies, a user's denial profile across a
|
|
2434
|
+
* catalog) is valid only while this returns the number the memo was filled under. It moves when an
|
|
2435
|
+
* `EntityInfo` is built, when an entity's `EnableFieldLevelSecurity` flag is assigned, and when
|
|
2436
|
+
* `EntityInfo.InvalidateFieldSecurityCache()` is called.
|
|
2437
|
+
*/
|
|
2438
|
+
export function CurrentFieldSecurityGeneration() {
|
|
2439
|
+
return _fieldSecurityGeneration;
|
|
2440
|
+
}
|
|
2441
|
+
const NO_ANCESTORS = Object.freeze([]);
|
|
2442
|
+
/** Whether two ancestor lists hold the same entities, by identity, in the same order. */
|
|
2443
|
+
function sameAncestors(a, b) {
|
|
2444
|
+
if (a === b) {
|
|
2445
|
+
return true;
|
|
2446
|
+
}
|
|
2447
|
+
if (a.length !== b.length) {
|
|
2448
|
+
return false;
|
|
2449
|
+
}
|
|
2450
|
+
for (let i = 0; i < a.length; i++) {
|
|
2451
|
+
if (a[i] !== b[i]) {
|
|
2452
|
+
return false;
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
return true;
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* The role IDs a user holds, in order, as one string: changes whenever a role is added, removed
|
|
2459
|
+
* or swapped. Field-level security reads nothing else about a user, so this, with
|
|
2460
|
+
* {@link CurrentFieldSecurityGeneration}, keys any memo of a user's field-security answers.
|
|
2461
|
+
*/
|
|
2462
|
+
export function FieldSecurityUserRolesKey(user) {
|
|
2463
|
+
return userRolesKey(user);
|
|
2464
|
+
}
|
|
2465
|
+
function userRolesKey(user) {
|
|
2466
|
+
const roles = user.UserRoles;
|
|
2467
|
+
if (!roles || roles.length === 0) {
|
|
2468
|
+
return '';
|
|
2469
|
+
}
|
|
2470
|
+
let key = '';
|
|
2471
|
+
for (let i = 0; i < roles.length; i++) {
|
|
2472
|
+
key += `${roles[i]?.RoleID ?? ''}|`;
|
|
2473
|
+
}
|
|
2474
|
+
return key;
|
|
2475
|
+
}
|
|
2476
|
+
const _entityChildEntitiesCache = new WeakMap();
|
|
2100
2477
|
/**
|
|
2101
2478
|
* Entity Document Type Info object has information about the document types that exist across all entities. When Entity Documents are created they are associated with a document type.
|
|
2102
2479
|
*/
|
|
@@ -2222,6 +2599,28 @@ export class EntityInfo extends BaseInfo {
|
|
|
2222
2599
|
LogError(`EntityInfo '${this.Name}': SubtypeSelector 'UseForLoadedRecords' must be true or false, but is ${JSON.stringify(value)}. Loads of this entity won't use the selector.`);
|
|
2223
2600
|
return false;
|
|
2224
2601
|
}
|
|
2602
|
+
/**
|
|
2603
|
+
* Whether field-level (column-level) security is enforced for this entity.
|
|
2604
|
+
*
|
|
2605
|
+
* This is the single gate every field-security enforcement point checks first, and it is
|
|
2606
|
+
* explicit — never inferred from whether permission rows happen to exist. It is `false` for
|
|
2607
|
+
* nearly every entity in nearly every deployment, so enforcement collapses to one boolean
|
|
2608
|
+
* test: no field iteration, no aggregation, no allocation.
|
|
2609
|
+
*
|
|
2610
|
+
* Turning it on snapshots the entity's existing entity-level permissions into per-field
|
|
2611
|
+
* rows, so enabling changes no behavior until an administrator tightens a field. Turning it
|
|
2612
|
+
* off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
|
|
2613
|
+
*
|
|
2614
|
+
* Assigning it moves the field-security generation ({@link CurrentFieldSecurityGeneration}),
|
|
2615
|
+
* so every memoized field-security answer that read the old value is recomputed.
|
|
2616
|
+
*/
|
|
2617
|
+
get EnableFieldLevelSecurity() {
|
|
2618
|
+
return this._enableFieldLevelSecurity;
|
|
2619
|
+
}
|
|
2620
|
+
set EnableFieldLevelSecurity(value) {
|
|
2621
|
+
this._enableFieldLevelSecurity = value;
|
|
2622
|
+
advanceFieldSecurityGeneration();
|
|
2623
|
+
}
|
|
2225
2624
|
/**
|
|
2226
2625
|
* True when at least one field is a writable Geo* source (street and/or native lat/lng).
|
|
2227
2626
|
* Person/Org PrimaryAddress* are virtual display fields and do **not** count.
|
|
@@ -2327,6 +2726,30 @@ export class EntityInfo extends BaseInfo {
|
|
|
2327
2726
|
get CloneConfig() {
|
|
2328
2727
|
return this.ConfigurationObject?.Clone ?? null;
|
|
2329
2728
|
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Controls which columns a save operation (`spCreate` / `spUpdate`) returns from the database
|
|
2731
|
+
* for this entity. Defaults to `'full'` when `Configuration.SaveReturnMode` is omitted or not
|
|
2732
|
+
* `'datestamps_only'`.
|
|
2733
|
+
*/
|
|
2734
|
+
get SaveReturnMode() {
|
|
2735
|
+
return this.ConfigurationObject?.SaveReturnMode === 'datestamps_only' ? 'datestamps_only' : 'full';
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Returns the ordered list of base-table fields returned by a `'datestamps_only'` save:
|
|
2739
|
+
* the entity's primary key field(s), followed by `__mj_CreatedAt` and `__mj_UpdatedAt`
|
|
2740
|
+
* when those columns exist on the entity's base table.
|
|
2741
|
+
*/
|
|
2742
|
+
get DatestampReturnFields() {
|
|
2743
|
+
const result = [...this.PrimaryKeys];
|
|
2744
|
+
const pkNames = new Set(result.map(k => k.Name.trim().toLowerCase()));
|
|
2745
|
+
const createdAt = this.Fields.find(f => !f.IsVirtual && f.IsCreatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
|
|
2746
|
+
if (createdAt)
|
|
2747
|
+
result.push(createdAt);
|
|
2748
|
+
const updatedAt = this.Fields.find(f => !f.IsVirtual && f.IsUpdatedAtField && !pkNames.has(f.Name.trim().toLowerCase()));
|
|
2749
|
+
if (updatedAt)
|
|
2750
|
+
result.push(updatedAt);
|
|
2751
|
+
return result;
|
|
2752
|
+
}
|
|
2330
2753
|
/**
|
|
2331
2754
|
* Returns true if cloning is explicitly enabled for this entity as a ROOT record.
|
|
2332
2755
|
*/
|
|
@@ -2379,6 +2802,87 @@ export class EntityInfo extends BaseInfo {
|
|
|
2379
2802
|
set _floatCount(value) {
|
|
2380
2803
|
this.FloatCount = value;
|
|
2381
2804
|
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Whether field-level security decides anything on this entity: its own
|
|
2807
|
+
* {@link EnableFieldLevelSecurity} flag is on, or an IS-A ancestor's is.
|
|
2808
|
+
*
|
|
2809
|
+
* **Enforcement points gate on this, never on the raw flag.** An IS-A child carries each
|
|
2810
|
+
* ancestor's fields as mirror fields of its own, read from the same row. Gating on the child's
|
|
2811
|
+
* own flag — off on nearly every child, because nobody turns it on there — let a role denied
|
|
2812
|
+
* `Phone` on the parent read it through the child's RunView, load and `GetAll()`. The denied
|
|
2813
|
+
* sets ({@link GetDeniedReadFields} and siblings) carry the ancestor's denials for those mirror
|
|
2814
|
+
* fields; this is the matching gate, so a caller that skips work when it is false never skips
|
|
2815
|
+
* an ancestor's rules.
|
|
2816
|
+
*
|
|
2817
|
+
* The raw flag stays the right question for CONFIGURATION — which entities reconciliation,
|
|
2818
|
+
* CodeGen and the system-user sweeps cover — because the rules themselves live on the entity
|
|
2819
|
+
* that owns the field.
|
|
2820
|
+
*/
|
|
2821
|
+
get FieldLevelSecurityApplies() {
|
|
2822
|
+
if (this.EnableFieldLevelSecurity) {
|
|
2823
|
+
return true; // checked first: one boolean for every entity that has it on
|
|
2824
|
+
}
|
|
2825
|
+
if (!this.ParentID) {
|
|
2826
|
+
return false; // one boolean for every entity outside an IS-A chain
|
|
2827
|
+
}
|
|
2828
|
+
// The ancestors are memoized; their flags are read live, so an in-place flip is seen at once.
|
|
2829
|
+
const ancestors = this.liveAncestors();
|
|
2830
|
+
for (let i = 0; i < ancestors.length; i++) {
|
|
2831
|
+
if (ancestors[i].EnableFieldLevelSecurity) {
|
|
2832
|
+
return true;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
return false;
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* This entity's IS-A ancestors, nearest first, as {@link ParentEntityInfo} resolves them NOW
|
|
2839
|
+
* rather than the cached {@link ParentChain}. The denied walk ({@link addFieldsDeniedOnParent})
|
|
2840
|
+
* follows the live parent, so the gate and the memo key must too: read from a chain cached
|
|
2841
|
+
* before a metadata refresh replaced an ancestor, the gate could answer "no" while the
|
|
2842
|
+
* ancestor now in place has rules, and every enforcement point would skip them.
|
|
2843
|
+
*
|
|
2844
|
+
* The walk is memoized, valid while the field-security generation (which moves whenever any
|
|
2845
|
+
* `EntityInfo` is built, so a rebuilt ancestor is noticed), the provider this entity resolves
|
|
2846
|
+
* its parent through (its owning provider, else the global one), that provider's entity array
|
|
2847
|
+
* and this entity's `ParentID` are unchanged. Ancestors resolve their own parents through
|
|
2848
|
+
* their own providers; a chain whose entities came from one provider, the only way providers
|
|
2849
|
+
* build them, is covered by that provider's array. A walk cut
|
|
2850
|
+
* short because a `ParentID` did not resolve (asked before the provider held its entities) is
|
|
2851
|
+
* returned but not kept, so it cannot pin an incomplete answer.
|
|
2852
|
+
*/
|
|
2853
|
+
liveAncestors() {
|
|
2854
|
+
if (!this.ParentID) {
|
|
2855
|
+
return NO_ANCESTORS;
|
|
2856
|
+
}
|
|
2857
|
+
const provider = MetadataProviderFor(this); // the provider ParentEntityInfo resolves through
|
|
2858
|
+
const entities = provider?.Entities;
|
|
2859
|
+
const memo = this._liveAncestorsMemo;
|
|
2860
|
+
if (memo !== null && memo.generation === _fieldSecurityGeneration && memo.provider === provider
|
|
2861
|
+
&& memo.entities === entities && memo.parentID === this.ParentID) {
|
|
2862
|
+
return memo.ancestors;
|
|
2863
|
+
}
|
|
2864
|
+
const generation = _fieldSecurityGeneration;
|
|
2865
|
+
const { ancestors, complete } = this.walkLiveAncestors();
|
|
2866
|
+
if (complete) {
|
|
2867
|
+
this._liveAncestorsMemo = { generation, provider, entities, parentID: this.ParentID, ancestors };
|
|
2868
|
+
}
|
|
2869
|
+
return ancestors;
|
|
2870
|
+
}
|
|
2871
|
+
/** The uncached walk behind {@link liveAncestors}; stops at the root or at a cycle. */
|
|
2872
|
+
walkLiveAncestors() {
|
|
2873
|
+
const ancestors = [];
|
|
2874
|
+
const visited = new Set([this]);
|
|
2875
|
+
let current = this;
|
|
2876
|
+
let ancestor = this.ParentEntityInfo;
|
|
2877
|
+
while (ancestor && !visited.has(ancestor)) {
|
|
2878
|
+
visited.add(ancestor);
|
|
2879
|
+
ancestors.push(ancestor);
|
|
2880
|
+
current = ancestor;
|
|
2881
|
+
ancestor = ancestor.ParentID ? ancestor.ParentEntityInfo : null;
|
|
2882
|
+
}
|
|
2883
|
+
const complete = !current.ParentID || ancestor !== null;
|
|
2884
|
+
return { ancestors: Object.freeze(ancestors), complete };
|
|
2885
|
+
}
|
|
2382
2886
|
/**
|
|
2383
2887
|
* The set of field names this user may NOT READ on this entity — the per-request primitive
|
|
2384
2888
|
* every field-security enforcement point is built on.
|
|
@@ -2389,12 +2893,13 @@ export class EntityInfo extends BaseInfo {
|
|
|
2389
2893
|
* re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
|
|
2390
2894
|
*
|
|
2391
2895
|
* Names are lowercased so callers can match case-insensitively, consistent with
|
|
2392
|
-
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when
|
|
2393
|
-
*
|
|
2896
|
+
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when field security
|
|
2897
|
+
* does not apply (see {@link FieldLevelSecurityApplies}) and when the user is denied nothing,
|
|
2898
|
+
* so callers can treat
|
|
2394
2899
|
* `size === 0` as the single "nothing to do" condition.
|
|
2395
2900
|
*/
|
|
2396
2901
|
GetDeniedReadFields(user) {
|
|
2397
|
-
return this.
|
|
2902
|
+
return this.getCachedDeniedFields(user, 'read', (p) => !p.CanRead);
|
|
2398
2903
|
}
|
|
2399
2904
|
/**
|
|
2400
2905
|
* Whether this user may READ the named field — the single-field question, answered the same
|
|
@@ -2410,10 +2915,10 @@ export class EntityInfo extends BaseInfo {
|
|
|
2410
2915
|
* The caller still decides what to render in place of the value, which is the part that
|
|
2411
2916
|
* differs per surface and should not be hidden inside a getter.
|
|
2412
2917
|
*
|
|
2413
|
-
* **Fails open** on a missing user, a missing field name, or an entity
|
|
2414
|
-
*
|
|
2415
|
-
* the real boundary; a UI that blanked out fields
|
|
2416
|
-
* worse than one that shows them.
|
|
2918
|
+
* **Fails open** on a missing user, a missing field name, or an entity where field security
|
|
2919
|
+
* does not apply ({@link FieldLevelSecurityApplies}) — matching `BaseEntity`'s own gate and
|
|
2920
|
+
* `MjFormFieldComponent`. The server is the real boundary; a UI that blanked out fields
|
|
2921
|
+
* because no user had resolved yet would be worse than one that shows them.
|
|
2417
2922
|
*
|
|
2418
2923
|
* PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
|
|
2419
2924
|
* entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
|
|
@@ -2423,7 +2928,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2423
2928
|
* @param user the acting user
|
|
2424
2929
|
*/
|
|
2425
2930
|
IsFieldReadableByUser(fieldName, user) {
|
|
2426
|
-
if (!this.
|
|
2931
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2427
2932
|
return true;
|
|
2428
2933
|
}
|
|
2429
2934
|
return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2435,7 +2940,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2435
2940
|
* three conditions.
|
|
2436
2941
|
*/
|
|
2437
2942
|
IsFieldUpdatableByUser(fieldName, user) {
|
|
2438
|
-
if (!this.
|
|
2943
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2439
2944
|
return true;
|
|
2440
2945
|
}
|
|
2441
2946
|
return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2449,7 +2954,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2449
2954
|
* the user typed, which is the case this exists to prevent.
|
|
2450
2955
|
*/
|
|
2451
2956
|
IsFieldCreatableByUser(fieldName, user) {
|
|
2452
|
-
if (!this.
|
|
2957
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2453
2958
|
return true;
|
|
2454
2959
|
}
|
|
2455
2960
|
return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2463,7 +2968,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2463
2968
|
* actually need rather than relying on that.
|
|
2464
2969
|
*/
|
|
2465
2970
|
GetDeniedUpdateFields(user) {
|
|
2466
|
-
return this.
|
|
2971
|
+
return this.getCachedDeniedFields(user, 'update', (p) => !p.CanUpdate);
|
|
2467
2972
|
}
|
|
2468
2973
|
/**
|
|
2469
2974
|
* The set of field names this user may NOT supply a value for when CREATING a record. Same
|
|
@@ -2473,11 +2978,69 @@ export class EntityInfo extends BaseInfo {
|
|
|
2473
2978
|
* create-denied field is dropped and the column takes its default.
|
|
2474
2979
|
*/
|
|
2475
2980
|
GetDeniedCreateFields(user) {
|
|
2476
|
-
return this.
|
|
2981
|
+
return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
|
|
2982
|
+
}
|
|
2983
|
+
/**
|
|
2984
|
+
* Discards the memoized field-security answers: this entity's denied-field sets, those of
|
|
2985
|
+
* every IS-A descendant, and every other memo keyed on the field-security generation
|
|
2986
|
+
* ({@link CurrentFieldSecurityGeneration}), such as a user's denial profile across a catalog.
|
|
2987
|
+
* Invalidation is process-wide because a rule on one entity can change answers about others.
|
|
2988
|
+
*
|
|
2989
|
+
* A metadata refresh needs no call: it builds new `EntityInfo` objects, which start with
|
|
2990
|
+
* empty memos. Call this only after changing field rules IN PLACE on an existing
|
|
2991
|
+
* `EntityInfo`, for example editing an {@link EntityFieldPermissionInfo}'s access values.
|
|
2992
|
+
*/
|
|
2993
|
+
InvalidateFieldSecurityCache() {
|
|
2994
|
+
advanceFieldSecurityGeneration();
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Memoized front for {@link getDeniedFields}, per (entity, user, verb).
|
|
2998
|
+
*
|
|
2999
|
+
* An entry is reused while four things are unchanged: the field-security generation (see
|
|
3000
|
+
* {@link CurrentFieldSecurityGeneration}; it moves on every rebuild, flag assignment and
|
|
3001
|
+
* {@link InvalidateFieldSecurityCache}), the IS-A ancestors as they resolve now, the field
|
|
3002
|
+
* array itself, and the role IDs the user holds. Counting fields or rules is not enough:
|
|
3003
|
+
* editing a rule from `Allow` to `Deny` changes neither count.
|
|
3004
|
+
*/
|
|
3005
|
+
getCachedDeniedFields(user, verb, isDenied) {
|
|
3006
|
+
if (!this.FieldLevelSecurityApplies) {
|
|
3007
|
+
return new Set(); // gate on inheritance too: an ancestor's rules still apply
|
|
3008
|
+
}
|
|
3009
|
+
if (!user || typeof user !== 'object') {
|
|
3010
|
+
return this.getDeniedFields(user, verb, isDenied);
|
|
3011
|
+
}
|
|
3012
|
+
const entry = this.deniedFieldsMemoEntryFor(user);
|
|
3013
|
+
const existing = entry[verb];
|
|
3014
|
+
if (existing !== undefined) {
|
|
3015
|
+
return existing;
|
|
3016
|
+
}
|
|
3017
|
+
const computed = this.getDeniedFields(user, verb, isDenied);
|
|
3018
|
+
entry[verb] = computed;
|
|
3019
|
+
return computed;
|
|
3020
|
+
}
|
|
3021
|
+
/** The current memo entry for `user`, replaced with an empty one when any part of its key moved. */
|
|
3022
|
+
deniedFieldsMemoEntryFor(user) {
|
|
3023
|
+
const generation = _fieldSecurityGeneration;
|
|
3024
|
+
const ancestors = this.liveAncestors();
|
|
3025
|
+
const rolesKey = userRolesKey(user);
|
|
3026
|
+
let byUser = _entityDeniedFieldsCache.get(this);
|
|
3027
|
+
if (!byUser) {
|
|
3028
|
+
byUser = new WeakMap();
|
|
3029
|
+
_entityDeniedFieldsCache.set(this, byUser);
|
|
3030
|
+
}
|
|
3031
|
+
let entry = byUser.get(user);
|
|
3032
|
+
if (!entry || entry.fieldsRef !== this._fields || entry.generation !== generation
|
|
3033
|
+
|| !sameAncestors(entry.ancestors, ancestors) || entry.rolesKey !== rolesKey) {
|
|
3034
|
+
entry = { fieldsRef: this._fields, generation, ancestors, rolesKey };
|
|
3035
|
+
byUser.set(user, entry);
|
|
3036
|
+
}
|
|
3037
|
+
return entry;
|
|
2477
3038
|
}
|
|
2478
3039
|
/**
|
|
2479
3040
|
* Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
|
|
2480
|
-
* {@link GetDeniedCreateFields}
|
|
3041
|
+
* {@link GetDeniedCreateFields}. Walks this entity's own fields when its
|
|
3042
|
+
* {@link EnableFieldLevelSecurity} is on, then adds the fields an IS-A parent denies (see
|
|
3043
|
+
* {@link addFieldsDeniedOnParent}).
|
|
2481
3044
|
*
|
|
2482
3045
|
* Every field is aggregated, including those carrying no permission records — on an enabled
|
|
2483
3046
|
* entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
|
|
@@ -2500,18 +3063,54 @@ export class EntityInfo extends BaseInfo {
|
|
|
2500
3063
|
* that collide on `CodeName` already collide on their generated property, which is not a
|
|
2501
3064
|
* schema CodeGen can emit.
|
|
2502
3065
|
*/
|
|
2503
|
-
getDeniedFields(user, isDenied) {
|
|
3066
|
+
getDeniedFields(user, verb, isDenied) {
|
|
2504
3067
|
const denied = new Set();
|
|
2505
|
-
if (
|
|
2506
|
-
|
|
3068
|
+
if (this.EnableFieldLevelSecurity) {
|
|
3069
|
+
for (const field of this._fields) {
|
|
3070
|
+
if (isDenied(field.GetUserFieldPermissions(user, true))) {
|
|
3071
|
+
this.addDeniedField(denied, field);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
this.addFieldsDeniedOnParent(denied, user, verb, isDenied);
|
|
3076
|
+
return denied;
|
|
3077
|
+
}
|
|
3078
|
+
/**
|
|
3079
|
+
* IS-A inheritance for the denied sets: a field the immediate parent denies is denied on this
|
|
3080
|
+
* entity's mirror of it, whatever this entity's own flag says. The parent's set already
|
|
3081
|
+
* includes ITS parent's denials (this method runs there too), so one hop covers any depth.
|
|
3082
|
+
*
|
|
3083
|
+
* A mirror is a field of this entity whose `Name` is the `Name` of one of the parent's own
|
|
3084
|
+
* fields: an IS-A child's view exposes each parent column under the parent's column name, and
|
|
3085
|
+
* a child cannot declare a column of its own with that name. Matching goes through the
|
|
3086
|
+
* parent's field list rather than straight into the parent's denied set, because that set also
|
|
3087
|
+
* holds each denied field's `CodeName`: a parent field `Base Salary` puts `base_salary` in it,
|
|
3088
|
+
* and a child's OWN field named `Base_Salary` must not be denied for that.
|
|
3089
|
+
*
|
|
3090
|
+
* Additive only. When this entity ALSO has field security on, a mirror with no rows of its own
|
|
3091
|
+
* is already denied by the walk above (no rows on an enabled entity means denied); the parent's
|
|
3092
|
+
* rules cannot re-open it.
|
|
3093
|
+
*/
|
|
3094
|
+
addFieldsDeniedOnParent(denied, user, verb, isDenied) {
|
|
3095
|
+
const parent = this.ParentEntityInfo;
|
|
3096
|
+
if (!parent?.FieldLevelSecurityApplies) {
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
const parentDenied = parent.getCachedDeniedFields(user, verb, isDenied);
|
|
3100
|
+
if (parentDenied.size === 0) {
|
|
3101
|
+
return;
|
|
2507
3102
|
}
|
|
2508
3103
|
for (const field of this._fields) {
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
3104
|
+
const parentField = parent.FieldByName(field.Name);
|
|
3105
|
+
if (parentField && parentDenied.has(parentField.Name.trim().toLowerCase())) {
|
|
3106
|
+
this.addDeniedField(denied, field);
|
|
2512
3107
|
}
|
|
2513
3108
|
}
|
|
2514
|
-
|
|
3109
|
+
}
|
|
3110
|
+
/** Adds a field under BOTH key spaces — see {@link getDeniedFields} for why. */
|
|
3111
|
+
addDeniedField(denied, field) {
|
|
3112
|
+
denied.add(field.Name.trim().toLowerCase());
|
|
3113
|
+
denied.add(field.CodeName.trim().toLowerCase());
|
|
2515
3114
|
}
|
|
2516
3115
|
/**
|
|
2517
3116
|
* O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
|
|
@@ -2528,8 +3127,9 @@ export class EntityInfo extends BaseInfo {
|
|
|
2528
3127
|
if (name == null)
|
|
2529
3128
|
return undefined;
|
|
2530
3129
|
if (this._fieldByNameMap === null) {
|
|
3130
|
+
const fields = this._fields ?? this.Fields ?? [];
|
|
2531
3131
|
const map = new Map();
|
|
2532
|
-
for (const f of
|
|
3132
|
+
for (const f of fields) {
|
|
2533
3133
|
if (f.Name != null)
|
|
2534
3134
|
map.set(f.Name.trim().toLowerCase(), f);
|
|
2535
3135
|
}
|
|
@@ -2537,6 +3137,62 @@ export class EntityInfo extends BaseInfo {
|
|
|
2537
3137
|
}
|
|
2538
3138
|
return this._fieldByNameMap.get(name.trim().toLowerCase());
|
|
2539
3139
|
}
|
|
3140
|
+
ensureFieldIndexEntry() {
|
|
3141
|
+
const fields = this._fields ?? this.Fields ?? [];
|
|
3142
|
+
let entry = _entityFieldIndexCache.get(this);
|
|
3143
|
+
if (!entry || entry.fieldsRef !== fields || entry.fieldsLen !== fields.length) {
|
|
3144
|
+
const byName = new Map();
|
|
3145
|
+
const byCodeName = new Map();
|
|
3146
|
+
for (let i = 0; i < fields.length; i++) {
|
|
3147
|
+
const f = fields[i];
|
|
3148
|
+
if (f.Name != null) {
|
|
3149
|
+
const nameKey = f.Name.trim().toLowerCase();
|
|
3150
|
+
if (!byName.has(nameKey)) {
|
|
3151
|
+
byName.set(nameKey, i);
|
|
3152
|
+
}
|
|
3153
|
+
const rawCodeName = f.CodeName ?? f.Name;
|
|
3154
|
+
const codeKey = rawCodeName.trim().toLowerCase();
|
|
3155
|
+
if (!byCodeName.has(codeKey)) {
|
|
3156
|
+
byCodeName.set(codeKey, i);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
entry = { fieldsRef: fields, fieldsLen: fields.length, byName, byCodeName };
|
|
3161
|
+
_entityFieldIndexCache.set(this, entry);
|
|
3162
|
+
}
|
|
3163
|
+
return entry;
|
|
3164
|
+
}
|
|
3165
|
+
/**
|
|
3166
|
+
* O(1) case-insensitive lookup of a field's positional index in `Fields` by `Name`.
|
|
3167
|
+
* Shared across all `BaseEntity` instances of this `EntityInfo` so individual entity
|
|
3168
|
+
* instances do not need to allocate their own `Map<string, EntityField>` on hydration.
|
|
3169
|
+
*/
|
|
3170
|
+
FieldIndexByName(name) {
|
|
3171
|
+
if (!name)
|
|
3172
|
+
return -1;
|
|
3173
|
+
return this.ensureFieldIndexEntry().byName.get(name.trim().toLowerCase()) ?? -1;
|
|
3174
|
+
}
|
|
3175
|
+
/**
|
|
3176
|
+
* O(1) case-insensitive lookup of a field's positional index in `Fields` by `CodeName`.
|
|
3177
|
+
*/
|
|
3178
|
+
FieldIndexByCodeName(codeName) {
|
|
3179
|
+
if (!codeName)
|
|
3180
|
+
return -1;
|
|
3181
|
+
return this.ensureFieldIndexEntry().byCodeName.get(codeName.trim().toLowerCase()) ?? -1;
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* O(1) case-insensitive field lookup by `CodeName`. Uses the shared `_entityFieldIndexCache`
|
|
3185
|
+
* index so GraphQL resolvers and mutation handlers can resolve client `CodeName` keys without
|
|
3186
|
+
* linear `Fields.find(...)` scans.
|
|
3187
|
+
*
|
|
3188
|
+
* @param codeName field CodeName (matched case-insensitively, whitespace-trimmed)
|
|
3189
|
+
* @returns the matching EntityFieldInfo, or undefined if not found
|
|
3190
|
+
*/
|
|
3191
|
+
FieldByCodeName(codeName) {
|
|
3192
|
+
const idx = this.FieldIndexByCodeName(codeName);
|
|
3193
|
+
const fields = this._fields ?? this.Fields;
|
|
3194
|
+
return idx >= 0 && fields ? fields[idx] : undefined;
|
|
3195
|
+
}
|
|
2540
3196
|
/**
|
|
2541
3197
|
* Returns the primary key field for the entity. For entities with a composite primary key, use the PrimaryKeys property which returns all.
|
|
2542
3198
|
* In the case of a composite primary key, the PrimaryKey property will return the first field in the sequence of the primary key fields.
|
|
@@ -2715,6 +3371,53 @@ export class EntityInfo extends BaseInfo {
|
|
|
2715
3371
|
get OrganicKeys() {
|
|
2716
3372
|
return this._organicKeys;
|
|
2717
3373
|
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Gets all registered alternate base views for this entity, sorted by `Sequence`, `Name`, and `ID`.
|
|
3376
|
+
* Alternate base views maintain 100% column parity with {@link BaseView} while applying a
|
|
3377
|
+
* curated canonical filter or join.
|
|
3378
|
+
* @returns {EntityAlternateBaseViewInfo[]} Array of alternate base view metadata definitions
|
|
3379
|
+
*/
|
|
3380
|
+
get AlternateBaseViews() {
|
|
3381
|
+
return this._alternateBaseViews;
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Finds a registered alternate base view on this entity by its semantic `Name`
|
|
3385
|
+
* (case-insensitive, whitespace-trimmed).
|
|
3386
|
+
*
|
|
3387
|
+
* @param name Semantic name of the alternate base view (for example, `'Active'`).
|
|
3388
|
+
* @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
|
|
3389
|
+
*/
|
|
3390
|
+
GetAlternateBaseView(name) {
|
|
3391
|
+
const trimmed = name?.trim().toLowerCase();
|
|
3392
|
+
if (!trimmed) {
|
|
3393
|
+
return undefined;
|
|
3394
|
+
}
|
|
3395
|
+
return this._alternateBaseViews.find(v => v.Name?.trim().toLowerCase() === trimmed);
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* Finds a registered alternate base view on this entity by its physical SQL `ViewName`
|
|
3399
|
+
* (and optional `schemaName`), matched case-insensitively and whitespace-trimmed.
|
|
3400
|
+
*
|
|
3401
|
+
* @param viewName Physical SQL view name (for example, `'vwActiveMembers'`).
|
|
3402
|
+
* @param schemaName Optional schema name; when supplied, must match `EffectiveSchemaName` case-insensitively.
|
|
3403
|
+
* @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
|
|
3404
|
+
*/
|
|
3405
|
+
GetAlternateBaseViewByViewName(viewName, schemaName) {
|
|
3406
|
+
const trimmedView = viewName?.trim().toLowerCase();
|
|
3407
|
+
if (!trimmedView) {
|
|
3408
|
+
return undefined;
|
|
3409
|
+
}
|
|
3410
|
+
const trimmedSchema = schemaName?.trim().toLowerCase();
|
|
3411
|
+
return this._alternateBaseViews.find(v => {
|
|
3412
|
+
if (v.ViewName?.trim().toLowerCase() !== trimmedView) {
|
|
3413
|
+
return false;
|
|
3414
|
+
}
|
|
3415
|
+
if (trimmedSchema && trimmedSchema.length > 0) {
|
|
3416
|
+
return v.EffectiveSchemaName.trim().toLowerCase() === trimmedSchema;
|
|
3417
|
+
}
|
|
3418
|
+
return true;
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
2718
3421
|
static { this.__createdAtFieldName = '__mj_CreatedAt'; }
|
|
2719
3422
|
static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
|
|
2720
3423
|
static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
|
|
@@ -2859,7 +3562,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2859
3562
|
get ParentEntityInfo() {
|
|
2860
3563
|
if (!this.ParentID)
|
|
2861
3564
|
return null;
|
|
2862
|
-
const p =
|
|
3565
|
+
const p = MetadataProviderFor(this);
|
|
2863
3566
|
if (p?.EntityByID) {
|
|
2864
3567
|
return p.EntityByID(this.ParentID) ?? null;
|
|
2865
3568
|
}
|
|
@@ -2875,7 +3578,23 @@ export class EntityInfo extends BaseInfo {
|
|
|
2875
3578
|
* only one child type is allowed per parent record (disjoint subtypes).
|
|
2876
3579
|
*/
|
|
2877
3580
|
get ChildEntities() {
|
|
2878
|
-
|
|
3581
|
+
const provider = MetadataProviderFor(this);
|
|
3582
|
+
const entities = provider?.Entities;
|
|
3583
|
+
if (!entities || entities.length === 0) {
|
|
3584
|
+
return [];
|
|
3585
|
+
}
|
|
3586
|
+
const cached = _entityChildEntitiesCache.get(this);
|
|
3587
|
+
if (cached && cached.provider === provider && cached.entitiesRef === entities && cached.entitiesLen === entities.length) {
|
|
3588
|
+
return cached.children;
|
|
3589
|
+
}
|
|
3590
|
+
const children = entities.filter(e => UUIDsEqual(e.ParentID, this.ID));
|
|
3591
|
+
_entityChildEntitiesCache.set(this, {
|
|
3592
|
+
provider,
|
|
3593
|
+
entitiesRef: entities,
|
|
3594
|
+
entitiesLen: entities.length,
|
|
3595
|
+
children,
|
|
3596
|
+
});
|
|
3597
|
+
return children;
|
|
2879
3598
|
}
|
|
2880
3599
|
/**
|
|
2881
3600
|
* Convenience alias: returns true when this entity is a parent type that allows
|
|
@@ -2889,22 +3608,33 @@ export class EntityInfo extends BaseInfo {
|
|
|
2889
3608
|
* Walks the IS-A chain upward from this entity to the root, returning all parent entities.
|
|
2890
3609
|
* Does NOT include this entity itself.
|
|
2891
3610
|
* Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
|
|
2892
|
-
* Results are cached after first computation for performance
|
|
3611
|
+
* Results are cached after first computation for performance — but only a COMPLETE chain:
|
|
3612
|
+
* one that ends at an entity with no parent, or at a cycle. A walk cut short because a
|
|
3613
|
+
* `ParentID` did not resolve (asked before the provider had its entities) is returned but not
|
|
3614
|
+
* kept, so it cannot pin an incomplete answer; field security reads this chain to decide
|
|
3615
|
+
* whether an ancestor's rules apply.
|
|
2893
3616
|
*/
|
|
2894
3617
|
get ParentChain() {
|
|
2895
3618
|
if (this._parentChainCache !== null)
|
|
2896
3619
|
return this._parentChainCache;
|
|
2897
3620
|
const chain = [];
|
|
2898
3621
|
let current = this.ParentEntityInfo;
|
|
3622
|
+
let complete = !this.ParentID || current !== null;
|
|
2899
3623
|
const visited = new Set(); // circular reference protection
|
|
2900
3624
|
while (current) {
|
|
2901
3625
|
if (visited.has(current.ID))
|
|
2902
3626
|
break; // prevent infinite loop
|
|
2903
3627
|
visited.add(current.ID);
|
|
2904
3628
|
chain.push(current);
|
|
2905
|
-
|
|
3629
|
+
const next = current.ParentEntityInfo;
|
|
3630
|
+
if (current.ParentID && !next) {
|
|
3631
|
+
complete = false;
|
|
3632
|
+
}
|
|
3633
|
+
current = next;
|
|
3634
|
+
}
|
|
3635
|
+
if (complete) {
|
|
3636
|
+
this._parentChainCache = chain;
|
|
2906
3637
|
}
|
|
2907
|
-
this._parentChainCache = chain;
|
|
2908
3638
|
return chain;
|
|
2909
3639
|
}
|
|
2910
3640
|
/**
|
|
@@ -3048,23 +3778,21 @@ export class EntityInfo extends BaseInfo {
|
|
|
3048
3778
|
*/
|
|
3049
3779
|
GetUserPermisions(user) {
|
|
3050
3780
|
try {
|
|
3051
|
-
const
|
|
3052
|
-
|
|
3053
|
-
const ep = this.Permissions[j];
|
|
3054
|
-
const roleMatch = user.UserRoles?.find((r) => UUIDsEqual(r.RoleID, ep.RoleID));
|
|
3055
|
-
if (roleMatch) // user has this role
|
|
3056
|
-
permissionList.push(ep);
|
|
3057
|
-
}
|
|
3058
|
-
// Aggregate Allow and Deny separately, then subtract Deny from Allow per action.
|
|
3781
|
+
const _ = user.UserRoles; // preserve throw-on-null behavior
|
|
3782
|
+
const userRoleIds = getUserRoleIdSet(user);
|
|
3059
3783
|
const allow = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
3060
3784
|
const deny = { CanCreate: false, CanRead: false, CanUpdate: false, CanDelete: false };
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3785
|
+
if (userRoleIds.size > 0) {
|
|
3786
|
+
for (let j = 0; j < this.Permissions.length; j++) {
|
|
3787
|
+
const ep = this.Permissions[j];
|
|
3788
|
+
if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) {
|
|
3789
|
+
const bucket = ep.IsDeny ? deny : allow;
|
|
3790
|
+
bucket.CanCreate = bucket.CanCreate || !!ep.CanCreate;
|
|
3791
|
+
bucket.CanRead = bucket.CanRead || !!ep.CanRead;
|
|
3792
|
+
bucket.CanUpdate = bucket.CanUpdate || !!ep.CanUpdate;
|
|
3793
|
+
bucket.CanDelete = bucket.CanDelete || !!ep.CanDelete;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3068
3796
|
}
|
|
3069
3797
|
// An API-driven action also requires that the entity ALLOWS that action at all
|
|
3070
3798
|
// (`Allow*API`). No role grant can create/update/delete records of an entity whose
|
|
@@ -3084,6 +3812,45 @@ export class EntityInfo extends BaseInfo {
|
|
|
3084
3812
|
return null;
|
|
3085
3813
|
}
|
|
3086
3814
|
}
|
|
3815
|
+
/**
|
|
3816
|
+
* Checks whether the specified user has the requested permission (`Read`, `Create`, `Update`, or `Delete`)
|
|
3817
|
+
* on this entity. When `this.Permissions` is empty/uninitialized (e.g., in lightweight unit-test fixtures),
|
|
3818
|
+
* returns `true` so unconfigured fixtures are not blocked.
|
|
3819
|
+
*/
|
|
3820
|
+
CheckPermissions(user, type, throwError = false) {
|
|
3821
|
+
if (!user || !this.Permissions || this.Permissions.length === 0) {
|
|
3822
|
+
return true;
|
|
3823
|
+
}
|
|
3824
|
+
const perms = this.GetUserPermisions(user);
|
|
3825
|
+
if (!perms) {
|
|
3826
|
+
if (throwError) {
|
|
3827
|
+
throw new Error(`Permission check failed for user on entity ${this.Name}`);
|
|
3828
|
+
}
|
|
3829
|
+
return false;
|
|
3830
|
+
}
|
|
3831
|
+
let allowed = false;
|
|
3832
|
+
switch (type) {
|
|
3833
|
+
case EntityPermissionType.Read:
|
|
3834
|
+
allowed = perms.CanRead;
|
|
3835
|
+
break;
|
|
3836
|
+
case EntityPermissionType.Create:
|
|
3837
|
+
allowed = perms.CanCreate;
|
|
3838
|
+
break;
|
|
3839
|
+
case EntityPermissionType.Update:
|
|
3840
|
+
allowed = perms.CanUpdate;
|
|
3841
|
+
break;
|
|
3842
|
+
case EntityPermissionType.Delete:
|
|
3843
|
+
allowed = perms.CanDelete;
|
|
3844
|
+
break;
|
|
3845
|
+
default:
|
|
3846
|
+
allowed = false;
|
|
3847
|
+
break;
|
|
3848
|
+
}
|
|
3849
|
+
if (!allowed && throwError) {
|
|
3850
|
+
throw new Error(`User does not have ${type} permission on ${this.Name}`);
|
|
3851
|
+
}
|
|
3852
|
+
return allowed;
|
|
3853
|
+
}
|
|
3087
3854
|
/**
|
|
3088
3855
|
* Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
|
|
3089
3856
|
*
|
|
@@ -3099,13 +3866,17 @@ export class EntityInfo extends BaseInfo {
|
|
|
3099
3866
|
* @returns
|
|
3100
3867
|
*/
|
|
3101
3868
|
UserExemptFromRowLevelSecurity(user, type) {
|
|
3869
|
+
const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
|
|
3870
|
+
const userRoleIds = getUserRoleIdSet(user);
|
|
3871
|
+
if (userRoleIds.size === 0) {
|
|
3872
|
+
return false;
|
|
3873
|
+
}
|
|
3102
3874
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
3103
3875
|
const ep = this.Permissions[j];
|
|
3104
3876
|
if (ep.IsDeny) {
|
|
3105
3877
|
continue; // a Deny row's Can* flags are denials, never grants — it cannot exempt anyone
|
|
3106
3878
|
}
|
|
3107
|
-
|
|
3108
|
-
if (roleMatch) { // user has this role
|
|
3879
|
+
if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
|
|
3109
3880
|
switch (type) {
|
|
3110
3881
|
case EntityPermissionType.Create:
|
|
3111
3882
|
if (ep.CanCreate && !ep.CreateRLSFilterID)
|
|
@@ -3146,14 +3917,18 @@ export class EntityInfo extends BaseInfo {
|
|
|
3146
3917
|
* @returns
|
|
3147
3918
|
*/
|
|
3148
3919
|
GetUserRowLevelSecurityInfo(user, type) {
|
|
3920
|
+
const _ = user.UserRoles; // preserve throw-on-null behavior if user is null/undefined
|
|
3149
3921
|
const rlsList = [];
|
|
3922
|
+
const userRoleIds = getUserRoleIdSet(user);
|
|
3923
|
+
if (userRoleIds.size === 0) {
|
|
3924
|
+
return rlsList;
|
|
3925
|
+
}
|
|
3150
3926
|
for (let j = 0; j < this.Permissions.length; j++) {
|
|
3151
3927
|
const ep = this.Permissions[j];
|
|
3152
3928
|
if (ep.IsDeny) {
|
|
3153
3929
|
continue; // never a grant — see the doc comment
|
|
3154
3930
|
}
|
|
3155
|
-
|
|
3156
|
-
if (roleMatch) { // user has this role
|
|
3931
|
+
if (ep.RoleID && userRoleIds.has(ep.RoleID.trim().toLowerCase())) { // user has this role
|
|
3157
3932
|
let matchObject = null;
|
|
3158
3933
|
switch (type) {
|
|
3159
3934
|
case EntityPermissionType.Create:
|
|
@@ -3269,7 +4044,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3269
4044
|
const clauses = [...mine]
|
|
3270
4045
|
.sort((a, b) => a.FilterID.localeCompare(b.FilterID)) // deterministic order for the cache fingerprint (INV-2)
|
|
3271
4046
|
.map(b => {
|
|
3272
|
-
const filter =
|
|
4047
|
+
const filter = MetadataProviderFor(this)?.RowLevelSecurityFilters?.find(f => UUIDsEqual(f.ID, b.FilterID));
|
|
3273
4048
|
if (!filter) {
|
|
3274
4049
|
LogError(`API-key row filter ${b.FilterID} for entity ${this.Name} not found in metadata — resolving to (1=0)`);
|
|
3275
4050
|
return '(1=0)';
|
|
@@ -3441,7 +4216,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3441
4216
|
const conditions = [];
|
|
3442
4217
|
// Resolve the spoke entity's own organic key (matching by Name) to pull its
|
|
3443
4218
|
// per-column normalization. Falls back to the hub's expression if not found.
|
|
3444
|
-
const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey);
|
|
4219
|
+
const spokeOrganicKey = EntityInfo.resolveSpokeOrganicKey(relatedEntity, organicKey, record.EntityInfo?.OwningProvider);
|
|
3445
4220
|
for (let i = 0; i < matchFields.length; i++) {
|
|
3446
4221
|
const value = record.Get(matchFields[i]);
|
|
3447
4222
|
if (value == null) {
|
|
@@ -3565,7 +4340,21 @@ export class EntityInfo extends BaseInfo {
|
|
|
3565
4340
|
default: return `'${escapedValue}'`;
|
|
3566
4341
|
}
|
|
3567
4342
|
}
|
|
3568
|
-
|
|
4343
|
+
static { this.CHILD_INIT_KEYS = new Set([
|
|
4344
|
+
'EntityFields', '_Fields', 'Fields',
|
|
4345
|
+
'EntityPermissions', '_Permissions', 'Permissions',
|
|
4346
|
+
'EntitySettings', '_Settings', 'Settings',
|
|
4347
|
+
'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
|
|
4348
|
+
'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
|
|
4349
|
+
'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
|
|
4350
|
+
]); }
|
|
4351
|
+
/**
|
|
4352
|
+
* @param initData - the raw entity row (or another EntityInfo being re-instantiated)
|
|
4353
|
+
* @param md - the provider that owns this metadata. Sibling lookups ({@link ParentEntityInfo},
|
|
4354
|
+
* {@link ChildEntities}, RLS filters) resolve through it rather than the global provider, so
|
|
4355
|
+
* they stay correct in a multi-provider process. Optional for backward compatibility.
|
|
4356
|
+
*/
|
|
4357
|
+
constructor(initData = null, md) {
|
|
3569
4358
|
super();
|
|
3570
4359
|
/**
|
|
3571
4360
|
* Unique identifier for the entity
|
|
@@ -3715,19 +4504,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3715
4504
|
* client-side IndexedDB cache. Zero overhead on hot save/query paths.
|
|
3716
4505
|
*/
|
|
3717
4506
|
this.AllowCaching = false;
|
|
3718
|
-
|
|
3719
|
-
* Whether field-level (column-level) security is enforced for this entity.
|
|
3720
|
-
*
|
|
3721
|
-
* This is the single gate every field-security enforcement point checks first, and it is
|
|
3722
|
-
* explicit — never inferred from whether permission rows happen to exist. It is `false` for
|
|
3723
|
-
* nearly every entity in nearly every deployment, so enforcement collapses to one boolean
|
|
3724
|
-
* test: no field iteration, no aggregation, no allocation.
|
|
3725
|
-
*
|
|
3726
|
-
* Turning it on snapshots the entity's existing entity-level permissions into per-field
|
|
3727
|
-
* rows, so enabling changes no behavior until an administrator tightens a field. Turning it
|
|
3728
|
-
* off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
|
|
3729
|
-
*/
|
|
3730
|
-
this.EnableFieldLevelSecurity = false;
|
|
4507
|
+
this._enableFieldLevelSecurity = false;
|
|
3731
4508
|
/**
|
|
3732
4509
|
* Whether this entity is available through the GraphQL API
|
|
3733
4510
|
*/
|
|
@@ -4019,6 +4796,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4019
4796
|
this.ParentBaseView = null;
|
|
4020
4797
|
this._fieldCategories = null;
|
|
4021
4798
|
this._organicKeys = [];
|
|
4799
|
+
this._alternateBaseViews = [];
|
|
4022
4800
|
this.HasIdField = false;
|
|
4023
4801
|
this.VirtualCount = 0;
|
|
4024
4802
|
this.ManyToManyCount = 0;
|
|
@@ -4039,14 +4817,20 @@ export class EntityInfo extends BaseInfo {
|
|
|
4039
4817
|
this._nameFieldCache = undefined;
|
|
4040
4818
|
/** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
|
|
4041
4819
|
this._displayNamePluralCache = undefined;
|
|
4820
|
+
/** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
|
|
4821
|
+
this._liveAncestorsMemo = null;
|
|
4042
4822
|
this._hasInactiveFields = undefined;
|
|
4043
4823
|
this._hasSearchFields = undefined;
|
|
4044
4824
|
// Cache for ParentChain to avoid repeated walks
|
|
4045
4825
|
this._parentChainCache = null;
|
|
4046
4826
|
// Cache for ParentEntityFieldNames
|
|
4047
4827
|
this._parentEntityFieldNamesCache = null;
|
|
4828
|
+
// A new entity can replace one that memoized field-security answers point at (a metadata
|
|
4829
|
+
// refresh, an IS-A ancestor rebuilt), so building one moves the generation.
|
|
4830
|
+
advanceFieldSecurityGeneration();
|
|
4831
|
+
this.SetOwningProvider(md);
|
|
4048
4832
|
if (initData) {
|
|
4049
|
-
this.copyInitData(initData);
|
|
4833
|
+
this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
|
|
4050
4834
|
// do some special handling to create class instances instead of just data objects
|
|
4051
4835
|
// copy the Entity Fields (accept EntityFields, _Fields, or Fields as input names)
|
|
4052
4836
|
this._fields = [];
|
|
@@ -4077,7 +4861,9 @@ export class EntityInfo extends BaseInfo {
|
|
|
4077
4861
|
const ef = initData.EntityFields || initData._Fields || initData.Fields;
|
|
4078
4862
|
if (ef) {
|
|
4079
4863
|
for (let j = 0; j < ef.length; j++) {
|
|
4080
|
-
|
|
4864
|
+
const field = new EntityFieldInfo(ef[j]);
|
|
4865
|
+
field.SetOwningProvider(md);
|
|
4866
|
+
this._fields.push(field);
|
|
4081
4867
|
}
|
|
4082
4868
|
}
|
|
4083
4869
|
// copy the Entity Permissions
|
|
@@ -4085,7 +4871,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4085
4871
|
const ep = initData.EntityPermissions || initData._Permissions || initData.Permissions;
|
|
4086
4872
|
if (ep) {
|
|
4087
4873
|
for (let j = 0; j < ep.length; j++) {
|
|
4088
|
-
this._permissions.push(new EntityPermissionInfo(ep[j]));
|
|
4874
|
+
this._permissions.push(new EntityPermissionInfo(ep[j], md));
|
|
4089
4875
|
}
|
|
4090
4876
|
}
|
|
4091
4877
|
// copy the Entity settings
|
|
@@ -4131,6 +4917,22 @@ export class EntityInfo extends BaseInfo {
|
|
|
4131
4917
|
this._organicKeys.push(new EntityOrganicKeyInfo(item));
|
|
4132
4918
|
}
|
|
4133
4919
|
}
|
|
4920
|
+
// copy the Alternate Base Views (sorted by Sequence, Name, ID, then linked via ParentID)
|
|
4921
|
+
this._alternateBaseViews = [];
|
|
4922
|
+
const abv = initData.EntityAlternateBaseViews || initData._AlternateBaseViews || initData.AlternateBaseViews;
|
|
4923
|
+
if (abv && Array.isArray(abv)) {
|
|
4924
|
+
for (const item of abv) {
|
|
4925
|
+
const viewInfo = new EntityAlternateBaseViewInfo(item, this.SchemaName);
|
|
4926
|
+
viewInfo.SetOwningProvider(md);
|
|
4927
|
+
this._alternateBaseViews.push(viewInfo);
|
|
4928
|
+
}
|
|
4929
|
+
this._alternateBaseViews.sort((a, b) => {
|
|
4930
|
+
const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 0;
|
|
4931
|
+
const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 0;
|
|
4932
|
+
return (aSeq - bSeq) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID);
|
|
4933
|
+
});
|
|
4934
|
+
EntityAlternateBaseViewInfo.LinkHierarchy(this._alternateBaseViews, this.SchemaName);
|
|
4935
|
+
}
|
|
4134
4936
|
this.prepareSpecialFields();
|
|
4135
4937
|
}
|
|
4136
4938
|
}
|