Search code examples
c#.networkflow-foundation-4

WF4: How do I evaluate an expression only known at runtime?


I am trying to create a simple WF4 activity that accepts a string that contains a VB.NET expression (from say the database), evaluates that string using the variables available in the current scope of the workflow and returns the result. Unfortunately, with the ways I've tried it, whether it be with a plain on Activity or a full-fledged NativeActivity, I keep hitting a wall.

My first attempt was with a simple Activity, and I was able to make a simple class that evaluates an expression given some object as its input:

public class Eval<T, TResult> : Activity<TResult>
{
    [RequiredArgument]
    public InArgument<T> Value { get; set; }

    public Eval(string predicate)
    {
        this.Implementation = () => new Assign<TResult>
        {
            Value = new InArgument<TResult>(new VisualBasicValue<TResult>(predicate)),
            To = new ArgumentReference<TResult>("Result")
        };
    }

    public TResult EvalWith(T value)
    {
        return WorkflowInvoker.Invoke(this, new Dictionary<string, object>{ {"Value", value } });
    }
}

This woks nicely, and the following expression evaluates to 7:

new Eval<int, int>("Value + 2").EvalWith(5)

Unfortunately, I can't use it the way I want since the expression string is given as a constructor argument instead of as an InArgument<string>, so it can't be easily incorporated (dragged and dropped) into a workflow. My second attempt was to try and use NativeActivity to get rid of that pesky constructor parameter:

public class NativeEval<T, TResult> : NativeActivity<TResult>
{
    [RequiredArgument] public InArgument<string> ExpressionText { get; set; }
    [RequiredArgument] public InArgument<T> Value { get; set; }

    private Assign Assign { get; set; }
    private VisualBasicValue<TResult> Predicate { get; set; }
    private Variable<TResult> ResultVar { get; set; }

    protected override void CacheMetadata(NativeActivityMetadata metadata)
    {
        base.CacheMetadata(metadata);

        Predicate = new VisualBasicValue<TResult>();
        ResultVar = new Variable<TResult>("ResultVar");
        Assign = new Assign { To = new OutArgument<TResult>(ResultVar), Value = new InArgument<TResult>(Predicate) };

        metadata.AddVariable(ResultVar);
        metadata.AddChild(Assign);
    }

    protected override void Execute(NativeActivityContext context)
    {
        Predicate.ExpressionText = ExpressionText.Get(context);
        context.ScheduleActivity(Assign, new CompletionCallback(AssignComplete));
    }

    private void AssignComplete(NativeActivityContext context, ActivityInstance completedInstance)
    {
        Result.Set(context, ResultVar.Get(context));
    }
}

I tried running NativeEval with the following:

WorkflowInvoker.Invoke(new NativeEval<int, int>(), new Dictionary<string, object>
    { { "ExpressionText", "Value + 2" }, { "Value", 5 } });

But got the following exception:

Activity '1: NativeEval' cannot access this variable because it is declared at the scope of activity '1: NativeEval'. An activity can only access its own implementation variables.

So I changed metadata.AddVariable(ResultVar); to metadata.AddImplementationVariable(ResultVar); but then I got a different exception:

The following errors were encountered while processing the workflow tree: 'VariableReference': The referenced Variable object (Name = 'ResultVar') is not visible at this scope. There may be another location reference with the same name that is visible at this scope, but it does not reference the same location.

I tried using .ScheduleFunc() as described here to schedule a VisualBasicValue activity, but the result it returned was always null (but oddly enough no exceptions were thrown).

I'm stumped. The metaprogramming model of WF4 seems much more difficult than the metaprogramming model of System.Linq.Expressions, which albeit difficult and often perplexing (like metaprogramming usually is), at least I was able to wrap my head around it. I guess it's because it has the added complexity of needing to represent a persistable, resumable, asynchronous, relocatable program, rather than just a plain old program.


EDIT: Since I don't think the issue I'm experiencing is caused by the fact that I'm trying to evaluate an expression that isn't hardcoded, the following alteration can be made to the NativeActivity that cause it to have a static expression:

Replace

Predicate = new VisualBasicValue<TResult>();

With

Predicate = new VisualBasicValue<TResult>("ExpressionText.Length");

And remove the line

Predicate.ExpressionText = ExpressionText.Get(context);

Now even though with those lines the expression is static, I'm still getting the same errors.


EDIT2: This article addressed the exception I was getting. I had to change both variable and child activity to be an "implementation", so this:

metadata.AddVariable(ResultVar);
metadata.AddChild(Assign);

Changed to this:

metadata.AddImplementationVariable(ResultVar);
metadata.AddImplementationChild(Assign);

And caused all the exceptions to go away. Unfortunately, it revealed that the following line does absolutely nothing:

Predicate.ExpressionText = ExpressionText.Get(context);

Changing the ExpressionText property of a VisualBasicValue during runtime has no effect. A quick check with ILSpy reveals why - the expression text is only evaluated and converted to an expression tree when CacheMetadata() is called, at which point the expression is not yet know, which is why I used the parameterless constructor which initialized and crystallized the expression to a no-op. I even tried saving the NativeActivityMetadata object I got in my own CacheMetadata overridden method and then use reflection to force a call to VisualBasicValue's CacheMetadata(), but that just ended up throwing a different cryptic exception ("Ambiguous match found." of type AmbiguousMatchException).

At this point it doesn't seem possible to fully integrate a dynamic expression into a workflow, exposing all the in-scope variables to it. I guess I'll have the method used in my Eval class within the NativeEval class.


Solution

  • I ended up using the following activity. It can't access the workflow's variables, instead it accepts a single argument 'Value' that can be used by the same name inside the dynamic expression. Other than that it works pretty well.

    public class Evaluate<TIn, TOut> : NativeActivity<TOut>
    {
        [RequiredArgument]
        public InArgument<string> ExpressionText { get; set; }
    
        [RequiredArgument]
        public InArgument<TIn> Value { get; set; }
    
        protected override void Execute(NativeActivityContext context)
        {
            var result = new ExpressionEvaluator<TIn, TOut>(ExpressionText.Get(context)).EvalWith(Value.Get(context));
            Result.Set(context, result);
        }
    }
    
    public class ExpressionEvaluator<TIn, TOut> : Activity<TOut>
    {
        [RequiredArgument]
        public InArgument<TIn> Value { get; set; }
    
        public ExpressionEvaluator(string predicate)
        {
            VisualBasic.SetSettingsForImplementation(this, VbSettings);
    
            Implementation = () => new Assign<TOut>
            {
                Value = new InArgument<TOut>(new VisualBasicValue<TOut>(predicate)),
                To = new ArgumentReference<TOut>("Result")
            };
        }
    
        public TOut EvalWith(TIn value)
        {
            return WorkflowInvoker.Invoke(this, new Dictionary<string, object> { { "Value", value } });
        }
    
        private static readonly VisualBasicSettings VbSettings;
    
        static ExpressionEvaluator()
        {
            VbSettings = new VisualBasicSettings();
            AddImports(typeof(TIn), VbSettings.ImportReferences);
            AddImports(typeof(TOut), VbSettings.ImportReferences);
        }
    
        private static void AddImports(Type type, ISet<VisualBasicImportReference> imports)
        {
            if (type.IsPrimitive || type == typeof(void) || type.Namespace == "System")
                return;
    
            var wasAdded = imports.Add(new VisualBasicImportReference { Assembly = type.Assembly.GetName().Name, Import = type.Namespace });
    
            if (!wasAdded)
                return;
    
            if (type.BaseType != null)
                AddImports(type.BaseType, imports); 
    
            foreach (var interfaceType in type.GetInterfaces())
                AddImports(interfaceType, imports);
    
            foreach (var property in type.GetProperties())
                AddImports(property.PropertyType, imports);
    
            foreach (var method in type.GetMethods())
            {
                AddImports(method.ReturnType, imports);
    
                foreach (var parameter in method.GetParameters())
                    AddImports(parameter.ParameterType, imports);
    
                if (method.IsGenericMethod)
                {
                    foreach (var genericArgument in method.GetGenericArguments())
                        AddImports(genericArgument, imports);
                }
            }
    
            if (type.IsGenericType)
            {
                foreach (var genericArgument in type.GetGenericArguments())
                    AddImports(genericArgument, imports);
            }
        }
    }
    

    EDIT: Updated the class to include complete assembly and namespace imports, lest you get the dreaded (and unhelpful) error message:

    'Value' is not declared. It may be inaccessible due to its protection level.

    Also, moved the ExpressionEvaluator class outside and made it public, so you can used it outside of WF, like so:

    new ExpressionEvaluator<int, double>("Value * Math.PI").EvalWith(2);
    

    Which will return:

    6.28318530717959