Skip to content
Merged
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
35 changes: 22 additions & 13 deletions src/RulesEngine/Actions/ActionContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,31 @@ public class ActionContext
public ActionContext(IDictionary<string, object> context, RuleResultTree parentResult)
{
_context = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in context)
if (context != null)
{
string key = kv.Key;
string value;
switch (kv.Value.GetType().Name)
foreach (var kv in context)
{
case "String":
case "JsonElement":
value = kv.Value.ToString();
break;
default:
value = JsonSerializer.Serialize(kv.Value);
break;

string key = kv.Key;
string value;
if (kv.Value == null)
{
value = null;
}
else
{
switch (kv.Value.GetType().Name)
{
case "String":
case "JsonElement":
value = kv.Value.ToString();
break;
default:
value = JsonSerializer.Serialize(kv.Value);
break;
}
}
_context.Add(key, value);
}
_context.Add(key, value);
}
_parentResult = parentResult;
}
Expand Down
51 changes: 31 additions & 20 deletions src/RulesEngine/RulesEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ public async ValueTask<ActionRuleResult> ExecuteActionWorkflowAsync(string workf
var compiledRule = CompileRule(workflowName, ruleName, ruleParameters);
var extendedRuleParameters = EvaluateGlobalsAdHoc(workflowName, ruleParameters);
var resultTree = compiledRule(extendedRuleParameters);
// Mirror ExecuteAllRulesAsync's behavior: format the per-rule ErrorMessage template
// into ExceptionMessage before any action runs / before returning. See #519.
FormatErrorMessages(new[] { resultTree });
var actionResult = await ExecuteActionForRuleResult(resultTree, true);
ThrowIfActionExceptionShouldPropagate(actionResult, resultTree);
return actionResult;
Expand Down Expand Up @@ -541,9 +544,7 @@ private IEnumerable<RuleResultTree> FormatErrorMessages(IEnumerable<RuleResultTr
var property = paramVal?.Substring(2, paramVal.Length - 3);
if (property?.Split('.')?.Count() > 1)
{
var typeName = property?.Split('.')?[0];
var propertyName = property?.Split('.')?[1];
errorMessage = UpdateErrorMessage(errorMessage, inputs, property, typeName, propertyName);
errorMessage = UpdateErrorMessage(errorMessage, inputs, property);
}
else
{
Expand All @@ -562,28 +563,38 @@ private IEnumerable<RuleResultTree> FormatErrorMessages(IEnumerable<RuleResultTr
}

/// <summary>
/// Updates the error message.
/// Resolves a dotted-path placeholder like $(input1.Inner.Name) against the rule inputs,
/// walking arbitrary depth. See #696.
/// </summary>
/// <param name="errorMessage">The error message.</param>
/// <param name="evaluatedParams">The evaluated parameters.</param>
/// <param name="property">The property.</param>
/// <param name="typeName">Name of the type.</param>
/// <param name="propertyName">Name of the property.</param>
/// <returns>Updated error message.</returns>
private static string UpdateErrorMessage(string errorMessage, IDictionary<string, object> inputs, string property, string typeName, string propertyName)
private static string UpdateErrorMessage(string errorMessage, IDictionary<string, object> inputs, string property)
{
var arrParams = inputs?.Select(c => new { Name = c.Key, c.Value });
var model = arrParams?.Where(a => string.Equals(a.Name, typeName))?.FirstOrDefault();
if (model != null)
var segments = property.Split('.');
var typeName = segments[0];
var model = inputs?.FirstOrDefault(c => string.Equals(c.Key, typeName));
if (model?.Value == null)
{
return errorMessage;
}

var modelJson = JsonSerializer.Serialize(model.Value.Value);
JsonNode current = JsonNode.Parse(modelJson);
for (var i = 1; i < segments.Length && current != null; i++)
{
current = current is JsonObject jObj && jObj.TryGetPropertyValue(segments[i], out var next)
? next
: null;
}

if (current == null)
{
var modelJson = JsonSerializer.Serialize(model?.Value);
var jObj = JsonObject.Parse(modelJson).AsObject();
JsonNode jToken = null;
var val = jObj?.TryGetPropertyValue(propertyName, out jToken);
errorMessage = errorMessage.Replace($"$({property})", jToken != null ? jToken?.ToString() : $"({property})");
return errorMessage;
}

return errorMessage;
// JsonValue (leaf scalar) should render without quotes; objects/arrays render as JSON.
var replacement = current is JsonValue v && v.TryGetValue<string>(out var stringValue)
? stringValue
: current.ToString();
return errorMessage.Replace($"$({property})", replacement);
}
#endregion
}
Expand Down
62 changes: 62 additions & 0 deletions test/RulesEngine.UnitTest/Issue519Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue519Test
{
[Fact]
public async Task ExecuteActionWorkflowAsync_FailingRule_PopulatesExceptionMessageFromErrorMessage()
{
var workflow = new Workflow
{
WorkflowName = "wf",
Rules = new[] {
new Rule {
RuleName = "R",
Expression = "input1 == \"expected\"",
ErrorMessage = "Input was $(input1), expected `expected`"
}
}
};
var engine = new RulesEngine(new[] { workflow });

var actionResult = await engine.ExecuteActionWorkflowAsync("wf", "R",
new[] { RuleParameter.Create("input1", "actual-value") });

var ruleResult = actionResult.Results.Single();
Assert.False(ruleResult.IsSuccess);
// Before the fix, ExceptionMessage was empty; after, it should contain the interpolated ErrorMessage.
Assert.False(string.IsNullOrEmpty(ruleResult.ExceptionMessage));
Assert.Contains("actual-value", ruleResult.ExceptionMessage);
}

[Fact]
public async Task ExecuteAllRulesAsync_BehavesTheSameForReference()
{
var workflow = new Workflow
{
WorkflowName = "wf",
Rules = new[] {
new Rule {
RuleName = "R",
Expression = "input1 == \"expected\"",
ErrorMessage = "Input was $(input1), expected `expected`"
}
}
};
var engine = new RulesEngine(new[] { workflow });
var results = await engine.ExecuteAllRulesAsync("wf", "actual-value");

Assert.False(results[0].IsSuccess);
Assert.Contains("actual-value", results[0].ExceptionMessage);
}
}
}
69 changes: 69 additions & 0 deletions test/RulesEngine.UnitTest/Issue576Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue576Test
{
private class NoContextAction : ActionBase
{
public static bool WasRun;
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
WasRun = true;
return new ValueTask<object>("done");
}
}

[Fact]
public async Task CustomAction_WithNullContext_DoesNotThrow()
{
NoContextAction.WasRun = false;

var workflow = new Workflow
{
WorkflowName = "wf",
Rules = new[] {
new Rule {
RuleName = "R",
Expression = "true",
Actions = new RuleActions {
OnSuccess = new ActionInfo {
Name = "noctx",
Context = null
}
}
}
}
};
var settings = new ReSettings
{
CustomActions = new Dictionary<string, Func<ActionBase>>
{
{ "noctx", () => new NoContextAction() }
}
};
var engine = new RulesEngine(new[] { workflow }, settings);
var results = await engine.ExecuteAllRulesAsync("wf", "x");

Assert.True(NoContextAction.WasRun, "Custom action should have run even with null Context");
Assert.Null(results[0].ActionResult?.Exception);
}

[Fact]
public void ActionContext_NullDictionary_DoesNotThrow()
{
var ex = Record.Exception(() => new ActionContext(null, null));
Assert.Null(ex);
}
}
}
50 changes: 50 additions & 0 deletions test/RulesEngine.UnitTest/Issue581Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using RulesEngine.Models;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using Xunit;

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue581Support
{
public class MyParam
{
public string Value1 { get; set; }
public string Value2 { get; set; }
}
}

[ExcludeFromCodeCoverage]
public class Issue581Test
{
[Fact]
public async Task CustomParameterName_IsHonored()
{
var workflow = new Workflow
{
WorkflowName = "my_workflow",
Rules = new[] {
new Rule {
RuleName = "MatchesFabrikam",
Enabled = true,
RuleExpressionType = RuleExpressionType.LambdaExpression,
Expression = "myValue.Value1 == \"Fabrikam\""
}
}
};

var input = new Issue581Support.MyParam { Value1 = "Fabrikam", Value2 = "x" };
var rp = new RuleParameter("myValue", input);
var engine = new RulesEngine(new[] { workflow });

var results = await engine.ExecuteAllRulesAsync("my_workflow", new[] { rp });

Assert.True(results[0].IsSuccess,
$"Expected success. Got: {results[0].ExceptionMessage}");
}
}
}
65 changes: 65 additions & 0 deletions test/RulesEngine.UnitTest/Issue590Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue590Test
{
public class FlakyInput
{
private int _counter;
private string _simpleProp;
public string SimpleProp
{
get
{
if (_counter++ == 0)
{
throw new ArgumentException("first-call-failure");
}
return _simpleProp;
}
set { _simpleProp = value; }
}
}

[Fact]
public async Task ExceptionMessage_FromPriorRunDoesNotLeakIntoNextSuccessfulRun()
{
var input = new FlakyInput { SimpleProp = "simpleProp" };
var workflow = new Workflow
{
WorkflowName = "wf",
Rules = new[] {
new Rule {
RuleName = "CheckSimpleProp",
RuleExpressionType = RuleExpressionType.LambdaExpression,
Expression = "SimpleProp == \"simpleProp\"",
ErrorMessage = "should not leak"
}
}
};
var settings = new ReSettings { UseFastExpressionCompiler = false };
var engine = new RulesEngine(new[] { workflow }, settings);

var firstRun = await engine.ExecuteAllRulesAsync("wf", input);
Assert.False(firstRun[0].IsSuccess);
Assert.False(string.IsNullOrEmpty(firstRun[0].ExceptionMessage));

var secondRun = await engine.ExecuteAllRulesAsync("wf", input);
Assert.True(secondRun[0].IsSuccess,
$"Second run should succeed. Got ExceptionMessage = `{secondRun[0].ExceptionMessage}`");
Assert.True(string.IsNullOrEmpty(secondRun[0].ExceptionMessage),
$"Second run ExceptionMessage should be empty. Got `{secondRun[0].ExceptionMessage}`");
}
}
}
Loading
Loading