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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Features
- `IRulesEngine.ExecuteAllRulesAsync` gains an overload accepting a `CancellationToken`, observed cooperatively between rules and before each action. The existing `params object[]` and `params RuleParameter[]` overloads are unchanged; call-site overload resolution continues to pick them when no token is supplied (#609).
- New `ReSettings.AutoExecuteActions` (default `true`). Set to `false` to evaluate rules without automatically running their OnSuccess/OnFailure actions, so callers can run actions selectively via `ExecuteActionWorkflowAsync` (#596).
- Documented and tested passing computed `additionalInputs` into the `EvaluateRule` action — the additionalInput `Name` is referenced directly in the target rule's expression (#573).

## [6.0.1-preview.1]

### Performance
Expand Down
15 changes: 13 additions & 2 deletions src/RulesEngine/Interfaces/IRulesEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using RulesEngine.Models;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace RulesEngine.Interfaces
Expand All @@ -24,6 +25,16 @@ public interface IRulesEngine
/// <param name="ruleParams">A variable number of rule parameters</param>
/// <returns>List of rule results</returns>
ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams);

/// <summary>
/// This will execute all the rules of the specified workflow with cooperative cancellation.
/// </summary>
/// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
/// <param name="ruleParams">The rule parameters</param>
/// <param name="cancellationToken">Token observed between rules and before each action</param>
/// <returns>List of rule results</returns>
ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken);

ValueTask<ActionRuleResult> ExecuteActionWorkflowAsync(string workflowName, string ruleName, RuleParameter[] ruleParameters);

/// <summary>
Expand All @@ -41,8 +52,8 @@ public interface IRulesEngine
/// Removes the workflow from RulesEngine
/// </summary>
/// <param name="workflowNames"></param>
void RemoveWorkflow(params string[] workflowNames);

void RemoveWorkflow(params string[] workflowNames);

/// <summary>
/// Checks is workflow exist.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions src/RulesEngine/Models/ReSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ internal ReSettings(ReSettings reSettings)
AutoRegisterInputType = reSettings.AutoRegisterInputType;
UseFastExpressionCompiler = reSettings.UseFastExpressionCompiler;
EnableExceptionAsErrorMessageForRuleExpressionParsing = reSettings.EnableExceptionAsErrorMessageForRuleExpressionParsing;
AutoExecuteActions = reSettings.AutoExecuteActions;
}


Expand Down Expand Up @@ -90,6 +91,13 @@ internal ReSettings(ReSettings reSettings)
/// Default: true
/// </summary>
public bool EnableExceptionAsErrorMessageForRuleExpressionParsing { get; set; } = true;

/// <summary>
/// When true (default), ExecuteAllRulesAsync automatically runs each matched rule's
/// OnSuccess/OnFailure action after evaluation. Set to false to evaluate rules only and
/// run actions yourself (e.g. via ExecuteActionWorkflowAsync) for selective control. See #596.
/// </summary>
public bool AutoExecuteActions { get; set; } = true;
}

public enum NestedRuleExecutionMode
Expand Down
34 changes: 27 additions & 7 deletions src/RulesEngine/RulesEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace RulesEngine
Expand Down Expand Up @@ -102,21 +103,39 @@ public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflo
/// <param name="ruleParams">A variable number of rule parameters</param>
/// <returns>List of rule results</returns>
public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, params RuleParameter[] ruleParams)
{
return await ExecuteAllRulesAsync(workflowName, ruleParams, default);
}

/// <summary>
/// This will execute all the rules of the specified workflow with cooperative cancellation.
/// </summary>
/// <param name="workflowName">The name of the workflow with rules to execute against the inputs</param>
/// <param name="ruleParams">The rule parameters</param>
/// <param name="cancellationToken">Token observed between rules and before each action. A single
/// rule's compiled expression is not interrupted mid-evaluation; cancellation is cooperative at
/// rule and action boundaries.</param>
/// <returns>List of rule results</returns>
public async ValueTask<List<RuleResultTree>> ExecuteAllRulesAsync(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken)
{
var sortedRuleParams = ruleParams.ToList();
sortedRuleParams.Sort((RuleParameter a, RuleParameter b) => string.Compare(a.Name, b.Name));
var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray());
await ExecuteActionAsync(ruleResultList);
var ruleResultList = ValidateWorkflowAndExecuteRule(workflowName, sortedRuleParams.ToArray(), cancellationToken);
if (_reSettings.AutoExecuteActions)
{
await ExecuteActionAsync(ruleResultList, cancellationToken);
}
return ruleResultList;
}

private async ValueTask ExecuteActionAsync(IEnumerable<RuleResultTree> ruleResultList)
private async ValueTask ExecuteActionAsync(IEnumerable<RuleResultTree> ruleResultList, CancellationToken cancellationToken = default)
{
foreach (var ruleResult in ruleResultList)
{
cancellationToken.ThrowIfCancellationRequested();
if(ruleResult.ChildResults != null)
{
await ExecuteActionAsync(ruleResult.ChildResults);
await ExecuteActionAsync(ruleResult.ChildResults, cancellationToken);
}
var actionResult = await ExecuteActionForRuleResult(ruleResult, false);
ruleResult.ActionResult = new ActionResult {
Expand Down Expand Up @@ -304,13 +323,13 @@ public void RemoveWorkflow(params string[] workflowNames)
/// <param name="input">input</param>
/// <param name="workflowName">workflow name</param>
/// <returns>list of rule result set</returns>
private List<RuleResultTree> ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams)
private List<RuleResultTree> ValidateWorkflowAndExecuteRule(string workflowName, RuleParameter[] ruleParams, CancellationToken cancellationToken = default)
{
List<RuleResultTree> result;

if (RegisterRule(workflowName, ruleParams))
{
result = ExecuteAllRuleByWorkflow(workflowName, ruleParams);
result = ExecuteAllRuleByWorkflow(workflowName, ruleParams, cancellationToken);
}
else
{
Expand Down Expand Up @@ -430,7 +449,7 @@ private static void CollectAllElementTypes(Type t, ISet<Type> collector)
/// <param name="workflowName"></param>
/// <param name="ruleParams"></param>
/// <returns>list of rule result set</returns>
private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters)
private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleParameter[] ruleParameters, CancellationToken cancellationToken = default)
{
var result = new List<RuleResultTree>();
var compiledRulesCacheKey = GetCompiledRulesKey(workflowName, ruleParameters);
Expand All @@ -456,6 +475,7 @@ private List<RuleResultTree> ExecuteAllRuleByWorkflow(string workflowName, RuleP

foreach (var compiledRule in compiledRules)
{
cancellationToken.ThrowIfCancellationRequested();
RuleResultTree resultTree;
if (globalEvaluationException != null && ruleByName != null && ruleByName.TryGetValue(compiledRule.Key, out var rule))
{
Expand Down
68 changes: 68 additions & 0 deletions test/RulesEngine.UnitTest/Issue573Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue573Test
{
// Demonstrates the correct way to pass computed additionalInputs into an EvaluateRule
// action. The target rule can reference the additionalInput by its Name. The key detail
// (the source of the "Unknown identifier" confusion in #573) is that the additionalInput
// Name must match the identifier used in the target rule's expression.
[Fact]
public async Task EvaluateRuleAction_AdditionalInputs_AreAvailableToTargetRule()
{
var workflow = new Workflow
{
WorkflowName = "wf",
Rules = new[]
{
new Rule
{
RuleName = "Parent",
Expression = "input1.Value > 0",
Actions = new RuleActions
{
OnSuccess = new ActionInfo
{
Name = "EvaluateRule",
Context = new Dictionary<string, object>
{
{ "workflowName", "wf" },
{ "ruleName", "Child" },
// Compute a new input named "doubled" from input1 and pass it on.
{ "additionalInputs", new List<ScopedParam>
{
new ScopedParam { Name = "doubled", Expression = "input1.Value * 2" }
}
}
}
}
}
},
new Rule
{
RuleName = "Child",
// References the additionalInput by name.
Expression = "doubled == 20"
}
}
};

var engine = new RulesEngine(new[] { workflow });
var result = await engine.ExecuteActionWorkflowAsync("wf", "Parent",
new[] { RuleParameter.Create("input1", new { Value = 10 }) });

// Child rule succeeded because "doubled" (10*2=20) was available to it.
Assert.NotNull(result.Results);
Assert.Contains(result.Results, r => r.Rule.RuleName == "Child" && r.IsSuccess);
}
}
}
82 changes: 82 additions & 0 deletions test/RulesEngine.UnitTest/Issue596Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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;
using System.Threading.Tasks;
using Xunit;

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue596Test
{
private class CountingAction : ActionBase
{
public static int RunCount;
public override ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
Interlocked.Increment(ref RunCount);
return new ValueTask<object>("done");
}
}

private static Workflow WorkflowWithAction() => new Workflow
{
WorkflowName = "wf",
Rules = new[] {
new Rule {
RuleName = "R",
Expression = "true",
Actions = new RuleActions {
OnSuccess = new ActionInfo { Name = "counting", Context = new Dictionary<string, object>() }
}
}
}
};

[Fact]
public async Task AutoExecuteActions_True_RunsActions_DefaultBehavior()
{
CountingAction.RunCount = 0;
var settings = new ReSettings
{
CustomActions = new Dictionary<string, Func<ActionBase>> { { "counting", () => new CountingAction() } }
};
var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings);

var results = await engine.ExecuteAllRulesAsync("wf", "x");

Assert.True(results[0].IsSuccess);
Assert.Equal(1, CountingAction.RunCount);
}

[Fact]
public async Task AutoExecuteActions_False_EvaluatesRulesButSkipsActions()
{
CountingAction.RunCount = 0;
var settings = new ReSettings
{
AutoExecuteActions = false,
CustomActions = new Dictionary<string, Func<ActionBase>> { { "counting", () => new CountingAction() } }
};
var engine = new RulesEngine(new[] { WorkflowWithAction() }, settings);

var results = await engine.ExecuteAllRulesAsync("wf", "x");

// Rule still evaluated...
Assert.True(results[0].IsSuccess);
// ...but the action did NOT run automatically.
Assert.Equal(0, CountingAction.RunCount);

// Caller can still run the action selectively afterwards.
var actionResult = await engine.ExecuteActionWorkflowAsync("wf", "R", new RuleParameter[0]);
Assert.Equal("done", actionResult.Output);
Assert.Equal(1, CountingAction.RunCount);
}
}
}
60 changes: 60 additions & 0 deletions test/RulesEngine.UnitTest/Issue609Test.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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

namespace RulesEngine.UnitTest
{
[ExcludeFromCodeCoverage]
public class Issue609Test
{
private static Workflow ManyRulesWorkflow(int count) => new Workflow
{
WorkflowName = "wf",
Rules = Enumerable.Range(0, count)
.Select(i => new Rule { RuleName = $"R{i}", Expression = "input1 >= 0" })
.ToArray()
};

[Fact]
public async Task ExecuteAllRulesAsync_WithUncancelledToken_RunsNormally()
{
var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) });
var results = await engine.ExecuteAllRulesAsync(
"wf", new[] { RuleParameter.Create("input1", 1) }, CancellationToken.None);
Assert.Equal(5, results.Count);
Assert.All(results, r => Assert.True(r.IsSuccess));
}

[Fact]
public async Task ExecuteAllRulesAsync_WithAlreadyCancelledToken_Throws()
{
var engine = new RulesEngine(new[] { ManyRulesWorkflow(5) });
using var cts = new CancellationTokenSource();
cts.Cancel();

await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
await engine.ExecuteAllRulesAsync(
"wf", new[] { RuleParameter.Create("input1", 1) }, cts.Token));
}

[Fact]
public async Task ExecuteAllRulesAsync_DefaultOverloads_StillWork_NoBehavioralBreakage()
{
// The pre-existing signatures still resolve correctly. The new 3-arg overload is
// strictly more specific than `params object[]`, so call sites that pass
// (string, array) continue to bind to the params overloads as before.
var engine = new RulesEngine(new[] { ManyRulesWorkflow(3) });
var byParams = await engine.ExecuteAllRulesAsync("wf", 1);
var byRuleParams = await engine.ExecuteAllRulesAsync("wf", new[] { RuleParameter.Create("input1", 1) });
Assert.Equal(3, byParams.Count);
Assert.Equal(3, byRuleParams.Count);
}
}
}
Loading