@memberjunction/core 6.2.0-edge.4 → 6.2.0-edge.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/BaseEntitySaveQueue.d.ts +22 -0
- package/dist/generic/BaseEntitySaveQueue.d.ts.map +1 -1
- package/dist/generic/BaseEntitySaveQueue.js +43 -4
- package/dist/generic/BaseEntitySaveQueue.js.map +1 -1
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts +18 -0
- package/dist/generic/JSONType-interfaces/IEntityConfiguration.d.ts.map +1 -1
- package/dist/generic/alternateBaseViews.d.ts +119 -0
- package/dist/generic/alternateBaseViews.d.ts.map +1 -0
- package/dist/generic/alternateBaseViews.js +223 -0
- package/dist/generic/alternateBaseViews.js.map +1 -0
- package/dist/generic/baseEngine.d.ts +82 -14
- package/dist/generic/baseEngine.d.ts.map +1 -1
- package/dist/generic/baseEngine.js +311 -46
- package/dist/generic/baseEngine.js.map +1 -1
- package/dist/generic/baseEngineRegistry.d.ts.map +1 -1
- package/dist/generic/baseEngineRegistry.js +2 -1
- package/dist/generic/baseEngineRegistry.js.map +1 -1
- package/dist/generic/baseEntity.d.ts +194 -14
- package/dist/generic/baseEntity.d.ts.map +1 -1
- package/dist/generic/baseEntity.js +638 -218
- package/dist/generic/baseEntity.js.map +1 -1
- package/dist/generic/baseInfo.d.ts +31 -3
- package/dist/generic/baseInfo.d.ts.map +1 -1
- package/dist/generic/baseInfo.js +138 -40
- package/dist/generic/baseInfo.js.map +1 -1
- package/dist/generic/contextTokens.d.ts +136 -0
- package/dist/generic/contextTokens.d.ts.map +1 -0
- package/dist/generic/contextTokens.js +319 -0
- package/dist/generic/contextTokens.js.map +1 -0
- package/dist/generic/databaseProviderBase.d.ts +103 -13
- package/dist/generic/databaseProviderBase.d.ts.map +1 -1
- package/dist/generic/databaseProviderBase.js +321 -77
- package/dist/generic/databaseProviderBase.js.map +1 -1
- package/dist/generic/embeddedRecord.d.ts +6 -0
- package/dist/generic/embeddedRecord.d.ts.map +1 -1
- package/dist/generic/embeddedRecord.js +15 -1
- package/dist/generic/embeddedRecord.js.map +1 -1
- package/dist/generic/entityCompanion.d.ts +7 -0
- package/dist/generic/entityCompanion.d.ts.map +1 -1
- package/dist/generic/entityCompanion.js +8 -0
- package/dist/generic/entityCompanion.js.map +1 -1
- package/dist/generic/entityConfiguration.d.ts +8 -2
- package/dist/generic/entityConfiguration.d.ts.map +1 -1
- package/dist/generic/entityConfiguration.js +9 -0
- package/dist/generic/entityConfiguration.js.map +1 -1
- package/dist/generic/entityInfo.d.ts +306 -12
- package/dist/generic/entityInfo.d.ts.map +1 -1
- package/dist/generic/entityInfo.js +894 -92
- package/dist/generic/entityInfo.js.map +1 -1
- package/dist/generic/entityValueFieldSecurity.d.ts +139 -0
- package/dist/generic/entityValueFieldSecurity.d.ts.map +1 -0
- package/dist/generic/entityValueFieldSecurity.js +250 -0
- package/dist/generic/entityValueFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts +350 -0
- package/dist/generic/fieldSecuritySQLGate.d.ts.map +1 -0
- package/dist/generic/fieldSecuritySQLGate.js +898 -0
- package/dist/generic/fieldSecuritySQLGate.js.map +1 -0
- package/dist/generic/indexRestrictedFields.d.ts +47 -0
- package/dist/generic/indexRestrictedFields.d.ts.map +1 -0
- package/dist/generic/indexRestrictedFields.js +129 -0
- package/dist/generic/indexRestrictedFields.js.map +1 -0
- package/dist/generic/infoProviderResolution.d.ts +16 -0
- package/dist/generic/infoProviderResolution.d.ts.map +1 -0
- package/dist/generic/infoProviderResolution.js +17 -0
- package/dist/generic/infoProviderResolution.js.map +1 -0
- package/dist/generic/interfaces.d.ts +39 -2
- package/dist/generic/interfaces.d.ts.map +1 -1
- package/dist/generic/interfaces.js.map +1 -1
- package/dist/generic/localCacheManager.d.ts +318 -51
- package/dist/generic/localCacheManager.d.ts.map +1 -1
- package/dist/generic/localCacheManager.js +675 -109
- package/dist/generic/localCacheManager.js.map +1 -1
- package/dist/generic/logging.d.ts.map +1 -1
- package/dist/generic/logging.js +7 -1
- package/dist/generic/logging.js.map +1 -1
- package/dist/generic/metadata.d.ts +3 -2
- package/dist/generic/metadata.d.ts.map +1 -1
- package/dist/generic/metadata.js +5 -2
- package/dist/generic/metadata.js.map +1 -1
- package/dist/generic/mjapiUrl.d.ts +21 -0
- package/dist/generic/mjapiUrl.d.ts.map +1 -0
- package/dist/generic/mjapiUrl.js +25 -0
- package/dist/generic/mjapiUrl.js.map +1 -0
- package/dist/generic/platformSQL.d.ts +54 -0
- package/dist/generic/platformSQL.d.ts.map +1 -1
- package/dist/generic/platformSQL.js +55 -0
- package/dist/generic/platformSQL.js.map +1 -1
- package/dist/generic/providerBase.d.ts +370 -57
- package/dist/generic/providerBase.d.ts.map +1 -1
- package/dist/generic/providerBase.js +1085 -284
- package/dist/generic/providerBase.js.map +1 -1
- package/dist/generic/queryInfo.d.ts +23 -9
- package/dist/generic/queryInfo.d.ts.map +1 -1
- package/dist/generic/queryInfo.js +73 -33
- package/dist/generic/queryInfo.js.map +1 -1
- package/dist/generic/queryInfoInterfaces.d.ts +8 -0
- package/dist/generic/queryInfoInterfaces.d.ts.map +1 -1
- package/dist/generic/queryTemplateContext.d.ts +49 -0
- package/dist/generic/queryTemplateContext.d.ts.map +1 -0
- package/dist/generic/queryTemplateContext.js +113 -0
- package/dist/generic/queryTemplateContext.js.map +1 -0
- package/dist/generic/recordChangeFieldSecurity.d.ts.map +1 -1
- package/dist/generic/recordChangeFieldSecurity.js +13 -2
- package/dist/generic/recordChangeFieldSecurity.js.map +1 -1
- package/dist/generic/relatedRecordCollection.d.ts +13 -0
- package/dist/generic/relatedRecordCollection.d.ts.map +1 -1
- package/dist/generic/relatedRecordCollection.js +44 -5
- package/dist/generic/relatedRecordCollection.js.map +1 -1
- package/dist/generic/runQuery.d.ts +42 -0
- package/dist/generic/runQuery.d.ts.map +1 -1
- package/dist/generic/runQuery.js +14 -0
- package/dist/generic/runQuery.js.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.d.ts.map +1 -1
- package/dist/generic/runQuerySQLFilterImplementations.js +38 -2
- package/dist/generic/runQuerySQLFilterImplementations.js.map +1 -1
- package/dist/generic/saveEntityGraphOperation.d.ts.map +1 -1
- package/dist/generic/saveEntityGraphOperation.js +3 -0
- package/dist/generic/saveEntityGraphOperation.js.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.d.ts.map +1 -1
- package/dist/generic/scoring/ReciprocalRankFusion.js +10 -3
- package/dist/generic/scoring/ReciprocalRankFusion.js.map +1 -1
- package/dist/generic/securityInfo.d.ts +17 -6
- package/dist/generic/securityInfo.d.ts.map +1 -1
- package/dist/generic/securityInfo.js +25 -10
- package/dist/generic/securityInfo.js.map +1 -1
- package/dist/generic/telemetryManager.d.ts +9 -0
- package/dist/generic/telemetryManager.d.ts.map +1 -1
- package/dist/generic/telemetryManager.js +5 -1
- package/dist/generic/telemetryManager.js.map +1 -1
- package/dist/generic/util.d.ts.map +1 -1
- package/dist/generic/util.js +3 -0
- package/dist/generic/util.js.map +1 -1
- package/dist/generic/wellKnownUserSource.d.ts +3 -2
- package/dist/generic/wellKnownUserSource.d.ts.map +1 -1
- package/dist/generic/wellKnownUserSource.js +18 -9
- package/dist/generic/wellKnownUserSource.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/views/runView.d.ts +89 -0
- package/dist/views/runView.d.ts.map +1 -1
- package/dist/views/runView.js +36 -0
- package/dist/views/runView.js.map +1 -1
- package/dist/views/viewProperties.d.ts +55 -0
- package/dist/views/viewProperties.d.ts.map +1 -0
- package/dist/views/viewProperties.js +258 -0
- package/dist/views/viewProperties.js.map +1 -0
- package/package.json +3 -3
- package/readme.md +29 -4
|
@@ -1,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,8 @@ 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 { FormatBinaryChangeValue, ScanSQLForScreening, SQLExpressionValidator, uuidv4 } from "@memberjunction/global";
|
|
11
13
|
import { GetDialect } from "@memberjunction/sql-dialect";
|
|
12
14
|
// Re-export PlatformSQL types from their canonical location for backward compatibility
|
|
13
15
|
export { IsPlatformSQL } from "./platformSQL.js";
|
|
@@ -710,6 +712,110 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
710
712
|
/**************************************************************************/
|
|
711
713
|
// START ---- Record Dependencies
|
|
712
714
|
/**************************************************************************/
|
|
715
|
+
/**
|
|
716
|
+
* High-volume system audit/telemetry entities excluded when `options.IncludeSystemEntities === false`.
|
|
717
|
+
*/
|
|
718
|
+
static { this._systemDependencyEntityNames = new Set([
|
|
719
|
+
'mj: record changes',
|
|
720
|
+
'mj: audit logs',
|
|
721
|
+
'mj: user record logs',
|
|
722
|
+
'mj: user recordaccess',
|
|
723
|
+
'mj: entity record documents',
|
|
724
|
+
]); }
|
|
725
|
+
/**
|
|
726
|
+
* Normalizes `(contextUserOrOptions, options)` so callers can invoke `GetRecordDependencies`
|
|
727
|
+
* with either `(entityName, key, options)` or `(entityName, key, contextUser, options)`.
|
|
728
|
+
*/
|
|
729
|
+
resolveRecordDependencyArgs(contextUserOrOptions, options) {
|
|
730
|
+
if (options !== undefined) {
|
|
731
|
+
return { contextUser: contextUserOrOptions, options };
|
|
732
|
+
}
|
|
733
|
+
if (!contextUserOrOptions) {
|
|
734
|
+
return { contextUser: undefined, options: undefined };
|
|
735
|
+
}
|
|
736
|
+
if (contextUserOrOptions instanceof UserInfo ||
|
|
737
|
+
'UserRoles' in contextUserOrOptions ||
|
|
738
|
+
'Email' in contextUserOrOptions) {
|
|
739
|
+
return { contextUser: contextUserOrOptions, options: undefined };
|
|
740
|
+
}
|
|
741
|
+
return { contextUser: undefined, options: contextUserOrOptions };
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Determines whether `relatedEntity` should be included in a record dependency scan
|
|
745
|
+
* based on `options` (`IncludeSystemEntities`, `EntityNames`) and `contextUser` read permissions.
|
|
746
|
+
*/
|
|
747
|
+
shouldIncludeDependencyEntity(relatedEntity, options, contextUser) {
|
|
748
|
+
if (!this.shouldIncludeDependencyEntityName(relatedEntity.Name, options)) {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
if (contextUser && typeof relatedEntity.CheckPermissions === 'function') {
|
|
752
|
+
if (!relatedEntity.CheckPermissions(contextUser, EntityPermissionType.Read, false)) {
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Checks name-based dependency filters (`IncludeSystemEntities` and `EntityNames`)
|
|
760
|
+
* when only the entity name is available.
|
|
761
|
+
*/
|
|
762
|
+
shouldIncludeDependencyEntityName(relatedEntityName, options) {
|
|
763
|
+
const normalizedName = relatedEntityName.trim().toLowerCase();
|
|
764
|
+
if (options?.IncludeSystemEntities === false) {
|
|
765
|
+
if (DatabaseProviderBase._systemDependencyEntityNames.has(normalizedName)) {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (options?.EntityNames && options.EntityNames.length > 0) {
|
|
770
|
+
const allowed = options.EntityNames.some(n => n.trim().toLowerCase() === normalizedName);
|
|
771
|
+
if (!allowed) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Resolves an `EntityDependency[] | string` argument to an `EntityDependency[]` array.
|
|
779
|
+
* Supports passing either a pre-resolved `EntityDependency[]` list or an `entityName` string.
|
|
780
|
+
*/
|
|
781
|
+
resolveEntityDependencies(entityDependenciesOrName) {
|
|
782
|
+
if (Array.isArray(entityDependenciesOrName)) {
|
|
783
|
+
return entityDependenciesOrName;
|
|
784
|
+
}
|
|
785
|
+
const eName = entityDependenciesOrName.trim().toLowerCase();
|
|
786
|
+
const result = [];
|
|
787
|
+
for (const re of this.Entities) {
|
|
788
|
+
const relatedFields = re.Fields.filter(f => f.RelatedEntity?.trim().toLowerCase() === eName && (!f.EntityIDFieldName || f.EntityIDFieldName.trim().length === 0));
|
|
789
|
+
for (const f of relatedFields) {
|
|
790
|
+
result.push({
|
|
791
|
+
EntityName: entityDependenciesOrName,
|
|
792
|
+
RelatedEntityName: re.Name,
|
|
793
|
+
FieldName: f.Name,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Selects `relatedEntity.BaseTable` when `BaseTable` is defined, the entity is not virtual,
|
|
801
|
+
* and all referenced columns (`PrimaryKeys` plus `referencedFieldNames`) are physical (`!f.IsVirtual`).
|
|
802
|
+
* Falls back to `relatedEntity.BaseView` when any referenced column is virtual or `BaseTable` is absent.
|
|
803
|
+
*/
|
|
804
|
+
resolveDependencySourceObject(relatedEntity, referencedFieldNames) {
|
|
805
|
+
if (!relatedEntity.BaseTable || relatedEntity.BaseTable.trim().length === 0 || relatedEntity.VirtualEntity) {
|
|
806
|
+
return relatedEntity.BaseView;
|
|
807
|
+
}
|
|
808
|
+
if (relatedEntity.PrimaryKeys?.some(pk => pk.IsVirtual === true)) {
|
|
809
|
+
return relatedEntity.BaseView;
|
|
810
|
+
}
|
|
811
|
+
for (const fieldName of referencedFieldNames) {
|
|
812
|
+
const field = relatedEntity.Fields?.find(f => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
|
|
813
|
+
if (field?.IsVirtual === true) {
|
|
814
|
+
return relatedEntity.BaseView;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return relatedEntity.BaseTable;
|
|
818
|
+
}
|
|
713
819
|
/**
|
|
714
820
|
* Returns a list of record-level dependencies — records in other entities linked to
|
|
715
821
|
* the specified entity/record via foreign keys (hard links) or EntityIDFieldName
|
|
@@ -717,10 +823,12 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
717
823
|
*
|
|
718
824
|
* @param entityName The entity name to check
|
|
719
825
|
* @param compositeKey The primary key(s) of the record
|
|
720
|
-
* @param contextUser Optional context user
|
|
826
|
+
* @param contextUser Optional context user (or options when called with 3 args)
|
|
827
|
+
* @param options Optional filtering options (`LinkType`, `IncludeSystemEntities`, `EntityNames`)
|
|
721
828
|
*/
|
|
722
|
-
async GetRecordDependencies(entityName, compositeKey, contextUser) {
|
|
829
|
+
async GetRecordDependencies(entityName, compositeKey, contextUser, options) {
|
|
723
830
|
try {
|
|
831
|
+
const { contextUser: resolvedUser, options: resolvedOptions } = this.resolveRecordDependencyArgs(contextUser, options);
|
|
724
832
|
const recordDependencies = [];
|
|
725
833
|
// Validate up front. Reporting "no dependencies" for an entity we cannot resolve would be
|
|
726
834
|
// indistinguishable, to a caller about to delete a record, from "this record is safe to
|
|
@@ -728,19 +836,40 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
728
836
|
if (!this.EntityByName(entityName)) {
|
|
729
837
|
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
730
838
|
}
|
|
731
|
-
const
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
839
|
+
const linkType = resolvedOptions?.LinkType ?? 'both';
|
|
840
|
+
let hardSQL = '';
|
|
841
|
+
if (linkType === 'both' || linkType === 'hard') {
|
|
842
|
+
const rawEntityDependencies = await this.GetEntityDependencies(entityName);
|
|
843
|
+
const entityDependencies = rawEntityDependencies.filter((dep) => {
|
|
844
|
+
const relEntity = this.EntityByName(dep.RelatedEntityName);
|
|
845
|
+
if (relEntity) {
|
|
846
|
+
return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
|
|
847
|
+
}
|
|
848
|
+
return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
|
|
849
|
+
});
|
|
850
|
+
// Deliberately NO early return when there are no hard (foreign key) dependents. An entity
|
|
851
|
+
// whose only dependents are polymorphic EntityID/RecordID links is precisely the case the
|
|
852
|
+
// soft-link query exists to serve, and returning here skipped building it entirely.
|
|
853
|
+
hardSQL = entityDependencies.length > 0
|
|
854
|
+
? this.BuildHardLinkDependencySQL(entityDependencies, compositeKey, resolvedOptions, resolvedUser)
|
|
855
|
+
: '';
|
|
856
|
+
}
|
|
857
|
+
const softSQL = linkType === 'both' || linkType === 'soft'
|
|
858
|
+
? this.BuildSoftLinkDependencySQL(entityName, compositeKey, resolvedOptions, resolvedUser)
|
|
859
|
+
: '';
|
|
737
860
|
const sSQL = [hardSQL, softSQL].filter(s => s.length > 0).join(' UNION ALL ');
|
|
738
861
|
if (!sSQL)
|
|
739
862
|
return recordDependencies;
|
|
740
|
-
const result = await this.ExecuteSQL(sSQL, undefined, undefined,
|
|
863
|
+
const result = await this.ExecuteSQL(sSQL, undefined, undefined, resolvedUser);
|
|
741
864
|
if (!result || result.length === 0)
|
|
742
865
|
return recordDependencies;
|
|
743
|
-
return this.parseRecordDependencyResults(result)
|
|
866
|
+
return this.parseRecordDependencyResults(result).filter((dep) => {
|
|
867
|
+
const relEntity = this.EntityByName(dep.RelatedEntityName);
|
|
868
|
+
if (relEntity) {
|
|
869
|
+
return this.shouldIncludeDependencyEntity(relEntity, resolvedOptions, resolvedUser);
|
|
870
|
+
}
|
|
871
|
+
return this.shouldIncludeDependencyEntityName(dep.RelatedEntityName, resolvedOptions);
|
|
872
|
+
});
|
|
744
873
|
}
|
|
745
874
|
catch (e) {
|
|
746
875
|
LogError(e);
|
|
@@ -787,10 +916,16 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
787
916
|
pkeys[parts[0]] = parts.slice(1).join(CompositeKey.DefaultValueDelimiter);
|
|
788
917
|
});
|
|
789
918
|
depCompositeKey.LoadFromEntityInfoAndRecord(relatedEntityInfo, pkeys);
|
|
919
|
+
const fieldName = r.FieldName;
|
|
920
|
+
const fieldInfo = relatedEntityInfo.Fields?.find((f) => f.Name?.trim().toLowerCase() === fieldName?.trim().toLowerCase());
|
|
921
|
+
const fieldDisplayName = fieldInfo
|
|
922
|
+
? (fieldInfo.DisplayNameOrName || fieldInfo.DisplayName || fieldInfo.Name)
|
|
923
|
+
: undefined;
|
|
790
924
|
recordDependencies.push({
|
|
791
925
|
EntityName: r.EntityName,
|
|
792
926
|
RelatedEntityName: r.RelatedEntityName,
|
|
793
|
-
FieldName:
|
|
927
|
+
FieldName: fieldName,
|
|
928
|
+
FieldDisplayName: fieldDisplayName,
|
|
794
929
|
PrimaryKey: depCompositeKey,
|
|
795
930
|
// Both dialects emit this literal on every row of the union: 1/true for the polymorphic
|
|
796
931
|
// branch, 0/false for the foreign key branch.
|
|
@@ -933,8 +1068,23 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
933
1068
|
// Always offer the record-change-free form to the SQL logger, not only when the
|
|
934
1069
|
// entity tracks record changes: it is also the replay-safe form of a create for
|
|
935
1070
|
// migration recordings (MemberJunction/MJ#4503). Execution still uses fullSQL.
|
|
936
|
-
if (sqlDetails
|
|
937
|
-
opts
|
|
1071
|
+
if ('simpleSQL' in sqlDetails) {
|
|
1072
|
+
Object.defineProperty(opts, 'simpleSQLFallback', {
|
|
1073
|
+
enumerable: true,
|
|
1074
|
+
configurable: true,
|
|
1075
|
+
get() {
|
|
1076
|
+
return sqlDetails.simpleSQL || undefined;
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
if ('literalSQL' in sqlDetails) {
|
|
1081
|
+
Object.defineProperty(opts, 'literalSQL', {
|
|
1082
|
+
enumerable: true,
|
|
1083
|
+
configurable: true,
|
|
1084
|
+
get() {
|
|
1085
|
+
return sqlDetails.literalSQL || undefined;
|
|
1086
|
+
},
|
|
1087
|
+
});
|
|
938
1088
|
}
|
|
939
1089
|
return opts;
|
|
940
1090
|
}
|
|
@@ -946,8 +1096,23 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
946
1096
|
isMutation: true,
|
|
947
1097
|
description: `Delete ${entity.EntityInfo.Name}`,
|
|
948
1098
|
};
|
|
949
|
-
if (entity.EntityInfo.TrackRecordChanges && sqlDetails
|
|
950
|
-
opts
|
|
1099
|
+
if (entity.EntityInfo.TrackRecordChanges && 'simpleSQL' in sqlDetails) {
|
|
1100
|
+
Object.defineProperty(opts, 'simpleSQLFallback', {
|
|
1101
|
+
enumerable: true,
|
|
1102
|
+
configurable: true,
|
|
1103
|
+
get() {
|
|
1104
|
+
return sqlDetails.simpleSQL || undefined;
|
|
1105
|
+
},
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
if ('literalSQL' in sqlDetails) {
|
|
1109
|
+
Object.defineProperty(opts, 'literalSQL', {
|
|
1110
|
+
enumerable: true,
|
|
1111
|
+
configurable: true,
|
|
1112
|
+
get() {
|
|
1113
|
+
return sqlDetails.literalSQL || undefined;
|
|
1114
|
+
},
|
|
1115
|
+
});
|
|
951
1116
|
}
|
|
952
1117
|
return opts;
|
|
953
1118
|
}
|
|
@@ -976,26 +1141,27 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
976
1141
|
/**************************************************************************/
|
|
977
1142
|
/**
|
|
978
1143
|
* Validates a user-provided SQL clause (WHERE, ORDER BY, etc.) to prevent SQL injection.
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
1144
|
+
* The clause is read with this provider's SQL dialect. It is rejected when it contains a
|
|
1145
|
+
* comment, text that cannot be read the way the database reads it, a forbidden keyword
|
|
1146
|
+
* (INSERT, UPDATE, DELETE, EXEC, DROP, UNION, etc.), a semicolon, or an `xp_` name.
|
|
1147
|
+
* String literals and quoted identifiers are not checked for keywords.
|
|
982
1148
|
*
|
|
983
1149
|
* @param clause The SQL clause to validate
|
|
984
1150
|
* @returns true if the clause is safe, false if it contains forbidden patterns
|
|
985
1151
|
*/
|
|
986
1152
|
ValidateUserProvidedSQLClause(clause) {
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const lowerClause =
|
|
1153
|
+
// 🚨 SECURITY: this uses the SHARED scanner in @memberjunction/global, which reads quotes,
|
|
1154
|
+
// [bracket] and "double-quoted" identifiers, comments, and PostgreSQL E'…' and $$…$$ strings
|
|
1155
|
+
// the way this provider's database does, and refuses text it cannot classify. Do NOT inline
|
|
1156
|
+
// a regex here: this screen and SQLExpressionValidator must read SQL the same way.
|
|
1157
|
+
const scan = ScanSQLForScreening(clause, this.PlatformKey);
|
|
1158
|
+
if (!scan.Classified || scan.CommentCount > 0)
|
|
1159
|
+
return false;
|
|
1160
|
+
const lowerClause = scan.Code.toLowerCase();
|
|
995
1161
|
const forbiddenPatterns = [
|
|
996
1162
|
/\binsert\b/, /\bupdate\b/, /\bdelete\b/,
|
|
997
1163
|
/\bexec\b/, /\bexecute\b/, /\bdrop\b/,
|
|
998
|
-
/--/, /\/\*/, /\*\//, /\bunion\b/,
|
|
1164
|
+
/--/, /\/\*/, /\*\//, /\bunion\b/, /;/,
|
|
999
1165
|
// Time-based blind injection vector — no legitimate filter/order-by clause uses WAITFOR.
|
|
1000
1166
|
/\bwaitfor\b/,
|
|
1001
1167
|
];
|
|
@@ -1003,7 +1169,8 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1003
1169
|
if (pattern.test(lowerClause))
|
|
1004
1170
|
return false;
|
|
1005
1171
|
}
|
|
1006
|
-
|
|
1172
|
+
// Extended stored procedures are names, so a quoted name such as [xp_cmdshell] is checked too.
|
|
1173
|
+
return !/\bxp_/.test(scan.CodeWithIdentifiers.toLowerCase());
|
|
1007
1174
|
}
|
|
1008
1175
|
/**
|
|
1009
1176
|
* Checks that the given user has read permissions on the specified entity.
|
|
@@ -1052,6 +1219,7 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1052
1219
|
const result = validator.validate(agg.expression, {
|
|
1053
1220
|
context: 'aggregate',
|
|
1054
1221
|
entityFields: fieldNames,
|
|
1222
|
+
Dialect: this.PlatformKey,
|
|
1055
1223
|
});
|
|
1056
1224
|
if (!result.valid) {
|
|
1057
1225
|
validationErrors.push({
|
|
@@ -1138,14 +1306,20 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1138
1306
|
}
|
|
1139
1307
|
}
|
|
1140
1308
|
/**
|
|
1141
|
-
* Builds the
|
|
1142
|
-
*
|
|
1309
|
+
* Builds the query that reads a record's name fields by its primary key.
|
|
1310
|
+
*
|
|
1311
|
+
* Key columns are written from the entity's metadata and key values are bound as parameters,
|
|
1312
|
+
* so nothing the caller supplies reaches the SQL text. When a user is given, their effective
|
|
1313
|
+
* read row filter (role RLS and API-key row filters) is ANDed in. A caller that gives no user
|
|
1314
|
+
* is trusted server code, as for a single-record Load.
|
|
1143
1315
|
*
|
|
1144
1316
|
* @param entityName The entity name
|
|
1145
1317
|
* @param compositeKey The record's primary key
|
|
1146
|
-
* @
|
|
1318
|
+
* @param contextUser The acting user, whose read row filter applies
|
|
1319
|
+
* @returns The SQL and its parameter values, or null if the entity has no name field or the
|
|
1320
|
+
* key does not name the entity's primary key fields
|
|
1147
1321
|
*/
|
|
1148
|
-
BuildEntityRecordNameSQL(entityName, compositeKey) {
|
|
1322
|
+
BuildEntityRecordNameSQL(entityName, compositeKey, contextUser) {
|
|
1149
1323
|
const e = this.EntityByName(entityName);
|
|
1150
1324
|
if (!e)
|
|
1151
1325
|
throw new Error('Entity ' + entityName + ' not found');
|
|
@@ -1154,30 +1328,44 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1154
1328
|
LogError('Entity ' + entityName + ' does not have a NameField, returning null');
|
|
1155
1329
|
return null;
|
|
1156
1330
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
where += this.QuoteIdentifier(pkv.FieldName) + "='" + EscapeSQLString(String(pkv.Value)) + "'";
|
|
1166
|
-
}
|
|
1167
|
-
else {
|
|
1168
|
-
// Unquoted (numeric) key column: the value is spliced in bare, so refuse anything
|
|
1169
|
-
// that is not a plain number rather than letting it reach the SQL text.
|
|
1170
|
-
const raw = String(pkv.Value);
|
|
1171
|
-
if (!/^-?\d+(\.\d+)?$/.test(raw)) {
|
|
1172
|
-
LogError(`BuildEntityRecordNameSQL: non-numeric value provided for numeric key field ${pkv.FieldName} on entity ${entityName}`);
|
|
1173
|
-
return null;
|
|
1174
|
-
}
|
|
1175
|
-
where += this.QuoteIdentifier(pkv.FieldName) + '=' + raw;
|
|
1176
|
-
}
|
|
1331
|
+
const keyFields = this.RecordNameKeyFieldsOf(e, compositeKey);
|
|
1332
|
+
if (!keyFields) {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
const conditions = keyFields.map((f, i) => this.QuoteIdentifier(f.Name) + '=' + this.BuildParameterPlaceholder(i));
|
|
1336
|
+
const rowFilter = contextUser ? e.GetEffectiveRowFilterWhereClause(contextUser, EntityPermissionType.Read, '') : '';
|
|
1337
|
+
if (rowFilter) {
|
|
1338
|
+
conditions.push('(' + rowFilter + ')');
|
|
1177
1339
|
}
|
|
1178
1340
|
// SELECT all name fields so InternalGetEntityRecordName can concatenate them
|
|
1179
1341
|
const selectFields = nameFields.map(f => this.QuoteIdentifier(f.Name)).join(', ');
|
|
1180
|
-
return
|
|
1342
|
+
return {
|
|
1343
|
+
SQL: 'SELECT ' + selectFields + ' FROM ' + this.QuoteSchemaAndView(e.SchemaName, e.BaseView) + ' WHERE ' + conditions.join(' AND '),
|
|
1344
|
+
Parameters: compositeKey.KeyValuePairs.map(kv => kv.Value),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* The primary key field each pair of a record-name key names, in key order. Names match
|
|
1349
|
+
* case-insensitively. Null, after logging, when the key is empty or names a field that is not
|
|
1350
|
+
* one of the entity's primary keys.
|
|
1351
|
+
*/
|
|
1352
|
+
RecordNameKeyFieldsOf(entity, compositeKey) {
|
|
1353
|
+
const pairs = compositeKey?.KeyValuePairs ?? [];
|
|
1354
|
+
if (pairs.length === 0) {
|
|
1355
|
+
LogError(`Record name lookup on ${entity.Name}: the key names no fields`);
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
const keyFields = [];
|
|
1359
|
+
for (const pair of pairs) {
|
|
1360
|
+
const name = String(pair.FieldName ?? '').trim().toLowerCase();
|
|
1361
|
+
const field = entity.PrimaryKeys.find(pk => pk.Name.trim().toLowerCase() === name);
|
|
1362
|
+
if (!field) {
|
|
1363
|
+
LogError(`Record name lookup on ${entity.Name}: ${JSON.stringify(pair.FieldName)} is not a primary key field`);
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
keyFields.push(field);
|
|
1367
|
+
}
|
|
1368
|
+
return keyFields;
|
|
1181
1369
|
}
|
|
1182
1370
|
/**
|
|
1183
1371
|
* The fields a record's display name is built from, in order: every `IsNameField` field by
|
|
@@ -1199,7 +1387,7 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1199
1387
|
* because there is nobody to check against.
|
|
1200
1388
|
*/
|
|
1201
1389
|
CanUserReadRecordName(entity, contextUser) {
|
|
1202
|
-
if (!entity.
|
|
1390
|
+
if (!entity.FieldLevelSecurityApplies) {
|
|
1203
1391
|
return true;
|
|
1204
1392
|
}
|
|
1205
1393
|
if (!contextUser) {
|
|
@@ -1210,7 +1398,8 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1210
1398
|
}
|
|
1211
1399
|
/**
|
|
1212
1400
|
* Retrieves the display name for a single entity record.
|
|
1213
|
-
* Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation
|
|
1401
|
+
* Uses BuildEntityRecordNameSQL for dialect-neutral SQL generation, which limits the lookup to
|
|
1402
|
+
* rows the acting user's read row filter allows.
|
|
1214
1403
|
*
|
|
1215
1404
|
* Answers with an empty string, without querying, when field-level security withholds any of
|
|
1216
1405
|
* the name fields from the acting user — the same answer as a record that does not exist, so
|
|
@@ -1222,9 +1411,9 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1222
1411
|
if (entity && !this.CanUserReadRecordName(entity, contextUser)) {
|
|
1223
1412
|
return '';
|
|
1224
1413
|
}
|
|
1225
|
-
const
|
|
1226
|
-
if (
|
|
1227
|
-
const data = await this.ExecuteSQL(
|
|
1414
|
+
const query = this.BuildEntityRecordNameSQL(entityName, compositeKey, contextUser);
|
|
1415
|
+
if (query) {
|
|
1416
|
+
const data = await this.ExecuteSQL(query.SQL, query.Parameters, undefined, contextUser);
|
|
1228
1417
|
if (data && data.length === 1) {
|
|
1229
1418
|
// Concatenate all returned fields with spaces (supports multi-name entities)
|
|
1230
1419
|
const values = Object.values(data[0])
|
|
@@ -1407,22 +1596,45 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1407
1596
|
}
|
|
1408
1597
|
// Step 4: Generate provider-specific SQL
|
|
1409
1598
|
const sqlDetails = await this.GenerateSaveSQL(entity, bNewRecord, user, options);
|
|
1599
|
+
const effectiveReturnMode = options?.SaveReturnMode ?? entity.EntityInfo.SaveReturnMode;
|
|
1600
|
+
// Snapshot the entity's in-memory field values at the exact point SQL parameters were bound
|
|
1601
|
+
// (before awaiting ExecuteSQL). If the caller mutates the entity in memory while an async
|
|
1602
|
+
// save is in flight (e.g. BaseEntitySaveQueue pipelining an INSERT + UPDATE), merging
|
|
1603
|
+
// post-await entity.GetAll() would falsely mark those in-flight mutations as clean/saved.
|
|
1604
|
+
const preSaveValues = effectiveReturnMode === 'datestamps_only'
|
|
1605
|
+
? entity.GetAll()
|
|
1606
|
+
: undefined;
|
|
1607
|
+
if (preSaveValues && bNewRecord) {
|
|
1608
|
+
// On a new record saved with 'datestamps_only', the SP does not query BaseView,
|
|
1609
|
+
// so virtual non-PK fields (e.g. joined name columns) that were never set stay
|
|
1610
|
+
// null. Omit them from preSaveValues so finalizeSave marks them NotLoaded=true.
|
|
1611
|
+
for (const f of entity.EntityInfo.Fields) {
|
|
1612
|
+
if (f.IsVirtual && !f.IsPrimaryKey && (preSaveValues[f.Name] === null || preSaveValues[f.Name] === undefined)) {
|
|
1613
|
+
delete preSaveValues[f.Name];
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1410
1617
|
if (entity.TransactionGroup && !bReplay) {
|
|
1411
1618
|
// ---- Transaction Group path ----
|
|
1412
1619
|
entity.RaiseReadyForTransaction();
|
|
1413
1620
|
suspendRefresh();
|
|
1414
1621
|
const extraData = this.GetTransactionExtraData(entity);
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1622
|
+
Object.defineProperty(extraData, 'simpleSQLFallback', {
|
|
1623
|
+
get: () => sqlDetails.simpleSQL,
|
|
1624
|
+
enumerable: true,
|
|
1625
|
+
configurable: true,
|
|
1626
|
+
});
|
|
1418
1627
|
extraData.entityName = entity.EntityInfo.Name;
|
|
1419
|
-
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
|
|
1628
|
+
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, entityResult.Type === 'create' ? 'Create' : 'Update', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
|
|
1420
1629
|
resumeRefresh();
|
|
1421
1630
|
entityResult.EndedAt = new Date();
|
|
1422
1631
|
if (success && transactionResult) {
|
|
1423
1632
|
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1424
1633
|
entityResult.Success = true;
|
|
1425
|
-
|
|
1634
|
+
const mergedResult = preSaveValues
|
|
1635
|
+
? { ...preSaveValues, ...transactionResult }
|
|
1636
|
+
: transactionResult;
|
|
1637
|
+
entityResult.NewValues = this.MapTransactionResultToNewValues(mergedResult);
|
|
1426
1638
|
}
|
|
1427
1639
|
else {
|
|
1428
1640
|
entityResult.Success = false;
|
|
@@ -1448,20 +1660,36 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1448
1660
|
if (result && result.length > 0) {
|
|
1449
1661
|
this.OnAfterSaveExecute(entity, user, options, saveContext);
|
|
1450
1662
|
entityResult.Success = true;
|
|
1663
|
+
// In 'datestamps_only' mode, the SP/query returned only the PK and
|
|
1664
|
+
// __mj_CreatedAt/__mj_UpdatedAt columns. Merge those returned values
|
|
1665
|
+
// onto the pre-save snapshot so caller-set fields remain intact without
|
|
1666
|
+
// falsely marking concurrent in-flight mutations as clean.
|
|
1667
|
+
let mergedRow;
|
|
1668
|
+
if (preSaveValues) {
|
|
1669
|
+
mergedRow = { ...preSaveValues, ...result[0] };
|
|
1670
|
+
Object.defineProperty(mergedRow, '__mj_preMerged', {
|
|
1671
|
+
value: true,
|
|
1672
|
+
enumerable: false,
|
|
1673
|
+
configurable: true,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
mergedRow = result[0];
|
|
1678
|
+
}
|
|
1451
1679
|
// OnSaveCompleted may return patch fields (e.g., updated lat/lng
|
|
1452
1680
|
// from geocoding) that need to be merged onto the SP result before
|
|
1453
1681
|
// the entity loads the data via finalizeSave().
|
|
1454
1682
|
const patches = await this.OnSaveCompleted(entity, sqlDetails, user, options, saveContext);
|
|
1455
1683
|
if (patches) {
|
|
1456
|
-
Object.assign(
|
|
1684
|
+
Object.assign(mergedRow, patches);
|
|
1457
1685
|
}
|
|
1458
|
-
return
|
|
1686
|
+
return mergedRow;
|
|
1459
1687
|
}
|
|
1460
1688
|
else {
|
|
1461
1689
|
if (bNewRecord)
|
|
1462
|
-
throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.fullSQL}`);
|
|
1690
|
+
throw new Error(`SQL Error: Error creating new record, no rows returned from SQL: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1463
1691
|
else
|
|
1464
|
-
throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.fullSQL}`);
|
|
1692
|
+
throw new Error(`SQL Error: Error updating record, no MATCHING rows found within the database: ${sqlDetails.literalSQL ?? sqlDetails.fullSQL}`);
|
|
1465
1693
|
}
|
|
1466
1694
|
}
|
|
1467
1695
|
}
|
|
@@ -1531,20 +1759,24 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1531
1759
|
// ---- Transaction Group path ----
|
|
1532
1760
|
entity.RaiseReadyForTransaction();
|
|
1533
1761
|
const extraData = this.GetTransactionExtraData(entity);
|
|
1534
|
-
if (entity.EntityInfo.TrackRecordChanges
|
|
1535
|
-
extraData
|
|
1762
|
+
if (entity.EntityInfo.TrackRecordChanges) {
|
|
1763
|
+
Object.defineProperty(extraData, 'simpleSQLFallback', {
|
|
1764
|
+
get: () => sqlDetails.simpleSQL,
|
|
1765
|
+
enumerable: true,
|
|
1766
|
+
configurable: true,
|
|
1767
|
+
});
|
|
1536
1768
|
}
|
|
1537
1769
|
extraData.entityName = entity.EntityInfo.Name;
|
|
1538
|
-
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.fullSQL, sqlDetails.parameters ?? null, extraData, (transactionResult, success) => {
|
|
1770
|
+
entity.TransactionGroup.AddTransaction(new TransactionItem(entity, 'Delete', sqlDetails.literalSQL ?? sqlDetails.fullSQL, sqlDetails.literalSQL ? null : (sqlDetails.parameters ?? null), extraData, (transactionResult, success) => {
|
|
1539
1771
|
entityResult.EndedAt = new Date();
|
|
1540
1772
|
if (success && transactionResult) {
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
}
|
|
1773
|
+
// Same key check as the direct path, through the provider's own override
|
|
1774
|
+
// (PostgreSQL accepts its legacy `_result_id` shape there).
|
|
1775
|
+
if (!this.ValidateDeleteResult(entity, [transactionResult], entityResult)) {
|
|
1776
|
+
entityResult.Success = false;
|
|
1777
|
+
return;
|
|
1547
1778
|
}
|
|
1779
|
+
this.OnAfterDeleteExecute(entity, user, options);
|
|
1548
1780
|
entityResult.NewValues = this.MapTransactionResultToNewValues(transactionResult);
|
|
1549
1781
|
entityResult.Success = true;
|
|
1550
1782
|
}
|
|
@@ -1890,6 +2122,11 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1890
2122
|
duplicateRun.Set('ProcessingStatus', 'In Progress');
|
|
1891
2123
|
duplicateRun.Set('ApprovalStatus', 'Pending');
|
|
1892
2124
|
duplicateRun.Set('SourceListID', listEntity.Get('ID'));
|
|
2125
|
+
// Without a document, the server-side Duplicate Run entity defaults to the entity's
|
|
2126
|
+
// duplicate-detection document.
|
|
2127
|
+
if (params.EntityDocumentID) {
|
|
2128
|
+
duplicateRun.Set('EntityDocumentID', params.EntityDocumentID);
|
|
2129
|
+
}
|
|
1893
2130
|
const saveResult = await duplicateRun.Save();
|
|
1894
2131
|
if (!saveResult) {
|
|
1895
2132
|
throw new Error('Failed to save Duplicate Run Entity');
|
|
@@ -1926,6 +2163,13 @@ export class DatabaseProviderBase extends ProviderBase {
|
|
|
1926
2163
|
throw new Error(`Record ${key.ToString()} of ${request.EntityName} has a ${child.ChildEntityName} subtype row; merging records another entity extends is not supported yet`);
|
|
1927
2164
|
}
|
|
1928
2165
|
}
|
|
2166
|
+
// The field map is client input applied to the surviving record: a caller with any field
|
|
2167
|
+
// denial may name only fields they can read, and a denied name and an unknown name get the
|
|
2168
|
+
// same refusal. Checked before the merge log and the transaction, so a refusal leaves no trace.
|
|
2169
|
+
if (request.FieldMap?.length) {
|
|
2170
|
+
const values = Object.fromEntries(request.FieldMap.map(f => [f.FieldName, f.Value]));
|
|
2171
|
+
AssertClientInputWritableForUser(e, values, contextUser);
|
|
2172
|
+
}
|
|
1929
2173
|
const result = {
|
|
1930
2174
|
Success: false,
|
|
1931
2175
|
RecordMergeLogID: null,
|