@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,4 +1,5 @@
|
|
|
1
1
|
import { ProviderBase } from "./providerBase.js";
|
|
2
|
+
import { UserInfo } from "./securityInfo.js";
|
|
2
3
|
import { EntityFieldTSType, EntityPermissionType } from "./entityInfo.js";
|
|
3
4
|
import { BaseEntityResult, SerializeCloneChangeContext } from "./baseEntity.js";
|
|
4
5
|
import { EntitySaveOptions, EntityDeleteOptions } from "./interfaces.js";
|
|
@@ -7,7 +8,9 @@ import { TransactionItem } from "./transactionGroup.js";
|
|
|
7
8
|
import { CompositeKey } from "./compositeKey.js";
|
|
8
9
|
import { LogError } from "./logging.js";
|
|
9
10
|
import { LocalCacheManager } from "./localCacheManager.js";
|
|
10
|
-
import {
|
|
11
|
+
import { AssertClientInputWritableForUser } from "./entityValueFieldSecurity.js";
|
|
12
|
+
import { CanonicalPrimaryKeyValue } from "./util.js";
|
|
13
|
+
import { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4, EscapeSQLString } from "@memberjunction/global";
|
|
11
14
|
import { GetDialect } from "@memberjunction/sql-dialect";
|
|
12
15
|
// Re-export PlatformSQL types from their canonical location for backward compatibility
|
|
13
16
|
export { IsPlatformSQL } from "./platformSQL.js";
|
|
@@ -319,6 +322,28 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
319
322
|
get MJCoreSchemaName() {
|
|
320
323
|
return this.ConfigData?.MJCoreSchemaName ?? '__mj';
|
|
321
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Builds a batched UNION ALL query probing all `childEntities`' `BaseView`s for a set
|
|
327
|
+
* of parent primary-key values (`WHERE pk IN (...)`), returning `(EntityName, RecordPK, Priority)`.
|
|
328
|
+
* Dialect providers override this to use platform-native string-literal and CAST syntax.
|
|
329
|
+
*/
|
|
330
|
+
BuildChildDiscoveryBatchSQL(childEntities, recordPKValues) {
|
|
331
|
+
if (recordPKValues.length === 0)
|
|
332
|
+
return '';
|
|
333
|
+
const escapedKeys = recordPKValues
|
|
334
|
+
.map(v => `'${EscapeSQLString(String(v))}'`)
|
|
335
|
+
.join(', ');
|
|
336
|
+
const unionParts = childEntities
|
|
337
|
+
.filter(child => child.PrimaryKeys.length > 0)
|
|
338
|
+
.map((child, idx) => {
|
|
339
|
+
const schema = child.SchemaName || this.MJCoreSchemaName || '__mj';
|
|
340
|
+
const sourceRef = this.QuoteSchemaAndView(schema, child.BaseView);
|
|
341
|
+
const pkRef = this.QuoteIdentifier(child.FirstPrimaryKey.Name); // first-pk-ok: IS-A children share the parent's single key
|
|
342
|
+
const escapedName = `'${EscapeSQLString(child.Name)}'`;
|
|
343
|
+
return `SELECT ${escapedName} AS ${this.QuoteIdentifier('EntityName')}, CAST(${pkRef} AS VARCHAR(255)) AS ${this.QuoteIdentifier('RecordPK')}, ${idx + 1} AS ${this.QuoteIdentifier('Priority')} FROM ${sourceRef} WHERE ${pkRef} IN (${escapedKeys})`;
|
|
344
|
+
});
|
|
345
|
+
return unionParts.length > 0 ? unionParts.join(' UNION ALL ') : '';
|
|
346
|
+
}
|
|
322
347
|
/**
|
|
323
348
|
* Combined regex covering UUID functions from ALL platforms.
|
|
324
349
|
* Used by the static convenience method for code that doesn't have a provider instance.
|
|
@@ -583,6 +608,91 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
583
608
|
}
|
|
584
609
|
return [];
|
|
585
610
|
}
|
|
611
|
+
static { this.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE = 500; }
|
|
612
|
+
/**
|
|
613
|
+
* Maximum number of parent primary keys included in a single `BuildChildDiscoveryBatchSQL`
|
|
614
|
+
* `WHERE PK IN (...)` statement inside {@link FindISAChildEntitiesBatch}.
|
|
615
|
+
*/
|
|
616
|
+
get ISADiscoveryBatchChunkSize() {
|
|
617
|
+
return DatabaseProviderBase.DEFAULT_ISA_DISCOVERY_BATCH_CHUNK_SIZE;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Batch-discovers IS-A child entity types for multiple parent primary keys in a single
|
|
621
|
+
* UNION ALL SQL query (chunked at {@link ISADiscoveryBatchChunkSize} keys per statement to
|
|
622
|
+
* stay well within SQL limits).
|
|
623
|
+
*/
|
|
624
|
+
async FindISAChildEntitiesBatch(entityInfo, recordPKValues, contextUser) {
|
|
625
|
+
const resultMap = new Map();
|
|
626
|
+
const childEntities = entityInfo.ChildEntities;
|
|
627
|
+
if (childEntities.length === 0 || recordPKValues.length === 0) {
|
|
628
|
+
return resultMap;
|
|
629
|
+
}
|
|
630
|
+
// IS-A children share the parent's single key, so the parent's key type decides how values
|
|
631
|
+
// are canonicalized (UUIDs case-insensitively, anything else by exact text).
|
|
632
|
+
const pkIsUuid = Boolean(entityInfo.FirstPrimaryKey?.IsUniqueIdentifier); // first-pk-ok: IS-A children share the parent's single key
|
|
633
|
+
const uniquePKs = this.deduplicateCanonicalPKs(recordPKValues, pkIsUuid);
|
|
634
|
+
if (uniquePKs.length === 0) {
|
|
635
|
+
return resultMap;
|
|
636
|
+
}
|
|
637
|
+
const priorityByChildName = new Map();
|
|
638
|
+
for (let i = 0; i < childEntities.length; i++) {
|
|
639
|
+
priorityByChildName.set(childEntities[i].Name.toLowerCase(), i + 1);
|
|
640
|
+
}
|
|
641
|
+
const chunkSize = this.ISADiscoveryBatchChunkSize;
|
|
642
|
+
const collected = new Map();
|
|
643
|
+
for (let start = 0; start < uniquePKs.length; start += chunkSize) {
|
|
644
|
+
const chunk = uniquePKs.slice(start, start + chunkSize);
|
|
645
|
+
await this.collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser);
|
|
646
|
+
}
|
|
647
|
+
for (const [pkKey, entries] of collected) {
|
|
648
|
+
entries.sort((a, b) => a.priority - b.priority);
|
|
649
|
+
const names = [];
|
|
650
|
+
for (const entry of entries) {
|
|
651
|
+
if (!names.includes(entry.name)) {
|
|
652
|
+
names.push(entry.name);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
resultMap.set(pkKey, names);
|
|
656
|
+
}
|
|
657
|
+
return resultMap;
|
|
658
|
+
}
|
|
659
|
+
deduplicateCanonicalPKs(recordPKValues, pkIsUuid) {
|
|
660
|
+
const uniquePKs = [];
|
|
661
|
+
const seen = new Set();
|
|
662
|
+
for (const rawPk of recordPKValues) {
|
|
663
|
+
if (!rawPk)
|
|
664
|
+
continue;
|
|
665
|
+
const norm = CanonicalPrimaryKeyValue(String(rawPk), pkIsUuid);
|
|
666
|
+
if (!seen.has(norm)) {
|
|
667
|
+
seen.add(norm);
|
|
668
|
+
uniquePKs.push(String(rawPk).trim());
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return uniquePKs;
|
|
672
|
+
}
|
|
673
|
+
async collectISAChildBatchChunk(childEntities, chunk, pkIsUuid, priorityByChildName, collected, contextUser) {
|
|
674
|
+
const batchSQL = this.BuildChildDiscoveryBatchSQL(childEntities, chunk);
|
|
675
|
+
if (!batchSQL)
|
|
676
|
+
return;
|
|
677
|
+
const rows = await this.ExecuteSQL(batchSQL, undefined, undefined, contextUser);
|
|
678
|
+
if (!rows || rows.length === 0)
|
|
679
|
+
return;
|
|
680
|
+
for (const row of rows) {
|
|
681
|
+
const entityName = row.EntityName ?? row.ChildEntityName;
|
|
682
|
+
if (!entityName || row.RecordPK == null)
|
|
683
|
+
continue;
|
|
684
|
+
const pkKey = CanonicalPrimaryKeyValue(String(row.RecordPK), pkIsUuid);
|
|
685
|
+
const priority = typeof row.Priority === 'number'
|
|
686
|
+
? row.Priority
|
|
687
|
+
: (priorityByChildName.get(entityName.toLowerCase()) ?? Number.MAX_SAFE_INTEGER);
|
|
688
|
+
let bucket = collected.get(pkKey);
|
|
689
|
+
if (!bucket) {
|
|
690
|
+
bucket = [];
|
|
691
|
+
collected.set(pkKey, bucket);
|
|
692
|
+
}
|
|
693
|
+
bucket.push({ name: entityName, priority });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
586
696
|
/**
|
|
587
697
|
* Checks whether a given entity matches the target name, or is an ancestor
|
|
588
698
|
* of the target (i.e., the target is somewhere in its descendant sub-tree).
|
|
@@ -710,6 +820,110 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
710
820
|
/**************************************************************************/
|
|
711
821
|
// START ---- Record Dependencies
|
|
712
822
|
/**************************************************************************/
|
|
823
|
+
/**
|
|
824
|
+
* High-volume system audit/telemetry entities excluded when `options.IncludeSystemEntities === false`.
|
|
825
|
+
*/
|
|
826
|
+
static { this._systemDependencyEntityNames = new Set([
|
|
827
|
+
'mj: record changes',
|
|
828
|
+
'mj: audit logs',
|
|
829
|
+
'mj: user record logs',
|
|
830
|
+
'mj: user recordaccess',
|
|
831
|
+
'mj: entity record documents',
|
|
832
|
+
]); }
|
|
833
|
+
/**
|
|
834
|
+
* Normalizes `(contextUserOrOptions, options)` so callers can invoke `GetRecordDependencies`
|
|
835
|
+
* with either `(entityName, key, options)` or `(entityName, key, contextUser, options)`.
|
|
836
|
+
*/
|
|
837
|
+
resolveRecordDependencyArgs(contextUserOrOptions, options) {
|
|
838
|
+
if (options !== undefined) {
|
|
839
|
+
return { contextUser: contextUserOrOptions, options };
|
|
840
|
+
}
|
|
841
|
+
if (!contextUserOrOptions) {
|
|
842
|
+
return { contextUser: undefined, options: undefined };
|
|
843
|
+
}
|
|
844
|
+
if (contextUserOrOptions instanceof UserInfo ||
|
|
845
|
+
'UserRoles' in contextUserOrOptions ||
|
|
846
|
+
'Email' in contextUserOrOptions) {
|
|
847
|
+
return { contextUser: contextUserOrOptions, options: undefined };
|
|
848
|
+
}
|
|
849
|
+
return { contextUser: undefined, options: contextUserOrOptions };
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Determines whether `relatedEntity` should be included in a record dependency scan
|
|
853
|
+
* based on `options` (`IncludeSystemEntities`, `EntityNames`) and `contextUser` read permissions.
|
|
854
|
+
*/
|
|
855
|
+
shouldIncludeDependencyEntity(relatedEntity, options, contextUser) {
|
|
856
|
+
if (!this.shouldIncludeDependencyEntityName(relatedEntity.Name, options)) {
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
if (contextUser && typeof relatedEntity.CheckPermissions === 'function') {
|
|
860
|
+
if (!relatedEntity.CheckPermissions(contextUser, EntityPermissionType.Read, false)) {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Checks name-based dependency filters (`IncludeSystemEntities` and `EntityNames`)
|
|
868
|
+
* when only the entity name is available.
|
|
869
|
+
*/
|
|
870
|
+
shouldIncludeDependencyEntityName(relatedEntityName, options) {
|
|
871
|
+
const normalizedName = relatedEntityName.trim().toLowerCase();
|
|
872
|
+
if (options?.IncludeSystemEntities === false) {
|
|
873
|
+
if (DatabaseProviderBase._systemDependencyEntityNames.has(normalizedName)) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
if (options?.EntityNames && options.EntityNames.length > 0) {
|
|
878
|
+
const allowed = options.EntityNames.some(n => n.trim().toLowerCase() === normalizedName);
|
|
879
|
+
if (!allowed) {
|
|
880
|
+
return false;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Resolves an `EntityDependency[] | string` argument to an `EntityDependency[]` array.
|
|
887
|
+
* Supports passing either a pre-resolved `EntityDependency[]` list or an `entityName` string.
|
|
888
|
+
*/
|
|
889
|
+
resolveEntityDependencies(entityDependenciesOrName) {
|
|
890
|
+
if (Array.isArray(entityDependenciesOrName)) {
|
|
891
|
+
return entityDependenciesOrName;
|
|
892
|
+
}
|
|
893
|
+
const eName = entityDependenciesOrName.trim().toLowerCase();
|
|
894
|
+
const result = [];
|
|
895
|
+
for (const re of this.Entities) {
|
|
896
|
+
const relatedFields = re.Fields.filter(f => f.RelatedEntity?.trim().toLowerCase() === eName && (!f.EntityIDFieldName || f.EntityIDFieldName.trim().length === 0));
|
|
897
|
+
for (const f of relatedFields) {
|
|
898
|
+
result.push({
|
|
899
|
+
EntityName: entityDependenciesOrName,
|
|
900
|
+
RelatedEntityName: re.Name,
|
|
901
|
+
FieldName: f.Name,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return result;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Selects `relatedEntity.BaseTable` when `BaseTable` is defined, the entity is not virtual,
|
|
909
|
+
* and all referenced columns (`PrimaryKeys` plus `referencedFieldNames`) are physical (`!f.IsVirtual`).
|
|
910
|
+
* Falls back to `relatedEntity.BaseView` when any referenced column is virtual or `BaseTable` is absent.
|
|
911
|
+
*/
|
|
912
|
+
resolveDependencySourceObject(relatedEntity, referencedFieldNames) {
|
|
913
|
+
if (!relatedEntity.BaseTable || relatedEntity.BaseTable.trim().length === 0 || relatedEntity.VirtualEntity) {
|
|
914
|
+
return relatedEntity.BaseView;
|
|
915
|
+
}
|
|
916
|
+
if (relatedEntity.PrimaryKeys?.some(pk => pk.IsVirtual === true)) {
|
|
917
|
+
return relatedEntity.BaseView;
|
|
918
|
+
}
|
|
919
|
+
for (const fieldName of referencedFieldNames) {
|
|
920
|
+
const field = relatedEntity.Fields?.find(f => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
|
|
921
|
+
if (field?.IsVirtual === true) {
|
|
922
|
+
return relatedEntity.BaseView;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return relatedEntity.BaseTable;
|
|
926
|
+
}
|
|
713
927
|
/**
|
|
714
928
|
* Returns a list of record-level dependencies — records in other entities linked to
|
|
715
929
|
* the specified entity/record via foreign keys (hard links) or EntityIDFieldName
|
|
@@ -717,10 +931,12 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
717
931
|
*
|
|
718
932
|
* @param entityName The entity name to check
|
|
719
933
|
* @param compositeKey The primary key(s) of the record
|
|
720
|
-
* @param contextUser Optional context user
|
|
934
|
+
* @param contextUser Optional context user (or options when called with 3 args)
|
|
935
|
+
* @param options Optional filtering options (`LinkType`, `IncludeSystemEntities`, `EntityNames`)
|
|
721
936
|
*/
|
|
722
|
-
async GetRecordDependencies(entityName, compositeKey, contextUser) {
|
|
937
|
+
async GetRecordDependencies(entityName, compositeKey, contextUser, options) {
|
|
723
938
|
try {
|
|
939
|
+
const { contextUser: resolvedUser, options: resolvedOptions } = this.resolveRecordDependencyArgs(contextUser, options);
|
|
724
940
|
const recordDependencies = [];
|
|
725
941
|
// Validate up front. Reporting "no dependencies" for an entity we cannot resolve would be
|
|
726
942
|
// indistinguishable, to a caller about to delete a record, from "this record is safe to
|
|
@@ -728,19 +944,40 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
728
944
|
if (!this.EntityByName(entityName)) {
|
|
729
945
|
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
730
946
|
}
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
947
|
+
const linkType = resolvedOptions?.LinkType ?? 'both';
|
|
948
|
+
let hardSQL = '';
|
|
949
|
+
if (linkType === 'both' || linkType === 'hard') {
|
|
950
|
+
const rawEntityDependencies = await this.GetEntityDependencies(entityName);
|
|
951
|
+
const entityDependencies = rawEntityDependencies.filter((dep) => {
|
|
952
|
+
const relEntity = this.EntityByName(dep.RelatedEntityName);
|
|
953
|
+
if (relEntity) {
|
|
954
|
+
return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
|
|
955
|
+
}
|
|
956
|
+
return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
|
|
957
|
+
});
|
|
958
|
+
// Deliberately NO early return when there are no hard (foreign key) dependents. An entity
|
|
959
|
+
// whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
|
|
960
|
+
// soft-link query exists to serve, and returning here skipped building it entirely.
|
|
961
|
+
hardSQL = entityDependencies.length > 0
|
|
962
|
+
? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey, resolvedOptions, resolvedUser)
|
|
963
|
+
: '';
|
|
964
|
+
}
|
|
965
|
+
const softSQL = linkType === 'both' || linkType === 'soft'
|
|
966
|
+
? this.BuildSoftLinkDependencySQL(entityName, compositeKey, resolvedOptions, resolvedUser)
|
|
967
|
+
: '';
|
|
737
968
|
const sSQL = [hardSQL, softSQL].filter(s => s.length > 0).join(' UNION ALL ');
|
|
738
969
|
if (!sSQL)
|
|
739
970
|
return recordDependencies;
|
|
740
|
-
const result = await this.ExecuteSQL(sSQL, undefined, undefined,
|
|
971
|
+
const result = await this.ExecuteSQL(sSQL, undefined, undefined, resolvedUser);
|
|
741
972
|
if (!result || result.length === 0)
|
|
742
973
|
return recordDependencies;
|
|
743
|
-
return this.parseRecordDependencyResults(result)
|
|
974
|
+
return this.parseRecordDependencyResults(result).filter((dep) => {
|
|
975
|
+
const relEntity = this.EntityByName(dep.RelatedEntityName);
|
|
976
|
+
if (relEntity) {
|
|
977
|
+
return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
|
|
978
|
+
}
|
|
979
|
+
return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
|
|
980
|
+
});
|
|
744
981
|
}
|
|
745
982
|
catch (e) {
|
|
746
983
|
LogError(e);
|
|
@@ -787,10 +1024,16 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
787
1024
|
pkeys[parts[0]] = parts.slice(1).join(CompositeKey.DefaultValueDelimiter);
|
|
788
1025
|
});
|
|
789
1026
|
depCompositeKey.LoadFromEntityInfoAndRecord(relatedEntityInfo, pkeys);
|
|
1027
|
+
const fieldName = r.FieldName;
|
|
1028
|
+
const fieldInfo = relatedEntityInfo.Fields?.find((f) => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
|
|
1029
|
+
const fieldDisplayName = fieldInfo
|
|
1030
|
+
? (fieldInfo.DisplayNameOrName || fieldInfo.DisplayName || fieldInfo.Name)
|
|
1031
|
+
: undefined;
|
|
790
1032
|
recordDependencies.push({
|
|
791
1033
|
EntityName: r.EntityName,
|
|
792
1034
|
RelatedEntityName: r.RelatedEntityName,
|
|
793
|
-
FieldName:
|
|
1035
|
+
FieldName: fieldName,
|
|
1036
|
+
FieldDisplayName: fieldDisplayName,
|
|
794
1037
|
PrimaryKey: depCompositeKey,
|
|
795
1038
|
// Both dialects emit this literal on every row of the union: 1/true for the polymorphic
|
|
796
1039
|
// branch, 0/false for the foreign key branch.
|
|
@@ -824,6 +1067,27 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
824
1067
|
async OnBeforeSaveExecute(_entity, _user, _options, _context) {
|
|
825
1068
|
/* no-op by default */
|
|
826
1069
|
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Returns true when `Save()` must enlist {@link OnDurableAfterSaveExecute} transactionally
|
|
1072
|
+
* (wrapping direct saves in an {@link EntityTransactionScope}, and awaiting durable action
|
|
1073
|
+
* submission inside `TransactionGroup` save callbacks before group completion).
|
|
1074
|
+
*
|
|
1075
|
+
* Default: `false` (zero extra transaction overhead on ordinary entity saves).
|
|
1076
|
+
*/
|
|
1077
|
+
async ShouldEnlistSaveInTransaction(_entity, _user, _options, _isNewRecord) {
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Awaited immediately after the save SQL succeeds (inside direct `Save()`'s
|
|
1082
|
+
* {@link EntityTransactionScope} before commit, or inside a `TransactionGroup` item's completion
|
|
1083
|
+
* callback after hydrating DB-generated PKs and before {@link OnAfterSaveExecute}) when
|
|
1084
|
+
* {@link ShouldEnlistSaveInTransaction} returned `true`.
|
|
1085
|
+
* Used by `GenericDatabaseProvider` to enlist durable after-save entity actions atomically in the
|
|
1086
|
+
* same SQL transaction as the entity write.
|
|
1087
|
+
*/
|
|
1088
|
+
async OnDurableAfterSaveExecute(_entity, _user, _options, _context) {
|
|
1089
|
+
/* no-op by default */
|
|
1090
|
+
}
|
|
827
1091
|
/**
|
|
828
1092
|
* Called after a successful save (both direct and transaction-callback paths).
|
|
829
1093
|
* Intentionally synchronous (fire-and-forget) — SQL Server overrides to dispatch
|
|
@@ -1252,7 +1516,7 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1252
1516
|
* because there is nobody to check against.
|
|
1253
1517
|
*/
|
|
1254
1518
|
CanUserReadRecordName(entity, contextUser) {
|
|
1255
|
-
if (!entity.
|
|
1519
|
+
if (!entity.FieldLevelSecurityApplies) {
|
|
1256
1520
|
return true;
|
|
1257
1521
|
}
|
|
1258
1522
|
if (!contextUser) {
|
|
@@ -1490,10 +1754,28 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1490
1754
|
configurable: true,
|
|
1491
1755
|
});
|
|
1492
1756
|
extraData.entityName = entity.EntityInfo.Name;
|
|
1493
|
-
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
|
|
1757
|
+
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, async (transactionResult, success) => {
|
|
1494
1758
|
resumeRefresh();
|
|
1495
1759
|
entityResult.EndedAt = new Date();
|
|
1496
1760
|
if (success && transactionResult) {
|
|
1761
|
+
if (bNewRecord) {
|
|
1762
|
+
let hydratedPk = false;
|
|
1763
|
+
for (const pkField of entity.EntityInfo.PrimaryKeys) {
|
|
1764
|
+
const fieldObj = entity.Fields.find((f) => f.Name === pkField.Name);
|
|
1765
|
+
if (fieldObj &&
|
|
1766
|
+
(fieldObj.Value === null || fieldObj.Value === undefined) &&
|
|
1767
|
+
transactionResult[pkField.Name] != null) {
|
|
1768
|
+
fieldObj.Value = transactionResult[pkField.Name];
|
|
1769
|
+
hydratedPk = true;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
if (hydratedPk) {
|
|
1773
|
+
entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
if (await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord)) {
|
|
1777
|
+
await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
|
|
1778
|
+
}
|
|
1497
1779
|
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1498
1780
|
entityResult.Success = true;
|
|
1499
1781
|
const mergedResult = preSaveValues
|
|
@@ -1503,59 +1785,94 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1503
1785
|
}
|
|
1504
1786
|
else {
|
|
1505
1787
|
entityResult.Success = false;
|
|
1506
|
-
entityResult.Message
|
|
1788
|
+
if (!entityResult.Message) {
|
|
1789
|
+
entityResult.Message = transactionResult instanceof Error
|
|
1790
|
+
? transactionResult.message
|
|
1791
|
+
: 'Transaction Failed';
|
|
1792
|
+
}
|
|
1507
1793
|
}
|
|
1508
1794
|
}));
|
|
1509
1795
|
return true;
|
|
1510
1796
|
}
|
|
1511
1797
|
else {
|
|
1512
1798
|
// ---- Direct execution path ----
|
|
1799
|
+
const enlistInTx = !bReplay && await this.ShouldEnlistSaveInTransaction(entity, user, options, bNewRecord);
|
|
1800
|
+
const saveTxScope = enlistInTx ? await this.BeginEntityTransaction() : null;
|
|
1801
|
+
let saveTxCommitted = false;
|
|
1513
1802
|
suspendRefresh();
|
|
1514
1803
|
let result;
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
else {
|
|
1519
|
-
const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
|
|
1520
|
-
const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
|
|
1521
|
-
result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
|
|
1522
|
-
}
|
|
1523
|
-
resumeRefresh();
|
|
1524
|
-
entityResult.EndedAt = new Date();
|
|
1525
|
-
if (result && result.length > 0) {
|
|
1526
|
-
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1527
|
-
entityResult.Success = true;
|
|
1528
|
-
// In 'datestamps_only' mode, the SP/query returned only the PK and
|
|
1529
|
-
// __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
|
|
1530
|
-
// onto the pre-save snapshot so caller-set fields remain intact without
|
|
1531
|
-
// falsely marking concurrent in-flight mutations as clean.
|
|
1532
|
-
let mergedRow;
|
|
1533
|
-
if (preSaveValues) {
|
|
1534
|
-
mergedRow = { ...preSaveValues, ...result[0] };
|
|
1535
|
-
Object.defineProperty(mergedRow, '__mj_preMerged', {
|
|
1536
|
-
value: true,
|
|
1537
|
-
enumerable: false,
|
|
1538
|
-
configurable: true,
|
|
1539
|
-
});
|
|
1804
|
+
try {
|
|
1805
|
+
if (bReplay) {
|
|
1806
|
+
result = [entity.GetAll()];
|
|
1540
1807
|
}
|
|
1541
1808
|
else {
|
|
1542
|
-
|
|
1809
|
+
const execOptions = this.BuildSaveExecuteOptions(entity, sqlDetails);
|
|
1810
|
+
const rawResult = await this.ExecuteSQL(sqlDetails.fullSQL, sqlDetails.parameters ?? undefined, execOptions, user);
|
|
1811
|
+
result = await this.PostProcessRows(rawResult, entity.EntityInfo, user);
|
|
1812
|
+
}
|
|
1813
|
+
resumeRefresh();
|
|
1814
|
+
entityResult.EndedAt = new Date();
|
|
1815
|
+
if (result && result.length > 0) {
|
|
1816
|
+
if (bNewRecord) {
|
|
1817
|
+
let hydratedPk = false;
|
|
1818
|
+
for (const pkField of entity.EntityInfo.PrimaryKeys) {
|
|
1819
|
+
const fieldObj = entity.Fields.find(f => f.Name === pkField.Name);
|
|
1820
|
+
if (fieldObj && (fieldObj.Value === null || fieldObj.Value === undefined) && result[0][pkField.Name] != null) {
|
|
1821
|
+
fieldObj.Value = result[0][pkField.Name];
|
|
1822
|
+
hydratedPk = true;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (hydratedPk) {
|
|
1826
|
+
entity.PrimaryKey.LoadFromEntityFields(entity.PrimaryKeys);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
if (enlistInTx) {
|
|
1830
|
+
await this.OnDurableAfterSaveExecute(entity, user, options, saveContext);
|
|
1831
|
+
}
|
|
1832
|
+
if (saveTxScope) {
|
|
1833
|
+
await saveTxScope.Commit();
|
|
1834
|
+
saveTxCommitted = true;
|
|
1835
|
+
}
|
|
1543
1836
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
Object.assign(mergedRow, patches);
|
|
1837
|
+
else {
|
|
1838
|
+
if (bNewRecord)
|
|
1839
|
+
throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1840
|
+
else
|
|
1841
|
+
throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1550
1842
|
}
|
|
1551
|
-
|
|
1843
|
+
}
|
|
1844
|
+
catch (innerErr) {
|
|
1845
|
+
if (saveTxScope && !saveTxCommitted) {
|
|
1846
|
+
await saveTxScope.Rollback();
|
|
1847
|
+
}
|
|
1848
|
+
throw innerErr;
|
|
1849
|
+
}
|
|
1850
|
+
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1851
|
+
entityResult.Success = true;
|
|
1852
|
+
// In 'datestamps_only' mode, the SP/query returned only the PK and
|
|
1853
|
+
// __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
|
|
1854
|
+
// onto the pre-save snapshot so caller-set fields remain intact without
|
|
1855
|
+
// falsely marking concurrent in-flight mutations as clean.
|
|
1856
|
+
let mergedRow;
|
|
1857
|
+
if (preSaveValues) {
|
|
1858
|
+
mergedRow = { ...preSaveValues, ...result[0] };
|
|
1859
|
+
Object.defineProperty(mergedRow, '__mj_preMerged', {
|
|
1860
|
+
value: true,
|
|
1861
|
+
enumerable: false,
|
|
1862
|
+
configurable: true,
|
|
1863
|
+
});
|
|
1552
1864
|
}
|
|
1553
1865
|
else {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1866
|
+
mergedRow = result[0];
|
|
1867
|
+
}
|
|
1868
|
+
// OnSaveCompleted may return patch fields (e.g., updated lat/lng
|
|
1869
|
+
// from geocoding) that need to be merged onto the SP result before
|
|
1870
|
+
// the entity loads the data via finalizeSave().
|
|
1871
|
+
const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
|
|
1872
|
+
if (patches) {
|
|
1873
|
+
Object.assign(mergedRow, patches);
|
|
1558
1874
|
}
|
|
1875
|
+
return mergedRow;
|
|
1559
1876
|
}
|
|
1560
1877
|
}
|
|
1561
1878
|
else {
|
|
@@ -1987,6 +2304,11 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1987
2304
|
duplicateRun.Set('ProcessingStatus', 'In Progress');
|
|
1988
2305
|
duplicateRun.Set('ApprovalStatus', 'Pending');
|
|
1989
2306
|
duplicateRun.Set('SourceListID', listEntity.Get('ID'));
|
|
2307
|
+
// Without a document, the server-side Duplicate Run entity defaults to the entity's
|
|
2308
|
+
// duplicate-detection document.
|
|
2309
|
+
if (params.EntityDocumentID) {
|
|
2310
|
+
duplicateRun.Set('EntityDocumentID', params.EntityDocumentID);
|
|
2311
|
+
}
|
|
1990
2312
|
const saveResult = await duplicateRun.Save();
|
|
1991
2313
|
if (!saveResult) {
|
|
1992
2314
|
throw new Error('Failed to save Duplicate Run Entity');
|
|
@@ -2023,6 +2345,13 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
2023
2345
|
throw new Error(`Record ${key.ToString()} of ${request.EntityName} has a ${child.ChildEntityName} subtype row; merging records another entity extends is not supported yet`);
|
|
2024
2346
|
}
|
|
2025
2347
|
}
|
|
2348
|
+
// The field map is client input applied to the surviving record: a caller with any field
|
|
2349
|
+
// denial may name only fields they can read, and a denied name and an unknown name get the
|
|
2350
|
+
// same refusal. Checked before the merge log and the transaction, so a refusal leaves no trace.
|
|
2351
|
+
if (request.FieldMap?.length) {
|
|
2352
|
+
const values = Object.fromEntries(request.FieldMap.map(f => [f.FieldName, f.Value]));
|
|
2353
|
+
AssertClientInputWritableForUser(e, values, contextUser);
|
|
2354
|
+
}
|
|
2026
2355
|
const result = {
|
|
2027
2356
|
Success: false,
|
|
2028
2357
|
RecordMergeLogID: null,
|