From f964c8a931a1c330e642f99b0e95b0d3c9e24b86 Mon Sep 17 00:00:00 2001 From: Skovlund Date: Thu, 6 Aug 2026 13:20:04 +0200 Subject: [PATCH] System-managed attributes copied onto Target for post-operation visibility no longer satisfy Update filtering attributes. --- RELEASE_NOTES.md | 5 +- src/XrmMockup365/Core.cs | 18 +++- .../Internal/ExecutionPipelineContext.cs | 6 ++ src/XrmMockup365/Internal/ICoreOperations.cs | 4 +- src/XrmMockup365/Plugin/PluginManager.cs | 28 +++--- src/XrmMockup365/Plugin/PluginTrigger.cs | 26 ++++-- src/XrmMockup365/RequestExecutionPipeline.cs | 19 ++-- src/XrmMockup365/Workflow/WorkflowManager.cs | 86 +++++++------------ .../TaskFilteredAttributesProbePlugin.cs | 31 +++++++ .../TestFilteredAttributes.cs | 62 +++++++++++++ 10 files changed, 200 insertions(+), 85 deletions(-) create mode 100644 tests/TestPluginAssembly365/Plugins/TaskFilteredAttributesProbePlugin.cs create mode 100644 tests/XrmMockup365Test/TestFilteredAttributes.cs diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 70dd0e14..0490c69a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,8 @@ +### 1.19.1 - 6 August 2026 +* Fix: System-managed attributes copied onto Target for post-operation visibility no longer satisfy Update filtering attributes (#346) + ### 1.19.0 - 5 August 2026 -* Fix: Handle PrivilegeDepth.RecordFilter like PrivilegeDepth.Global instead of throwing exception +* Fix: Handle PrivilegeDepth.RecordFilter like PrivilegeDepth.Global instead of throwing exception (#345) ### 1.18.8 - 14 July 2026 * Fix: Align plugin Target with Dataverse for system-managed fields (#344) diff --git a/src/XrmMockup365/Core.cs b/src/XrmMockup365/Core.cs index 90e73904..c3f22623 100644 --- a/src/XrmMockup365/Core.cs +++ b/src/XrmMockup365/Core.cs @@ -785,14 +785,20 @@ public EntityReference GetBusinessUnit(EntityReference owner) return Utility.GetBusinessUnit(db, owner); } - public void CopySystemAttributes(Entity postImage, Entity target) + public ISet CopySystemAttributes(Entity postImage, Entity target) { - if (target == null) return; + var injected = new HashSet(); + if (target == null) return injected; foreach (var systemAttributeName in systemAttributeNames) { if (postImage.Contains(systemAttributeName)) { + // Only attributes this copy *adds* are reported. One the caller (or a + // pre-operation plugin) already put in the Target is a genuine part of the + // update and must keep counting towards plugin/workflow attribute filters. + var wasAbsent = !target.Contains(systemAttributeName); + if (postImage[systemAttributeName] is EntityReference reference) { target[systemAttributeName] = new EntityReference(reference.LogicalName, reference.Id); @@ -805,8 +811,16 @@ public void CopySystemAttributes(Entity postImage, Entity target) { target[systemAttributeName] = new OptionSetValue(optionSet.Value); } + else + { + continue; + } + + if (wasAbsent) injected.Add(systemAttributeName); } } + + return injected; } public void HandleInternalPreOperations(OrganizationRequest request, EntityReference userRef) diff --git a/src/XrmMockup365/Internal/ExecutionPipelineContext.cs b/src/XrmMockup365/Internal/ExecutionPipelineContext.cs index 0964dabd..f052d591 100644 --- a/src/XrmMockup365/Internal/ExecutionPipelineContext.cs +++ b/src/XrmMockup365/Internal/ExecutionPipelineContext.cs @@ -1,5 +1,6 @@ using Microsoft.Xrm.Sdk; using System; +using System.Collections.Generic; namespace DG.Tools.XrmMockup.Internal { @@ -28,6 +29,11 @@ internal class ExecutionPipelineContext public Entity SyncPostImage { get; set; } // fetched at start of PostOperation (sync) public Entity AsyncPostImage { get; set; } // fetched before async staging + // System-managed attributes copied onto the Target at the start of PostOperation. They are + // visible to post-operation plugins (as in Dataverse) but must not count towards Update + // filtering attributes, since the caller never asked for them to change. + public ISet SystemInjectedAttributes { get; set; } + // Output — set by the main operation stage public OrganizationResponse Response { get; set; } } diff --git a/src/XrmMockup365/Internal/ICoreOperations.cs b/src/XrmMockup365/Internal/ICoreOperations.cs index 458fbe57..65c1919b 100644 --- a/src/XrmMockup365/Internal/ICoreOperations.cs +++ b/src/XrmMockup365/Internal/ICoreOperations.cs @@ -30,8 +30,8 @@ internal interface ICoreOperations // Pre-context setup void HandleInternalPreOperations(OrganizationRequest request, EntityReference userRef); - // Post-operation image helper - void CopySystemAttributes(Entity postImage, Entity target); + // Post-operation image helper — returns the attributes it added to the target + ISet CopySystemAttributes(Entity postImage, Entity target); // Request handler list — used by pipeline for security check and pre-op init List RequestHandlers { get; } diff --git a/src/XrmMockup365/Plugin/PluginManager.cs b/src/XrmMockup365/Plugin/PluginManager.cs index 884d7670..28dd812f 100644 --- a/src/XrmMockup365/Plugin/PluginManager.cs +++ b/src/XrmMockup365/Plugin/PluginManager.cs @@ -322,9 +322,10 @@ private static void SortAllLists(Dictionary p } public void TriggerSync(string operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func executionOrderFilter) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func executionOrderFilter, + ISet systemInjectedAttributes = null) { - TriggerSyncInternal(operation, stage, entity, preImage, postImage, pluginContext, executionOrderFilter); + TriggerSyncInternal(operation, stage, entity, preImage, postImage, pluginContext, executionOrderFilter, systemInjectedAttributes); // Check if this is a Single -> Multiple request var isKnownOp = Enum.TryParse(operation, out var knownOp); @@ -361,7 +362,7 @@ public void TriggerSync(string operation, ExecutionStage stage, }; - TriggerSyncInternal(multipleOperation.ToString(), stage, entityCollection, null, null, multiplePluginContext, executionOrderFilter); + TriggerSyncInternal(multipleOperation.ToString(), stage, entityCollection, null, null, multiplePluginContext, executionOrderFilter, systemInjectedAttributes); } // Check if this is a Multiple -> Single request @@ -407,13 +408,14 @@ public void TriggerSync(string operation, ExecutionStage stage, var entityPostImage = pluginContext.PostEntityImagesCollection.Length > i && pluginContext.PostEntityImagesCollection[i].TryGetValue("PostImage", out var post) ? post : postImage; - TriggerSyncInternal(singleOperation.ToString(), stage, targetEntity, entityPreImage, entityPostImage, singlePluginContext, executionOrderFilter); + TriggerSyncInternal(singleOperation.ToString(), stage, targetEntity, entityPreImage, entityPostImage, singlePluginContext, executionOrderFilter, systemInjectedAttributes); } } } private void TriggerSyncInternal(EventOperation operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func executionOrderFilter) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func executionOrderFilter, + ISet systemInjectedAttributes = null) { if (!disableRegisteredPlugins && registeredPlugins.TryGetValue(operation, out var operationPlugins) && operationPlugins.TryGetValue(stage, out var stagePlugins)) stagePlugins @@ -421,7 +423,7 @@ private void TriggerSyncInternal(EventOperation operation, ExecutionStage stage, .Where(executionOrderFilter) .OrderBy(p => p.GetExecutionOrder()) .ToList() - .ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core)); + .ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core, systemInjectedAttributes: systemInjectedAttributes)); if (temporaryPlugins.TryGetValue(operation, out var tempOperationPlugins) && tempOperationPlugins.TryGetValue(stage, out var tempStagePlugins)) tempStagePlugins @@ -429,17 +431,18 @@ private void TriggerSyncInternal(EventOperation operation, ExecutionStage stage, .Where(executionOrderFilter) .OrderBy(p => p.GetExecutionOrder()) .ToList() - .ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core)); + .ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core, systemInjectedAttributes: systemInjectedAttributes)); } public void StageAsync(EventOperation operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, + ISet systemInjectedAttributes = null) { if (!disableRegisteredPlugins && registeredPlugins.TryGetValue(operation, out var operationPlugins) && operationPlugins.TryGetValue(stage, out var stagePlugins)) stagePlugins .Where(p => p.GetExecutionMode() == ExecutionMode.Asynchronous) .OrderBy(p => p.GetExecutionOrder()) - .Select(p => p.ToPluginExecution(entity, preImage, postImage, pluginContext, _core)) + .Select(p => p.ToPluginExecution(entity, preImage, postImage, pluginContext, _core, systemInjectedAttributes)) .ToList() .ForEach(pendingAsyncPlugins.Enqueue); @@ -447,7 +450,7 @@ public void StageAsync(EventOperation operation, ExecutionStage stage, tempStagePlugins .Where(p => p.GetExecutionMode() == ExecutionMode.Asynchronous) .OrderBy(p => p.GetExecutionOrder()) - .Select(p => p.ToPluginExecution(entity, preImage, postImage, pluginContext, _core)) + .Select(p => p.ToPluginExecution(entity, preImage, postImage, pluginContext, _core, systemInjectedAttributes)) .ToList() .ForEach(pendingAsyncPlugins.Enqueue); } @@ -461,7 +464,8 @@ public void TriggerAsyncWaitingJobs() } public void TriggerSystem(EventOperation operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, + ISet systemInjectedAttributes = null) { if (!registeredSystemPlugins.TryGetValue(operation, out var stagePlugins)) { @@ -475,7 +479,7 @@ public void TriggerSystem(EventOperation operation, ExecutionStage stage, // System plugins are XrmMockup's own internal simulation, not user-registered steps, // so they are excluded from the grouped plugin trace log. - plugins.ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core, recordTrace: false)); + plugins.ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core, recordTrace: false, systemInjectedAttributes: systemInjectedAttributes)); } private string GeneratePluginCacheKey(IEnumerable basePluginTypes, IEnumerable plugins) diff --git a/src/XrmMockup365/Plugin/PluginTrigger.cs b/src/XrmMockup365/Plugin/PluginTrigger.cs index 359a3233..c3934048 100644 --- a/src/XrmMockup365/Plugin/PluginTrigger.cs +++ b/src/XrmMockup365/Plugin/PluginTrigger.cs @@ -63,7 +63,8 @@ public int GetExecutionOrder() } // Saves "execution" for Async plugins to be executed after sync plugins. - public PluginExecutionProvider ToPluginExecution(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core) + public PluginExecutionProvider ToPluginExecution(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core, + ISet systemInjectedAttributes = null) { var entity = entityObject as Entity; var entityRef = entityObject as EntityReference; @@ -71,7 +72,7 @@ public PluginExecutionProvider ToPluginExecution(object entityObject, Entity pre var guid = (entity != null) ? entity.Id : entityRef.Id; var logicalName = (entity != null) ? entity.LogicalName : entityRef.LogicalName; - if (VerifyPluginTrigger(entity, logicalName, guid, preImage, postImage, pluginContext)) + if (VerifyPluginTrigger(entity, logicalName, guid, preImage, postImage, pluginContext, systemInjectedAttributes)) { // Create the plugin context var thisPluginContext = CreatePluginContext(pluginContext, guid, logicalName, preImage, postImage); @@ -86,7 +87,8 @@ public PluginExecutionProvider ToPluginExecution(object entityObject, Entity pre return null; } - public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core, bool recordTrace = true) + public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core, bool recordTrace = true, + ISet systemInjectedAttributes = null) { // Check if it is supposed to execute. Returns preemptively, if it should not. var entity = entityObject as Entity; @@ -106,7 +108,7 @@ public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImag ? entityRef.LogicalName : entityCollection.EntityName; - if (VerifyPluginTrigger(entity, logicalName, guid, preImage, postImage, pluginContext)) + if (VerifyPluginTrigger(entity, logicalName, guid, preImage, postImage, pluginContext, systemInjectedAttributes)) { var thisPluginContext = CreatePluginContext(pluginContext, guid, logicalName, preImage, postImage); @@ -173,7 +175,7 @@ private Entity AddPostImageAttributesToEntity(Entity entity, Entity preImage, En return entity; } - private bool FilteredAttributesMatches(Entity entity) + private bool FilteredAttributesMatches(Entity entity, ISet systemInjectedAttributes) { if (!Operation.Matches(EventOperation.Update) || Attributes.Count == 0) { @@ -183,6 +185,15 @@ private bool FilteredAttributesMatches(Entity entity) bool foundAttr = false; foreach (var attr in entity.Attributes) { + // System-managed attributes that XrmMockup copied onto the Target for post-operation + // visibility were not part of the update the caller asked for, so they must not + // satisfy the filter — otherwise a step filtered on e.g. statecode would fire on + // every update of the table. + if (systemInjectedAttributes != null && systemInjectedAttributes.Contains(attr.Key)) + { + continue; + } + if (Attributes.Contains(attr.Key)) { foundAttr = true; @@ -192,7 +203,8 @@ private bool FilteredAttributesMatches(Entity entity) return foundAttr; } - private bool VerifyPluginTrigger(Entity entity, string logicalName, Guid guid, Entity preImage, Entity postImage, PluginContext pluginContext) + private bool VerifyPluginTrigger(Entity entity, string logicalName, Guid guid, Entity preImage, Entity postImage, PluginContext pluginContext, + ISet systemInjectedAttributes = null) { if (EntityName != "" && EntityName != logicalName) return false; @@ -205,7 +217,7 @@ private bool VerifyPluginTrigger(Entity entity, string logicalName, Guid guid, E entity = AddPostImageAttributesToEntity(entity, preImage, postImage); CheckSpecialRequest(); - if (FilteredAttributesMatches(entity)) + if (FilteredAttributesMatches(entity, systemInjectedAttributes)) { return true; } diff --git a/src/XrmMockup365/RequestExecutionPipeline.cs b/src/XrmMockup365/RequestExecutionPipeline.cs index 324c7888..0aa5ccdd 100644 --- a/src/XrmMockup365/RequestExecutionPipeline.cs +++ b/src/XrmMockup365/RequestExecutionPipeline.cs @@ -282,32 +282,37 @@ private void ExecutePostOperationStage(ExecutionPipelineContext ctx) ctx.SyncPostImage = core.TryRetrieve(ctx.PrimaryRef); if (ctx.SyncPostImage != null) - core.CopySystemAttributes(ctx.SyncPostImage, ctx.EntityInfo.Item1 as Entity); + ctx.SystemInjectedAttributes = + core.CopySystemAttributes(ctx.SyncPostImage, ctx.EntityInfo.Item1 as Entity); // Sync post-operation: system first, then user plugins ordered by ExecutionOrder, interleaved with workflows pluginManager.TriggerSystem(ctx.RequestMessage, ExecutionStage.PostOperation, - ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext); + ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext, + ctx.SystemInjectedAttributes); pluginManager.TriggerSync(ctx.RequestMessage, ExecutionStage.PostOperation, ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext, - p => p.GetExecutionOrder() == 0); + p => p.GetExecutionOrder() == 0, ctx.SystemInjectedAttributes); if (ctx.Settings.TriggerWorkflows) workflowManager.TriggerSync(ctx.RequestMessage, ExecutionStage.PostOperation, - ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext); + ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext, + ctx.SystemInjectedAttributes); pluginManager.TriggerSync(ctx.RequestMessage, ExecutionStage.PostOperation, ctx.EntityInfo.Item1, ctx.PreImage, ctx.SyncPostImage, ctx.PluginContext, - p => p.GetExecutionOrder() != 0); + p => p.GetExecutionOrder() != 0, ctx.SystemInjectedAttributes); // Stage async work — re-fetch post-image so async jobs see the final committed state ctx.AsyncPostImage = core.TryRetrieve(ctx.PrimaryRef); pluginManager.StageAsync(ctx.RequestMessage, ExecutionStage.PostOperation, - ctx.EntityInfo.Item1, ctx.PreImage, ctx.AsyncPostImage, ctx.PluginContext); + ctx.EntityInfo.Item1, ctx.PreImage, ctx.AsyncPostImage, ctx.PluginContext, + ctx.SystemInjectedAttributes); if (ctx.Settings.TriggerWorkflows) workflowManager.StageAsync(ctx.RequestMessage, ExecutionStage.PostOperation, - ctx.EntityInfo.Item1, ctx.PreImage, ctx.AsyncPostImage, ctx.PluginContext); + ctx.EntityInfo.Item1, ctx.PreImage, ctx.AsyncPostImage, ctx.PluginContext, + ctx.SystemInjectedAttributes); // Async jobs only fire at the top-level call, not from within a plugin if (ctx.ParentPluginContext == null) diff --git a/src/XrmMockup365/Workflow/WorkflowManager.cs b/src/XrmMockup365/Workflow/WorkflowManager.cs index ec1dcc3e..38491a3e 100644 --- a/src/XrmMockup365/Workflow/WorkflowManager.cs +++ b/src/XrmMockup365/Workflow/WorkflowManager.cs @@ -107,9 +107,10 @@ public WorkflowManager(ICoreOperations core, IEnumerable codeActivityInsta /// /// public void TriggerSync(string operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, + ISet systemInjectedAttributes = null) { - var toExecute = synchronousWorkflows.Where(x => ShouldExecute(x, operation, stage, entity, pluginContext)).ToList(); + var toExecute = synchronousWorkflows.Where(x => ShouldExecute(x, operation, stage, entity, pluginContext, systemInjectedAttributes)).ToList(); foreach (var workflow in toExecute) { Execute(workflow, operation, entity, preImage, postImage, pluginContext); @@ -133,12 +134,13 @@ public void TriggerAsync() } public void StageAsync(string operation, ExecutionStage stage, - object entity, Entity preImage, Entity postImage, PluginContext pluginContext) + object entity, Entity preImage, Entity postImage, PluginContext pluginContext, + ISet systemInjectedAttributes = null) { - var toExecute = asynchronousWorkflows.Where(x => ShouldStage(x, operation, stage, entity, pluginContext)).ToList(); + var toExecute = asynchronousWorkflows.Where(x => ShouldStage(x, operation, stage, entity, pluginContext, systemInjectedAttributes)).ToList(); foreach (var workflow in toExecute) { - Stage(workflow, operation, stage, entity,pluginContext); + Stage(workflow, operation, stage, entity, pluginContext, systemInjectedAttributes); } } @@ -199,7 +201,7 @@ private PluginContext createPluginContext(PluginContext pluginContext, Entity wo } private void Stage(Entity workflow, string operation, ExecutionStage stage, - object entityObject, PluginContext pluginContext) + object entityObject, PluginContext pluginContext, ISet systemInjectedAttributes) { if (workflow.LogicalName != "workflow") return; var entity = entityObject as Entity; @@ -217,19 +219,8 @@ private void Stage(Entity workflow, string operation, ExecutionStage stage, var isDelete = operation.Matches(EventOperation.Delete); if (!ShouldTriggerOnAction(isCreate, isUpdate, isDelete, workflow)) return; - - var triggerFields = new HashSet(); - if (workflow.GetAttributeValue("triggeronupdateattributelist") != null) - { - foreach (var field in workflow.GetAttributeValue("triggeronupdateattributelist").Split(',')) - { - triggerFields.Add(field); - } - } - if (isUpdate && ( - workflow.GetAttributeValue("triggeronupdateattributelist") == null || - workflow.GetAttributeValue("triggeronupdateattributelist") == "" || - !entity.Attributes.Any(a => workflow.GetAttributeValue("triggeronupdateattributelist").Split(',').Any(f => a.Key == f)))) return; + + if (isUpdate && !MatchesTriggerFields(workflow, entity, systemInjectedAttributes)) return; var thisStage = isCreate ? workflow.GetOptionSetValue("createstage") : (isDelete ? workflow.GetOptionSetValue("deletestage") : workflow.GetOptionSetValue("updatestage")); @@ -249,7 +240,7 @@ private void Stage(Entity workflow, string operation, ExecutionStage stage, } private bool ShouldStage(Entity workflow, string operation, ExecutionStage stage, - object entityObject, PluginContext pluginContext) + object entityObject, PluginContext pluginContext, ISet systemInjectedAttributes) { if (workflow.LogicalName != "workflow") return false; var entity = entityObject as Entity; @@ -268,18 +259,7 @@ private bool ShouldStage(Entity workflow, string operation, ExecutionStage stage if (!ShouldTriggerOnAction(isCreate, isUpdate, isDelete, workflow)) return false; - var triggerFields = new HashSet(); - if (workflow.GetAttributeValue("triggeronupdateattributelist") != null) - { - foreach (var field in workflow.GetAttributeValue("triggeronupdateattributelist").Split(',')) - { - triggerFields.Add(field); - } - } - if (isUpdate && ( - workflow.GetAttributeValue("triggeronupdateattributelist") == null || - workflow.GetAttributeValue("triggeronupdateattributelist") == "" || - !entity.Attributes.Any(a => workflow.GetAttributeValue("triggeronupdateattributelist").Split(',').Any(f => a.Key == f)))) return false; + if (isUpdate && !MatchesTriggerFields(workflow, entity, systemInjectedAttributes)) return false; var thisStage = isCreate ? workflow.GetOptionSetValue("createstage") : (isDelete ? workflow.GetOptionSetValue("deletestage") : workflow.GetOptionSetValue("updatestage")); @@ -298,6 +278,23 @@ private bool ShouldStage(Entity workflow, string operation, ExecutionStage stage return true; } + /// + /// Matches the workflow's triggering attributes against the attributes present in the Target. + /// Attributes XrmMockup copied onto the Target for post-operation visibility are skipped — + /// they were not part of the update the caller requested, so they must not trigger a workflow + /// (a workflow triggering on e.g. statecode would otherwise run on every update of the table). + /// + private static bool MatchesTriggerFields(Entity workflow, Entity entity, ISet systemInjectedAttributes) + { + var triggerList = workflow.GetAttributeValue("triggeronupdateattributelist"); + if (string.IsNullOrEmpty(triggerList)) return false; + + var triggerFields = new HashSet(triggerList.Split(',')); + return entity.Attributes.Keys.Any(key => + (systemInjectedAttributes == null || !systemInjectedAttributes.Contains(key)) + && triggerFields.Contains(key)); + } + private bool ShouldTriggerOnAction(bool isCreate,bool isUpdate,bool isDelete,Entity workflow) { if (!isCreate && !isUpdate && !isDelete) return false; @@ -306,7 +303,8 @@ private bool ShouldTriggerOnAction(bool isCreate,bool isUpdate,bool isDelete,Ent return true; } - private bool ShouldExecute(Entity workflow, string operation, ExecutionStage stage, object entityObject, PluginContext pluginContext) + private bool ShouldExecute(Entity workflow, string operation, ExecutionStage stage, object entityObject, PluginContext pluginContext, + ISet systemInjectedAttributes) { // Check if it is supposed to execute. Returns preemptively, if it should not. if (workflow.LogicalName != "workflow") return false; @@ -326,18 +324,7 @@ private bool ShouldExecute(Entity workflow, string operation, ExecutionStage sta if (!ShouldTriggerOnAction(isCreate, isUpdate, isDelete, workflow)) return false; - var triggerFields = new HashSet(); - if (workflow.GetAttributeValue("triggeronupdateattributelist") != null) - { - foreach (var field in workflow.GetAttributeValue("triggeronupdateattributelist").Split(',')) - { - triggerFields.Add(field); - } - } - if (isUpdate && ( - workflow.GetAttributeValue("triggeronupdateattributelist") == null || - workflow.GetAttributeValue("triggeronupdateattributelist") == "" || - !entity.Attributes.Any(a => workflow.GetAttributeValue("triggeronupdateattributelist").Split(',').Any(f => a.Key == f)))) return false; + if (isUpdate && !MatchesTriggerFields(workflow, entity, systemInjectedAttributes)) return false; var thisStage = isCreate ? workflow.GetOptionSetValue("createstage") : (isDelete ? workflow.GetOptionSetValue("deletestage") : workflow.GetOptionSetValue("updatestage")); @@ -371,15 +358,6 @@ private void Execute(Entity workflow, string operation, object entityObject, Ent var isUpdate = operation.Matches(EventOperation.Update); var isDelete = operation.Matches(EventOperation.Delete); - var triggerFields = new HashSet(); - if (workflow.GetAttributeValue("triggeronupdateattributelist") != null) - { - foreach (var field in workflow.GetAttributeValue("triggeronupdateattributelist").Split(',')) - { - triggerFields.Add(field); - } - } - var thisStage = isCreate ? workflow.GetOptionSetValue("createstage") : (isDelete ? workflow.GetOptionSetValue("deletestage") : workflow.GetOptionSetValue("updatestage")); diff --git a/tests/TestPluginAssembly365/Plugins/TaskFilteredAttributesProbePlugin.cs b/tests/TestPluginAssembly365/Plugins/TaskFilteredAttributesProbePlugin.cs new file mode 100644 index 00000000..77f981e2 --- /dev/null +++ b/tests/TestPluginAssembly365/Plugins/TaskFilteredAttributesProbePlugin.cs @@ -0,0 +1,31 @@ +namespace DG.Some.Namespace { + using Microsoft.Xrm.Sdk; + using DG.XrmFramework.BusinessDomain.ServiceContext; + using XrmPluginCore; + using XrmPluginCore.Enums; + + public class TaskFilteredAttributesProbePlugin : Plugin { + public TaskFilteredAttributesProbePlugin() { +#pragma warning disable CS0618 // Type or member is obsolete + RegisterPluginStep( + EventOperation.Update, + ExecutionStage.PostOperation, + Execute) + .AddFilteredAttributes( + x => x.StatusCode, + x => x.StateCode, + x => x.Description); +#pragma warning restore CS0618 // Type or member is obsolete + } + + protected void Execute(LocalPluginContext localContext) + { + var target = (Entity)localContext.PluginExecutionContext.InputParameters["Target"]; + + // Stamping the marker is itself an update, so skip it when this execution is that update. + if (target.Contains("category")) return; + + localContext.OrganizationService.Update(new Task { Id = target.Id, Category = "FilterMatched" }); + } + } +} diff --git a/tests/XrmMockup365Test/TestFilteredAttributes.cs b/tests/XrmMockup365Test/TestFilteredAttributes.cs new file mode 100644 index 00000000..79b3b9bc --- /dev/null +++ b/tests/XrmMockup365Test/TestFilteredAttributes.cs @@ -0,0 +1,62 @@ +using System; +using DG.Some.Namespace; +using DG.XrmFramework.BusinessDomain.ServiceContext; +using Xunit; + +namespace DG.XrmMockupTest +{ + /// + /// Covers Update filtering attributes against the system-managed attributes XrmMockup copies onto + /// the Target at the start of the post-operation stage (statecode, statuscode, ownerid, ...). + /// Those are visible to post-operation plugins, as in Dataverse, but must not satisfy the filter. + /// + public class TestFilteredAttributes : UnitTestBase + { + + private Guid taskId; + + public TestFilteredAttributes(XrmMockupFixture fixture) : base(fixture) + { + taskId = orgAdminService.Create(new Task { Subject = "Created" }); + } + + private string GetMarker(Guid taskId) => + Task.Retrieve(orgAdminService, taskId, x => x.Category).Category; + + [Fact] + public void TestFilteredStepNotTriggeredByUnfilteredAttribute() + { + orgAdminService.Update(new Task + { + Id = taskId, + Subject = "Updated", + }); + + Assert.Null(GetMarker(taskId)); + } + + [Fact] + public void TestFilteredStepTriggeredByFilteredAttribute() + { + orgAdminService.Update(new Task + { + Id = taskId, + Description = "Updated", + }); + + Assert.Equal("FilterMatched", GetMarker(taskId)); + } + + [Fact] + public void TestFilteredStepTriggeredByStatusCodeSentByCaller() + { + orgAdminService.Update(new Task + { + Id = taskId, + StatusCode = task_statuscode.InProgress, + }); + + Assert.Equal("FilterMatched", GetMarker(taskId)); + } + } +}