Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
18 changes: 16 additions & 2 deletions src/XrmMockup365/Core.cs
Original file line number Diff line number Diff line change
Expand Up @@ -785,14 +785,20 @@ public EntityReference GetBusinessUnit(EntityReference owner)
return Utility.GetBusinessUnit(db, owner);
}

public void CopySystemAttributes(Entity postImage, Entity target)
public ISet<string> CopySystemAttributes(Entity postImage, Entity target)
{
if (target == null) return;
var injected = new HashSet<string>();
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);
Expand All @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/XrmMockup365/Internal/ExecutionPipelineContext.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;

namespace DG.Tools.XrmMockup.Internal
{
Expand Down Expand Up @@ -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<string> SystemInjectedAttributes { get; set; }

// Output — set by the main operation stage
public OrganizationResponse Response { get; set; }
}
Expand Down
4 changes: 2 additions & 2 deletions src/XrmMockup365/Internal/ICoreOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> CopySystemAttributes(Entity postImage, Entity target);

// Request handler list — used by pipeline for security check and pre-op init
List<RequestHandler> RequestHandlers { get; }
Expand Down
28 changes: 16 additions & 12 deletions src/XrmMockup365/Plugin/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,9 +322,10 @@ private static void SortAllLists(Dictionary<EventOperation, StageToTriggerMap> p
}

public void TriggerSync(string operation, ExecutionStage stage,
object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func<PluginTrigger, bool> executionOrderFilter)
object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func<PluginTrigger, bool> executionOrderFilter,
ISet<string> 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<EventOperationEnum>(operation, out var knownOp);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -407,47 +408,49 @@ 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<PluginTrigger, bool> executionOrderFilter)
object entity, Entity preImage, Entity postImage, PluginContext pluginContext, Func<PluginTrigger, bool> executionOrderFilter,
ISet<string> systemInjectedAttributes = null)
{
if (!disableRegisteredPlugins && registeredPlugins.TryGetValue(operation, out var operationPlugins) && operationPlugins.TryGetValue(stage, out var stagePlugins))
stagePlugins
.Where(p => p.GetExecutionMode() == ExecutionMode.Synchronous)
.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
.Where(p => p.GetExecutionMode() == ExecutionMode.Synchronous)
.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<string> 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);

if (temporaryPlugins.TryGetValue(operation, out var tempOperationPlugins) && tempOperationPlugins.TryGetValue(stage, out var tempStagePlugins))
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);
}
Expand All @@ -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<string> systemInjectedAttributes = null)
{
if (!registeredSystemPlugins.TryGetValue(operation, out var stagePlugins))
{
Expand All @@ -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<Type> basePluginTypes, IEnumerable<MetaPlugin> plugins)
Expand Down
26 changes: 19 additions & 7 deletions src/XrmMockup365/Plugin/PluginTrigger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,16 @@ 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<string> systemInjectedAttributes = null)
{
var entity = entityObject as Entity;
var entityRef = entityObject as EntityReference;

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);
Expand All @@ -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<string> systemInjectedAttributes = null)
{
// Check if it is supposed to execute. Returns preemptively, if it should not.
var entity = entityObject as Entity;
Expand All @@ -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);

Expand Down Expand Up @@ -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<string> systemInjectedAttributes)
{
if (!Operation.Matches(EventOperation.Update) || Attributes.Count == 0)
{
Expand All @@ -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;
Expand All @@ -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<string> systemInjectedAttributes = null)
{
if (EntityName != "" && EntityName != logicalName) return false;

Expand All @@ -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;
}
Expand Down
19 changes: 12 additions & 7 deletions src/XrmMockup365/RequestExecutionPipeline.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading