NRules: Issue using DSL extension on Rule with custom base class

1.3k views Asked by At

I am using NRules to define rules that all inherit from a common base class, which itself inherits from Rule.

When I use a DSL extension to insert a new fact that wraps a matched object, it seems that the matched object passed to the extension method is null.

Here's a self-contained example that should demonstrate the problem. I am using the xUnit test framework to define two rules, each with identical tests. The first one passes, the second one fails.

using NRules;
using NRules.Fluent;
using NRules.Fluent.Dsl;
using Xunit;
using System.Linq;
using System.Reflection;

namespace IntegrationTests.Engine
{
    // A simple domain model
    public interface IFruit { }

    public class Apple : IFruit { }

    public class Basket
    {
        public Basket(IFruit apple)
        {
            MyApple = apple;
        }

        public IFruit MyApple { get; private set; }
    }


    // A base class for the rules
    public abstract class RuleBase : Rule
    {
        public override void Define()
        {
            // Empty
        }
    }

    // The first rule, which does not use the extension:
    public class TestRule : RuleBase
    {
        public override void Define()
        {
            base.Define();

            Apple a = null;
            When()
                .Match(() => a);

            Then()
                .Do(ctx => ctx.Insert(new Basket(a)));
        }
    }

    // The second rule, which uses an extension to add a new fact
    public class TestRuleWithExtension : RuleBase
    {
        public override void Define()
        {
            base.Define();

            Apple apple = null;
            When()
                .Match(() => apple);

            Then()
                .AddToBasket(apple);
        }
    }

    // The DSL extension
    public static class DslExtensions
    {
        public static IRightHandSideExpression AddToBasket(this IRightHandSideExpression rhs, IFruit fruit)
        {
            return rhs.Do(ctx => ctx.Insert(new Basket(fruit)));
        }
    }

    // The tests
    public class ExtensionTest
    {
        // This one tests the first rule and passes
        [Fact]
        public void TestInsert()
        {
            //Load rules
            var repository = new RuleRepository();
            repository.Load(x => x
                .From(Assembly.GetExecutingAssembly())
                .Where(rule => rule.Name.EndsWith("TestRule")));

            //Compile rules
            var factory = repository.Compile();

            //Create a working session
            var session = factory.CreateSession();

            //Load domain model
            var apple = new Apple();

            //Insert facts into rules engine's memory
            session.Insert(apple);

            //Start match/resolve/act cycle
            session.Fire();

            // Query for inserted facts
            var bananas = session.Query<Basket>().FirstOrDefault();

            // Assert that the rule has been applied
            Assert.Equal(apple, bananas.MyApple);
        }

        // This one tests the second rule, and fails
        [Fact]
        public void TestInsertWithExtension()
        {
            //Load rules
            var repository = new RuleRepository();
            repository.Load(x => x
                .From(Assembly.GetExecutingAssembly())
                .Where(rule => rule.Name.EndsWith("TestRuleWithExtension")));

            //Compile rules
            var factory = repository.Compile();

            //Create a working session
            var session = factory.CreateSession();

            //Load domain model
            var apple = new Apple();

            //Insert facts into rules engine's memory
            session.Insert(apple);

            //Start match/resolve/act cycle
            session.Fire();

            // Query for inserted facts
            var bananas = session.Query<Basket>().FirstOrDefault();

            // Assert that the rule has been applied
            Assert.Equal(apple, bananas.MyApple);
        }
    }
}

The question is why does the second rule with the DSL extension not work properly? Am I doing something wrong and how can I fix it?

1

There are 1 answers

3
Sergiy Nikolayev On BEST ANSWER

First thing to note with the NRules DSL is what happens when you declare a match variable in a rule and bind to it:

Apple apple = null;
When()
    .Match(() => apple);

No value is actually ever assigned to this variable. It is captured as an expression tree, and its name is extracted, and used to later find other expressions referencing the same variable. The engine then replaces those references with the actual matched fact. For instance:

Then()
    .Do(ctx => ctx.Insert(new Basket(apple)));

Here "apple" is the same apple variable form the When clause, so NRules recognizes that and correctly stitches the expressions together.

When you extracted an extension method, you named the variable "fruit":

public static IRightHandSideExpression AddToBasket(this IRightHandSideExpression rhs, IFruit fruit)
{
    return rhs.Do(ctx => ctx.Insert(new Basket(fruit)));
}

The engine no longer recognizes this as the same fact reference, since "fruit" and "apple" don't match.

So, fix #1 is to just name the variable the same way as the declaration:

public static class DslExtensions
{
    public static IRightHandSideExpression AddToBasket(this IRightHandSideExpression rhs, IFruit apple)
    {
        return rhs.Do(ctx => ctx.Insert(new Basket(apple)));
    }
}

Obviously this is not ideal, since you are relying on matching naming of variable. Since NRules operates in terms of the expression trees, a better way to build a generic extension method would be to also write it in terms on expression trees, and no longer depend on the variable naming.

So, fix #2 is to write the extension method using lambda expressions.

public class TestRuleWithExtension : RuleBase
{
    public override void Define()
    {
        base.Define();

        Apple apple = null;
        When()
            .Match(() => apple);

        Then()
            .AddToBasket(() => apple);
    }
}

public static class DslExtensions
{
    public static IRightHandSideExpression AddToBasket(this IRightHandSideExpression rhs, Expression<Func<IFruit>> alias)
    {
        var context = Expression.Parameter(typeof(IContext), "ctx");

        var ctor = typeof(Basket).GetConstructor(new[] {typeof(IFruit)});
        var newBasket = Expression.New(ctor, alias.Body);

        var action = Expression.Lambda<Action<IContext>>(
            Expression.Call(context, nameof(IContext.Insert), null, newBasket), 
            context);
        return rhs.Do(action);
    }
}

Note that AddToBasket(() => apple) now captures the lambda expression, which is later extracted and used in the implementation of the extension method. With some expression magic I then constructed a lambda expression equivalent to the one you had, but this time not relying on any specific variable naming.