efcore icon indicating copy to clipboard operation
efcore copied to clipboard

Using projection with nested expressions.

Open boppercr opened this issue 1 year ago • 1 comments

Is there a way to still use projections with nested expression?

Here's some additionnal information. I have an entity that has one or many DTOs of it. Theses DTOs contain other DTOs from other entities, say a Blog has a Category which in turn has a SubCategory. I created a Mapper that works really well when there's only a few layers that looks like this :

public static class BlogMapper
{
    public static Expression<Func<Blog, BlogDTO>> ToBlogDTO = x =>
        new Blog
        {
            //Other properties
            Category = CategoryMapper.ToCategoryDTO.Compile.Invoke(x.Category);
        }
}

public static class CategoryMapper
{
    public static Expression<Func<Category, CategoryDTO>> ToCategoryDTO = x =>
        new Category
        {
            //Other properties
            SubCategory = SubCategoryMapper.ToSubCategoryDTO.Compile.Invoke(x.SubCategory);
        }
}

public static class SubCategoryMapper
{
    public static Expression<Func<SubCategory, SubCategoryDTO>> ToSubCategoryDTO = x =>
        new SubCategory
        {
            //Other properties
        }
}

Then I can use it like the following :

_context.Blogs.Select(BlogMapper.ToBlogDTO).ToList();

The issue arises because the query created by EF Core doesn't contain the SubCategory table and therefor its value is null when trying to map.

It feels like the projection stops at a certain level and I'm not sure I understand why.

The main thing is that I wouldn't want the BlogMapper to take care of the Category and SubCategory mappings since I know I'll already have dedicated mappers for this exact purpose. I'm not gonna lie, I feel like I'm either missing something or that it "doesn't make sense" to go at it like that but I can't seem to find the alternative.

I've tried an approach with simple constructors and they won't work with projection as well. I even tried to create an ExpressionVisitor to "flatten" the resulting expression somehow, but it didn't work as well. I can provide the code if need be, but like I said, I feel like I'm gone too far in the wrong direction.

Include provider and version information

EF Core version: 8.0.10 Database provider: Microsoft.EntityFrameworkCore.SqlServer Target framework: .NET 8.0

boppercr avatar Oct 09 '24 18:10 boppercr

This issue is lacking enough information for us to be able to fully understand what is happening. Please attach a small, runnable project or post a small, runnable code listing that reproduces what you are seeing so that we can investigate.

roji avatar Oct 12 '24 15:10 roji

Sorry for the delay, I was away for a little time.

I added a small project to help understand better the issue. efcore-issue-34866.zip

Additionnal information

You can use the efcore-issue-34866.http file to test the API. The /current endpoint gives you the current behavior of EF Core when using expressions in the mapping.

You can see that the Expression<Func<Data, DTO>> is correctly translated to SQL but only to a single level.

The /expected endpoint gives you the expected behavior of EF Core when using expressions in the mapping which is the result when mapping manually.

The main goal is to not have to repeat the mapping behavior everytime it happens to be nested.

boppercr avatar Oct 22 '24 15:10 boppercr

Does the code that I gave is sufficient to understand the issue or do you require more information?

boppercr avatar Nov 15 '24 15:11 boppercr

@boppercr I unfortunately haven't gotten around to looking at this issue - with the .NET 9 release this week a lot of time went into that... As things stabilize we'll have more time to perform investigations - feel free to ping me again in a few weeks' time if there's still no answer, and thanks for your patience!

roji avatar Nov 15 '24 19:11 roji

In your lambda:

public static Expression<Func<ClassProgram, ProgramDTO>> ToProgramDTO => program => new ProgramDTO  
{  
    Id = program.Id,  
    Session = SessionMapper.ToSessionDTO.Compile()(program.Session)  
    //Posts = blog.Posts.Select(PostMapper.ToPostDTO).ToList()  
};

Compile() compiles the ToSessionDTO expression tree, returning a Func<Session,SessionID> - this is not an expression tree, but runnable .NET code, just as if you called a regular .NET function there. Since it's not an expression tree, EF cannot see it and translate it to SQL, so it can't add the JOIN you're expecting. What's happening is that EF simply invokes your function, since ToProgramDTO is passed to the top-level select, so since the lambda isn't fully translatable, that part of it (the result of Compile()) is evaluated client-side, in .NET, and does not affect the SQL.

Composing/nesting expression trees can indeed be tricky. You can do this by doing manual expression tree construction via the Expression APIs (e.g. Expression.Lambda), at which point you can create any expression tree structure you want, including nesting. But that is quite an advanced API, and very easy to get wrong - I'd recommend staying away from it unless you really need to. I understand you're trying to nest expression trees as a way of reusing code, but in this instance you're probably better off simply doing a bit of repetition (i.e. in the way that your ToExpectedProgramDTO looks like).

I'll go ahead and close this as everything is working as expected, but if you have further questions or need more guidance, feel free to post back here.

roji avatar Nov 24 '24 11:11 roji

Thanks for the answer. The Expression API is indeed tricky and that's a reason why we wanted to have a simpler approach. I also noticed that using a constructor that receives the actual entity to perform the mapping isn't translated as well, but it probably the same issue you mentionned where it's client-side and not translatable to SQL?

boppercr avatar Nov 26 '24 14:11 boppercr

I'm not sure exactly to what you're referring to (a code sample is always best when asking a question), but yeah, that's probable.

roji avatar Nov 26 '24 14:11 roji

I adjusted the project to reflect what I was talking about. efcore-issue-34866-2.zip

boppercr avatar Nov 26 '24 14:11 boppercr

Yes, see https://github.com/dotnet/efcore/pull/32812.

roji avatar Nov 26 '24 15:11 roji

So, with EF 9.0, it'll handle the constructors recursively and therefor the scenario of the second project would be handled properly? Or did I understand that totally wrong?

boppercr avatar Nov 27 '24 18:11 boppercr

@boppercr AutoMapper doesn't seem to be in favour much these days but this is something it handles well, fwiw. You would configure it like this:

CreateProjection<Blog, BlogDTO>();
CreateProjection<Category, CategoryDTO>();
CreateProjection<SubCategory, SubCategoryDTO>();

And query like this:

_context.Blogs.ProjectTo<BlogDto>(mapper.ConfigurationProvider).ToList();

The simple CreateProjection calls would map based on names (but you can manually configure each property if necessary). Blog.Category would be auto-mapped to BlogDTO.Category; as these are different types, it would also incorporate the mapping you created between those types into the expression (i.e. Category to CategoryDTO), and so on.

stevendarby avatar Nov 28 '24 00:11 stevendarby

The main issue with the use of AutoMapper is the lack (from what i've seen using it) of compile time errors/feedback. I'd be curious to know how the projection of AutoMapper works, though and maybe inspire from it to do what we want.

boppercr avatar Nov 28 '24 15:11 boppercr