@memberjunction/core 6.2.0-edge.5 → 6.2.0-edge.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/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/EntityFieldRules.d.ts.map +1 -1
- package/dist/generic/EntityFieldRules.js +1 -0
- package/dist/generic/EntityFieldRules.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 +171 -17
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +722 -117
- 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 +216 -21
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +969 -100
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseRemotableOperation.d.ts +95 -6
- package/dist/generic/baseRemotableOperation.d.ts.map +1 -1
- package/dist/generic/baseRemotableOperation.js +112 -8
- package/dist/generic/baseRemotableOperation.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 -5
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +381 -52
- 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 +234 -9
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +592 -77
- 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/externalDataSourceReadRouter.d.ts +10 -0
- package/dist/generic/externalDataSourceReadRouter.d.ts.map +1 -1
- package/dist/generic/externalDataSourceReadRouter.js +12 -0
- package/dist/generic/externalDataSourceReadRouter.js.map +1 -1
- package/dist/generic/fieldSecuritySQLGate.d.ts +359 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +902 -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 +64 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/listMembershipFilter.d.ts +5 -0
- package/dist/generic/listMembershipFilter.d.ts.map +1 -0
- package/dist/generic/listMembershipFilter.js +23 -0
- package/dist/generic/listMembershipFilter.js.map +1 -0
- package/dist/generic/localCacheManager.d.ts +126 -14
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +348 -48
- 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 +408 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1051 -310
- 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 +18 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +55 -7
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/remoteOperationDispatch.d.ts.map +1 -1
- package/dist/generic/remoteOperationDispatch.js +14 -2
- package/dist/generic/remoteOperationDispatch.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 +33 -6
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +119 -29
- 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/generic/transportError.d.ts +41 -0
- package/dist/generic/transportError.d.ts.map +1 -0
- package/dist/generic/transportError.js +167 -0
- package/dist/generic/transportError.js.map +1 -0
- package/dist/generic/util.d.ts +12 -0
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +52 -3
- package/dist/generic/util.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +102 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +40 -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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { BaseInfo } from "./baseInfo.js";
|
|
2
2
|
import { Metadata } from "./metadata.js";
|
|
3
3
|
import { MetadataProviderFor } from "./infoProviderResolution.js";
|
|
4
|
-
import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString } from "./util.js";
|
|
4
|
+
import { TypeScriptTypeFromSQLType, SQLFullType, SQLMaxLength, SQLMaxByteLength, FormatValue, CodeNameFromString, CanonicalPrimaryKeyValue } 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
|
|
@@ -1508,6 +1695,14 @@ export class EntityFieldInfo extends BaseInfo {
|
|
|
1508
1695
|
this._isUniqueIdentifierCache = t === 'uniqueidentifier' || t === 'uuid';
|
|
1509
1696
|
return this._isUniqueIdentifierCache;
|
|
1510
1697
|
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Returns the canonical string lookup key for a value of this field when matching records
|
|
1700
|
+
* across heterogeneous sources (e.g., SQL Server upper-case vs. PostgreSQL lower-case UUIDs,
|
|
1701
|
+
* while preserving exact case for non-UUID string keys).
|
|
1702
|
+
*/
|
|
1703
|
+
FormatCanonicalValue(value) {
|
|
1704
|
+
return CanonicalPrimaryKeyValue(String(value ?? ''), this.IsUniqueIdentifier);
|
|
1705
|
+
}
|
|
1511
1706
|
/**
|
|
1512
1707
|
* Returns true if the field's SQL type is **fixed-width / space-padded**
|
|
1513
1708
|
* (SQL Server `char`/`nchar`, PostgreSQL `char`/`character`/`bpchar`).
|
|
@@ -2228,6 +2423,64 @@ function getUserRoleIdSet(user) {
|
|
|
2228
2423
|
}
|
|
2229
2424
|
const _entityFieldIndexCache = new WeakMap();
|
|
2230
2425
|
const _entityDeniedFieldsCache = new WeakMap();
|
|
2426
|
+
/**
|
|
2427
|
+
* The field-security generation: one process-wide number that moves whenever an answer derived
|
|
2428
|
+
* from field-security configuration may have changed. It moves when an `EntityInfo` is built (a
|
|
2429
|
+
* metadata refresh rebuilds every entity), when an entity's {@link EntityInfo.EnableFieldLevelSecurity}
|
|
2430
|
+
* flag is assigned, and when {@link EntityInfo.InvalidateFieldSecurityCache} is called. Every memo
|
|
2431
|
+
* of a field-security answer records the number it was filled under and is discarded once the
|
|
2432
|
+
* number has moved, so a memo is never served across any of those changes. Numbers only grow.
|
|
2433
|
+
*/
|
|
2434
|
+
let _fieldSecurityGeneration = 0;
|
|
2435
|
+
/** Marks every memoized field-security answer stale (see {@link CurrentFieldSecurityGeneration}). */
|
|
2436
|
+
function advanceFieldSecurityGeneration() {
|
|
2437
|
+
_fieldSecurityGeneration++;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* The current field-security generation. A memo of anything derived from field-security
|
|
2441
|
+
* configuration (denied sets, whether field security applies, a user's denial profile across a
|
|
2442
|
+
* catalog) is valid only while this returns the number the memo was filled under. It moves when an
|
|
2443
|
+
* `EntityInfo` is built, when an entity's `EnableFieldLevelSecurity` flag is assigned, and when
|
|
2444
|
+
* `EntityInfo.InvalidateFieldSecurityCache()` is called.
|
|
2445
|
+
*/
|
|
2446
|
+
export function CurrentFieldSecurityGeneration() {
|
|
2447
|
+
return _fieldSecurityGeneration;
|
|
2448
|
+
}
|
|
2449
|
+
const NO_ANCESTORS = Object.freeze([]);
|
|
2450
|
+
/** Whether two ancestor lists hold the same entities, by identity, in the same order. */
|
|
2451
|
+
function sameAncestors(a, b) {
|
|
2452
|
+
if (a === b) {
|
|
2453
|
+
return true;
|
|
2454
|
+
}
|
|
2455
|
+
if (a.length !== b.length) {
|
|
2456
|
+
return false;
|
|
2457
|
+
}
|
|
2458
|
+
for (let i = 0; i < a.length; i++) {
|
|
2459
|
+
if (a[i] !== b[i]) {
|
|
2460
|
+
return false;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
return true;
|
|
2464
|
+
}
|
|
2465
|
+
/**
|
|
2466
|
+
* The role IDs a user holds, in order, as one string: changes whenever a role is added, removed
|
|
2467
|
+
* or swapped. Field-level security reads nothing else about a user, so this, with
|
|
2468
|
+
* {@link CurrentFieldSecurityGeneration}, keys any memo of a user's field-security answers.
|
|
2469
|
+
*/
|
|
2470
|
+
export function FieldSecurityUserRolesKey(user) {
|
|
2471
|
+
return userRolesKey(user);
|
|
2472
|
+
}
|
|
2473
|
+
function userRolesKey(user) {
|
|
2474
|
+
const roles = user.UserRoles;
|
|
2475
|
+
if (!roles || roles.length === 0) {
|
|
2476
|
+
return '';
|
|
2477
|
+
}
|
|
2478
|
+
let key = '';
|
|
2479
|
+
for (let i = 0; i < roles.length; i++) {
|
|
2480
|
+
key += `${roles[i]?.RoleID ?? ''}|`;
|
|
2481
|
+
}
|
|
2482
|
+
return key;
|
|
2483
|
+
}
|
|
2231
2484
|
const _entityChildEntitiesCache = new WeakMap();
|
|
2232
2485
|
/**
|
|
2233
2486
|
* 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 +2607,28 @@ export class EntityInfo extends BaseInfo {
|
|
|
2354
2607
|
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
2608
|
return false;
|
|
2356
2609
|
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Whether field-level (column-level) security is enforced for this entity.
|
|
2612
|
+
*
|
|
2613
|
+
* This is the single gate every field-security enforcement point checks first, and it is
|
|
2614
|
+
* explicit — never inferred from whether permission rows happen to exist. It is `false` for
|
|
2615
|
+
* nearly every entity in nearly every deployment, so enforcement collapses to one boolean
|
|
2616
|
+
* test: no field iteration, no aggregation, no allocation.
|
|
2617
|
+
*
|
|
2618
|
+
* Turning it on snapshots the entity's existing entity-level permissions into per-field
|
|
2619
|
+
* rows, so enabling changes no behavior until an administrator tightens a field. Turning it
|
|
2620
|
+
* off leaves the rows in place, inactive, so re-enabling does not lose the configuration.
|
|
2621
|
+
*
|
|
2622
|
+
* Assigning it moves the field-security generation ({@link CurrentFieldSecurityGeneration}),
|
|
2623
|
+
* so every memoized field-security answer that read the old value is recomputed.
|
|
2624
|
+
*/
|
|
2625
|
+
get EnableFieldLevelSecurity() {
|
|
2626
|
+
return this._enableFieldLevelSecurity;
|
|
2627
|
+
}
|
|
2628
|
+
set EnableFieldLevelSecurity(value) {
|
|
2629
|
+
this._enableFieldLevelSecurity = value;
|
|
2630
|
+
advanceFieldSecurityGeneration();
|
|
2631
|
+
}
|
|
2357
2632
|
/**
|
|
2358
2633
|
* True when at least one field is a writable Geo* source (street and/or native lat/lng).
|
|
2359
2634
|
* Person/Org PrimaryAddress* are virtual display fields and do **not** count.
|
|
@@ -2535,6 +2810,87 @@ export class EntityInfo extends BaseInfo {
|
|
|
2535
2810
|
set _floatCount(value) {
|
|
2536
2811
|
this.FloatCount = value;
|
|
2537
2812
|
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Whether field-level security decides anything on this entity: its own
|
|
2815
|
+
* {@link EnableFieldLevelSecurity} flag is on, or an IS-A ancestor's is.
|
|
2816
|
+
*
|
|
2817
|
+
* **Enforcement points gate on this, never on the raw flag.** An IS-A child carries each
|
|
2818
|
+
* ancestor's fields as mirror fields of its own, read from the same row. Gating on the child's
|
|
2819
|
+
* own flag — off on nearly every child, because nobody turns it on there — let a role denied
|
|
2820
|
+
* `Phone` on the parent read it through the child's RunView, load and `GetAll()`. The denied
|
|
2821
|
+
* sets ({@link GetDeniedReadFields} and siblings) carry the ancestor's denials for those mirror
|
|
2822
|
+
* fields; this is the matching gate, so a caller that skips work when it is false never skips
|
|
2823
|
+
* an ancestor's rules.
|
|
2824
|
+
*
|
|
2825
|
+
* The raw flag stays the right question for CONFIGURATION — which entities reconciliation,
|
|
2826
|
+
* CodeGen and the system-user sweeps cover — because the rules themselves live on the entity
|
|
2827
|
+
* that owns the field.
|
|
2828
|
+
*/
|
|
2829
|
+
get FieldLevelSecurityApplies() {
|
|
2830
|
+
if (this.EnableFieldLevelSecurity) {
|
|
2831
|
+
return true; // checked first: one boolean for every entity that has it on
|
|
2832
|
+
}
|
|
2833
|
+
if (!this.ParentID) {
|
|
2834
|
+
return false; // one boolean for every entity outside an IS-A chain
|
|
2835
|
+
}
|
|
2836
|
+
// The ancestors are memoized; their flags are read live, so an in-place flip is seen at once.
|
|
2837
|
+
const ancestors = this.liveAncestors();
|
|
2838
|
+
for (let i = 0; i < ancestors.length; i++) {
|
|
2839
|
+
if (ancestors[i].EnableFieldLevelSecurity) {
|
|
2840
|
+
return true;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
return false;
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* This entity's IS-A ancestors, nearest first, as {@link ParentEntityInfo} resolves them NOW
|
|
2847
|
+
* rather than the cached {@link ParentChain}. The denied walk ({@link addFieldsDeniedOnParent})
|
|
2848
|
+
* follows the live parent, so the gate and the memo key must too: read from a chain cached
|
|
2849
|
+
* before a metadata refresh replaced an ancestor, the gate could answer "no" while the
|
|
2850
|
+
* ancestor now in place has rules, and every enforcement point would skip them.
|
|
2851
|
+
*
|
|
2852
|
+
* The walk is memoized, valid while the field-security generation (which moves whenever any
|
|
2853
|
+
* `EntityInfo` is built, so a rebuilt ancestor is noticed), the provider this entity resolves
|
|
2854
|
+
* its parent through (its owning provider, else the global one), that provider's entity array
|
|
2855
|
+
* and this entity's `ParentID` are unchanged. Ancestors resolve their own parents through
|
|
2856
|
+
* their own providers; a chain whose entities came from one provider, the only way providers
|
|
2857
|
+
* build them, is covered by that provider's array. A walk cut
|
|
2858
|
+
* short because a `ParentID` did not resolve (asked before the provider held its entities) is
|
|
2859
|
+
* returned but not kept, so it cannot pin an incomplete answer.
|
|
2860
|
+
*/
|
|
2861
|
+
liveAncestors() {
|
|
2862
|
+
if (!this.ParentID) {
|
|
2863
|
+
return NO_ANCESTORS;
|
|
2864
|
+
}
|
|
2865
|
+
const provider = MetadataProviderFor(this); // the provider ParentEntityInfo resolves through
|
|
2866
|
+
const entities = provider?.Entities;
|
|
2867
|
+
const memo = this._liveAncestorsMemo;
|
|
2868
|
+
if (memo !== null && memo.generation === _fieldSecurityGeneration && memo.provider === provider
|
|
2869
|
+
&& memo.entities === entities && memo.parentID === this.ParentID) {
|
|
2870
|
+
return memo.ancestors;
|
|
2871
|
+
}
|
|
2872
|
+
const generation = _fieldSecurityGeneration;
|
|
2873
|
+
const { ancestors, complete } = this.walkLiveAncestors();
|
|
2874
|
+
if (complete) {
|
|
2875
|
+
this._liveAncestorsMemo = { generation, provider, entities, parentID: this.ParentID, ancestors };
|
|
2876
|
+
}
|
|
2877
|
+
return ancestors;
|
|
2878
|
+
}
|
|
2879
|
+
/** The uncached walk behind {@link liveAncestors}; stops at the root or at a cycle. */
|
|
2880
|
+
walkLiveAncestors() {
|
|
2881
|
+
const ancestors = [];
|
|
2882
|
+
const visited = new Set([this]);
|
|
2883
|
+
let current = this;
|
|
2884
|
+
let ancestor = this.ParentEntityInfo;
|
|
2885
|
+
while (ancestor && !visited.has(ancestor)) {
|
|
2886
|
+
visited.add(ancestor);
|
|
2887
|
+
ancestors.push(ancestor);
|
|
2888
|
+
current = ancestor;
|
|
2889
|
+
ancestor = ancestor.ParentID ? ancestor.ParentEntityInfo : null;
|
|
2890
|
+
}
|
|
2891
|
+
const complete = !current.ParentID || ancestor !== null;
|
|
2892
|
+
return { ancestors: Object.freeze(ancestors), complete };
|
|
2893
|
+
}
|
|
2538
2894
|
/**
|
|
2539
2895
|
* The set of field names this user may NOT READ on this entity — the per-request primitive
|
|
2540
2896
|
* every field-security enforcement point is built on.
|
|
@@ -2545,8 +2901,9 @@ export class EntityInfo extends BaseInfo {
|
|
|
2545
2901
|
* re-scans `user.UserRoles` and allocates. A Set lookup costs neither.
|
|
2546
2902
|
*
|
|
2547
2903
|
* Names are lowercased so callers can match case-insensitively, consistent with
|
|
2548
|
-
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when
|
|
2549
|
-
*
|
|
2904
|
+
* {@link ProjectRowsToFields}. Returns an EMPTY set — never null — both when field security
|
|
2905
|
+
* does not apply (see {@link FieldLevelSecurityApplies}) and when the user is denied nothing,
|
|
2906
|
+
* so callers can treat
|
|
2550
2907
|
* `size === 0` as the single "nothing to do" condition.
|
|
2551
2908
|
*/
|
|
2552
2909
|
GetDeniedReadFields(user) {
|
|
@@ -2566,10 +2923,10 @@ export class EntityInfo extends BaseInfo {
|
|
|
2566
2923
|
* The caller still decides what to render in place of the value, which is the part that
|
|
2567
2924
|
* differs per surface and should not be hidden inside a getter.
|
|
2568
2925
|
*
|
|
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.
|
|
2926
|
+
* **Fails open** on a missing user, a missing field name, or an entity where field security
|
|
2927
|
+
* does not apply ({@link FieldLevelSecurityApplies}) — matching `BaseEntity`'s own gate and
|
|
2928
|
+
* `MjFormFieldComponent`. The server is the real boundary; a UI that blanked out fields
|
|
2929
|
+
* because no user had resolved yet would be worse than one that shows them.
|
|
2573
2930
|
*
|
|
2574
2931
|
* PERFORMANCE: this delegates to {@link GetDeniedReadFields}, which walks every field on the
|
|
2575
2932
|
* entity. Fine for the handful of chrome reads it exists for; do NOT call it per row in a
|
|
@@ -2579,7 +2936,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2579
2936
|
* @param user the acting user
|
|
2580
2937
|
*/
|
|
2581
2938
|
IsFieldReadableByUser(fieldName, user) {
|
|
2582
|
-
if (!this.
|
|
2939
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2583
2940
|
return true;
|
|
2584
2941
|
}
|
|
2585
2942
|
return !this.GetDeniedReadFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2591,7 +2948,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2591
2948
|
* three conditions.
|
|
2592
2949
|
*/
|
|
2593
2950
|
IsFieldUpdatableByUser(fieldName, user) {
|
|
2594
|
-
if (!this.
|
|
2951
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2595
2952
|
return true;
|
|
2596
2953
|
}
|
|
2597
2954
|
return !this.GetDeniedUpdateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2605,7 +2962,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
2605
2962
|
* the user typed, which is the case this exists to prevent.
|
|
2606
2963
|
*/
|
|
2607
2964
|
IsFieldCreatableByUser(fieldName, user) {
|
|
2608
|
-
if (!this.
|
|
2965
|
+
if (!this.FieldLevelSecurityApplies || !fieldName || !user) {
|
|
2609
2966
|
return true;
|
|
2610
2967
|
}
|
|
2611
2968
|
return !this.GetDeniedCreateFields(user).has(fieldName.trim().toLowerCase());
|
|
@@ -2631,51 +2988,67 @@ export class EntityInfo extends BaseInfo {
|
|
|
2631
2988
|
GetDeniedCreateFields(user) {
|
|
2632
2989
|
return this.getCachedDeniedFields(user, 'create', (p) => !p.CanCreate);
|
|
2633
2990
|
}
|
|
2991
|
+
/**
|
|
2992
|
+
* Discards the memoized field-security answers: this entity's denied-field sets, those of
|
|
2993
|
+
* every IS-A descendant, and every other memo keyed on the field-security generation
|
|
2994
|
+
* ({@link CurrentFieldSecurityGeneration}), such as a user's denial profile across a catalog.
|
|
2995
|
+
* Invalidation is process-wide because a rule on one entity can change answers about others.
|
|
2996
|
+
*
|
|
2997
|
+
* A metadata refresh needs no call: it builds new `EntityInfo` objects, which start with
|
|
2998
|
+
* empty memos. Call this only after changing field rules IN PLACE on an existing
|
|
2999
|
+
* `EntityInfo`, for example editing an {@link EntityFieldPermissionInfo}'s access values.
|
|
3000
|
+
*/
|
|
3001
|
+
InvalidateFieldSecurityCache() {
|
|
3002
|
+
advanceFieldSecurityGeneration();
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Memoized front for {@link getDeniedFields}, per (entity, user, verb).
|
|
3006
|
+
*
|
|
3007
|
+
* An entry is reused while four things are unchanged: the field-security generation (see
|
|
3008
|
+
* {@link CurrentFieldSecurityGeneration}; it moves on every rebuild, flag assignment and
|
|
3009
|
+
* {@link InvalidateFieldSecurityCache}), the IS-A ancestors as they resolve now, the field
|
|
3010
|
+
* array itself, and the role IDs the user holds. Counting fields or rules is not enough:
|
|
3011
|
+
* editing a rule from `Allow` to `Deny` changes neither count.
|
|
3012
|
+
*/
|
|
2634
3013
|
getCachedDeniedFields(user, verb, isDenied) {
|
|
2635
|
-
if (!this.
|
|
2636
|
-
return new Set();
|
|
3014
|
+
if (!this.FieldLevelSecurityApplies) {
|
|
3015
|
+
return new Set(); // gate on inheritance too: an ancestor's rules still apply
|
|
2637
3016
|
}
|
|
2638
3017
|
if (!user || typeof user !== 'object') {
|
|
2639
|
-
return this.getDeniedFields(user, isDenied);
|
|
3018
|
+
return this.getDeniedFields(user, verb, isDenied);
|
|
2640
3019
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
3020
|
+
const entry = this.deniedFieldsMemoEntryFor(user);
|
|
3021
|
+
const existing = entry[verb];
|
|
3022
|
+
if (existing !== undefined) {
|
|
3023
|
+
return existing;
|
|
2644
3024
|
}
|
|
2645
|
-
const
|
|
2646
|
-
|
|
3025
|
+
const computed = this.getDeniedFields(user, verb, isDenied);
|
|
3026
|
+
entry[verb] = computed;
|
|
3027
|
+
return computed;
|
|
3028
|
+
}
|
|
3029
|
+
/** The current memo entry for `user`, replaced with an empty one when any part of its key moved. */
|
|
3030
|
+
deniedFieldsMemoEntryFor(user) {
|
|
3031
|
+
const generation = _fieldSecurityGeneration;
|
|
3032
|
+
const ancestors = this.liveAncestors();
|
|
3033
|
+
const rolesKey = userRolesKey(user);
|
|
2647
3034
|
let byUser = _entityDeniedFieldsCache.get(this);
|
|
2648
3035
|
if (!byUser) {
|
|
2649
3036
|
byUser = new WeakMap();
|
|
2650
3037
|
_entityDeniedFieldsCache.set(this, byUser);
|
|
2651
3038
|
}
|
|
2652
3039
|
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
|
-
};
|
|
3040
|
+
if (!entry || entry.fieldsRef !== this._fields || entry.generation !== generation
|
|
3041
|
+
|| !sameAncestors(entry.ancestors, ancestors) || entry.rolesKey !== rolesKey) {
|
|
3042
|
+
entry = { fieldsRef: this._fields, generation, ancestors, rolesKey };
|
|
2666
3043
|
byUser.set(user, entry);
|
|
2667
3044
|
}
|
|
2668
|
-
|
|
2669
|
-
if (existing !== undefined) {
|
|
2670
|
-
return existing;
|
|
2671
|
-
}
|
|
2672
|
-
const computed = this.getDeniedFields(user, isDenied);
|
|
2673
|
-
entry[verb] = computed;
|
|
2674
|
-
return computed;
|
|
3045
|
+
return entry;
|
|
2675
3046
|
}
|
|
2676
3047
|
/**
|
|
2677
3048
|
* Shared walk behind {@link GetDeniedReadFields} / {@link GetDeniedUpdateFields} /
|
|
2678
|
-
* {@link GetDeniedCreateFields}
|
|
3049
|
+
* {@link GetDeniedCreateFields}. Walks this entity's own fields when its
|
|
3050
|
+
* {@link EnableFieldLevelSecurity} is on, then adds the fields an IS-A parent denies (see
|
|
3051
|
+
* {@link addFieldsDeniedOnParent}).
|
|
2679
3052
|
*
|
|
2680
3053
|
* Every field is aggregated, including those carrying no permission records — on an enabled
|
|
2681
3054
|
* entity those are denied. Unrestrictable fields (primary keys, `__mj_` columns) come back
|
|
@@ -2698,18 +3071,54 @@ export class EntityInfo extends BaseInfo {
|
|
|
2698
3071
|
* that collide on `CodeName` already collide on their generated property, which is not a
|
|
2699
3072
|
* schema CodeGen can emit.
|
|
2700
3073
|
*/
|
|
2701
|
-
getDeniedFields(user, isDenied) {
|
|
3074
|
+
getDeniedFields(user, verb, isDenied) {
|
|
2702
3075
|
const denied = new Set();
|
|
2703
|
-
if (
|
|
2704
|
-
|
|
3076
|
+
if (this.EnableFieldLevelSecurity) {
|
|
3077
|
+
for (const field of this._fields) {
|
|
3078
|
+
if (isDenied(field.GetUserFieldPermissions(user, true))) {
|
|
3079
|
+
this.addDeniedField(denied, field);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
this.addFieldsDeniedOnParent(denied, user, verb, isDenied);
|
|
3084
|
+
return denied;
|
|
3085
|
+
}
|
|
3086
|
+
/**
|
|
3087
|
+
* IS-A inheritance for the denied sets: a field the immediate parent denies is denied on this
|
|
3088
|
+
* entity's mirror of it, whatever this entity's own flag says. The parent's set already
|
|
3089
|
+
* includes ITS parent's denials (this method runs there too), so one hop covers any depth.
|
|
3090
|
+
*
|
|
3091
|
+
* A mirror is a field of this entity whose `Name` is the `Name` of one of the parent's own
|
|
3092
|
+
* fields: an IS-A child's view exposes each parent column under the parent's column name, and
|
|
3093
|
+
* a child cannot declare a column of its own with that name. Matching goes through the
|
|
3094
|
+
* parent's field list rather than straight into the parent's denied set, because that set also
|
|
3095
|
+
* holds each denied field's `CodeName`: a parent field `Base Salary` puts `base_salary` in it,
|
|
3096
|
+
* and a child's OWN field named `Base_Salary` must not be denied for that.
|
|
3097
|
+
*
|
|
3098
|
+
* Additive only. When this entity ALSO has field security on, a mirror with no rows of its own
|
|
3099
|
+
* is already denied by the walk above (no rows on an enabled entity means denied); the parent's
|
|
3100
|
+
* rules cannot re-open it.
|
|
3101
|
+
*/
|
|
3102
|
+
addFieldsDeniedOnParent(denied, user, verb, isDenied) {
|
|
3103
|
+
const parent = this.ParentEntityInfo;
|
|
3104
|
+
if (!parent?.FieldLevelSecurityApplies) {
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
const parentDenied = parent.getCachedDeniedFields(user, verb, isDenied);
|
|
3108
|
+
if (parentDenied.size === 0) {
|
|
3109
|
+
return;
|
|
2705
3110
|
}
|
|
2706
3111
|
for (const field of this._fields) {
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
3112
|
+
const parentField = parent.FieldByName(field.Name);
|
|
3113
|
+
if (parentField && parentDenied.has(parentField.Name.trim().toLowerCase())) {
|
|
3114
|
+
this.addDeniedField(denied, field);
|
|
2710
3115
|
}
|
|
2711
3116
|
}
|
|
2712
|
-
|
|
3117
|
+
}
|
|
3118
|
+
/** Adds a field under BOTH key spaces — see {@link getDeniedFields} for why. */
|
|
3119
|
+
addDeniedField(denied, field) {
|
|
3120
|
+
denied.add(field.Name.trim().toLowerCase());
|
|
3121
|
+
denied.add(field.CodeName.trim().toLowerCase());
|
|
2713
3122
|
}
|
|
2714
3123
|
/**
|
|
2715
3124
|
* O(1) case-insensitive field lookup by name. Use this instead of `Fields.find(f => f.Name === name)`
|
|
@@ -2970,6 +3379,53 @@ export class EntityInfo extends BaseInfo {
|
|
|
2970
3379
|
get OrganicKeys() {
|
|
2971
3380
|
return this._organicKeys;
|
|
2972
3381
|
}
|
|
3382
|
+
/**
|
|
3383
|
+
* Gets all registered alternate base views for this entity, sorted by `Sequence`, `Name`, and `ID`.
|
|
3384
|
+
* Alternate base views maintain 100% column parity with {@link BaseView} while applying a
|
|
3385
|
+
* curated canonical filter or join.
|
|
3386
|
+
* @returns {EntityAlternateBaseViewInfo[]} Array of alternate base view metadata definitions
|
|
3387
|
+
*/
|
|
3388
|
+
get AlternateBaseViews() {
|
|
3389
|
+
return this._alternateBaseViews;
|
|
3390
|
+
}
|
|
3391
|
+
/**
|
|
3392
|
+
* Finds a registered alternate base view on this entity by its semantic `Name`
|
|
3393
|
+
* (case-insensitive, whitespace-trimmed).
|
|
3394
|
+
*
|
|
3395
|
+
* @param name Semantic name of the alternate base view (for example, `'Active'`).
|
|
3396
|
+
* @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
|
|
3397
|
+
*/
|
|
3398
|
+
GetAlternateBaseView(name) {
|
|
3399
|
+
const trimmed = name?.trim().toLowerCase();
|
|
3400
|
+
if (!trimmed) {
|
|
3401
|
+
return undefined;
|
|
3402
|
+
}
|
|
3403
|
+
return this._alternateBaseViews.find(v => v.Name?.trim().toLowerCase() === trimmed);
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Finds a registered alternate base view on this entity by its physical SQL `ViewName`
|
|
3407
|
+
* (and optional `schemaName`), matched case-insensitively and whitespace-trimmed.
|
|
3408
|
+
*
|
|
3409
|
+
* @param viewName Physical SQL view name (for example, `'vwActiveMembers'`).
|
|
3410
|
+
* @param schemaName Optional schema name; when supplied, must match `EffectiveSchemaName` case-insensitively.
|
|
3411
|
+
* @returns The matching {@link EntityAlternateBaseViewInfo}, or `undefined` if not found.
|
|
3412
|
+
*/
|
|
3413
|
+
GetAlternateBaseViewByViewName(viewName, schemaName) {
|
|
3414
|
+
const trimmedView = viewName?.trim().toLowerCase();
|
|
3415
|
+
if (!trimmedView) {
|
|
3416
|
+
return undefined;
|
|
3417
|
+
}
|
|
3418
|
+
const trimmedSchema = schemaName?.trim().toLowerCase();
|
|
3419
|
+
return this._alternateBaseViews.find(v => {
|
|
3420
|
+
if (v.ViewName?.trim().toLowerCase() !== trimmedView) {
|
|
3421
|
+
return false;
|
|
3422
|
+
}
|
|
3423
|
+
if (trimmedSchema && trimmedSchema.length > 0) {
|
|
3424
|
+
return v.EffectiveSchemaName.trim().toLowerCase() === trimmedSchema;
|
|
3425
|
+
}
|
|
3426
|
+
return true;
|
|
3427
|
+
});
|
|
3428
|
+
}
|
|
2973
3429
|
static { this.__createdAtFieldName = '__mj_CreatedAt'; }
|
|
2974
3430
|
static { this.__updatedAtFieldName = '__mj_UpdatedAt'; }
|
|
2975
3431
|
static { this.__deletedAtFieldName = '__mj_DeletedAt'; }
|
|
@@ -3160,22 +3616,33 @@ export class EntityInfo extends BaseInfo {
|
|
|
3160
3616
|
* Walks the IS-A chain upward from this entity to the root, returning all parent entities.
|
|
3161
3617
|
* Does NOT include this entity itself.
|
|
3162
3618
|
* Example: For Webinars (IS-A Meetings IS-A Products), returns [Meetings, Products].
|
|
3163
|
-
* Results are cached after first computation for performance
|
|
3619
|
+
* Results are cached after first computation for performance — but only a COMPLETE chain:
|
|
3620
|
+
* one that ends at an entity with no parent, or at a cycle. A walk cut short because a
|
|
3621
|
+
* `ParentID` did not resolve (asked before the provider had its entities) is returned but not
|
|
3622
|
+
* kept, so it cannot pin an incomplete answer; field security reads this chain to decide
|
|
3623
|
+
* whether an ancestor's rules apply.
|
|
3164
3624
|
*/
|
|
3165
3625
|
get ParentChain() {
|
|
3166
3626
|
if (this._parentChainCache !== null)
|
|
3167
3627
|
return this._parentChainCache;
|
|
3168
3628
|
const chain = [];
|
|
3169
3629
|
let current = this.ParentEntityInfo;
|
|
3630
|
+
let complete = !this.ParentID || current !== null;
|
|
3170
3631
|
const visited = new Set(); // circular reference protection
|
|
3171
3632
|
while (current) {
|
|
3172
3633
|
if (visited.has(current.ID))
|
|
3173
3634
|
break; // prevent infinite loop
|
|
3174
3635
|
visited.add(current.ID);
|
|
3175
3636
|
chain.push(current);
|
|
3176
|
-
|
|
3637
|
+
const next = current.ParentEntityInfo;
|
|
3638
|
+
if (current.ParentID && !next) {
|
|
3639
|
+
complete = false;
|
|
3640
|
+
}
|
|
3641
|
+
current = next;
|
|
3642
|
+
}
|
|
3643
|
+
if (complete) {
|
|
3644
|
+
this._parentChainCache = chain;
|
|
3177
3645
|
}
|
|
3178
|
-
this._parentChainCache = chain;
|
|
3179
3646
|
return chain;
|
|
3180
3647
|
}
|
|
3181
3648
|
/**
|
|
@@ -3353,6 +3820,45 @@ export class EntityInfo extends BaseInfo {
|
|
|
3353
3820
|
return null;
|
|
3354
3821
|
}
|
|
3355
3822
|
}
|
|
3823
|
+
/**
|
|
3824
|
+
* Checks whether the specified user has the requested permission (`Read`, `Create`, `Update`, or `Delete`)
|
|
3825
|
+
* on this entity. When `this.Permissions` is empty/uninitialized (e.g., in lightweight unit-test fixtures),
|
|
3826
|
+
* returns `true` so unconfigured fixtures are not blocked.
|
|
3827
|
+
*/
|
|
3828
|
+
CheckPermissions(user, type, throwError = false) {
|
|
3829
|
+
if (!user || !this.Permissions || this.Permissions.length === 0) {
|
|
3830
|
+
return true;
|
|
3831
|
+
}
|
|
3832
|
+
const perms = this.GetUserPermisions(user);
|
|
3833
|
+
if (!perms) {
|
|
3834
|
+
if (throwError) {
|
|
3835
|
+
throw new Error(`Permission check failed for user on entity ${this.Name}`);
|
|
3836
|
+
}
|
|
3837
|
+
return false;
|
|
3838
|
+
}
|
|
3839
|
+
let allowed = false;
|
|
3840
|
+
switch (type) {
|
|
3841
|
+
case EntityPermissionType.Read:
|
|
3842
|
+
allowed = perms.CanRead;
|
|
3843
|
+
break;
|
|
3844
|
+
case EntityPermissionType.Create:
|
|
3845
|
+
allowed = perms.CanCreate;
|
|
3846
|
+
break;
|
|
3847
|
+
case EntityPermissionType.Update:
|
|
3848
|
+
allowed = perms.CanUpdate;
|
|
3849
|
+
break;
|
|
3850
|
+
case EntityPermissionType.Delete:
|
|
3851
|
+
allowed = perms.CanDelete;
|
|
3852
|
+
break;
|
|
3853
|
+
default:
|
|
3854
|
+
allowed = false;
|
|
3855
|
+
break;
|
|
3856
|
+
}
|
|
3857
|
+
if (!allowed && throwError) {
|
|
3858
|
+
throw new Error(`User does not have ${type} permission on ${this.Name}`);
|
|
3859
|
+
}
|
|
3860
|
+
return allowed;
|
|
3861
|
+
}
|
|
3356
3862
|
/**
|
|
3357
3863
|
* Determines if a given user, for a given permission type, is exempt from RowLevelSecurity or not.
|
|
3358
3864
|
*
|
|
@@ -3480,18 +3986,16 @@ export class EntityInfo extends BaseInfo {
|
|
|
3480
3986
|
return '';
|
|
3481
3987
|
}
|
|
3482
3988
|
const userRLS = this.GetUserRowLevelSecurityInfo(user, type);
|
|
3483
|
-
if (userRLS
|
|
3484
|
-
// userRLS has all of the objects that apply to this user. The user is NOT exempt from RLS, so we need to OR together all of the RLS object filters
|
|
3485
|
-
let sRLSSQL = '';
|
|
3486
|
-
userRLS.forEach((rls) => {
|
|
3487
|
-
if (sRLSSQL.length > 0)
|
|
3488
|
-
sRLSSQL += ' OR ';
|
|
3489
|
-
sRLSSQL += `(${rls.MarkupFilterText(user)})`;
|
|
3490
|
-
});
|
|
3491
|
-
return sRLSSQL.length > 0 ? `${returnPrefix && returnPrefix.length > 0 ? returnPrefix + ' ' : ''}${sRLSSQL}` : '';
|
|
3492
|
-
}
|
|
3493
|
-
else
|
|
3989
|
+
if (!userRLS || userRLS.length === 0) {
|
|
3494
3990
|
return '';
|
|
3991
|
+
}
|
|
3992
|
+
// The user is NOT exempt, so every filter that applies is OR'd together. Each filter is
|
|
3993
|
+
// parenthesized, and a group of two or more is parenthesized as a whole, so a predicate the
|
|
3994
|
+
// caller ANDs beside the clause (`WHERE ID = @p0 AND <clause>`) applies to every filter rather
|
|
3995
|
+
// than binding to the first one only. A single filter keeps its one pair of parentheses.
|
|
3996
|
+
const terms = userRLS.map((rls) => `(${rls.MarkupFilterText(user)})`);
|
|
3997
|
+
const sRLSSQL = terms.length > 1 ? `(${terms.join(' OR ')})` : terms[0];
|
|
3998
|
+
return `${returnPrefix && returnPrefix.length > 0 ? returnPrefix + ' ' : ''}${sRLSSQL}`;
|
|
3495
3999
|
}
|
|
3496
4000
|
/**
|
|
3497
4001
|
* The EFFECTIVE row-filter clause for a user + permission type: role RLS (subject to the
|
|
@@ -3848,6 +4352,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
3848
4352
|
'EntitySettings', '_Settings', 'Settings',
|
|
3849
4353
|
'EntityRelationships', '_RelatedEntities', 'RelatedEntities',
|
|
3850
4354
|
'EntityOrganicKeys', '_OrganicKeys', 'OrganicKeys',
|
|
4355
|
+
'EntityAlternateBaseViews', '_AlternateBaseViews', 'AlternateBaseViews',
|
|
3851
4356
|
]); }
|
|
3852
4357
|
/**
|
|
3853
4358
|
* @param initData - the raw entity row (or another EntityInfo being re-instantiated)
|
|
@@ -4005,19 +4510,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4005
4510
|
* client-side IndexedDB cache. Zero overhead on hot save/query paths.
|
|
4006
4511
|
*/
|
|
4007
4512
|
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;
|
|
4513
|
+
this._enableFieldLevelSecurity = false;
|
|
4021
4514
|
/**
|
|
4022
4515
|
* Whether this entity is available through the GraphQL API
|
|
4023
4516
|
*/
|
|
@@ -4309,6 +4802,7 @@ export class EntityInfo extends BaseInfo {
|
|
|
4309
4802
|
this.ParentBaseView = null;
|
|
4310
4803
|
this._fieldCategories = null;
|
|
4311
4804
|
this._organicKeys = [];
|
|
4805
|
+
this._alternateBaseViews = [];
|
|
4312
4806
|
this.HasIdField = false;
|
|
4313
4807
|
this.VirtualCount = 0;
|
|
4314
4808
|
this.ManyToManyCount = 0;
|
|
@@ -4329,12 +4823,17 @@ export class EntityInfo extends BaseInfo {
|
|
|
4329
4823
|
this._nameFieldCache = undefined;
|
|
4330
4824
|
/** Memoized computed plural, keyed by the display name it was derived from (see `DisplayNamePlural`). */
|
|
4331
4825
|
this._displayNamePluralCache = undefined;
|
|
4826
|
+
/** See {@link liveAncestors}. A `_` field with no matching getter, so `toJSON()` leaves it out. */
|
|
4827
|
+
this._liveAncestorsMemo = null;
|
|
4332
4828
|
this._hasInactiveFields = undefined;
|
|
4333
4829
|
this._hasSearchFields = undefined;
|
|
4334
4830
|
// Cache for ParentChain to avoid repeated walks
|
|
4335
4831
|
this._parentChainCache = null;
|
|
4336
4832
|
// Cache for ParentEntityFieldNames
|
|
4337
4833
|
this._parentEntityFieldNamesCache = null;
|
|
4834
|
+
// A new entity can replace one that memoized field-security answers point at (a metadata
|
|
4835
|
+
// refresh, an IS-A ancestor rebuilt), so building one moves the generation.
|
|
4836
|
+
advanceFieldSecurityGeneration();
|
|
4338
4837
|
this.SetOwningProvider(md);
|
|
4339
4838
|
if (initData) {
|
|
4340
4839
|
this.copyInitData(initData, EntityInfo.CHILD_INIT_KEYS);
|
|
@@ -4424,6 +4923,22 @@ export class EntityInfo extends BaseInfo {
|
|
|
4424
4923
|
this._organicKeys.push(new EntityOrganicKeyInfo(item));
|
|
4425
4924
|
}
|
|
4426
4925
|
}
|
|
4926
|
+
// copy the Alternate Base Views (sorted by Sequence, Name, ID, then linked via ParentID)
|
|
4927
|
+
this._alternateBaseViews = [];
|
|
4928
|
+
const abv = initData.EntityAlternateBaseViews || initData._AlternateBaseViews || initData.AlternateBaseViews;
|
|
4929
|
+
if (abv && Array.isArray(abv)) {
|
|
4930
|
+
for (const item of abv) {
|
|
4931
|
+
const viewInfo = new EntityAlternateBaseViewInfo(item, this.SchemaName);
|
|
4932
|
+
viewInfo.SetOwningProvider(md);
|
|
4933
|
+
this._alternateBaseViews.push(viewInfo);
|
|
4934
|
+
}
|
|
4935
|
+
this._alternateBaseViews.sort((a, b) => {
|
|
4936
|
+
const aSeq = a.Sequence !== null && a.Sequence !== undefined ? a.Sequence : 0;
|
|
4937
|
+
const bSeq = b.Sequence !== null && b.Sequence !== undefined ? b.Sequence : 0;
|
|
4938
|
+
return (aSeq - bSeq) || ordinalCompare(a.Name, b.Name) || ordinalCompare(a.ID, b.ID);
|
|
4939
|
+
});
|
|
4940
|
+
EntityAlternateBaseViewInfo.LinkHierarchy(this._alternateBaseViews, this.SchemaName);
|
|
4941
|
+
}
|
|
4427
4942
|
this.prepareSpecialFields();
|
|
4428
4943
|
}
|
|
4429
4944
|
}
|