Arborist is a library for manipulating and combining C# expression trees consumed by the IQueryable interface and object-relational mappers such as EntityFramework. It provides full expression interpolation ("quasiquoting") capabilities, allowing you to interpolate expressions in a manner analagous to string interpolation, as well as a suite of generalized expression manipulation helpers.
Arborist differs from LINQKit in that it:
- provides a generalized approach to expression interpolation as compared to Expand/AsExpandable,
- adopts a composable, functional approach to expression manipulation supporting expressions in general as compared to mutable PredicateBuilder instances, and
- is significantly more performant.
One of the many problems with the IQueryable API is an almost total lack of composability. It is exceedingly difficult to combine and reuse existing queries, predicates, and projections as the provided APIs for combining and building expression trees make this monstrously difficult on any meaningful scale.
Arborist provides expression interpolation functionality which makes this easy to achieve, in a manner that is (mostly) typesafe. As an introductory motivating example, the following code:
var dogPredicate = ExpressionOn<Dog>.Of(d => d.Name == "Odie");
var ownerPredicate = ExpressionOn<Owner>.Interpolate(
new { dogPredicate },
static (x, o) => o.Name == "Jon"
&& o.Dogs.Any(x.Splice(x.Data.dogPredicate))
);
var catPredicate = ExpressionOn<Cat>.Interpolate(
new { ownerPredicate },
static (x, c) => c.Name == "Garfield"
&& x.SpliceBody(c.Owner, x.Data.ownerPredicate)
);
produces the following expression:
c => c.Name == "Garfield" && (
c.Owner.Name == "Jon"
&& c.Owner.Dogs.Any(d => d.Name == "Odie")
)
Expression interpolators operate on an input lambda expression where the first parameter is the interpolation context providing access to splicing methods and injected data injected into the interpolation process, and any additional parameters are those that appear in the expression resulting from the interpolation process. The interpolation context provides access to the splicing methods used to lower other expression trees into the result expression, as well as to any data that you want to inject into the interpolation process.
Lacking an actual compiler-provided interpolation syntax, the interpolation process works by
analyzing and replacing calls to the splicing methods defined on the interpolation context
provided as the first parameter to an interpolated expressions.
If we were to map expression interpolation to the most obvious analog with which most readers
will be familiar; then the interpolator methods are equivalent to an interpolated string literal
$"..."
, and the splicing methods are then the interpolated substrings {...}
in that they
splice/lower/unquote their argument expressions into the enclosing "quoted" expression tree.
Expressions are not strings, and as such multiple splicing methods are provided with different behaviors as detailed in the following sections.
Splices the argument expression into the tree. Can be used to splice an Expression<T> as a T, or any arbitrary expression node provided it does not capture any parameter references from a source expression.
var projection = ExpressionOn<string>.Of(v => v.Length);
ExpressionOn<IEnumerable<string>>.Interpolate(
new { projection },
static (x, a) => a.Select(x.Splice(x.Data.projection))
);
// a => a.Select(v => v.Length);
ExpressionOnNone.Interpolate(
static x => Math.Abs(x.Splice<int>(Expression.Constant(2)))
);
// () => Math.Abs(2)
Splices the body of the argument lambda expression into the interpolated expression, replacing parameter references in the spliced expression with the provided argument expressions from the parent expression tree.
ExpressionOn<Cat>.Interpolate(
static (x, c) => x.SpliceBody(c.Owner, o => o.Name == "Jon")
);
// c => c.Owner.Name == "Jon"
Splices the argument expression into the resulting expression tree as a quoted (literal) LambdaExpression using Expression.Quote. This method produces an inlined Expression<TDelegate> instead of the TDelegate resulting from Splice, and is important in scenarios where you need to splice an analyzable expression tree into a method on an IQueryable<T> instance (typically when writing a manual join or union on EntityFramework DbSets).
Splices the value of the provided argument into the expression tree as a constant value using
Expression.Constant, which is somewhat unfortunately named as it is easily confused with
the concept of const
in C#, but is also used to represent "constant references".
ExpressionOnNone.Interpolate(
new { value = 42 },
static x => x.SpliceValue(x.Data.value)
);
// () => 42
Arborist provide several helpers specifically to assist with manipulating and combining predicate (bool-returning) expressions.
Combines the provided collection of predicate expressions into a boolean AND operation, returning a true-valued predicate if the collection is empty.
And produces the left-associative expression tree which would result from naiively writing out the provided predicates, whereas AndTree can be used to produce a result expression in the form of a balanced tree with depth log2(n). This is an important consideration when producing expressions to be translated into SQL, as most database engines will enforce a limit on input expression depth.
ExpressionHelper.And([
ExpressionOn<Cat>.Of(c => c.Name == "Garfield"),
ExpressionOn<Cat>.Of(c => c.Name == "Nermal"),
ExpressionOn<Cat>.Of(c => c.Name == "Arlene"),
ExpressionOn<Cat>.Of(c => c.Name == "Mom")
]);
// c => (((c.Name == "Garfield") && c.Name == "Nermal") && c.Name == "Arlene") && c.Name == "Mom"
ExpressionHelper.AndTree([
ExpressionOn<Cat>.Of(c => c.Name == "Garfield"),
ExpressionOn<Cat>.Of(c => c.Name == "Nermal"),
ExpressionOn<Cat>.Of(c => c.Name == "Arlene"),
ExpressionOn<Cat>.Of(c => c.Name == "Mom")
]);
// c => (c.Name == "Garfield" && c.Name == "Nermal") && (c.Name == "Arlene" && c.Name == "Mom")
Produces the negated form of the input expression by applying the boolean NOT operator.
ExpressionHelper.Not(ExpressionOn<Cat>.Of(c => c.Name == "Garfield"));
// c => !(c.Name == "Garfield")
Given a predicate Expression<Func<A, bool>>, creates an Expression<Func<A?, bool>> asserting that the input value is not null, and satisfies the provided predicate expression.
ExpressionHelper.NotNullAnd(ExpressionOn<int>.Of(i => i % 2 == 0))
// i => i.HasValue && i.Value % 2 == 0
Given a predicate Expression<Func<A, bool>>, creates an Expression<Func<A?, bool>> asserting that the input value is null, or satisfies the provided predicate expression.
ExpressionHelper.NullOr(ExpressionOn<int>.Of(i => i % 2 == 0))
// i => !i.HasValue || i.Value % 2 == 0
Combines the provided collection of predicate expressions into a single boolean OR operation, returning a false-valued predicate if the collection is empty.
Or produces the left-associative expression tree which would result from naiively writing out the provided predicates, whereas OrTree can be used to produce a result expression in the form of a balanced tree with depth log2(n). This is an important consideration when producing expressions to be translated into SQL, as most database engines will enforce a limit on input expression depth.
ExpressionHelper.Or([
ExpressionOn<Cat>.Of(c => c.Name == "Garfield"),
ExpressionOn<Cat>.Of(c => c.Name == "Nermal"),
ExpressionOn<Cat>.Of(c => c.Name == "Arlene"),
ExpressionOn<Cat>.Of(c => c.Name == "Mom")
]);
// c => (((c.Name == "Garfield") || c.Name == "Nermal") || c.Name == "Arlene") || c.Name == "Mom"
ExpressionHelper.OrTree([
ExpressionOn<Cat>.Of(c => c.Name == "Garfield"),
ExpressionOn<Cat>.Of(c => c.Name == "Nermal"),
ExpressionOn<Cat>.Of(c => c.Name == "Arlene"),
ExpressionOn<Cat>.Of(c => c.Name == "Mom")
]);
// c => (c.Name == "Garfield" || c.Name == "Nermal") || (c.Name == "Arlene" || c.Name == "Mom")
The Arborist.Orderings namespace defines the Ordering<TSelector> type, which is a collection of OrderingTerm<TSelector> instances. An OrderingTerm<TSelector> is defined by the combination of a selector value identifying a sort of some kind, and an OrderingDirection - one of Ascending or Descending.
At a glance this might seem like a departure from the expression tooling goals of this library, however in practice generating IQueryable<T> orderings is a common pain point. As such, extension methods are provided which enable the application of an Ordering<Expression<Func<E, V>>> to an IQueryable<E> instance.
Generating such an ordering would typically be accomplished by using the monadic extensions (Select or SelectMany) to map a domain model selector type to the appropriate expression-based ordering, likely using expression helpers to recursively splice in orderings defined on other related entities.
System.Text.Json converters are provided for orderings and related types per the following simple grammar (recall an ordering is a list of terms):
Ordering ::= [ OrderingTerm* ]
OrderingTerm ::= [ TSelector, OrderingDirection ]
OrderingDirection ::= "a[scending]"
| "d[escending]"
As such the following ordering:
Ordering.By(
OrderingTerm.Ascending(0),
OrderingTerm.Descending(1)
)
has the following JSON representation:
[[0, "asc"], [1, "desc"]]