Mapster icon indicating copy to clipboard operation
Mapster copied to clipboard

Cannot CompileProjection for a mapping derived types between abstract classes with .Include()

Open Tommsy64 opened this issue 8 months ago • 9 comments

Description

When trying to create a mapping between abstract base classes and including their derived concrete implementations, Mapster throws an exception during CompileProjection(). There appears to be an issue in the expression tree generation.

Environment

  • Mapster version: 7.4.0
  • Mapster.EFCore 5.1.1
  • .NET version: net9.0
  • EF Core version: 9.0.4

I have tried Mapster 7.4.2-pre02 with net9.0 and net8.0 as well.

Steps to Reproduce

  1. Define the following class hierarchy:
public abstract class PocoA
{
    public int Id { get; set; }
}

public class PocoDerived : PocoA
{
    public int DerivedVal { get; set; }
}

public abstract class DtoA
{
    public int Id { get; set; }
}

public class DtoDerived : DtoA
{
    public int DerivedVal { get; set; }
}
  1. Configure the mapping:
config.NewConfig<PocoA, DtoA>()
    .Include<PocoDerived, DtoDerived>();
  
config.CompileProjection();

Alternatively, edit the WhenIncludeDerivedClasses test in Mapster.Tests so that the Compile() lines are CompileProjection();

Expected Behavior

The mapping should compile successfully, allowing for projection between these types.

Actual Behavior

An exception is thrown during CompileProjection():

Unhandled exception. Mapster.CompileException: Error while compiling
source=Backend.Services.Models.PocoA
destination=Backend.Services.Models.DtoA
type=Projection
 ---> System.InvalidCastException: Unable to cast object of type 'System.Linq.Expressions.UnaryExpression' to type 'System.Linq.Expressions.NewExpression'.
   at Mapster.Adapters.ClassAdapter.CreateInlineExpression(Expression source, CompileArgument arg)
   at Mapster.Adapters.BaseAdapter.CreateInlineExpressionBody(Expression source, CompileArgument arg)
   at Mapster.Adapters.BaseAdapter.CreateExpressionBody(Expression source, Expression destination, CompileArgument arg)
   at Mapster.Adapters.BaseAdapter.CreateAdaptFunc(CompileArgument arg)
   at Mapster.TypeAdapterConfig.CreateMapExpression(CompileArgument arg)
   --- End of inner exception stack trace ---
   at Mapster.TypeAdapterConfig.CreateMapExpression(CompileArgument arg)
   at Mapster.TypeAdapterConfig.CreateMapExpression(TypeTuple tuple, MapType mapType)
   at Mapster.TypeAdapterConfig.CreateProjectionCallExpression(TypeTuple tuple)
   at Mapster.TypeAdapterConfig.CompileProjection()

Tommsy64 avatar May 07 '25 20:05 Tommsy64

@Tommsy64 .Include to querytable not supported.

Projection is intended to create a query to the database. And at the moment of creating a query, we cannot know what derived type will be obtained from the database (if this is even possible).

Even if this can be done, the scope of application will be limited to Linq to Object cases (after loading from the database into memory).

DocSvartz avatar May 08 '25 01:05 DocSvartz

It is possible to know the type from within the queryable, this works:

class Address
{
  public int Id { get; set; }
  public string AddressLine { get; set; }
}

class DetailedAddress : Address
{
  public string HouseNumber { get; set; }
}

class AddressDto
{
  public int Id { get; set; }
  public string AddressLine { get; set; }
}

class DetailedAddressDto : AddressDto
{
  public string HouseNumber { get; set; }
}

// ....

var mappedDtos = await source.Select(x =>
    x.GetType() == typeof(DetailedAddress)
        ? new DetailedAddressDto { Id = x.Id, AddressLine = x.AddressLine, HouseNumber = ((DetailedAddress)x).HouseNumber }
        : new Address{ Id = x.Id, AddressLine = x.AddressLine }
).ToListAsync();

I don't know since when, but EFCore supports translating the .GetType() call.

zsr2531 avatar Jul 11 '25 07:07 zsr2531

I was able to get this working like so:

private static readonly LambdaExpression SimpleAddressProjection = TypeAdapterConfig
    .CreateMapExpression(new TypeTuple(typeof(Address), typeof(AddressDto)), MapType.Projection);
private static readonly LambdaExpression DetailedAddressProjection = TypeAdapterConfig
    .CreateMapExpression(new TypeTuple(typeof(DetailedAddress), typeof(DetailedAddressDto)), MapType.Projection);

public IQueryable<AddressDto> ProjectTo(IQueryable<Address> source)
{
    var param = Expression.Parameter(typeof(Address));
    var lambda = Expression.Lambda(Expression.Condition(
        Expression.TypeIs(param, typeof(DetailedAddress)),
        Expression.TypeAs(Expression.Invoke(DetailedAddressProjection, Expression.TypeAs(param, typeof(DetailedAddress))), typeof(AddressDto)),
        Expression.Invoke(SimpleAddressProjection, param)
    ), param);

    var selectExpr = CreateProjectionCallExpression<Address, AddressDto>(lambda);
    var finalExpr = Expression.Call(selectExpr.Method, source.Expression, selectExpr.Arguments[1]);

    return source.Provider.CreateQuery<AddressDto>(finalExpr);
}

// From Mapster
private MethodCallExpression CreateProjectionCallExpression<TSource, TDestination>(LambdaExpression lambda)
{
    var source = Expression.Parameter(typeof(IQueryable<>).MakeGenericType(typeof(TSource)));
    var methodInfo = (from method in typeof(Queryable).GetMethods()
                      where method.Name == nameof(Queryable.Select)
                      let p = method.GetParameters()[1]
                      where p.ParameterType.GetGenericArguments()[0].GetGenericTypeDefinition() == typeof(Func<,>)
                      select method).First().MakeGenericMethod(typeof(TSource), typeof(TDestination));
    return Expression.Call(methodInfo, source, Expression.Quote(lambda));
}

zsr2531 avatar Jul 11 '25 08:07 zsr2531

@zsr2531 If x.GetType() == typeof(DetailedAddress) it really is converted into a SQL query to the database. In this case adding is really possible.

Can you show the resulting SQL query?

DocSvartz avatar Jul 11 '25 08:07 DocSvartz

Sure:

select
	a."Discriminator" = 'DetailedAddress',
	a."AddressLine",
        a."HouseNumber"
from
	"Addresses" as a

It seems like they lower x.GetType() == ... to a discriminator column check.

zsr2531 avatar Jul 11 '25 08:07 zsr2531

Forgot to mention, but I am also on EFCore version 9.

zsr2531 avatar Jul 11 '25 08:07 zsr2531

It seems like they lower x.GetType() == ... to a discriminator column check.

Yes, if it can work, then only like this :)

DocSvartz avatar Jul 11 '25 08:07 DocSvartz

@zsr2531 It's a kind of filter.

Expression.TypeIs(param, typeof(DetailedAddress)), Expression.TypeAs(Expression.Invoke(DetailedAddressProjection, Expression.TypeAs(param, typeof(DetailedAddress))), typeof(AddressDto)),

But .Include works differently. Using the source type It create instance of destination type defined in the config.

DocSvartz avatar Jul 11 '25 09:07 DocSvartz

It seems like an idea of ​​how this can be done has appeared :) But it will be done, not quickly.

DocSvartz avatar Jul 11 '25 09:07 DocSvartz