How to write Repository method for .ThenInclude in EF Core 2

I found this repository method online and it does exactly what I wanted. Yared's answer was good, but not all the way there.

/// <summary>
/// Gets the first or default entity based on a predicate, orderby delegate and include delegate. This method default no-tracking query.
/// </summary>
/// <param name="selector">The selector for projection.</param>
/// <param name="predicate">A function to test each element for a condition.</param>
/// <param name="orderBy">A function to order elements.</param>
/// <param name="include">A function to include navigation properties</param>
/// <param name="disableTracking"><c>True</c> to disable changing tracking; otherwise, <c>false</c>. Default to <c>true</c>.</param>
/// <returns>An <see cref="IPagedList{TEntity}"/> that contains elements that satisfy the condition specified by <paramref name="predicate"/>.</returns>
/// <remarks>This method default no-tracking query.</remarks>
public TResult GetFirstOrDefault<TResult>(Expression<Func<TEntity, TResult>> selector,
                                          Expression<Func<TEntity, bool>> predicate = null,
                                          Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null,
                                          Func<IQueryable<TEntity>, IIncludableQueryable<TEntity, object>> include = null,
                                          bool disableTracking = true)
{
    IQueryable<TEntity> query = _dbSet;
    if (disableTracking)
    {
        query = query.AsNoTracking();
    }

    if (include != null)
    {
        query = include(query);
    }

    if (predicate != null)
    {
        query = query.Where(predicate);
    }

    if (orderBy != null)
    {
        return orderBy(query).Select(selector).FirstOrDefault();
    }
    else
    {
        return query.Select(selector).FirstOrDefault();
    }
}

Usage:

var affiliate = await affiliateRepository.GetFirstOrDefaultAsync(
    predicate: b => b.Id == id,
    include: source => source
        .Include(a => a.Branches)
        .ThenInclude(a => a.Emails)
        .Include(a => a.Branches)
        .ThenInclude(a => a.Phones));

Back in EF6 we could write something like this:

query.Include(t => t.Navigation1, t => t.Navigation2.Select(x => x.Child1));

And it was perfect and simple. We could expose it in an repository without dragging references from the EF assembly to other projects.

This was removed from EF Core, but since EF6 is open-source, the method that transforms the lambda expressions in paths can easily be extracted to use in EF Core so you can get the exact same behavior.

Here's the complete extension method.

/// <summary>
///     Provides extension methods to the <see cref="Expression" /> class.
/// </summary>
public static class ExpressionExtensions
{
    /// <summary>
    ///     Converts the property accessor lambda expression to a textual representation of it's path. <br />
    ///     The textual representation consists of the properties that the expression access flattened and separated by a dot character (".").
    /// </summary>
    /// <param name="expression">The property selector expression.</param>
    /// <returns>The extracted textual representation of the expression's path.</returns>
    public static string AsPath(this LambdaExpression expression)
    {
        if (expression == null)
            return null;

        TryParsePath(expression.Body, out var path);

        return path;
    }

    /// <summary>
    ///     Recursively parses an expression tree representing a property accessor to extract a textual representation of it's path. <br />
    ///     The textual representation consists of the properties accessed by the expression tree flattened and separated by a dot character (".").
    /// </summary>
    /// <param name="expression">The expression tree to parse.</param>
    /// <param name="path">The extracted textual representation of the expression's path.</param>
    /// <returns>True if the parse operation succeeds; otherwise, false.</returns>
    private static bool TryParsePath(Expression expression, out string path)
    {
        var noConvertExp = RemoveConvertOperations(expression);
        path = null;

        switch (noConvertExp)
        {
            case MemberExpression memberExpression:
            {
                var currentPart = memberExpression.Member.Name;

                if (!TryParsePath(memberExpression.Expression, out var parentPart))
                    return false;

                path = string.IsNullOrEmpty(parentPart) ? currentPart : string.Concat(parentPart, ".", currentPart);
                break;
            }

            case MethodCallExpression callExpression:
                switch (callExpression.Method.Name)
                {
                    case nameof(Queryable.Select) when callExpression.Arguments.Count == 2:
                    {
                        if (!TryParsePath(callExpression.Arguments[0], out var parentPart))
                            return false;

                        if (string.IsNullOrEmpty(parentPart))
                            return false;

                        if (!(callExpression.Arguments[1] is LambdaExpression subExpression))
                            return false;

                        if (!TryParsePath(subExpression.Body, out var currentPart))
                            return false;

                        if (string.IsNullOrEmpty(parentPart))
                            return false;

                        path = string.Concat(parentPart, ".", currentPart);
                        return true;
                    }

                    case nameof(Queryable.Where):
                        throw new NotSupportedException("Filtering an Include expression is not supported");
                    case nameof(Queryable.OrderBy):
                    case nameof(Queryable.OrderByDescending):
                        throw new NotSupportedException("Ordering an Include expression is not supported");
                    default:
                        return false;
                }
        }

        return true;
    }

    /// <summary>
    ///     Removes all casts or conversion operations from the nodes of the provided <see cref="Expression" />.
    ///     Used to prevent type boxing when manipulating expression trees.
    /// </summary>
    /// <param name="expression">The expression to remove the conversion operations.</param>
    /// <returns>The expression without conversion or cast operations.</returns>
    private static Expression RemoveConvertOperations(Expression expression)
    {
        while (expression.NodeType == ExpressionType.Convert || expression.NodeType == ExpressionType.ConvertChecked)
            expression = ((UnaryExpression)expression).Operand;

        return expression;
    }
}

Then you can use it like this (put it in an QueryableExtensions class or something like that):

 /// <summary>
 ///     Specifies related entities to include in the query result.
 /// </summary>
 /// <typeparam name="T">The type of entity being queried.</typeparam>
 /// <param name="source">The source <see cref="IQueryable{T}" /> on which to call Include.</param>
 /// <param name="paths">The lambda expressions representing the paths to include.</param>
 /// <returns>A new <see cref="IQueryable{T}" /> with the defined query path.</returns>
 internal static IQueryable<T> Include<T>(this IQueryable<T> source, params Expression<Func<T, object>>[] paths)
 {
     if (paths != null)
         source = paths.Aggregate(source, (current, include) => current.Include(include.AsPath()));

     return source;
 }

And then in your repository you call it normally like you would do in EF6:

query.Include(t => t.Navigation1, t => t.Navigation2.Select(x => x.Child1));

References:

How to pass lambda 'include' with multiple levels in Entity Framework Core?

https://github.com/aspnet/EntityFramework6


I had the same issue since EF Core doesn't support lazy loading but i tried to get workaround in the following way:

First create an attribute class to mark our desired navigation properties from other properties of a given class.

[AttributeUsage(AttributeTargets.Property, Inherited = false)]
public class NavigationPropertyAttribute : Attribute
{
    public NavigationPropertyAttribute()
    {
    }
}

Extension methods to filter out navigation properties and apply Include/ThenInclude using string based Eager loading.

public static class DbContextHelper
{

    public static Func<IQueryable<T>, IQueryable<T>> GetNavigations<T>() where T : BaseEntity
    {
        var type = typeof(T);
        var navigationProperties = new List<string>();

        //get navigation properties
        GetNavigationProperties(type, type, string.Empty, navigationProperties);

        Func<IQueryable<T>, IQueryable<T>> includes = ( query => {
                    return  navigationProperties.Aggregate(query, (current, inc) => current.Include(inc));   
            });

        return includes;
    }

    private static void GetNavigationProperties(Type baseType, Type type, string parentPropertyName, IList<string> accumulator)
    {
        //get navigation properties
        var properties = type.GetProperties();
        var navigationPropertyInfoList = properties.Where(prop => prop.IsDefined(typeof(NavigationPropertyAttribute)));

        foreach (PropertyInfo prop in navigationPropertyInfoList)
        {
            var propertyType = prop.PropertyType;
            var elementType = propertyType.GetTypeInfo().IsGenericType ? propertyType.GetGenericArguments()[0] : propertyType;

            //Prepare navigation property in {parentPropertyName}.{propertyName} format and push into accumulator
            var properyName = string.Format("{0}{1}{2}", parentPropertyName, string.IsNullOrEmpty(parentPropertyName) ? string.Empty : ".", prop.Name);
            accumulator.Add(properyName);

            //Skip recursion of propert has JsonIgnore attribute or current property type is the same as baseType
            var isJsonIgnored = prop.IsDefined(typeof(JsonIgnoreAttribute));
            if(!isJsonIgnored && elementType != baseType){
                GetNavigationProperties(baseType, elementType, properyName, accumulator);
            }
        }
    }
}

Sample POCO classes implementing NavigationPropertyAttribute

public class A : BaseEntity{
  public string Prop{ get; set; }
}

public class B : BaseEntity{
   [NavigationProperty]
   public virtual A A{ get; set; }
}

public class C : BaseEntity{
   [NavigationProperty]
   public virtual B B{ get; set; }
}

Usage in Repository

public async Task<T> GetAsync(Expression<Func<T, bool>> predicate)
{
    Func<IQueryable<T>, IQueryable<T>> includes = DbContextHelper.GetNavigations<T>();
    IQueryable<T> query = _context.Set<T>();
    if (includes != null)
    {
        query = includes(query);
    }

    var entity = await query.FirstOrDefaultAsync(predicate);
    return entity;
}

Json result for sample class C would be:

{
  "B" : {
        "A" : {
              "Prop" : "SOME_VALUE"
            }
      }
}