Converting traditional EF includes to EF Core includes
In a previous post I tried to give reasons why you should use the repository and unit of work design patterns even if you have a modern, object-relational framework like Entity Framework (well, modern would be up to debate in this case, but whatever).
Besides the usual reasons ('EF already implements these patterns'), a very common objection to using these patterns is 'but then how do I use eager loading?' (because no one uses lazy-loading, right?). Usually, when a question like this comes around, the ideal option is to find the right abstraction to hide the feature that you want to use behind, carefully avoiding any abstraction leaking. Like the answer I gave on a related stackoverflow question, I usually use this:
public static IQueryable<T> IncludeAll<T>(this IQueryable<T> query,
params Expression<Func<T, object>>[] includes)
{
foreach (var include in includes)
{
query = query.Include(include);
}
return query;
}
This is OK for Entity Framework. But an argument could be made that this is abstraction leaking: after all, we introduced the eager-loading parameter specific to Entity Framework to our framework agnostic repository. But to defend this solution, it can be argued that while it is true that is specific to EF, it could be used in a framework agnostic way as well. Basically what we need is a way to specify properties of a type, possibly with compile-time validation. And this signature (an array of Func<T,object>
) actually does just that. The argument could be that you didn't introduce an EF specific leak into your application, rather use the same solution that EF also happens to use to actually avoid abstraction leaking (after all, this takes no dependency to EF). So how could the same soltuion be applied to EF Core?
Eager loading with simple delegates for EF Core
The API for eager loading is very different in EF Core and the simple Func<T,object> delegates or strings don't work anymore. If you want to use the traditional signature, you have to first decompose the delegate into parts. Given an input like t=>t.Products.Select(p=>p.OrderDetails)
, it must be decomposed into a the output list of [t=>t.Products, p=>p.OrderDetails]
. This is the first crucial part of my code:
private static void GetExpressionParts(Expression expression, Stack<LambdaExpression> expressionParts)
{
if (expression is ParameterExpression)
return;
if (expression is MemberExpression memberExpression)
{
var member = memberExpression.Member;
if (!(member is PropertyInfo property))
throw new ArgumentException(string.Format(invalidMemberMessage, member));
var parameterExpression = Expression.Parameter(memberExpression.Expression.Type);
var propertyExpression = Expression.Property(parameterExpression, property);
expressionParts.Push(Expression.Lambda(propertyExpression, parameterExpression));
GetExpressionParts(memberExpression.Expression, expressionParts);
}
else if (expression is MethodCallExpression methodCallExpression)
{
if (methodCallExpression.Method.GetGenericMethodDefinition() != selectMethod)
throw new ArgumentException(string.Format(invalidMemberMessage, methodCallExpression));
if (!(methodCallExpression.Arguments[1] is LambdaExpression innerExpression))
throw new ArgumentException(string.Format(invalidMemberMessage, methodCallExpression.Arguments[1]));
GetExpressionParts(innerExpression.Body, expressionParts);
GetExpressionParts(methodCallExpression.Arguments[0], expressionParts);
}
else
{
throw new ArgumentException(string.Format(invalidMemberMessage, expression));
}
}
private static Stack<LambdaExpression> GetExpressionParts(Expression expression)
{
var stringExpression = expression.ToString();
var expressionParts = new Stack<LambdaExpression>(stringExpression.CountOf(".") - stringExpression.CountOf($".{selectMethodName}("));
if (!(expression is LambdaExpression lambda))
throw new ArgumentException(nameof(expression), $"Parameter must be of type {lamdaExpressionType}");
GetExpressionParts(lambda.Body, expressionParts);
return expressionParts;
}
So basically the input expression can be of 3 different types:
- Expressions in the form of
t=>t.OrderDetails
. If this is the case, the property is simply extracted and the rest of the expression is processed recursively. - Expressions in the form of
t=>t.Select(p=>p.OrderDetails)
. In this case, both the parameter of theSelect()
method is processed and the rest of the expression is processed recursively. - A simple
ParameterExpression
. This only happens at the end of the recursion, when everything else is already processed.
A stack is used for getting the list of subexpressions, so the reverse aspect is implicitly kept when processing the properties later. When the properties are extracted, it's only a matter of some reflection-magic to call the Include()
and ThenInclude()
methods of EF Core with the right parameters:
public static IQueryable<T> Include<T, TProperty>(this IQueryable<T> queryable, Expression<Func<T, TProperty>> navigationPropertyPath)
{
object query = queryable;
var includeParts = GetExpressionParts(navigationPropertyPath);
var firstPart = includeParts.Pop();
var actualIncludeMethod = includeMethod.MakeGenericMethod(new[] { firstPart.Parameters.Single().Type, firstPart.ReturnType });
query = actualIncludeMethod.Invoke(null, new[] { query, firstPart });
while (includeParts.TryPop(out var includePart))
{
var genericArguments = new[] { query.GetType().GetGenericArguments()[0], includePart.Parameters.Single().Type, includePart.ReturnType };
var includeArguments = new[] { query, includePart };
if (query.GetType().GenericTypeArguments[1].GetInterfaces().Any(f => f.GetGenericTypeDefinition() == openIEnumerableType))
{
query = thenIncludeForCollection.MakeGenericMethod(genericArguments).Invoke(null, includeArguments);
}
else
{
query = thenIncludeForEntity.MakeGenericMethod(genericArguments).Invoke(null, includeArguments);
}
}
return (IQueryable<T>)query;
}
And this gives you an Inlcude()
method for EF Core that works the same as it did for EF. You can check out the full source code with the helper methods and additional fields on Github. The uploaded sample uses an instance of the Northwind database. If you can't find it online, feel free to cehck out my awesome Northwind initiative and download it from there.