@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/BaseEntitySaveQueue.d.ts +15 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +21 -0
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +53 -9
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +194 -35
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +153 -17
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +345 -46
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +66 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +159 -12
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +228 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +574 -65
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/interfaces.d.ts +14 -1
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +80 -11
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +209 -38
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +254 -56
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +698 -195
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +13 -0
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +13 -0
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +11 -0
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +15 -0
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
|
@@ -4,7 +4,7 @@ import { MetadataProviderFor } from "./infoProviderResolution.js";
|
|
|
4
4
|
import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
|
|
5
5
|
import { IsFixedWidthStringSQLType } from "@memberjunction/sql-dialect";
|
|
6
6
|
import { LogError } from "./logging.js";
|
|
7
|
-
import { WarningManager, SafeJSONParse, UUIDsEqual, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
|
|
7
|
+
import { WarningManager, SafeJSONParse, UUIDsEqual, NormalizeUUID, ordinalCompare, GeneratePluralName } from "@memberjunction/global";
|
|
8
8
|
import { ReadRelationshipJoinFields, } from "./entityConfiguration.js";
|
|
9
9
|
/**
|
|
10
10
|
* Runtime domain for {@link EntityFieldInfo.ExtendedType}. This array is the single source of
|
|
@@ -306,6 +306,186 @@ export class EntityOrganicKeyRelatedEntityInfo extends BaseInfo {
|
|
|
306
306
|
}
|
|
307
307
|
}
|
|
308
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
|
+
}
|
|
309
489
|
export const EntityPermissionType = {
|
|
310
490
|
Read: 'Read',
|
|
311
491
|
Create: 'Create',
|
|
@@ -463,9 +643,16 @@ export const ReadableFieldsTransportKey = 'ReadableFields___';
|
|
|
463
643
|
* set and aggregate it — see {@link EntityFieldInfo.AggregateFieldRulesForUser}.
|
|
464
644
|
*/
|
|
465
645
|
export function IsRestrictingFieldRule(rule) {
|
|
466
|
-
return (rule?.ReadAccess
|
|
467
|
-
|
|
468
|
-
|
|
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';
|
|
469
656
|
}
|
|
470
657
|
/**
|
|
471
658
|
* The single wording for "you cannot use this field", modelled on SQL Server's posture of never
|
|
@@ -2228,6 +2415,64 @@ function getUserRoleIdSet(user) {
|
|
|
2228
2415
|
}
|
|
2229
2416
|
const _entityFieldIndexCache = new WeakMap();
|
|
2230
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++;
|
|
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
|
+
}
|
|
2231
2476
|
const _entityChildEntitiesCache = new WeakMap();
|
|
2232
2477
|
/**
|
|
2233
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.
|
|
@@ -2354,6 +2599,28 @@ export class EntityInfo extends BaseInfo {
|
|
|
2354
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.`);
|
|
2355
2600
|
return false;
|
|
2356
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
|
+
}
|
|
2357
2624
|
/**
|
|
2358
2625
|
* True when at least one field is a writable Geo* source (street and/or native lat/lng).
|
|
2359
2626
|
* Person/Org PrimaryAddress* are virtual display fields and do **not** count.
|
|
@@ -2535,6 +2802,87 @@ export class EntityInfo extends BaseInfo {
|
|
|
2535
2802
|
set _floatCount(value) {
|
|
2536
2803
|
this.FloatCount = value;
|
|
2537
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
|
+
}
|
|
2538
2886
|
/**
|
|
2539
2887
|
* The set of field names this user may NOT READ on this entity — the per-request primitive
|
|
2540
2888
|
* every field-security enforcement point is built on.
|
|
@@ -2545,8 +2893,9 @@ export class EntityInfo extends BaseInfo {
|
|
|
2545
2893
|
* re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
|
|
2546
2894
|
*
|
|
2547
2895
|
* Names are lowercased so callers can match case-insensitively, consistent with
|
|
2548
|
-
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when
|
|
2549
|
-
*
|
|
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
|
|
2550
2899
|
* `size === 0` as the single "nothing to do" condition.
|
|
2551
2900
|
*/
|
|
2552
2901
|
GetDeniedReadFields(user) {
|
|
@@ -2566,10 +2915,10 @@ export class EntityInfo extends BaseInfo {
|
|
|
2566
2915
|
* The caller still decides what to render in place of the value, which is the part that
|
|
2567
2916
|
* differs per surface and should not be hidden inside a getter.
|
|
2568
2917
|
*
|
|
2569
|
-
* **Fails open** on a missing user, a missing field name, or an entity
|
|
2570
|
-
*
|
|
2571
|
-
* the real boundary; a UI that blanked out fields
|
|
2572
|
-
* 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.
|
|
2573
2922
|
*
|
|
2574
2923
|
* PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
|
|
2575
2924
|
* entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
|
|
@@ -2579,7 +2928,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2579
2928
|
* @param user the acting user
|
|
2580
2929
|
*/
|
|
2581
2930
|
IsFieldReadableByUser(fieldName, user) {
|
|
2582
|
-
if (!this.
|
|
2931
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2583
2932
|
return true;
|
|
2584
2933
|
}
|
|
2585
2934
|
return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2591,7 +2940,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2591
2940
|
* three conditions.
|
|
2592
2941
|
*/
|
|
2593
2942
|
IsFieldUpdatableByUser(fieldName, user) {
|
|
2594
|
-
if (!this.
|
|
2943
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2595
2944
|
return true;
|
|
2596
2945
|
}
|
|
2597
2946
|
return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2605,7 +2954,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2605
2954
|
* the user typed, which is the case this exists to prevent.
|
|
2606
2955
|
*/
|
|
2607
2956
|
IsFieldCreatableByUser(fieldName, user) {
|
|
2608
|
-
if (!this.
|
|
2957
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2609
2958
|
return true;
|
|
2610
2959
|
}
|
|
2611
2960
|
return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2631,51 +2980,67 @@ export class EntityInfo extends BaseInfo {
|
|
|
2631
2980
|
GetDeniedCreateFields(user) {
|
|
2632
2981
|
return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
|
|
2633
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
|
+
*/
|
|
2634
3005
|
getCachedDeniedFields(user, verb, isDenied) {
|
|
2635
|
-
if (!this.
|
|
2636
|
-
return new Set();
|
|
3006
|
+
if (!this.FieldLevelSecurityApplies) {
|
|
3007
|
+
return new Set(); // gate on inheritance too: an ancestor's rules still apply
|
|
2637
3008
|
}
|
|
2638
3009
|
if (!user || typeof user !== 'object') {
|
|
2639
|
-
return this.getDeniedFields(user, isDenied);
|
|
3010
|
+
return this.getDeniedFields(user, verb, isDenied);
|
|
2640
3011
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
3012
|
+
const entry = this.deniedFieldsMemoEntryFor(user);
|
|
3013
|
+
const existing = entry[verb];
|
|
3014
|
+
if (existing !== undefined) {
|
|
3015
|
+
return existing;
|
|
2644
3016
|
}
|
|
2645
|
-
const
|
|
2646
|
-
|
|
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);
|
|
2647
3026
|
let byUser = _entityDeniedFieldsCache.get(this);
|
|
2648
3027
|
if (!byUser) {
|
|
2649
3028
|
byUser = new WeakMap();
|
|
2650
3029
|
_entityDeniedFieldsCache.set(this, byUser);
|
|
2651
3030
|
}
|
|
2652
3031
|
let entry = byUser.get(user);
|
|
2653
|
-
if (!entry ||
|
|
2654
|
-
entry.
|
|
2655
|
-
entry
|
|
2656
|
-
entry.totalPerms !== totalPerms ||
|
|
2657
|
-
entry.rolesRef !== rolesRef ||
|
|
2658
|
-
entry.rolesLen !== rolesLen) {
|
|
2659
|
-
entry = {
|
|
2660
|
-
fieldsRef: this._fields,
|
|
2661
|
-
fieldsLen: this._fields.length,
|
|
2662
|
-
totalPerms,
|
|
2663
|
-
rolesRef,
|
|
2664
|
-
rolesLen,
|
|
2665
|
-
};
|
|
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 };
|
|
2666
3035
|
byUser.set(user, entry);
|
|
2667
3036
|
}
|
|
2668
|
-
|
|
2669
|
-
if (existing !== undefined) {
|
|
2670
|
-
return existing;
|
|
2671
|
-
}
|
|
2672
|
-
const computed = this.getDeniedFields(user, isDenied);
|
|
2673
|
-
entry[verb] = computed;
|
|
2674
|
-
return computed;
|
|
3037
|
+
return entry;
|
|
2675
3038
|
}
|
|
2676
3039
|
/**
|
|
2677
3040
|
* Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
|
|
2678
|
-
* {@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}).
|
|
2679
3044
|
*
|
|
2680
3045
|
* Every field is aggregated, including those carrying no permission records — on an enabled
|
|
2681
3046
|
* entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
|
|
@@ -2698,18 +3063,54 @@ export class EntityInfo extends BaseInfo {
|
|
|
2698
3063
|
* that collide on `CodeName` already collide on their generated property, which is not a
|
|
2699
3064
|
* schema CodeGen can emit.
|
|
2700
3065
|
*/
|
|
2701
|
-
getDeniedFields(user, isDenied) {
|
|
3066
|
+
getDeniedFields(user, verb, isDenied) {
|
|
2702
3067
|
const denied = new Set();
|
|
2703
|
-
if (
|
|
2704
|
-
|
|
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;
|
|
2705
3102
|
}
|
|
2706
3103
|
for (const field of this._fields) {
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
3104
|
+
const parentField = parent.FieldByName(field.Name);
|
|
3105
|
+
if (parentField && parentDenied.has(parentField.Name.trim().toLowerCase())) {
|
|
3106
|
+
this.addDeniedField(denied, field);
|
|
2710
3107
|
}
|
|
2711
3108
|
}
|
|
2712
|
-
|
|
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());
|
|
2713
3114
|
}
|
|
2714
3115
|
/**
|
|
2715
3116
|
* O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
|
|
@@ -2970,6 +3371,53 @@ export class EntityInfo extends BaseInfo {
|
|
|
2970
3371
|
get OrganicKeys() {
|
|
2971
3372
|
return this._organicKeys;
|
|
2972
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
|
+
}
|
|
2973
3421
|
static { this.__createdAtFieldName = '__mj_CreatedAt'; }
|
|
2974
3422
|
static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
|
|
2975
3423
|
static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
|
|
@@ -3160,22 +3608,33 @@ export class EntityInfo extends BaseInfo {
|
|
|
3160
3608
|
* Walks the IS-A chain upward from this entity to the root, returning all parent entities.
|
|
3161
3609
|
* Does NOT include this entity itself.
|
|
3162
3610
|
* Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
|
|
3163
|
-
* 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.
|
|
3164
3616
|
*/
|
|
3165
3617
|
get ParentChain() {
|
|
3166
3618
|
if (this._parentChainCache !== null)
|
|
3167
3619
|
return this._parentChainCache;
|
|
3168
3620
|
const chain = [];
|
|
3169
3621
|
let current = this.ParentEntityInfo;
|
|
3622
|
+
let complete = !this.ParentID || current !== null;
|
|
3170
3623
|
const visited = new Set(); // circular reference protection
|
|
3171
3624
|
while (current) {
|
|
3172
3625
|
if (visited.has(current.ID))
|
|
3173
3626
|
break; // prevent infinite loop
|
|
3174
3627
|
visited.add(current.ID);
|
|
3175
3628
|
chain.push(current);
|
|
3176
|
-
|
|
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;
|
|
3177
3637
|
}
|
|
3178
|
-
this._parentChainCache = chain;
|
|
3179
3638
|
return chain;
|
|
3180
3639
|
}
|
|
3181
3640
|
/**
|
|
@@ -3353,6 +3812,45 @@ export class EntityInfo extends BaseInfo {
|
|
|
3353
3812
|
return null;
|
|
3354
3813
|
}
|
|
3355
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
|
+
}
|
|
3356
3854
|
/**
|
|
3357
3855
|
* Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
|
|
3358
3856
|
*
|
|
@@ -3848,6 +4346,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3848
4346
|
'EntitySettings', '_Settings', 'Settings',
|
|
3849
4347
|
'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
|
|
3850
4348
|
'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
|
|
4349
|
+
'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
|
|
3851
4350
|
]); }
|
|
3852
4351
|
/**
|
|
3853
4352
|
* @param initData - the raw entity row (or another EntityInfo being re-instantiated)
|
|
@@ -4005,19 +4504,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4005
4504
|
* client-side IndexedDB cache. Zero overhead on hot save/query paths.
|
|
4006
4505
|
*/
|
|
4007
4506
|
this.AllowCaching = false;
|
|
4008
|
-
|
|
4009
|
-
* Whether field-level (column-level) security is enforced for this entity.
|
|
4010
|
-
*
|
|
4011
|
-
* This is the single gate every field-security enforcement point checks first, and it is
|
|
4012
|
-
* explicit — never inferred from whether permission rows happen to exist. It is `false` for
|
|
4013
|
-
* nearly every entity in nearly every deployment, so enforcement collapses to one boolean
|
|
4014
|
-
* test: no field iteration, no aggregation, no allocation.
|
|
4015
|
-
*
|
|
4016
|
-
* Turning it on snapshots the entity's existing entity-level permissions into per-field
|
|
4017
|
-
* rows, so enabling changes no behavior until an administrator tightens a field. Turning it
|
|
4018
|
-
* off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
|
|
4019
|
-
*/
|
|
4020
|
-
this.EnableFieldLevelSecurity = false;
|
|
4507
|
+
this._enableFieldLevelSecurity = false;
|
|
4021
4508
|
/**
|
|
4022
4509
|
* Whether this entity is available through the GraphQL API
|
|
4023
4510
|
*/
|
|
@@ -4309,6 +4796,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4309
4796
|
this.ParentBaseView = null;
|
|
4310
4797
|
this._fieldCategories = null;
|
|
4311
4798
|
this._organicKeys = [];
|
|
4799
|
+
this._alternateBaseViews = [];
|
|
4312
4800
|
this.HasIdField = false;
|
|
4313
4801
|
this.VirtualCount = 0;
|
|
4314
4802
|
this.ManyToManyCount = 0;
|
|
@@ -4329,12 +4817,17 @@ export class EntityInfo extends BaseInfo {
|
|
|
4329
4817
|
this._nameFieldCache = undefined;
|
|
4330
4818
|
/** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
|
|
4331
4819
|
this._displayNamePluralCache = undefined;
|
|
4820
|
+
/** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
|
|
4821
|
+
this._liveAncestorsMemo = null;
|
|
4332
4822
|
this._hasInactiveFields = undefined;
|
|
4333
4823
|
this._hasSearchFields = undefined;
|
|
4334
4824
|
// Cache for ParentChain to avoid repeated walks
|
|
4335
4825
|
this._parentChainCache = null;
|
|
4336
4826
|
// Cache for ParentEntityFieldNames
|
|
4337
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();
|
|
4338
4831
|
this.SetOwningProvider(md);
|
|
4339
4832
|
if (initData) {
|
|
4340
4833
|
this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
|
|
@@ -4424,6 +4917,22 @@ export class EntityInfo extends BaseInfo {
|
|
|
4424
4917
|
this._organicKeys.push(new EntityOrganicKeyInfo(item));
|
|
4425
4918
|
}
|
|
4426
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
|
+
}
|
|
4427
4936
|
this.prepareSpecialFields();
|
|
4428
4937
|
}
|
|
4429
4938
|
}
|