Binding system extensibility
Binding properties
Each binding is, in fact, a Dictionary
of its properties - things like the executable delegate, javascript translation, the original string and so on. The property can be looked up by its type using GetProperty(Type)
method on the IBinding
instance or you can also use generic extension method binding.GetProperty<MyBindingProperty>()
. These properties cannot be added manually, they are fixed when the binding is created by the DotHTML page compiler, but there is a concept of "property resolver" which can compute a property from the properties that are already present in the binding - in fact almost all properties are resolved using one of these resolved from the base ones:
- The
KnockoutJsExpressionBindingProperty
(that contain Javascript Syntax Tree of the binding) is computed fromParsedExpressionBindingProperty
andDataContextStack
ParsedExpressionBindingProperty
is also a resolved property, specifically fromOriginalStringBindingProperty
,DataContextStack
andBindingParserOptions
BindingParserOptions
may be computed from the binding type, but here, the resolver is used just as a fallback, it is normally specified at binding construction.
You may now doubt about runtime performance if the property is parsed every time the property is requested. For that reason, the result is cached in the dictionary, so when resolved, the property behaves like it would be assigned to a constructor. And the binding instance is shared between requests, so it the expression is compiled only once in the application lifetime. Because of that, it's very important that the functions are pure and their result is immutable.
As you can see, these resolvers may form a dependency graph (only an acyclic one), if you are interested, almost all default resolvers are in the GeneralBindingPropertyResolvers class. As you can see, all of them are just simple functions that take the dependencies as arguments and return the result. The main point of this is extensibility, so let's write a custom property and resolver:
It will contain a property used in the binding, for example a PropertyInfo of Name
for binding {value: Name}
or {value: _root.Article.Name}
. First, we have to declare the binding property type:
public sealed class UsedPropertyBindingProperty {
public readonly PropertyInfo PropertyInfo;
public UsedPropertyBindingProperty(PropertyInfo prop) {
this.PropertyInfo = prop;
}
}
Then you can write the function. It requires the ParsedExpressionBindingProperty
which contains the parsed semantic tree of the binding.
public class MyResolvers {
public UsedPropertyBindingProperty GetUsedProperty(ParsedExpressionBindingProperty parsedExpression) {
var expr = parsedExpression.Expression;
// unwrap possible casts
while (expr.NodeType == ExpressionType.Convert) expr = ((UnaryExpression)expr).Operand;
// check the node type
if (expr.NodeType == ExpressionType.MemberAccess) {
var member = ((MemberExpression)expr).Member;
// check type type of the member
if (member is PropertyInfo) {
return new UsedPropertyBindingProperty((PropertyInfo)member);
} else {
throw new Exception($"Member expression {expr} is not a property access.");
}
} else {
throw new Exception($"Expression {expr} is not a property access.");
}
}
}
You can see, the resolver may throw an exception when the expression is not a member access (for example when the binding would be {value: Number + 1}
), this exception is thrown when the property resolver is invoked - when the GetProperty
method is called, which means that it will be thrown in control lifecycle event at runtime. If you'd like to report an error during the page compilation if the property can't be resolved, you can use [BindingCompilationRequirements(required: new [] { typeof(UsedPropertyBindingProperty) })]
attribute on the control property that contains the affected binding. Or if the property is optional, you can use binding.GetProperty<...>(ErrorHandlingMode.ReturnNull)
to return null without throwing exception.
Last thing missing is registration of the MyResolvers
class. It can be added to the BindingCompilationOptions.TransformerClasses
property using the Asp.Net Core configuration in the ConfigureServices
method:
services.Configure<BindingCompilationOptions>(o => {
o.TransformerClasses.Add(new MyResolvers());
});
Derived bindings
The binding properties allow you to create almost anything from other binding properties - including other bindings. Derived binding can, for example, contain a negated expression:
public NegatedBindingExpression NegateBinding(ParsedExpressionBindingProperty e, IBinding binding) {
return new NegatedBindingExpression(binding.DeriveBinding(
new ParsedExpressionBindingProperty(
// transform `!expr` -> `expr`
e.Expression.NodeType == ExpressionType.Not ? e.Expression.CastTo<UnaryExpression>().Operand :
// `a == b` -> `a != b`
e.Expression.NodeType == ExpressionType.Equal ? e.Expression.CastTo<BinaryExpression>().UpdateType(ExpressionType.NotEqual) :
// `a != b` -> `a == b`
e.Expression.NodeType == ExpressionType.NotEqual ? e.Expression.CastTo<BinaryExpression>().UpdateType(ExpressionType.Equal) :
// `expr` -> `!expr`
(Expression)Expression.Not(e.Expression)
)
));
}
Note the usage of DeriveBinding
extension method on the IBinding
instance - it copies the essential properties from the base binding (like data context, location in page), creates a binding of the same type and adds a new property of type ParsedExpressionBindingProperty
. You could potentially create a binding just from string expression (OriginalStringBindingProperty
), if don't want to bother with the expression trees.
This binding property is present by default in the DotVVM Framework, but you can define your own in the same way.
Post-process existing properties
You can register a resolver with signature like:
public ParsedExpressionBindingProperty WrapExpression(ParsedExpressionBindingProperty prop, some other dependencies) {
return new ParsedExpressionBindingProperty(Expression.Add(prop.Expression, Expression.Constant(1)));
}
It will be executed always after the property is resolved, which means that all bindings will be incremented by one. Incrementing all bindings does not seem to be much useful, but I'm sure post-processing expressions or tweaking generated Javascript is really powerful metaprogramming technique. Just please, use it wisely, all bindings incremented by one may be pretty tricky to debug for your teammates.
Custom binding type
You can even create your own binding, you just need to inherit from BindingExpression
and register the name at ControlResolverBase.BindingTypes
with its BindingParserOptions
. You can have a look how ResourceBindingExpression
is defined in the framework:
[BindingCompilationRequirements(
// the binding implicitly requires an executable BindingDelegate
required: new[] {typeof(CompiledBindingExpression.BindingDelegate)}
)]
[Options]
public class ResourceBindingExpression : BindingExpression, IStaticValueBinding
{
// You need a constructor with this exact signature
public ResourceBindingExpression(BindingCompilationService service, IEnumerable<object> properties) : base(service, properties) { }
// You can have helpers for the binding properties, so they can be accessed like normal .NET properties
public CompiledBindingExpression.BindingDelegate BindingDelegate => this.GetProperty<CompiledBindingExpression.BindingDelegate>();
public Type ResultType => this.GetProperty<ResultTypeBindingProperty>().Type;
// The [Options] attribute that is applied to this class.
public class OptionsAttribute : BindingCompilationOptionsAttribute
{
public override IEnumerable<Delegate> GetResolvers() => new Delegate[] {
// Here you can return your own resolvers. These override the default ones from the resolver classes, so you may have a custom parser, translator to Javascript or anything you want.
};
}
}
// It's also useful to have a generic variant of the class. This will allow you to use IStaticValueBinding<TResultType> with your binding type
public class ResourceBindingExpression<T> : ResourceBindingExpression, IStaticValueBinding<T>
{
public ResourceBindingExpression(BindingCompilationService service, IEnumerable<object> properties) : base(service, properties) { }
public new CompiledBindingExpression.BindingDelegate<T> BindingDelegate => base.BindingDelegate.ToGeneric<T>();
}
And it is registered in the collection like this:
ControlResolverBase.BindingTypes.Add(ParserConstants.ResourceBinding, BindingParserOptions.Create(typeof(ResourceBindingExpression<>)));