From ad703124fd808107af9af5d833d0ef144787afa0 Mon Sep 17 00:00:00 2001 From: MBrekhof Date: Tue, 20 Oct 2026 18:37:34 +0200 Subject: [PATCH] Format error messages recursively for nested child rules and ignore object cycles --- src/RulesEngine/RulesEngine.cs | 29 +++-- .../FormatErrorMessagesNestedAndCyclesTest.cs | 105 ++++++++++++++++++ 2 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 test/RulesEngine.UnitTest/FormatErrorMessagesNestedAndCyclesTest.cs diff --git a/src/RulesEngine/RulesEngine.cs b/src/RulesEngine/RulesEngine.cs index b31bb88c..4b5ee553 100644 --- a/src/RulesEngine/RulesEngine.cs +++ b/src/RulesEngine/RulesEngine.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. using FluentValidation; @@ -157,7 +157,7 @@ public async ValueTask ExecuteActionWorkflowAsync(string workf sortedRuleParams.Sort((a, b) => string.Compare(a.Name, b.Name)); var sortedArr = sortedRuleParams.ToArray(); - // Compile the whole workflow once and reuse — was previously recompiled every call, + // Compile the whole workflow once and reuse — was previously recompiled every call, // which was the hot path the reporter of #471 saw. if (!RegisterRule(workflowName, sortedArr)) { @@ -428,7 +428,7 @@ private bool RegisterRule(string workflowName, params RuleParameter[] ruleParams // Parallel compilation helps only when: // - the user opted in, - // - they're not also on UseFastExpressionCompiler (which regresses ~3× under + // - they're not also on UseFastExpressionCompiler (which regresses ~3× under // parallel contention; FEC's internal locking serializes effort), and // - there are enough rules to amortize Parallel.For's scheduling cost. var shouldParallelize = _reSettings.EnableParallelRuleCompilation @@ -577,12 +577,27 @@ private IDictionary> GetDefaultActionRegistry() /// /// The result. /// Updated error message. + private static readonly JsonSerializerOptions ErrorMessageSerializerOptions = new JsonSerializerOptions + { + ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles + }; + private IEnumerable FormatErrorMessages(IEnumerable ruleResultList) { - if (_reSettings.EnableFormattedErrorMessage) + if (_reSettings.EnableFormattedErrorMessage && ruleResultList != null) { - foreach (var ruleResult in ruleResultList?.Where(r => !r.IsSuccess)) + foreach (var ruleResult in ruleResultList) { + if (ruleResult?.ChildResults != null) + { + FormatErrorMessages(ruleResult.ChildResults); + } + + if (ruleResult == null || ruleResult.IsSuccess) + { + continue; + } + var errorMessage = ruleResult?.Rule?.ErrorMessage; if (string.IsNullOrWhiteSpace(ruleResult.ExceptionMessage) && errorMessage != null) { @@ -601,7 +616,7 @@ private IEnumerable FormatErrorMessages(IEnumerable new { Name = c.Key, c.Value }); var model = arrParams?.Where(a => string.Equals(a.Name, property))?.FirstOrDefault(); - var value = model?.Value != null ? JsonSerializer.Serialize(model?.Value) : null; + var value = model?.Value != null ? JsonSerializer.Serialize(model?.Value, ErrorMessageSerializerOptions) : null; errorMessage = errorMessage?.Replace($"$({property})", value ?? $"$({property})"); } } @@ -627,7 +642,7 @@ private static string UpdateErrorMessage(string errorMessage, IDictionary Orders { get; set; } = new List(); + } + + [Fact] + public async Task FormatErrorMessages_NestedChildRuleFailure_FormatsChildExceptionMessage() + { + var workflow = new Workflow + { + WorkflowName = "NestedErrorFormatWorkflow", + Rules = new List + { + new Rule + { + RuleName = "ParentRule", + Operator = "And", + ErrorMessage = "Parent failed for $(input1.Code)", + Rules = new List + { + new Rule + { + RuleName = "PassingChild", + Expression = "input1.Amount > 0", + ErrorMessage = "Amount $(input1.Amount) must be positive" + }, + new Rule + { + RuleName = "FailingChild", + Expression = "input1.Amount <= 100", + ErrorMessage = "Amount $(input1.Amount) exceeds limit 100" + } + } + } + } + }; + + var engine = new RulesEngine(new[] { workflow }); + var results = await engine.ExecuteAllRulesAsync("NestedErrorFormatWorkflow", + new RuleParameter("input1", new OrderNode { Code = "ORD-1", Amount = 500 })); + + Assert.Single(results); + Assert.False(results[0].IsSuccess); + Assert.Equal("Parent failed for ORD-1", results[0].ExceptionMessage); + + var failingChild = results[0].ChildResults.Single(c => c.Rule.RuleName == "FailingChild"); + Assert.False(failingChild.IsSuccess); + Assert.Equal("Amount 500 exceeds limit 100", failingChild.ExceptionMessage); + } + + [Fact] + public async Task FormatErrorMessages_InputWithCircularReferences_DoesNotThrowJsonException() + { + var customer = new CustomerNode { Name = "Acme" }; + var order = new OrderNode { Code = "ORD-99", Amount = 500, Customer = customer }; + customer.Orders.Add(order); // Circular reference: Order.Customer.Orders[0] == Order + + var workflow = new Workflow + { + WorkflowName = "CyclicErrorFormatWorkflow", + Rules = new List + { + new Rule + { + RuleName = "LimitRule", + Expression = "input1.Amount <= 100", + ErrorMessage = "Order $(input1.Code) for $(input1.Customer.Name) failed with amount $(input1.Amount)" + } + } + }; + + var engine = new RulesEngine(new[] { workflow }); + var results = await engine.ExecuteAllRulesAsync("CyclicErrorFormatWorkflow", + new RuleParameter("input1", order)); + + Assert.Single(results); + Assert.False(results[0].IsSuccess); + Assert.Equal("Order ORD-99 for Acme failed with amount 500", results[0].ExceptionMessage); + } + } +}