AutoMapper Version=13.0.1
Microsoft.EntityFrameworkCore Version=7.0.20
Microsoft.EntityFrameworkCore.InMemory Version=7.0.20
Expected behavior I would like to create one profile and combine the work for one DTO in memory and in the projection in the DB. Taking into account the specific requirements of queries from the translation DB. I have some problems, you can see more details in the reproduction steps. There are detailed comments on the current and expected behavior, as well as possible solutions.
Test1() - Basic profile setup and identification of the problems I encountered. This profile works well in ProjectTo, but does not work well in memory with Map, since there are no Null checks in Expression
Test2() - Works well in memory Map, but does not work in ProjectTo. The reasons are clear - the use of Func, which is not an Expression and cannot be translated into SQL
Test3() - 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
Test4() - 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 to FooDto. Also support both profiles when making changes to the DB model.
What is the most preferable solution to such a problem? From my thoughts, I would like to create 2 maps for the same pair of Source and Destination, so that one map would be used for working in memory, and the other in Projection. But Automapper does not allow this. Or, if it were possible to make 2 separate MapFroms. For example, opt.MapFromForMemory and opt.MapFromForProjection. Yes, indeed, Automapper covers 99% of the necessary work, but I got into that 1% that it does not cover and so far I do not see a good and beautiful solution. Help me with advice!
PS I understand that checks can be added to Expression, but if there are many languages and fields to translate, this will significantly increase the size and complexity of the resulting SQL query
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!;
}
As a result, I came to the only solution. If you encounter such a situation, the easiest way is to create 2 mappers, one for Projection and one for Map in memory. Automapper does not provide any other way out.
Although the Automapper developers suggested creating 2 different models and inheriting, this is not always a good solution, especially if there is a tree structure, like mine