在使用 AutoMapper 进行映射时,是否有一种有效的方法可以仅保留子集合的单个元素?

Is there an efficient way to only keep a single element of a child collection when mapping using AutoMapper?

我在我的 .NET 5.0 项目中配置了 AutoMapper,其中包含一个实体 (Setting) 及其 DTO (SettingByProfileDto)) 之间的映射。所述实体具有另一个实体的子集合 (SettingValue)(一对多)。第一个实体的子集合(SettingValues)映射到 DTO 内的单个项目(另一个 DTO:SettingValueDto),因为我只需要一个特定的此列表中的项目。

对于映射配置,我使用以下几行:

int profileId = default;

profile.CreateMap<Setting, SettingByProfileDto>()
    .ForMember(dto => dto.SettingValue, opt =>
    {
        opt.MapFrom(ss => 
            ss.SettingValues
            .FirstOrDefault(ssv => ssv.ProfileId == profileId)
        );
    });

当我想检索第一个实体时,我使用 AutoMapper ProjectTo 方法来仅请求 DTO 具有的字段。我给 ProjectTo 方法 profileId 参数的值,这样映射就可以知道必须在哪个 ID 上完成过滤器:

// ...
.Where(ss => ss.Id == request.Id)
.ProjectTo<SettingByProfileDto>(
    _mapper.ConfigurationProvider,
    new { profileId = request.ProfileId },
    dest => dest.SettingValue
) 
// ...

查询结果和映射都是正确的。但是,发送到数据库以获取结果的查询似乎没有得到优化。

这是结果查询:

SELECT [s8].[Description], [s8].[DisplayName], [s8].[Id], [s8].[Name], CASE
    WHEN (
        SELECT TOP(1) [s].[Id]
        FROM [SettingValue] AS [s]
        WHERE ([s8].[Id] = [s].[SettingId]) AND ([s].[ProfileId] = @__profileId_1)) IS NULL THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END, (
    SELECT TOP(1) [s2].[Id]
    FROM [SettingValue] AS [s2]
    WHERE ([s8].[Id] = [s2].[SettingId]) AND ([s2].[ProfileId] = @__profileId_1)), (
    SELECT TOP(1) [s3].[ProfileId]
    FROM [SettingValue] AS [s3]
    WHERE ([s8].[Id] = [s3].[SettingId]) AND ([s3].[ProfileId] = @__profileId_1)), (
    SELECT TOP(1) [s4].[SettingId]
    FROM [SettingValue] AS [s4]
    WHERE ([s8].[Id] = [s4].[SettingId]) AND ([s4].[ProfileId] = @__profileId_1)), (
    SELECT TOP(1) [s7].[Value]
    FROM [SettingValue] AS [s7]
    WHERE ([s8].[Id] = [s7].[SettingId]) AND ([s7].[ProfileId] = @__profileId_1))
FROM [Setting] AS [s8]
WHERE [s8].[Id] = @__request_Id_0
ORDER BY (SELECT 1)
OFFSET @__p_2 ROWS FETCH NEXT @__p_3 ROWS ONLY

这里是AutoMapper生成然后转换为SQL的查询表达式:

DbSet<Setting>()
    .AsNoTracking()
    .Where(ss => ss.Id == __request_Id_0)
    .Select(dtoSetting => new Object_1800281414___SettingValue_Description_DisplayName_Id_Name{ 
        __SettingValue = dtoSetting.SettingValues
            .FirstOrDefault(ssv => ssv.ProfileId == __profileId_1), 
        Description = dtoSetting.Description, 
        DisplayName = dtoSetting.DisplayName, 
        Id = dtoSetting.Id, 
        Name = dtoSetting.Name, 
    }
    )
    .Select(dtoLet => new SettingByProfileDto{ 
        Description = dtoLet.Description, 
        DisplayName = dtoLet.DisplayName, 
        Id = dtoLet.Id,  
        Name = dtoLet.Name, 
        SettingValue = dtoLet.__SettingValue == null ? null : new SettingValueDto{ 
            Id = dtoLet.__SettingValue.Id, 
            ProfileId = dtoLet.__SettingValue.ProfileId, 
            SettingId = dtoLet.__SettingValue.SettingId, 
            Value = dtoLet.__SettingValue.Value 
        }
    }
    )
    .Skip(__p_2)
    .Take(__p_3)

我尝试用 Where 替换映射配置中的 FirstOrDefault 子句(条件相同),在这种情况下,生成的查询将使用 LEFT JOIN避免每个字段重复一个 WHERE。但是,通过这种方式,我无法将子集合映射到单个项目,而只能映射到另一个集合(dto)。

我的问题如下:

  1. 有没有更好(更高效?)的方法来实现我想要的(只保留子集合中的一项)?
  2. 上面的查询是否被认为是优化的?如果是这样,我将能够继续我目前的做法

感谢您的帮助!

AutoMapper 投影通过使用生成的映射选择器注入 Select 调用来工作,或者仅在目标类型与源类型不匹配的查询表达式树中注入选择器。

问题是这些注入到哪里了。例如,在你的例子中它生成了这样的东西(伪代码,MapTo只是标记映射注入点)

SettingValue = source.SettingValues
    .FirstOrDefault(ssv => ssv.ProfileId == profileId)
    .MapTo<SettingByProfileDto>()

这里的谓词版本 FirstOrDefault 它别无选择(好吧,相对而言,请继续阅读),但即使您将其重写为等效的 Where(predicate) + FirstOrDefault() 链,它仍然在最后注入映射

SettingValue = source.SettingValues
    .Where(ssv => ssv.ProfileId == profileId)
    .FirstOrDefault()
    .MapTo<SettingByProfileDto>()

为时已晚,这就是 EF Core 生成低效查询的原因。

现在,人们可能认为这是 EF Core 查询翻译缺陷。但是如果映射是在 before the FirstOrDefault() call

SettingValue = source.SettingValues
    .Where(ssv => ssv.ProfileId == profileId)
    .MapTo<SettingByProfileDto>()
    .FirstOrDefault()

然后 Core 生成最佳翻译。

我没有找到强制 AM 这样做的方法,而且透明地发生这一切也很好。所以我写了一些自定义扩展。它所做的是插入 AutoMapper 管道并适当地转换以下 Enumerable 扩展方法(谓词和非谓词重载)- FirstFirstOrDefaultLastLastOrDefault, Single, SingleOrDefault.

这是源代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using AutoMapper.Internal;
using AutoMapper.QueryableExtensions;
using AutoMapper.QueryableExtensions.Impl;


namespace AutoMapper
{
    public static class SingeResultQueryMapper
    {
        public static IMapperConfigurationExpression AddSingleResultQueryMapping(this IMapperConfigurationExpression config)
        {
            config.Advanced.QueryableBinders.Insert(0, new Binder());
            config.Advanced.QueryableResultConverters.Insert(0, new ResultConverter());
            return config;
        }

        static string[] TargetMethodNames => new[]
        {
            nameof(Enumerable.First),
            nameof(Enumerable.FirstOrDefault),
            nameof(Enumerable.Last),
            nameof(Enumerable.LastOrDefault),
            nameof(Enumerable.Single),
            nameof(Enumerable.SingleOrDefault),
        };

        static HashSet<MethodInfo> TargetMethods { get; } =
            (from method in typeof(Enumerable).GetTypeInfo().DeclaredMethods
             join name in TargetMethodNames
             on method.Name equals name
             select method).ToHashSet();

        static bool IsTarget(IMemberMap propertyMap) =>
            propertyMap.SourceType != propertyMap.DestinationType &&
            propertyMap.ProjectToCustomSource is null &&
            propertyMap.CustomMapExpression?.Body is MethodCallExpression call &&
            call.Method.IsGenericMethod &&
            TargetMethods.Contains(call.Method.GetGenericMethodDefinition());

        class ResultConverter : IExpressionResultConverter
        {
            public bool CanGetExpressionResolutionResult(ExpressionResolutionResult expressionResolutionResult, IMemberMap propertyMap)
                => IsTarget(propertyMap);

            public ExpressionResolutionResult GetExpressionResolutionResult(ExpressionResolutionResult expressionResolutionResult, IMemberMap propertyMap, LetPropertyMaps letPropertyMaps)
                => new(propertyMap.CustomMapExpression.ReplaceParameters(propertyMap.CheckCustomSource(expressionResolutionResult, letPropertyMaps)));
        }

        class Binder : IExpressionBinder
        {
            public bool IsMatch(PropertyMap propertyMap, TypeMap propertyTypeMap, ExpressionResolutionResult result)
                => IsTarget(propertyMap);

            public MemberAssignment Build(IConfigurationProvider configuration, PropertyMap propertyMap, TypeMap propertyTypeMap, ExpressionRequest request, ExpressionResolutionResult result, IDictionary<ExpressionRequest, int> typePairCount, LetPropertyMaps letPropertyMaps)
            {
                var call = (MethodCallExpression)result.ResolutionExpression;
                var selectors = configuration.ExpressionBuilder.CreateMapExpression(
                    new(propertyMap.SourceType, propertyMap.DestinationType, request.MembersToExpand, request),
                    typePairCount, letPropertyMaps.New());
                if (selectors == null) return null;
                var query = call.Arguments[0];
                var method = call.Method.GetGenericMethodDefinition();
                if (call.Arguments.Count > 1)
                {
                    // Predicate version of the method
                    // Convert query.Method(predicate) to query.Where(predicate).Method()
                    query = Expression.Call(typeof(Enumerable), nameof(Enumerable.Where), new[] { propertyMap.SourceType }, query, call.Arguments[1]);
                    method = TargetMethods.First(m => m.Name == method.Name && m.GetParameters().Length == 1);
                }
                method = method.MakeGenericMethod(propertyMap.DestinationType);
                foreach (var selector in selectors)
                    query = Expression.Call(typeof(Enumerable), nameof(Enumerable.Select), new[] { selector.Parameters[0].Type, selector.ReturnType }, query, selector);
                call = Expression.Call(method, query);
                return Expression.Bind(propertyMap.DestinationMember, call);
            }
        }
    }
}

只需将它放在配置AutoMapper的项目中的代码文件中,然后使用提供的配置助手扩展方法(类似于Expression Mapping AutoMapper扩展)启用它,就像这样

var mapper = new MapperConfiguration(cfg => {
    cfg.AddSingleResultQueryMapping();
    // The rest ...
}).CreateMapper();

或与 DI

services.AddAutoMapper(cfg => {
    cfg.AddSingleResultQueryMapping();
}, /* assemblies with profiles */);

仅此而已。现在您的原始 DTO、映射和 ProjectTo 将产生最佳的 SQL 查询翻译(单个 LEFT OUTER JOIN)。