在 .NET 中使用 Entity Framework Core 时,如何重用 .Include.Where() 调用中的逻辑?

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

我正在 .NET 中使用 LINQ 编写数据库查询,并且我希望能够不重复我在

Where
方法调用中放入的代码。

我想返回有新鲜

Blogs
Comments
,并且想同时过滤
Blogs
Comments
(所以,没有没有评论的博客,也没有旧的评论)

这需要我写一些类似的东西:

ctx.Blogs.Include(blog => blog.Comments.Where(comment => comment.Created < dateTime))
    .Where(blog => blog.Comments.Any(comment => comment.Created < dateTime))
    .Select(b => new BlogEntryDTO(b));

注意,

comment => comment.Created < dateTime)
是如何完全相同的。

(当然,这是一个玩具示例,真正的查询有一个更复杂的过滤器)

我做了显而易见的事情,并尝试将过滤器提取为

Expression

public static IQueryable<BlogEntryDTO> GetBlogsExpression(MyContext ctx, DateTime dateTime)
{
    Expression<Func<Comment, bool>> inTime = comment => comment.Created < dateTime;

    return ctx
        .Blogs.Include(blog => blog.Comments.Where(inTime))
        .Where(blog => blog.Comments.Any(inTime))
        .Select(b => new BlogEntryDTO(b));
    }

但这会产生编译时错误:

Cannot resolve method 'Where(Expression<Func<Comment,bool>>)', candidates are:
IEnumerable<Comment> Where<Comment>(this IEnumerable<Comment>, Func<Comment,bool>) (in class Enumerable)
IEnumerable<Comment> Where<Comment>(this IEnumerable<Comment>, Func<Comment,int,bool>) (in class Enumerable)

这听起来像是它想要

Func
,而不是表达,所以我尝试这样做:

public static IQueryable<BlogEntryDTO> GetBlogsFunction(MyContext ctx, DateTime dateTime)
{
    Func<Comment, bool> inTime = comment => comment.Created < dateTime;

    return ctx
        .Blogs.Include(blog => blog.Comments.Where(inTime))
        .Where(blog => blog.Comments.Any(inTime))
        .Select(b => new BlogEntryDTO(b));
}

可以编译,但会产生运行时错误:

Unhandled exception.
ArgumentException: Expression of type 'Func`[Comment,Boolean]' cannot be used for parameter of type 'Expression`[Func`[Comment,Boolean]]' of method 'IQueryable`[Comment] Where[Comment](IQueryable`1[Comment], Expression`1[Func`2[Comment,Boolean]])' (Parameter 'arg1')
    at Dynamic.Utils.ExpressionUtils.ValidateOneArgument(MethodBase method, ExpressionType nodeKind, Expression arguments, ParameterInfo pi, String methodParamName, String argumentParamName, Int32 index)
    ...

毫不奇怪,它基本上不知道如何将

Func
转换为SQL。

此后我陷入困境。

这主要是在 .NET 中使用 Entity Framework Core 时如何重用Where调用中的逻辑?,但我在评论中被要求重新发布我自己的失败查询,所以我们开始吧。

完整的可运行代码示例:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Linq.Expressions;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp1;

internal class Program
{
    private static void Main(string[] args)
    {
        using var ctx = MyContext.MakeInMemoryContext();

        var blogs = GetBlogsInlined(ctx, DateTime.Today).ToList();
        Console.WriteLine(JsonSerializer.Serialize(blogs));
        var blogs2 = GetBlogsExpression(ctx, DateTime.Today).ToList();
        Console.WriteLine(JsonSerializer.Serialize(blogs2));
        var blogs3 = GetBlogsFunction(ctx, DateTime.Today).ToList();
        Console.WriteLine(JsonSerializer.Serialize(blogs3));
    }

    public static IQueryable<BlogEntryDTO> GetBlogsInlined(MyContext ctx, DateTime dateTime)
    {
        return ctx
            .Blogs.Include(blog => blog.Comments.Where(comment => comment.Created < dateTime))
            .Where(blog => blog.Comments.Any(comment => comment.Created < dateTime))
            .Select(b => new BlogEntryDTO(b));
    }

    // Compile time error:
    // Cannot resolve method 'Where(Expression<Func<Comment,bool>>)', candidates are:
    // IEnumerable<Comment> Where<Comment>(this IEnumerable<Comment>, Func<Comment,bool>) (in class Enumerable)
    // IEnumerable<Comment> Where<Comment>(this IEnumerable<Comment>, Func<Comment,int,bool>) (in class Enumerable)
    public static IQueryable<BlogEntryDTO> GetBlogsExpression(MyContext ctx, DateTime dateTime)
    {
        Expression<Func<Comment, bool>> inTime = comment => comment.Created < dateTime;

        return ctx
            .Blogs.Include(blog => blog.Comments.Where(inTime))
            .Where(blog => blog.Comments.Any(inTime))
            .Select(b => new BlogEntryDTO(b));
    }

    // Runtime error:
    // Unhandled exception.
    // ArgumentException: Expression of type 'Func`[Comment,Boolean]' cannot be used for parameter of type 'Expression`[Func`[Comment,Boolean]]' of method 'IQueryable`[Comment] Where[Comment](IQueryable`1[Comment], Expression`1[Func`2[Comment,Boolean]])' (Parameter 'arg1')
    // at Dynamic.Utils.ExpressionUtils.ValidateOneArgument(MethodBase method, ExpressionType nodeKind, Expression arguments, ParameterInfo pi, String methodParamName, String argumentParamName, Int32 index)

    public static IQueryable<BlogEntryDTO> GetBlogsFunction(MyContext ctx, DateTime dateTime)
    {
        Func<Comment, bool> inTime = comment => comment.Created < dateTime;

        return ctx
            .Blogs.Include(blog => blog.Comments.Where(inTime))
            .Where(blog => blog.Comments.Any(inTime))
            .Select(b => new BlogEntryDTO(b));
    }

    public class MyContext(DbContextOptions<MyContext> options) : DbContext(options)
    {
        public DbSet<BlogEntry> Blogs { get; set; }
        public DbSet<Comment> Comments { get; set; }

        public static MyContext MakeInMemoryContext()
        {
            var builder = new DbContextOptionsBuilder<MyContext>().UseInMemoryDatabase("context");
            var ctx = new MyContext(builder.Options);
            ctx.Database.EnsureDeleted();
            ctx.Database.EnsureCreated();
            ctx.SetupBlogs();
            return ctx;
        }

        private void SetupBlogs()
        {
            Blogs.AddRange(
                [
                    new BlogEntry
                    {
                        Name = "1",
                        Created = DateTime.Now.AddDays(-3),
                        Comments =
                        [
                            new Comment { Content = "c1", Created = DateTime.Now.AddDays(-2) },
                            new Comment { Content = "c2", Created = DateTime.Now.AddDays(-1) }
                        ]
                    },
                    new BlogEntry
                    {
                        Name = "2",
                        Created = DateTime.Now.AddDays(-2),
                        Comments = [new Comment { Content = "c3", Created = DateTime.Now.AddDays(-1) }]
                    },
                    new BlogEntry { Name = "2", Created = DateTime.Now.AddDays(-1), Comments = [] }
                ]
            );
            SaveChanges();
        }
    }

    public class BlogEntry
    {
        [Key]
        public int Id { get; set; }
        public string Name { get; set; }
        public DateTime Created { get; set; }
        public virtual ICollection<Comment> Comments { get; set; }
    }

    public class Comment
    {
        [Key]
        public int Id { get; set; }
        public string Content { get; set; }
        public DateTime Created { get; set; }
        public int BlogEntryId { get; set; }
        public virtual BlogEntry BlogEntry { get; set; }
    }

    public class BlogEntryDTO(BlogEntry blogEntry)
    {
        public int Id { get; set; } = blogEntry.Id;
        public string Name { get; set; } = blogEntry.Name;
        public string[] Comments { get; set; } = blogEntry.Comments.Select(c => c.Content).ToArray();
    }
}

.csproj:

<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <LangVersion>latest</LangVersion>
    </PropertyGroup>
    <ItemGroup>
        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.6" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.6" />
        <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.6">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
            <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
            <PrivateAssets>all</PrivateAssets>
        </PackageReference>
    </ItemGroup>
</Project>
.net linq entity-framework-core linq-expressions ef-core-8.0
1个回答
0
投票

表达式树表示“树状数据结构中的代码”。 与委托(代表已编译的方法)不同,语言或运行时中没有内置运算符来组合、组合、柯里化或非柯里化表达式树,因此,如果您想将您的

inTime
表达式包含在其他表达式中,您可以需要通过创建修改后的表达式树来手动执行此操作。

问题的答案我可以重用代码来使用 EF Core 为子属性选择自定义 DTO 对象吗?显示了几种允许进行表达式树编辑的工具,包括内置类型

ExpressionVisitor
。 因此,如果您想将
inTime
表达式注入到一些更复杂的表达式中,请首先创建以下扩展方法(改编自 here):

public static partial class ExpressionExtensions
{
    public static Expression<Func<T1, TResult>> InjectInto<T1, T2, T3, TResult>(this Expression<Func<T2, T3>> inner, Expression<Func<T1, Func<T2, T3>, TResult>> outer) =>
        outer.Inject(inner);

    // Uncurry and compose an Expression<Func<T1, Func<T2, T3>, TResult>> into an Expression<Func<T1, TResult>> by composing with an Expression<Func<T2, T3>>
    public static Expression<Func<T1, TResult>> Inject<T1, T2, T3, TResult>(this Expression<Func<T1, Func<T2, T3>, TResult>> outer, Expression<Func<T2, T3>> inner) =>
        Expression.Lambda<Func<T1, TResult>>(
            new InvokeReplacer((outer.Parameters[1], inner)).Visit(outer.Body), 
            false, outer.Parameters[0]);
}

class InvokeReplacer : ExpressionVisitor
{
    // Replace an Invoke() with the body of a lambda, replacing the formal paramaters of the lambda with the arguments of the invoke.
    // TODO: Handle replacing of functions that are not invoked but just passed as parameters to some external method, e.g.
    //   collection.Select(map) instead of collection.Select(i => map(i))
    readonly Dictionary<Expression, LambdaExpression> funcsToReplace;
    public InvokeReplacer(params (Expression func, LambdaExpression replacement) [] funcsToReplace) =>
        this.funcsToReplace = funcsToReplace.ToDictionary(p => p.func, p => p.replacement);
    protected override Expression VisitInvocation(InvocationExpression invoke) => 
        funcsToReplace.TryGetValue(invoke.Expression, out var lambda)
        ? (invoke.Arguments.Count != lambda.Parameters.Count
            ? throw new InvalidOperationException("Wrong number of arguments")
            : new ParameterReplacer(lambda.Parameters.Zip(invoke.Arguments)).Visit(lambda.Body))
        : base.VisitInvocation(invoke);
}   

class ParameterReplacer : ExpressionVisitor
{
    // Replace formal parameters (e.g. of a lambda body) with some containing expression in scope.
    readonly Dictionary<ParameterExpression, Expression> parametersToReplace;
    public ParameterReplacer(params (ParameterExpression parameter, Expression replacement) [] parametersToReplace) 
        : this(parametersToReplace.AsEnumerable()) { }
    public ParameterReplacer(IEnumerable<(ParameterExpression parameter, Expression replacement)> parametersToReplace) =>
        this.parametersToReplace = parametersToReplace.ToDictionary(p => p.parameter, p => p.replacement);
    protected override Expression VisitParameter(ParameterExpression p) => 
        parametersToReplace.TryGetValue(p, out var e) ? e : base.VisitParameter(p);
}

现在您可以重写您的

GetBlogsExpression()
方法,如下所示:

public static IQueryable<BlogEntryDTO> GetBlogsExpression(MyContext ctx, DateTime dateTime)
{
    Expression<Func<Comment, bool>> inTime = comment => comment.Created < dateTime;

    return ctx
        .Blogs
        .Include(inTime.InjectInto((BlogEntry b, Func<Comment, bool> f) => b.Comments.Where(c => f(c)))) // Injecting inTime into b.Comments.Where(f) so use c => f(c) instead
        .Where(inTime.InjectInto((BlogEntry b, Func<Comment, bool> f) => b.Comments.Any(c => f(c))))
        .Select(b => new BlogEntryDTO(b));
}

备注:

  • 您的

    GetBlogsFunction()
    失败,因为 EF Core 无法将表达式内的任意
    Invoke()
    调用(特别是
    intime(Comment c)
    的调用)转换为对底层数据库引擎的查询。

  • 您的

    GetBlogsExpression()
    无法编译,因为如前所述,没有构建表达式的内置功能。 当您尝试这样做时,会导致类型推断出错,最终导致编译器消息混乱。

演示小提琴这里

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