自定义 LINQ GroupBy 帮助程序出现错误,并显示“此函数只能从 LINQ 调用到实体”

本文关键字:LINQ 函数 实体 调用 显示 帮助程序 GroupBy 错误 自定义 | 更新日期: 2023-09-27 18:20:57

我正在尝试使GroupBy助手重载以简化常见的报告案例: 按日期(日、周、月、年等(分组

这是我的代码:

IEnumerable<TResult> GroupByDate<TSource, TResult>(IEnumerable<TSource> source,
    DateTime startDate,
    DateTime endDate,
    ReportDateType dateType,
    Func<TSource, System.DateTime?> keySelector,
    Func<ReportDate, IEnumerable<TSource>, TResult> resultSelector) where TResult: ReportRow, new()
{
    var results = from x in source
                    group x by
                    (dateType == ReportDateType.Day ? new ReportDate() { Year = keySelector(x).Value.Year, Month = keySelector(x).Value.Month, Week = SqlFunctions.DatePart("week", keySelector(x)) ?? 0, Day = keySelector(x).Value.Day } :
                    dateType == ReportDateType.Week ? new ReportDate() { Year = keySelector(x).Value.Year, Month = keySelector(x).Value.Month, Week = SqlFunctions.DatePart("week", keySelector(x)) ?? 0, Day = 0 } :
                    dateType == ReportDateType.Month ? new ReportDate() { Year = keySelector(x).Value.Year, Month = keySelector(x).Value.Month, Week = 0, Day = 0 } :
                    new ReportDate() { Year = keySelector(x).Value.Year, Month = 0, Week = 0, Day = 0 }) into g
                    orderby g.Key
                    select resultSelector(g.Key, g);
    var resultsGap = new List<TResult>();
    var currentDate = startDate;
    while (currentDate <= endDate)
    {
        var reportRow = (TResult)Activator.CreateInstance(typeof(TResult));
        if (dateType == ReportDateType.Day)
        {
            reportRow.Date = new ReportDate() { Year = currentDate.Year, Month = currentDate.Month, Week = currentDate.GetIso8601WeekOfYear(), Day = currentDate.Day };
            currentDate = currentDate.AddDays(1);
        }
        else if (dateType == ReportDateType.Week)
        {
            reportRow.Date = new ReportDate() { Year = currentDate.Year, Month = currentDate.Month, Week = currentDate.GetIso8601WeekOfYear() };
            currentDate = currentDate.AddDays(7);
        }
        else if (dateType == ReportDateType.Month)
        {
            reportRow.Date = new ReportDate() { Year = currentDate.Year, Month = currentDate.Month };
            currentDate = currentDate.AddMonths(1);
        }
        else if (dateType == ReportDateType.Year)
        {
            reportRow.Date = new ReportDate() { Year = currentDate.Year };
            currentDate = currentDate.AddYears(1);
        }
        resultsGap.Add(reportRow);
    }
    return results.ToList().Union(resultsGap, new ReportRowComparer<TResult>()).OrderBy(o => o.Date);
}

示例用例:

var results = GroupByDate(db.Orders, startDate.Value, endDate.Value, ReportDateType.Week, k => k.DateOrdered,
    (k, g) => new RevenueReportRow() { Date = k, Revenue = g.Sum(i => i.Total), Cost = g.Sum(i => i.Cost), Profit = g.Sum(i => i.Total) - g.Sum(i => i.Cost) })

我遇到了异常:

System.NotSupportedException was unhandled by user code
  HResult=-2146233067
  Message=This function can only be invoked from LINQ to Entities.
  Source=EntityFramework.SqlServer
  StackTrace:
       at System.Data.Entity.SqlServer.SqlFunctions.DatePart(String datePartArg, Nullable`1 date)

上面的代码在不是辅助方法时有效,当我用 Week = 0 替换Week = SqlFunctions.DatePart("week", keySelector(x)) ?? 0时有效,但总数是错误的......

我发现了一个类似的问题,它解决了使用谓词参数创建帮助程序的问题。不幸的是,我无法弄清楚如何应用相同的方法来指定group by值。

编辑

对于后代,我已经完成了帮助程序,它在这里可用:https://gist.github.com/albertbori/4944fd0e78534782cae2

自定义 LINQ GroupBy 帮助程序出现错误,并显示“此函数只能从 LINQ 调用到实体”

我真的很接近

不,你离得很远。

上面的代码在不是辅助方法时有效

这是因为帮助程序方法代码不等同于原始代码。主要区别在于原始代码在IQueryable<Source>上运行,而帮助程序方法代码 - 在IEnumerable<TSoure> 上运行。这是构建基块基元和执行行为的根本变化。前者在Expression<Func<..上运行,后者在Func<..上操作。前者构建并执行数据库查询,后者在内存中执行所有操作。有些函数仅适用于前者,有些函数仅适用于后者。简而言之,它们不能自由互换。

因此,为了使它们等效,您的帮助程序必须对 IQueryable<TSource> 进行操作,这反过来又需要参数Expression<...类型。

现在,您应该问自己的第一件事是帮助程序方法是否足够值得。使用表达式并不像使用函数那么容易,它需要对System.Linq.Expressions有很好的了解。此外,由于表达式必须由代码创建,因此不能再使用 LINQ 查询语法,因此您需要充分了解 Queryable 方法及其与 LINQ 语法构造的映射。

综上所述,并假设它是值得的,让我们看看我们如何解决具体问题。

(1( source参数类型变为IQueryable<TSource>

(2( keySelector参数类型变为Expression<Func<TSource, DateTime>>。请注意,我不使用DateTime?,您编写代码的方式暗示属性类型是DateTime,并且您使其可为空,只能调用SqlFunctions.DatePart。为了让生活更轻松,我们需要获得一个纯属性访问器,我们可以在需要时进行必要的转换。

(3( resultSelector参数类型变为Expression<Func<IGrouping<ReportDate, TSource>, TResult>>

(4(现在是棘手的部分-group by.我们需要一个表达式 将keySelector转换为Expression<Func<TSource, ReportDate>> .在这里,编码终于开始了:

static Expression<Func<TSource, ReportDate>> ToReportDate<TSource>(Expression<Func<TSource, DateTime>> keySelector, ReportDateType dateType)
{
    var source = keySelector.Parameters[0];
    var member = keySelector.Body;
    var year = Expression.Property(member, "Year");
    var month = dateType == ReportDateType.Day || dateType == ReportDateType.Week || dateType == ReportDateType.Month ? (Expression)
        Expression.Property(member, "Month") :
        Expression.Constant(0);
    var week = dateType == ReportDateType.Day || dateType == ReportDateType.Week ? (Expression)
        Expression.Convert(Expression.Call(typeof(SqlFunctions), "DatePart", null,
        Expression.Constant("week"), Expression.Convert(member, typeof(DateTime?))
        ), typeof(int)) :
        Expression.Constant(0);
    var day = dateType == ReportDateType.Day ? (Expression)
        Expression.Property(member, "Day") :
        Expression.Constant(0);
    var dateSelector = Expression.Lambda<Func<TSource, ReportDate>>(
        Expression.MemberInit(
            Expression.New(typeof(ReportDate)),
            Expression.Bind(typeof(ReportDate).GetProperty("Year"), year),
            Expression.Bind(typeof(ReportDate).GetProperty("Month"), month),
            Expression.Bind(typeof(ReportDate).GetProperty("Week"), week),
            Expression.Bind(typeof(ReportDate).GetProperty("Day"), day)
        ), source);
    return dateSelector;
}

如果您不理解上面的代码,请再次阅读"值得"和"所需知识"部分。

最后,帮助程序方法变成这样

static IEnumerable<TResult> GroupByDate<TSource, TResult>
(
    IQueryable<TSource> source,
    DateTime startDate,
    DateTime endDate,
    ReportDateType dateType,
    Expression<Func<TSource, DateTime>> keySelector,
    Expression<Func<IGrouping<ReportDate, TSource>, TResult>> resultSelector
)
where TResult : ReportRow, new()
{
    var results = source
        .GroupBy(ToReportDate(keySelector, dateType))
        .OrderBy(g => g.Key)
        .Select(resultSelector);
    // the rest of the code
}

和示例用法

var results = GroupByDate(db.Orders, startDate.Value, endDate.Value, ReportDateType.Week, k => k.DateOrdered,
    g => new RevenueReportRow() { Date = g.Key, Revenue = g.Sum(i => i.Total), Cost = g.Sum(i => i.Cost), Profit = g.Sum(i => i.Total) - g.Sum(i => i.Cost) });

因为您将输入和输出声明为 IEnumerable<T>所以调用该方法会使您脱离实体框架的上下文,进入 LINQ to Objects 世界,在该环境中,查询在内存中运行。在该内存中查询中,不能再使用 SqlFunctions

可以将输入和输出从 IEnumerable<T> 更改为IQueryable<T>,从Func<T>更改为Expresion<Func<T>>,以使其成为实体框架查询的一部分,但方法使用的内容 EF 将无法转换为正确的 SQL 查询(Activator.CreateInstancewhile循环等(,因此它也不起作用。你只会得到一个不同的错误。

您现在有两种方法可以解决问题:

  • 停止使用 SqlFunctions 并使其成为正确的 LINQ to 对象内存中查询
  • 将输入/输出参数更改为使用 IQueryable<T>Expression<> 而不是 IEnumerable<T> and Func<>',并使 EF 可以将逻辑转换为 SQL。

请注意,如果您选择第一个选项并执行

var results = GroupByDate(db.Orders, startDate.Value, endDate.Value, ReportDateType.Week, k => k.DateOrdered,
    (k, g) => new RevenueReportRow() { Date = k, Revenue = g.Sum(i => i.Total), Cost = g.Sum(i => i.Cost), Profit = g.Sum(i => i.Total) - g.Sum(i => i.Cost) })

它会将整个Orders表放入应用程序的内存中,并将分组作为 LINQ to Objects 查询运行。这几乎是100%的情况下,这不是你想要的。