WYSIWYG rule editor: create and test rules for any .NET type

In my previous post I showed a simple WYSIWYG rule editor (that internally uses Windows Workflow Foundation rule engine) that does not serialize rules using CodeDom notation. I wrote this editor simply to demonstrate the concept, and its code combined both rule editing, rule serialization and sample rule classes. Since I expect rule authoring and management to be hot topic in the projects I am involved, I decided to dedicate some time to clean up the rule editor and make it generic rule authoring tool that can be used with almost any class that can be loaded from an arbitrary .NET assembly.

Let me start presenting the new editor with a few screen shots that give a pretty good picture of how it can be used.

Simple rule editor in action

When you start the editor, first thing you need to do is load an assembly that contains types used to build rules.

SimpleRuleEditor1

I’ve chosen here one of Microsoft Enterprise Library assemblies, Microsoft.Practices.EnterpriseLibrary.Logging.dll. Next is to choose one of its type. Let it be LogEntry.

Now you can start defining and testing rules, but to make a rule definition task easier, the editor has a helper page that can be displayed by clicking an “Info” button:

InfoPage

The Rule Information page is displayed side by side with the main editor form, and it even supports drag and drop, so you can simply drag properties into the “Condition”, “Then” or “Else” text boxes. It can save you from typos, and I was able to quickly define a LogEntry rule: “If Severity == TraceEventType.Error and Message.Contains(“Space mission”) Then Priority = 5”:

LogEntryRule

Every rule should be tested, and our editor has an “Apply” button that displays a rule object data input page. The “Value” column is editable, so to test the rules we can enter some data there. Of course, the most interesting input is the one that should trigger the rule:

LogEntryObject1

And the rules are invoked by clicking the “Execute” button.

LogEntryObject2

As you can see, defining and testing a rule on an arbitraty .NET class is a matter of seconds, and the rules can be saved for later use. Now let’s inspect how this was done.

Loading assembly for type instantiation

While the task of assembly selection and loading is trivial, certain extra steps need to be performed to prevent subsequent call to Activator.CreateInstance to fail with FileNotFoundException. And this exception will be thrown if the instantiated types depend on types from other assemblies referenced by the assembly loaded with Assembly.LoadFile. Successful instantiation of types from this assembly requires two steps:

  • Loading referenced assemblies
  • Handling AssemblyResolve event that is fired by the current domain.
private Assembly LoadAssembly(string assemblyPath)
{
    Assembly assembly = Assembly.LoadFile(assemblyPath);
    foreach (AssemblyName assemblyRef in assembly.GetReferencedAssemblies())
    {
        Evidence evidence = new Evidence(
            new object[] { new Zone(SecurityZone.MyComputer) },
            new object[] { });

        string path = Path.Combine(Path.GetDirectoryName(assemblyPath), assemblyRef.Name) + ".dll";
        if (File.Exists(path))
        {
            AssemblyName asmName = new AssemblyName();
            asmName.Name = assemblyRef.Name;
            asmName.CodeBase = Path.GetDirectoryName(assemblyPath);
            Assembly asm = Assembly.LoadFrom(path, evidence);
        }
    }
    return assembly;
}

Code to handle domain-specific events can be added to the application Program class, and the event handlers can be registered right on program startup.

[STAThread]
static void Main()
{
    AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(CurrentDomain_AssemblyResolve);
    AppDomain.CurrentDomain.AssemblyLoad += new AssemblyLoadEventHandler(CurrentDomain_AssemblyLoad);

    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new MainForm());
}

static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
    if (Program.Assemblies.Keys.Contains(args.Name))
    {
        return Program.Assemblies[args.Name];
    }
    return null;
}

static void CurrentDomain_AssemblyLoad(object sender, AssemblyLoadEventArgs args)
{
    if (!Program.Assemblies.Keys.Contains(args.LoadedAssembly.FullName))
    {
        Program.Assemblies.Add(args.LoadedAssembly.FullName, args.LoadedAssembly);
    }
}

Rule serialization

Comparing to my previous post, I moved rule serialization code to a dedicated RuleSetSerializer class but kept it minimalistic. It stores rules in a plain tab-separated text file, and I did this on purpose. As I wrote earlier, I see a great value in keeping serialized rules in a human readable format. Would you have to spend more time on management of SQL queries if they all were converted into CodeDom trees? Would configuration files become more complex to work with if they were stored in binary form? Definitely. This is why I mean it is worth storing a rule “if a == 1 then b = 2” exactly as it looks. A simple serializer streaming the rules as tab-separated lines is just an example for this rule editor. When storing rules in a database conditions and action lists will most likely be written in different table columns but I would still keep their representation compact in case of non-complicated rule sets.

Populating property and method lists

There the fun began. I had to flatten property trees and build a list of all properties and methods for the top class and all nested classes. Imagine we create rules on a class called FirstLevelClass that include properties pointing to instances of SecondLevelClass that in turn has a property pointing to an instance of ThirdLevelClass:

public class FirstLevelClass
{
    public string Name { get; set; }
    public string Description { get; set; }
    public SecondLevelClass SecondLevel { get; set; }

    public bool FirstMethod() { return true; }
}

public class SecondLevelClass
{
    public string Name { get; set; }
    public string Description { get; set; }
    public ThirdLevelClass ThirdLevel { get; set; }

    public bool SecondMethod() { return true; }
}

public class ThirdLevelClass
{
    public string Name { get; set; }
    public string Description { get; set; }

    public bool ThirdMethod() { return true; }
}

For such class topology the following set of properties and methods should be available for rule assignment:

FirstLevelClass

To support recursive property resolution, I defined an auxilliary class RuleProperty:

public class RuleProperty
{
    public RuleProperty OwnerProperty { get; set; }
    public Type DeclaringType { get; set; }
    public Type Type { get { return this.PropertyInfo.PropertyType; } }
    public string Name { get { return this.PropertyInfo.Name; } }
    public PropertyInfo PropertyInfo { get; set; }

    public string FullName
    {
        get
        {
            string fullName = this.Name;
            var ownerProperty = this.OwnerProperty;
            while (ownerProperty != null)
            {
                fullName = ownerProperty.Name + "." + fullName;
                ownerProperty = ownerProperty.OwnerProperty;
            }
            return fullName;
        }
    }
}

Then populating property list can be implemented using recursive algorithm:

private List GetProperties(Type type, RuleProperty ownerProperty)
{
    var properties = new List();

    foreach (var property in from item in type.GetProperties() orderby item.Name select item)
    {
        var ruleProperty = new RuleProperty
        {
            DeclaringType = type,
            OwnerProperty = ownerProperty,
            PropertyInfo = property
        };
        if (property.PropertyType.GetProperties().Count() == 0 ||
            property.PropertyType.FullName.StartsWith("System."))
        {
            properties.Add(ruleProperty);
        }
        else if (!property.PropertyType.FullName.StartsWith("System."))
        {
            properties.AddRange(GetProperties(property.PropertyType, ruleProperty));
        }
    }

    return properties;
}

As you can see, I filtered properties based on system classes to avoid expansion of string properties that otherwise would have been treated as compound properties.

Instantiating rule object classes

This is something that is only needed to test rules in the editor – when working with rules using domain specific code you will typically just make a call to an object constructor: “myObject = new MyObject()”. But the rule editor does not have a convenience of knowledge of what objects it should create. It can only call Activator.CreateInstance, and in case of nested objects it will not be enough – it will need to traverse the whole object hierarchy, identify properties that requires instantiation of nested objects and call Activator.CreateInstance on them. I placed the code that does it in a method CreateObjectInstance:

public RuleObject CreateObjectInstance()
{
    RuleObject ruleObject = new RuleObject(this.Type);
    Dictionary ownerObjects = new Dictionary();

    for (int index = 0; index < this.Properties.Count; index++)
    {
        object propertyObject;
        if (this.Properties[index].OwnerProperty == null)
        {
            propertyObject = ruleObject.Instance;
        }
        else
        {
            if (!ownerObjects.ContainsKey(this.Properties[index].OwnerProperty))
            {
                object baseObject;
                if (this.Properties[index].OwnerProperty.OwnerProperty == null)
                {
                    baseObject = ruleObject.Instance;
                }
                else
                {
                    baseObject = ownerObjects[this.Properties[index].OwnerProperty.OwnerProperty];
                }
                propertyObject = this.Properties[index].OwnerProperty.PropertyInfo.GetValue(baseObject, null);
                if (propertyObject == null)
                {
                    propertyObject = Activator.CreateInstance(this.Properties[index].DeclaringType);
                }
                ownerObjects.Add(this.Properties[index].OwnerProperty, propertyObject);
                this.Properties[index].OwnerProperty.PropertyInfo.SetValue(baseObject, propertyObject, null);
            }
            else
            {
                propertyObject = ownerObjects[this.Properties[index].OwnerProperty];
            }
        }
        ruleObject.PropertyObjects.Add(this.Properties[index], propertyObject);
    }

    return ruleObject;
}

Assigning and retrieving property values

After all these preparations there is only one step left: manage property values. When testing rules, a user of the rule editor should be able assign values to a rule object, execute rules and display property values after rule execution. Code to get and set property values is quite simple:

public void SetPropertyValue(RuleObject ruleObject, int index, object value)
{
    object propertyValue;
    if (this.Properties[index].Type.IsEnum)
    {
        propertyValue = Enum.Parse(this.Properties[index].Type, value.ToString());
    }
    else
    {
        propertyValue = Convert.ChangeType(value, this.Properties[index].Type);
    }
    this.Properties[index].PropertyInfo.SetValue(ruleObject.PropertyObjects[this.Properties[index]], propertyValue, null);
}

public object GetPropertyValue(RuleObject ruleObject, int index)
{
    return this.Properties[index].PropertyInfo.GetValue(ruleObject.PropertyObjects[this.Properties[index]], null);
}

The rule editor is now completed and can be used to define and test rules on types from any .NET assembly. You can download the rule editor source code together with a few sample test classes (placed in a different assembly) and a few rules for them stored in text files.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s