Skip to content

Commit 6c1bfb7

Browse files
mkholtClaude <noreply@anthropic.com> via Conducktor
andauthored
Fix: calculated-field evaluation must not persist via a spurious Update (#340)
* Fix: calculated-field evaluation must not persist via a spurious Update When XrmMockup evaluated a classic calculated field during a Retrieve/RetrieveMultiple, the parsed calc workflow reached its terminal SetAttributeValue node and issued a real orgService.Update of the record. That write was wrong regardless: a calculated field should only compute a value and project it onto the returned entity. The spurious Update fired the update pipeline, bumped modifiedon, and ran UpdateRequestHandler's HasCircularReference guard — which rejects any record legitimately carrying a self-referential lookup (e.g. a systemuser whose createdby is itself), surfacing as the "circular reference" FaultException in the bug report. Fix (smallest change satisfying the constraints): add a suppressWrites parameter to WorkflowTree.Execute. It sets a "SuppressWrites" sentinel into Variables *after* Reset() (which reinitializes Variables), and SetAttributeValue.Execute returns instead of calling orgService.Update when the sentinel is set. ExecuteCalculatedFields passes suppressWrites: true; the computed value is already left in the primaryEntity variable and copied back as before. All other callers (real workflows via WorkflowManager, rollups via CalculateRollupFieldRequestHandler) keep the default false and still persist. ExecuteFormulaFields uses the PowerFx evaluator and never reaches SetAttributeValue, so it needs no change. Removing the write exposed a pre-existing latent NRE in Utility.GetFormattedValueLabel: a Money attribute's formatted value dereferenced transactioncurrencyid without a null check. Previously the spurious Update ran HandleCurrencies and backfilled the currency; without the write, a calculated Money column can be projected onto a record that has no currency, and RetrieveMultiple's SetFormattedValues threw. Guard the Money branch to omit the formatted value when there is no currency, mirroring the tolerant Lookup branch beside it. Regression test (TestMoney.TestCalculatedFieldRetrieveDoesNotPersistAsUpdate): creates a ctx_parent, advances the mock clock, then reads the calculated Money column via both Retrieve and RetrieveMultiple. Before the fix modifiedon jumped forward by the clock advance (proving a spurious Update); after, it is unchanged and the calc value is still projected. Full net8.0 suite: 620 passed, 1 skipped, 0 failed (green across repeated runs). Co-authored-by: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>
1 parent b5332b8 commit 6c1bfb7

6 files changed

Lines changed: 79 additions & 7 deletions

File tree

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 1.18.5 - 1 July 2026
2+
* Fix: Evaluating a classic calculated field during Retrieve/RetrieveMultiple no longer issues a spurious `Update` of the record. The calculated value is now computed and projected onto the returned entity without persisting, so reads no longer bump `modifiedon`, fire update plugins, or trip the update-time circular-reference guard (which rejected records carrying a self-referential lookup, e.g. a `systemuser` whose `createdby` is itself). Real workflows and rollup fields still update as before.
3+
* Fix: RetrieveMultiple no longer throws when building the formatted value for a Money attribute that has no associated `transactioncurrencyid` (e.g. a calculated Money column projected onto a record without a currency); the formatted value is simply omitted.
4+
15
### 1.18.4 - 1 July 2026
26
* Fix: AppendTo/security checks for business- and organization-owned entities (#339)
37

src/XrmMockup365/Core.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1166,7 +1166,7 @@ internal void ExecuteCalculatedFields(EntityMetadata entityMetadata, Entity enti
11661166
// the calculation workflow against a clone and copy the computed value back onto the
11671167
// returned entity (the workflow leaves its result in the "primaryEntity" variable).
11681168
var resultTree = tree.Execute(entity.CloneEntity(entityMetadata, new ColumnSet(true)), TimeOffset, GetWorkflowService(),
1169-
factory, factory.GetService<ITracingService>());
1169+
factory, factory.GetService<ITracingService>(), suppressWrites: true);
11701170

11711171
if (resultTree.Variables.TryGetValue("InputEntities(\"primaryEntity\")", out var resultObj)
11721172
&& resultObj is Entity result && result.Contains(attr.LogicalName))

src/XrmMockup365/Internal/Utility.cs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -883,12 +883,19 @@ private static string GetFormattedValueLabel(XrmDb db, AttributeMetadata metadat
883883

884884
if (metadataAtt is MoneyAttributeMetadata)
885885
{
886-
var currencysymbol =
887-
db.GetEntity(
888-
db.GetEntity(entity.ToEntityReference())
889-
.GetAttributeValue<EntityReference>("transactioncurrencyid"))
890-
.GetAttributeValue<string>("currencysymbol");
886+
// A Money value can be present without an associated transactioncurrencyid — e.g. a
887+
// calculated Money field projected onto the entity during a read (which, unlike a real
888+
// write, does not run HandleCurrencies to backfill the currency). Without a currency we
889+
// cannot resolve a symbol, so skip the formatted value rather than dereferencing a null
890+
// currency reference (which previously threw and broke RetrieveMultiple's formatting).
891+
var currencyRef = db.GetEntity(entity.ToEntityReference())
892+
.GetAttributeValue<EntityReference>("transactioncurrencyid");
893+
if (currencyRef == null)
894+
{
895+
return null;
896+
}
891897

898+
var currencysymbol = db.GetEntity(currencyRef).GetAttributeValue<string>("currencysymbol");
892899
return currencysymbol + (value as Money).Value.ToString();
893900
}
894901

src/XrmMockup365/Workflow/WorkflowNode/SetAttributeValue.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ public void Execute(ref Dictionary<string, object> variables, TimeSpan timeOffse
3636
throw new WorkflowException($"The variable with id '{VariableId}' before being set, check the workflow has the correct format.");
3737
}
3838

39+
// When evaluating a calculated/formula field during a read, persistence is suppressed: the
40+
// computed value already lives in the "primaryEntity" variable for the caller to project onto
41+
// the returned entity, so re-Updating the whole record would be a spurious write (firing the
42+
// update pipeline, mutating data, and tripping the update-time circular-reference guard).
43+
// Real workflows leave this flag unset and still persist.
44+
if (variables.TryGetValue(WorkflowTree.SuppressWritesKey, out var suppress) && suppress is bool b && b)
45+
{
46+
return;
47+
}
48+
3949
var entity = variables[VariableId] as Entity;
4050
orgService.Update(entity);
4151

src/XrmMockup365/Workflow/WorkflowTree.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,28 @@ public WorkflowTree(IWorkflowNode StartActivity, bool? TriggerOnCreate, bool? Tr
101101
this.Output = Output;
102102
}
103103

104+
// A calculated/formula field is evaluated on demand during a Retrieve and must never persist:
105+
// it should only compute a value and leave it in the "primaryEntity" variable for the caller to
106+
// project onto the returned entity. When suppressWrites is true the terminal SetAttributeValue
107+
// node skips its orgService.Update, so calc evaluation cannot fire the update pipeline, mutate
108+
// data, or trip the update-time circular-reference guard. Real workflows and rollups pass the
109+
// default (false) so their genuine "update record" steps still write.
110+
public const string SuppressWritesKey = "SuppressWrites";
111+
104112
public WorkflowTree Execute(Entity primaryEntity, TimeSpan timeOffset,
105-
IOrganizationService orgService, IOrganizationServiceFactory factory, ITracingService trace)
113+
IOrganizationService orgService, IOrganizationServiceFactory factory, ITracingService trace,
114+
bool suppressWrites = false)
106115
{
107116
if (primaryEntity.Id == Guid.Empty)
108117
{
109118
throw new WorkflowException("The primary entity must have an id");
110119
}
111120
Reset();
121+
// Set unconditionally after Reset() (which doesn't touch this key): a WorkflowTree instance
122+
// can be reused across executions, so the flag must reflect only the current call - never a
123+
// value left over from a previous suppressWrites: true run, which would otherwise silently
124+
// suppress a real workflow's Update.
125+
Variables[SuppressWritesKey] = suppressWrites;
112126
Variables["InputEntities(\"primaryEntity\")"] = primaryEntity;
113127
Variables["ExecutionTime"] = DateTime.Now.Add(timeOffset);
114128
var transactioncurrencyid = "transactioncurrencyid";

tests/XrmMockup365Test/TestMoney.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,43 @@ public void TestCalculatedIsSetRetrieveMultiple()
8888
// Dropped assertion on dg_AllConditions: no equivalent field exists on ctx_parent.
8989
}
9090

91+
// Regression for the calculated-field write-as-update bug: evaluating a calculated field during
92+
// a Retrieve/RetrieveMultiple must only compute a value and project it onto the returned entity —
93+
// it must never re-Update the record. Before the fix, calc evaluation reached the terminal
94+
// SetAttributeValue workflow node which called orgService.Update; that spurious write ran the full
95+
// update pipeline (bumping modifiedon, firing update plugins, and running UpdateRequestHandler's
96+
// HasCircularReference guard — the "circular reference" FaultException in the original report).
97+
//
98+
// We detect the write via modifiedon: capture it after create, advance the mock clock, then read
99+
// the calc field. If the spurious Update still fires, Touch() rewrites modifiedon to the advanced
100+
// time; a compute-only read leaves it untouched. The clock advance makes the two cases
101+
// unambiguously distinguishable.
102+
[Fact]
103+
public void TestCalculatedFieldRetrieveDoesNotPersistAsUpdate()
104+
{
105+
var bus = new ctx_parent { ctx_Name = "CalcRead", ctx_Amount = 30 };
106+
bus.Id = orgAdminService.Create(bus);
107+
108+
var createdModifiedOn = orgAdminService
109+
.Retrieve(ctx_parent.EntityLogicalName, bus.Id, new ColumnSet("modifiedon"))
110+
.GetAttributeValue<DateTime>("modifiedon");
111+
112+
// Advance the clock so any spurious Touch() produces a clearly different modifiedon.
113+
crm.AddDays(5);
114+
115+
// Retrieve selecting the calculated column (ctx_AmountCalcClassic = ctx_Amount * 20).
116+
var retrieved = ctx_parent.Retrieve(orgAdminService, bus.Id, x => x.ctx_AmountCalcClassic, x => x.ModifiedOn);
117+
Assert.Equal(30m * 20, retrieved.ctx_AmountCalcClassic); // calc value still projected
118+
// Before the fix modifiedon jumped forward 5 days (the spurious Update); after, it is unchanged.
119+
Assert.Equal(createdModifiedOn, retrieved.ModifiedOn);
120+
121+
// Same via RetrieveMultiple (the code path in the original bug report).
122+
var q = new QueryExpression("ctx_parent") { ColumnSet = new ColumnSet(true) };
123+
var multi = (ctx_parent)orgAdminService.RetrieveMultiple(q).Entities.Single(e => e.Id == bus.Id);
124+
Assert.Equal(30m * 20, multi.ctx_AmountCalcClassic);
125+
Assert.Equal(createdModifiedOn, multi.ModifiedOn);
126+
}
127+
91128
[Fact]
92129
public void TestRollUp()
93130
{

0 commit comments

Comments
 (0)