AutoMapper Map 和 ProjectTo 的一个配置文件存在问题

问题描述 投票:0回答:1

自动映射器版本=13.0.1

Microsoft.EntityFrameworkCore 版本=7.0.20

Microsoft.EntityFrameworkCore.InMemory 版本=7.0.20

预期行为 我想创建一个配置文件,并将内存中的一个 DTO 的工作与数据库中的投影结合起来。考虑到翻译数据库查询的具体要求。我遇到了一些问题,您可以在重现步骤中查看更多详细信息。有关于当前和预期行为的详细评论,以及可能的解决方案。

Test1() - 基本配置文件设置和我遇到的问题的识别。此配置文件在 ProjectTo 中运行良好,但在内存中与 Map 一起运行不佳,因为 Expression 中没有 Null 检查

Test2() - 在内存 Map 中运行良好,但在 ProjectTo 中不起作用。原因很清楚 - 使用 Func,它不是表达式,无法翻译成 SQL

Test3() - 一种解决方案是始终使用 AsQueryable 和 Projection,即使在内存中也是如此,以便闭包起作用。但是,在我看来,这也是一种黑客行为,而不是解决方案,并且它有自己的问题

Test4() - 我想到的唯一可能的解决方案是创建一个从 Dto 继承的对象并为其创建一个单独的配置文件并仅将其用于投影,并仅在内存中使用主配置文件。这有效。但这也远非理想,您必须始终记住,一个 Dto 应该仅在内存中使用,而另一个仅在投影中使用。还执行从 FooDtoForProjection 到 FooDto 的转换。在更改数据库模型时也支持这两种配置文件。

解决此类问题的最佳方案是什么? 根据我的想法,我想为同一对源和目标创建 2 个贴图,以便一个贴图用于在内存中工作,另一个贴图用于投影。但 Automapper 不允许这样做。或者,如果可以制作 2 个独立的 MapFrom。例如,opt.MapFromForMemory 和 opt.MapFromForProjection。是的,确实,Automapper 涵盖了 99% 的必要工作,但我遇到了它没有涵盖的那 1%,到目前为止我还没有看到一个好的、漂亮的解决方案。帮我出出主意吧!

PS我知道可以在Expression中添加检查,但是如果有很多语言和字段需要翻译,这会显着增加生成的SQL查询的大小和复杂性

using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

try
{
    await Helper.Populate();
    await Helper.Test1();
    await Helper.Test2();
    await Helper.Test3();
    await Helper.Test4();
}
catch (Exception ex)
{
}


public static class Helper
{
    public static FooContext CreateContext()
    {
        DbContextOptionsBuilder<FooContext> builder =
            new DbContextOptionsBuilder<FooContext>()
                .UseInMemoryDatabase("foo_in_memory")
                .ConfigureWarnings(b => b.Ignore(InMemoryEventId.TransactionIgnoredWarning));

        FooContext dbContext = new FooContext(builder.Options);
        return dbContext;
    }

    public static async Task Populate()
    {
        FooContext dbContext = CreateContext();

        await dbContext.Database.EnsureCreatedAsync();

        Foo foo = new Foo();
        foo.Name = "Foo";

        foo.Translate = new List<FooTranslate>
        {
            new()
            {
                Culture = "en",
                Name = "FooEn"
            },
            new()
            {
                Culture = "fr",
                Name = "FooFr"
            },
        };

        dbContext.Add(foo);
        await dbContext.SaveChangesAsync();
        await dbContext.DisposeAsync();
    }


    // Works on the DB side, but does not work in memory
    public static async Task Test1()
    {
        MapperConfiguration mapperConfiguration = 
            new MapperConfiguration(expression =>
            {
                expression.AddProfile(new FooProfile());
            });
        IMapper mapper = mapperConfiguration.CreateMapper();


        // 1. Projection
        // This works because the expression is transformed to SQL and all Null checks
        // are performed on the DB side and the correct value is returned
        FooContext dbContext_1 = CreateContext();
        IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
        IQueryable<FooDto> queryProjection_1 = 
            mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
        FooDto dto_1 = await queryProjection_1.FirstAsync();
        await dbContext_1.DisposeAsync();

        // dto_1.Name = "FooFr" - Working


        
        // 2. Map with Include
        // The closure does not work. culture in the profile = null. 
        // When executed in memory (not on the DB side),
        // expression throws a NullReferenceException and 
        // Automapper simply swallows it and the Name field remains Null.
        // Why does the closure not work with Map?
        FooContext dbContext_2 = CreateContext();
        IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
        Foo foo_2 = await query_2.FirstAsync();
        FooDto dto_2 = mapper.Map<FooDto>(foo_2, 
            options => options.Items["culture"] = "fr");
        await dbContext_2.DisposeAsync();

        //dto_2.Name = null - NOT working


        // 3. Map with Include and BeforeMap
        // The situation in point 2 can be solved by adding BeforeMap to the profile,
        // but this looks more like a crutch

        MapperConfiguration mapperConfigurationWithBeforeMap =
            new MapperConfiguration(expression =>
            {
                expression.AddProfile(new FooProfileWithBeforeMap());
            });
        IMapper mapperWithBeforeMap = mapperConfigurationWithBeforeMap.CreateMapper();

        FooContext dbContext_3 = CreateContext();
        IQueryable<Foo> query_3 = dbContext_3.FooDbSet.Include(q => q.Translate);
        Foo foo_3 = await query_3.FirstAsync();
        FooDto dto_3 = mapperWithBeforeMap.Map<FooDto>(foo_3,
            options => options.Items["culture"] = "fr");
        await dbContext_3.DisposeAsync();

        //dto_3.Name = "FooFr" - NOT working


        // 4. Map WITHOUT Include and BeforeMap
        // But even the crutch from point 3 does
        // not save from the situation when the required translation
        // is not available (it is simply not translated into the required language
        // or was not requested from the Include DB).
        // The default name from foo.Name is not returned because
        // expression in memory throws a NullReferenceException after FirstOrDefault()

        FooContext dbContext_4 = CreateContext();
        IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
        Foo foo_4 = await query_4.FirstAsync();
        FooDto dto_4 = mapperWithBeforeMap.Map<FooDto>(foo_4,
            options => options.Items["culture"] = "fr");
        await dbContext_4.DisposeAsync();

        //dto_4.Name = null - NOT working


        // 5. Map With Include and BeforeMap not exists translate
        string notExistsCulture = "ua";

        FooContext dbContext_5 = CreateContext();
        IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
        Foo foo_5 = await query_5.FirstAsync();
        FooDto dto_5 = mapperWithBeforeMap.Map<FooDto>(foo_5,
            options => options.Items["culture"] = notExistsCulture);
        await dbContext_5.DisposeAsync();

        //dto_5.Name = null - NOT working
    }


    // Works in memory (with hacks - BeforeMap and set culture), but does not work on DB side
    public static async Task Test2()
    {
        MapperConfiguration mapperConfiguration = 
            new MapperConfiguration(expression =>
            {
                expression.AddProfile(new FooProfileWithUseFunc());
            });
        IMapper mapper = mapperConfiguration.CreateMapper();


        // 1. Projection
        // Does not work for the obvious reason of using Func rather
        // than Expression when building a profile map
        FooContext dbContext_1 = CreateContext();
        IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
        IQueryable<FooDto> queryProjection_1 =
            mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
        FooDto dto_1 = await queryProjection_1.FirstAsync();
        await dbContext_1.DisposeAsync();

        // dto_1.Name = "Foo" - expected "FooFr" - NOT working


        // 2. Map with Include
        // The closure does not work. culture in the profile = null. 
        // Why does the closure not work with Map?
        FooContext dbContext_2 = CreateContext();
        IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
        Foo foo_2 = await query_2.FirstAsync();
        FooDto dto_2 = mapper.Map<FooDto>(foo_2, 
            options => options.Items["culture"] = "fr");
        await dbContext_2.DisposeAsync();

        //dto_2.Name = "Foo" - expected "FooFr" - NOT working


        // 3. Map with Include and BeforeMap
        // The situation in point 2 can be solved by adding BeforeMap to the profile,
        // but this looks more like a crutch

        MapperConfiguration mapperConfigurationWithBeforeMap = 
            new MapperConfiguration(expression =>
            {
                expression.AddProfile(new FooProfileUseFuncWithBeforeMap());
            });
        IMapper mapperWithBeforeMap = mapperConfigurationWithBeforeMap.CreateMapper();

        FooContext dbContext_3 = CreateContext();
        IQueryable<Foo> query_3 = dbContext_3.FooDbSet.Include(q => q.Translate);
        Foo foo_3 = await query_3.FirstAsync();
        FooDto dto_3 = mapperWithBeforeMap.Map<FooDto>(foo_3, 
            options => options.Items["culture"] = "fr");
        await dbContext_3.DisposeAsync();

        //dto_3.Name = "FooFr" - working (hack)


        // 4. Map WITHOUT Include and BeforeMap
        FooContext dbContext_4 = CreateContext();
        IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
        Foo foo_4 = await query_4.FirstAsync();
        FooDto dto_4 = mapperWithBeforeMap.Map<FooDto>(foo_4, 
            options => options.Items["culture"] = "fr");
        await dbContext_4.DisposeAsync();

        //dto_4.Name = "Foo" - working


        // 5. Map With Include and BeforeMap not exists translate
        string notExistsCulture = "ua";

        FooContext dbContext_5 = CreateContext();
        IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
        Foo foo_5 = await query_5.FirstAsync();
        FooDto dto_5 = mapperWithBeforeMap.Map<FooDto>(foo_5, 
            options => options.Items["culture"] = notExistsCulture);
        await dbContext_5.DisposeAsync();

        //dto_5.Name = "Foo" - working
    }


    // One solution is to use AsQueryable and Projection always,
    // even in memory, so that the closure works.
    // But, in my opinion, this is also a hack,
    // not a solution and it has its own problems
    public static async Task Test3()
    {
        MapperConfiguration mapperConfiguration = 
            new MapperConfiguration(
                expression =>
                {
                    expression.AddProfile(new FooProfile());
                });
        IMapper mapper = mapperConfiguration.CreateMapper();


        // 1. Projection
        FooContext dbContext_1 = CreateContext();
        IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
        IQueryable<FooDto> queryProjection_1 = 
            mapper.ProjectTo<FooDto>(query_1, new { culture = "fr" });
        FooDto dto_1 = await queryProjection_1.FirstAsync();
        await dbContext_1.DisposeAsync();

        // dto_1.Name = "FooFr" - working


        // 2. Map with Include
        FooContext dbContext_2 = CreateContext();
        IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
        List<Foo> list_2 = await query_2.ToListAsync();
        IQueryable<FooDto> queryProjection_2 = 
            mapper.ProjectTo<FooDto>(list_2.AsQueryable(), new { culture = "fr" });
        FooDto dto_2 = queryProjection_2.First();
        await dbContext_2.DisposeAsync();

        //dto_2.Name = "FooFr" - working


        // 3. Map with Include and BeforeMap
        // this test is not needed here


        // 4. Map WITHOUT Include
        FooContext dbContext_4 = CreateContext();
        IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
        List<Foo> list_4 = await query_4.ToListAsync();
        IQueryable<FooDto> queryProjection_4 = 
            mapper.ProjectTo<FooDto>(list_4.AsQueryable(), new { culture = "fr" });
        try
        {
            // in this case, AsQueryable is still executed in memory, not on the DB side,
            // so WITHOUT checking for Null in expression we get a NullReferenceException
            FooDto dto_4 = queryProjection_4.First();
        }
        catch (Exception ex)
        {
        }

        await dbContext_4.DisposeAsync();

        //Exception - NOT working


        // 5. Map With Include and BeforeMap not exists translate
        string notExistsCulture = "ua";

        FooContext dbContext_5 = CreateContext();
        IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
        List<Foo> list_5 = await query_5.ToListAsync();
        IQueryable<FooDto> queryProjection_5 = 
            mapper.ProjectTo<FooDto>(list_5.AsQueryable(), new { culture = notExistsCulture });
        try
        {
            // in this case, AsQueryable is still executed in memory, not on the DB side,
            // so WITHOUT checking for Null in expression we get a NullReferenceException
            FooDto dto_5 = queryProjection_5.First();
        }
        catch (Exception ex)
        {
        }

        await dbContext_5.DisposeAsync();

        //Exception - NOT working
    }



    // The only possible solution that came to my mind is to create an object inherited
    // from Dto and create a separate profile for it and use it
    // ONLY for Projection, and use the main profile only in memory.
    // This works.
    // But it is also far from ideal,
    // you must always remember that one Dto should be used
    // only in memory, and the other only in Projection.
    // Also perform transformations from FooDtoForProjection => FooDto.
    // Also support both profiles when making changes to the DB model
    public static async Task Test4()
    {
        MapperConfiguration mapperConfiguration = new MapperConfiguration(expression =>
        {
            expression.AddProfile(new FooProfileForMemory());
            expression.AddProfile(new FooProfileForProjection());
        });
        IMapper mapper = mapperConfiguration.CreateMapper();


        // 1. Projection
        FooContext dbContext_1 = CreateContext();
        IQueryable<Foo> query_1 = dbContext_1.FooDbSet;
        IQueryable<FooDtoForProjection> queryProjection_1 =
            mapper.ProjectTo<FooDtoForProjection>(query_1, new { culture = "fr" });
        FooDto dto_1 = await queryProjection_1.FirstAsync();
        await dbContext_1.DisposeAsync();

        // dto_1.Name = "FooFr" - Working


        // 2. Map with Include
        FooContext dbContext_2 = CreateContext();
        IQueryable<Foo> query_2 = dbContext_2.FooDbSet.Include(q => q.Translate);
        Foo foo_2 = await query_2.FirstAsync();
        FooDto dto_2 = 
            mapper.Map<FooDto>(foo_2, options => options.Items["culture"] = "fr");
        await dbContext_2.DisposeAsync();

        // dto_2.Name = "FooFr" - Working


        // 3. Map with Include and BeforeMap
        // this test is not needed here


        // 4. Map WITHOUT Include
        FooContext dbContext_4 = CreateContext();
        IQueryable<Foo> query_4 = dbContext_4.FooDbSet;
        Foo foo_4 = await query_4.FirstAsync();
        FooDto dto_4 = 
            mapper.Map<FooDto>(foo_4, options => options.Items["culture"] = "fr");
        await dbContext_4.DisposeAsync();

        //dto_4.Name = "Foo" - working


        // 5. Map With Include not exists translate
        string notExistsCulture = "ua";

        FooContext dbContext_5 = CreateContext();
        IQueryable<Foo> query_5 = dbContext_5.FooDbSet.Include(q => q.Translate);
        Foo foo_5 = await query_5.FirstAsync();
        FooDto dto_5 =
            mapper.Map<FooDto>(foo_5, options => options.Items["culture"] = notExistsCulture);
        await dbContext_5.DisposeAsync();

        //dto_5.Name = "Foo" - working
    }
}

public class FooProfile : Profile
{
    public FooProfile()
    {
        string? culture = null;

        CreateMap<Foo, FooDto>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom(src =>
                    src.Translate.Any(q => q.Culture == culture && q.Name != null)
                        ? src.Translate.First(q => q.Culture == culture).Name
                        : src.Name)
            );
    }
}

public class FooProfileWithBeforeMap : Profile
{
    public FooProfileWithBeforeMap()
    {
        string? culture = null;

        CreateMap<Foo, FooDto>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom(src => 
                    src.Translate.FirstOrDefault(q => q.Culture == culture).Name 
                    ?? src.Name))
            .BeforeMap((foo, dto, context) =>
            {
                culture = context.Items["culture"]?.ToString(); 
            });
    }
}

public class FooProfileWithUseFunc : Profile
{
    public FooProfileWithUseFunc()
    {
        string? culture = null;

        CreateMap<Foo, FooDto>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom((src, dest) =>
                {
                    return src.Translate.FirstOrDefault(q => q.Culture == culture)?.Name 
                           ?? src.Name;
                }));
    }
}

public class FooProfileUseFuncWithBeforeMap : Profile
{
    public FooProfileUseFuncWithBeforeMap()
    {
        string? culture = null;

        CreateMap<Foo, FooDto>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom((src, dest) =>
                {
                    return src.Translate.FirstOrDefault(q => q.Culture == culture)?.Name 
                           ?? src.Name;
                }))
            .BeforeMap((foo, dto, context) =>
            {
                culture = context.Items["culture"]?.ToString();
            });
    }
}

public class Foo
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public List<FooTranslate> Translate { get; set; } = new();
}

public class FooTranslate
{
    public int Id { get; set; }
    public int FooId { get; set; }
    public string? Culture { get; set; }
    public string? Name { get; set; }
    public string? Description { get; set; }
    public Foo? Foo { get; set; }
}

public class FooDto
{
    public string Name { get; set; } = null!;
}


public class FooDtoForProjection : FooDto
{
}


public class FooProfileForMemory : Profile
{
    public FooProfileForMemory()
    {
        CreateMap<Foo, FooDto>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom((src, dest, _, context) =>
                {
                    return src.Translate
                        .FirstOrDefault(q => 
                            q.Culture == context.Items["culture"]?.ToString())?.Name 
                           ?? src.Name;
                }));
    }
}

public class FooProfileForProjection : Profile
{
    public FooProfileForProjection()
    {
        string? culture = null;

        CreateMap<Foo, FooDtoForProjection>()
            .ForMember(
                desc => desc.Name,
                opt => opt.MapFrom(src => 
                    src.Translate.FirstOrDefault(q => q.Culture == culture).Name 
                    ?? src.Name));
    }
}

public class FooContext : DbContext
{
    public FooContext(DbContextOptions options) : base(options)
    {
    }

    public DbSet<Foo> FooDbSet { get; set; } = null!;
    public DbSet<FooTranslate> FooTranslateDbSet { get; set; } = null!;
}
c# entity-framework-core automapper .net-7.0 automapper-13
1个回答
0
投票

我会对我使用的方法表示敬意,供您考虑。在我的 ViewModels/DTO 中,我将自动映射器配置封装在静态工厂方法中。它使映射保持井井有条,因为我讨厌筛选巨大的映射配置文件。

例如在 PositionSummaryViewModel 中,表示 Position 实体的摘要:

public static MapperConfigurationExpression BuildConfigExpression(MapperConfigurationExpression? configExpression = null)
{
    if (configExpression == null)
       configExpression = new MapperConfigurationExpression();

    configExpression.CreateMap<Position, PositionSummaryViewModel>();
    // append any ForMember specific details.
    return configExpression;
}
public static MapperConfiguration BuildConfig(MapperConfigurationExpression? configExpression = null)
{
    var config = new MapperConfiguration(BuildConfigExpression(configExpression));
    return config;
}

“BuildConfigExpression”可用,以便视图模型可以链接调用来构建单个配置表达式,以反映它们包含的任何相关视图模型。准备

ProjectTo<T>()
的调用者只需要在顶层视图模型/DTO上调用“BuildConfig”。

然后,例如,当我有一个类似 EmployeeDetailViewModel 的东西,其中包含一系列职位(SummaryVM)时,那么它的构建配置将链接该职位(以及它也可能填充的任何其他视图模型):

public static MapperConfigurationExpression BuildConfigExpression(MapperConfigurationExpression? configExpression = null)
{
    if (configExpression == null)
       configExpression = new MapperConfigurationExpression();

    configExpression = PositionSummaryViewModel.BuildConfigExpression(configExpression);

    configExpression.CreateMap<Employee, EmployeeDetailViewModel>()
    // append any ForMember specific details.
        .ForMember(x => x.Positions, opt => opt.MapFrom(src => src.Positions
            .OrderByDescending(p => p.DateStarted));
    return configExpression;
}
public static MapperConfiguration BuildConfig(MapperConfigurationExpression? configExpression = null)
{
    var config = new MapperConfiguration(BuildConfigExpression(configExpression));
    return config;
}

当代码想要构建 EmployeeDetailViewModel 的投影时,它会从 BuildConfig() 调用中获取 Automapper 配置,其中每个视图模型负责链接它包含的任何其他视图模型。 (BuildConfigExpression 将追加或创建)

因此,获取员工详细视图模型的查询如下所示:

var employeeVM = await _context.Employees
    .Where(x => x.EmployeeId == employeeId)
    .ProjectTo<EmployeeDetailConfig>(EmployeeDetailViewModel.BuildConfig())
    .SingleAsync();
return View(employeeVM);

我只使用映射进行投影,而不是

Map()
。当涉及到从内存中实体<->视图模型中继更改时,我更喜欢使用显式设置器而不是
Map<src>()
Map<src,dest>()
。这些映射表达式仍然有效,并且可以包括返回到相关实体的映射,前提是相关数据已预先加载。不过,我使用显式设置器,因为它在检查代码以查找值何时更改时提供了更多可见性。
Map()
隐藏修改点,否则这些修改点会通过“查找引用”显示出来。在大多数情况下,更新/插入之类的事情是通过操作请求视图模型完成的,而不是通过其他方式发送的投影。

© www.soinside.com 2019 - 2024. All rights reserved.