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
29 changes: 22 additions & 7 deletions src/RulesEngine/RulesEngine.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation.
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using FluentValidation;
Expand Down Expand Up @@ -157,7 +157,7 @@ public async ValueTask<ActionRuleResult> 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))
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -577,12 +577,27 @@ private IDictionary<string, Func<ActionBase>> GetDefaultActionRegistry()
/// </summary>
/// <param name="ruleResultList">The result.</param>
/// <returns>Updated error message.</returns>
private static readonly JsonSerializerOptions ErrorMessageSerializerOptions = new JsonSerializerOptions
{
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};

private IEnumerable<RuleResultTree> FormatErrorMessages(IEnumerable<RuleResultTree> 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)
{
Expand All @@ -601,7 +616,7 @@ private IEnumerable<RuleResultTree> FormatErrorMessages(IEnumerable<RuleResultTr
{
var arrParams = inputs?.Select(c => 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})");
}
}
Expand All @@ -627,7 +642,7 @@ private static string UpdateErrorMessage(string errorMessage, IDictionary<string
return errorMessage;
}

var modelJson = JsonSerializer.Serialize(model.Value.Value);
var modelJson = JsonSerializer.Serialize(model.Value.Value, ErrorMessageSerializerOptions);
JsonNode current = JsonNode.Parse(modelJson);
for (var i = 1; i < segments.Length && current != null; i++)
{
Expand Down
105 changes: 105 additions & 0 deletions test/RulesEngine.UnitTest/FormatErrorMessagesNestedAndCyclesTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using RulesEngine.Models;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using Xunit;

namespace RulesEngine.UnitTest
{
[Trait("Category", "Unit")]
[ExcludeFromCodeCoverage]
public class FormatErrorMessagesNestedAndCyclesTest
{
public class OrderNode
{
public string Code { get; set; }
public int Amount { get; set; }
public CustomerNode Customer { get; set; }
}

public class CustomerNode
{
public string Name { get; set; }
public List<OrderNode> Orders { get; set; } = new List<OrderNode>();
}

[Fact]
public async Task FormatErrorMessages_NestedChildRuleFailure_FormatsChildExceptionMessage()
{
var workflow = new Workflow
{
WorkflowName = "NestedErrorFormatWorkflow",
Rules = new List<Rule>
{
new Rule
{
RuleName = "ParentRule",
Operator = "And",
ErrorMessage = "Parent failed for $(input1.Code)",
Rules = new List<Rule>
{
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<Rule>
{
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);
}
}
}