Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Automapper ProjectTo adds ToList into child properties

I use projection to map the Entity classes to DTOs using Entity Framework Core. However, projection adds ToList into child collection properties and this slows down the query a lot.

Company Entity:

public class Company
{
    public Company()
    {
        Employees = new List<CompanyEmployee>();
    }

    public string Address { get; set; }
    public virtual ICollection<CompanyEmployee> Employees { get; set; }
    ...
}

Company DTO:

public class CompanyDTO
{
    public CompanyDTO()
    {
        CompanyEmployees = new List<EmployeeDTO>();
    }

    public string Address { get; set; }
    public List<EmployeeDTO> CompanyEmployees { get; set; }
    ...
}

Configuration:

CreateMap<Company, CompanyDTO>()
    .ForMember(c => c.CompanyEmployees, a => a.MapFrom(src => src.Employees));
CreateMap<CompanyEmployee, EmployeeDTO>();

Query:

UnitOfWork.Repository<Company>()
    .ProjectTo<CompanyDTO>(AutoMapper.Mapper.Configuration)
    .Take(10)
    .ToList();

Inspecting the generated query using the Expression property after ProjectTo yields the following:

Company.AsNoTracking()
    .Select(dtoCompany => new CompanyDTO() 
    {
        Address = dtoCompany.Address, 
        ...
        CompanyEmployees = dtoCompany.Employees.Select(dtoCompanyEmployee => new EmployeeDTO() 
                            {
                                CreatedDate = dtoCompanyEmployee.CreatedDate, 
                                ...
                            }).ToList() // WHY??????
    })

That ToList call causes to run select queries for each entity which is not what I want as you have guessed. I tested the query without that ToList (by manually copying the expression and running it) and everything works as expected. How can I prevent AutoMapper adding that call? I tried changing List type in DTO to IEnumerable but nothing changed..

like image 683
sotn Avatar asked Mar 06 '23 11:03

sotn


1 Answers

Let ignore the EF Core affect of the ToList call and concentrate on AutoMapper ProjectTo.

The behavior is hardcoded in EnumerableExpressionBinder class:

expression = Expression.Call(typeof(Enumerable), propertyMap.DestinationPropertyType.IsArray ? "ToArray" : "ToList", new[] { destinationListType }, expression);

This class in part of the AutoMapper QueryableExtensions processing pipeline and is responsible for converting source enumerable to destination enumerable. And as we can see, it always emits either ToArray or ToList.

In fact when the destination member type is ICollection<T> or IList<T>, the ToList call is needed because otherwise the expression won't compile. But when the destination member type is IEnumerable<T>, this is arbitrary.

So if you want to get rid of that behavior in the aforementioned scenario, you can inject a custom IExpressionBinder before the EnumerableExpressionBinder (the binders are called in order until IsMatch returns true) like this (

namespace AutoMapper
{
    using System.Collections.Generic;
    using System.Linq;
    using System.Linq.Expressions;
    using AutoMapper.Configuration.Internal;
    using AutoMapper.Mappers.Internal;
    using AutoMapper.QueryableExtensions;
    using AutoMapper.QueryableExtensions.Impl;

    public class GenericEnumerableExpressionBinder : IExpressionBinder
    {
        public bool IsMatch(PropertyMap propertyMap, TypeMap propertyTypeMap, ExpressionResolutionResult result) =>
            propertyMap.DestinationPropertyType.IsGenericType &&
            propertyMap.DestinationPropertyType.GetGenericTypeDefinition() == typeof(IEnumerable<>) &&
            PrimitiveHelper.IsEnumerableType(propertyMap.SourceType);

        public MemberAssignment Build(IConfigurationProvider configuration, PropertyMap propertyMap, TypeMap propertyTypeMap, ExpressionRequest request, ExpressionResolutionResult result, IDictionary<ExpressionRequest, int> typePairCount, LetPropertyMaps letPropertyMaps)
            => BindEnumerableExpression(configuration, propertyMap, request, result, typePairCount, letPropertyMaps);

        private static MemberAssignment BindEnumerableExpression(IConfigurationProvider configuration, PropertyMap propertyMap, ExpressionRequest request, ExpressionResolutionResult result, IDictionary<ExpressionRequest, int> typePairCount, LetPropertyMaps letPropertyMaps)
        {
            var expression = result.ResolutionExpression;

            if (propertyMap.DestinationPropertyType != expression.Type)
            {
                var destinationListType = ElementTypeHelper.GetElementType(propertyMap.DestinationPropertyType);
                var sourceListType = ElementTypeHelper.GetElementType(propertyMap.SourceType);
                var listTypePair = new ExpressionRequest(sourceListType, destinationListType, request.MembersToExpand, request);
                var transformedExpressions = configuration.ExpressionBuilder.CreateMapExpression(listTypePair, typePairCount, letPropertyMaps.New());
                if (transformedExpressions == null) return null;
                expression = transformedExpressions.Aggregate(expression, (source, lambda) => Select(source, lambda));
            }

            return Expression.Bind(propertyMap.DestinationProperty, expression);
        }

        private static Expression Select(Expression source, LambdaExpression lambda)
        {
            return Expression.Call(typeof(Enumerable), "Select", new[] { lambda.Parameters[0].Type, lambda.ReturnType }, source, lambda);
        }

        public static void InsertTo(List<IExpressionBinder> binders) =>
            binders.Insert(binders.FindIndex(b => b is EnumerableExpressionBinder), new GenericEnumerableExpressionBinder());
    }
}

It's basically a modified copy of the EnumerableExpressionBinder with different IsMatch check and removed ToList call emit code.

Now if you inject it to your AutoMapper configuration:

Mapper.Initialize(cfg =>
{
    GenericEnumerableExpressionBinder.InsertTo(cfg.Advanced.QueryableBinders);
    // ...
});

and make your DTO collection type IEnumerable<T>:

public IEnumerable<EmployeeDTO> CompanyEmployees { get; set; }

the ProjectTo will generate expression with Select but w/o ToList.

like image 189
Ivan Stoev Avatar answered Mar 15 '23 05:03

Ivan Stoev