如何在 LINQ to Entities 中重用字段过滤器

How to reuse a field filter in LINQ to Entities

我正在使用 EF 并有一个数据库 table,其中包含许多日期时间字段,这些字段会在对记录执行各种操作时填充。 我目前正在构建一个报告系统,该系统涉及按这些日期进行过滤,但是因为过滤器(这个日期在一个范围内,等等)在每个字段上都有相同的行为,所以我想重用我的过滤逻辑,所以我只编写一个日期过滤器并在每个字段上使用它。

我的初始过滤代码如下所示:

DateTime? dateOneIsBefore = null;
DateTime? dateOneIsAfter = null;

DateTime? dateTwoIsBefore = null;
DateTime? dateTwoIsAfter = null;

using (var context = new ReusableDataEntities())
{
    IEnumerable<TestItemTable> result = context.TestItemTables
        .Where(record => ((!dateOneIsAfter.HasValue
                || record.DateFieldOne > dateOneIsAfter.Value)
            && (!dateOneIsBefore.HasValue
                || record.DateFieldOne < dateOneIsBefore.Value)))
        .Where(record => ((!dateTwoIsAfter.HasValue
                || record.DateFieldTwo > dateTwoIsAfter.Value)
            && (!dateTwoIsBefore.HasValue
                || record.DateFieldTwo < dateTwoIsBefore.Value)))
        .ToList();

    return result;
}

这很好用,但我更愿意减少 'Where' 方法中的重复代码,因为每个日期字段的筛选算法都相同。

我更喜欢看起来像下面这样的东西(稍后我将为过滤器值创建一个 class 或结构),我可以在其中使用扩展方法封装匹配算法:

DateTime? dateOneIsBefore = null;
DateTime? dateOneIsAfter = null;

DateTime? dateTwoIsBefore = null;
DateTime? dateTwoIsAfter = null;

using (var context = new ReusableDataEntities())
{
    IEnumerable<TestItemTable> result = context.TestItemTables
        .WhereFilter(record => record.DateFieldOne, dateOneIsBefore, dateOneIsAfter)
        .WhereFilter(record => record.DateFieldTwo, dateTwoIsBefore, dateTwoIsAfter)
        .ToList();

    return result;
}

扩展方法可能类似于:

internal static IQueryable<TestItemTable> WhereFilter(this IQueryable<TestItemTable> source, Func<TestItemTable, DateTime> fieldData, DateTime? dateIsBefore, DateTime? dateIsAfter)
{
    source = source.Where(record => ((!dateIsAfter.HasValue
            || fieldData.Invoke(record) > dateIsAfter.Value)
        && (!dateIsBefore.HasValue
            || fieldData.Invoke(record) < dateIsBefore.Value)));

    return source;
}

使用上面的代码,如果我的过滤代码如下:

IEnumerable<TestItemTable> result = context.TestItemTables
    .WhereFilter(record => record.DateFieldOne, dateOneIsBefore, dateOneIsAfter)
    .WhereFilter(record => record.DateFieldTwo, dateTwoIsBefore, dateTwoIsAfter)
    .ToList();

我得到以下异常:

A first chance exception of type 'System.NotSupportedException' occurred in EntityFramework.SqlServer.dll

Additional information: LINQ to Entities does not recognize the method 'System.DateTime Invoke(RAC.Scratch.ReusableDataFilter.FrontEnd.TestItemTable)' method, and this method cannot be translated into a store expression.

我遇到的问题是使用 Invoke 获取被查询的特定字段,因为这种技术不能很好地解决 SQL,因为如果我将过滤代码修改为以下内容,它将 运行 没有错误:

IEnumerable<TestItemTable> result = context.TestItemTables
    .ToList()
    .AsQueryable()
    .WhereFilter(record => record.DateFieldOne, dateOneIsBefore, dateOneIsAfter)
    .WhereFilter(record => record.DateFieldTwo, dateTwoIsBefore, dateTwoIsAfter)
    .ToList();

这个问题是代码(在使用扩展方法过滤之前在整个 table 上使用 ToList)拉入整个数据库并将其作为对象查询而不是查询底层数据库,因此它是不可扩展。

我也研究过使用 Linqkit 中的 PredicateBuilder,但它找不到不使用 Invoke 方法编写代码的方法。

我知道有一些技术可以将部分查询表达为包含字段名称的字符串,但我更愿意使用一种类型更安全的方式来编写此代码。

此外,在理想情况下,我可以重新设计数据库,使多个 'date' 记录与单个 'item' 记录相关,但我不能随意以这种方式更改数据库模式.

我是否需要另一种方法来编写扩展,使其不使用 Invoke,或者我是否应该以不同的方式处理我的过滤代码的重用?

是的,LinqKit 是去这里的路。但是,您在扩展方法中遗漏了一些部分:

internal static IQueryable<TestItemTable> WhereFilter(this IQueryable<TestItemTable> source, Expression<Func<TestItemTable, DateTime>> fieldData, DateTime? dateIsBefore, DateTime? dateIsAfter)
{
    source = source.AsExpandable().Where(record => ((!dateIsAfter.HasValue
            || fieldData.Invoke(record) > dateIsAfter.Value)
            && (!dateIsBefore.HasValue
            || fieldData.Invoke(record) < dateIsBefore.Value)));

    return source;
}

我将第二个参数更改为 Expression<Func<TestItemTable, DateTime>> 并将缺少的调用添加到 LinqKit 的 AsExpandable() 方法。这样,Invoke() 将调用 LinqKit 的 Invoke(),然后它就可以发挥它的魔力了。

用法:

IEnumerable<TestItemTable> result = context.TestItemTables
    .WhereFilter(record => record.DateFieldOne, dateOneIsBefore, dateOneIsAfter)
    .WhereFilter(record => record.DateFieldTwo, dateTwoIsBefore, dateTwoIsAfter)
    .ToList();

在你的例子中没有太多重复,但我仍然会展示你如何用原始表达式做你想做的事(作为例子):

internal static class QueryableExtensions {
    internal static IQueryable<T> WhereFilter<T>(this IQueryable<T> source, Expression<Func<T, DateTime>> fieldData, DateTime? dateIsBefore, DateTime? dateIsAfter) {
        if (dateIsAfter == null && dateIsBefore == null)
            return source;
        // this represents you "record" parameter in lambda
        var arg = Expression.Parameter(typeof(T), "record");
        // this is the name of your field ("DateFieldOne")
        var dateFieldName = ((MemberExpression)fieldData.Body).Member.Name;
        // this is expression "c.DateFieldOne"
        var dateProperty = Expression.Property(arg, typeof(T), dateFieldName);
        Expression left = null;
        Expression right = null;
        // this is "c.DateFieldOne > dateIsAfter"
        if (dateIsAfter != null)
            left = Expression.GreaterThan(dateProperty, Expression.Constant(dateIsAfter.Value, typeof(DateTime)));
        // this is "c.DateFieldOne < dateIsBefore"
        if (dateIsBefore != null)
            right = Expression.LessThan(dateProperty, Expression.Constant(dateIsBefore.Value, typeof(DateTime)));
        // now we either combine with AND or not, depending on values
        Expression<Func<T, bool>> combined;
        if (left != null && right != null)
            combined = Expression.Lambda<Func<T, bool>>(Expression.And(left, right), arg);
        else if (left != null)
            combined = Expression.Lambda<Func<T, bool>>(left, arg);
        else
            combined = Expression.Lambda<Func<T, bool>>(right, arg);
        // applying that to where and done.
        source = source.Where(combined);
        return source;
    }
}

电话如您所料:

WhereFilter(record => record.DateFieldOne, dateOneIsBefore, dateOneIsAfter)

起初使用表达式可能看起来很奇怪,但至少对于简单的情况来说没有什么太复杂的。