65.9K
CodeProject 正在变化。 阅读更多。
Home

LINQ 和运行时动态谓词构造

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.88/5 (29投票s)

2008年8月14日

CPOL

7分钟阅读

viewsIcon

214002

downloadIcon

2269

演示 C# 3.0 新特性所支持的多谓词注入模式。

LINQDynamicPredicate_demo

引言

我非常信奉实际示例。所以,很快就会有一个,请稍等。不过在那之前,我必须承认,当我第一次看到 LINQ-to-SQL 时,我相当怀疑。我想,“哇,更多的语法糖。所以当我查询 SQL 时,感觉不像在查询任何东西。有什么大不了的。”毕竟,我以前见过这种事。

我没准备好的是,微软创造了一个巧妙的小杰作。LINQ 在某种意义上允许我们每个人扩展语言。我们可以利用 .NET 的类 JIT 反射(抱歉,是的,与 CAT-like 的双关语很容易错过),通过构建 LINQ 表达式树。

这听起来比实际要复杂得多,但简单来说,这意味着当我想根据运行时用户输入组合谓词(用于行包含在结果集中的测试)时,我可以省去一个组合级别的手动查询编写。

背景

使用 SQL Server 是很棒的,只要你对 T-SQL 语言有很好的理解,并且相对熟悉按需编写它。有各种捷径,例如 ORM 框架,它们可以使与 SQL 的交互在概念上更容易。尤其是在写长篇大论不是你的菜时。但说到底,在大多数这些 ORM 工具中,任何超越对象持久化的操作都可能很痛苦,并且通常需要一些手动 SQL 代码干预。

SQL 是 Web 应用程序的绝佳后端。在企业(字面上说是“企业”)环境中,你希望能够推出对其他企业员工有用的某种应用程序。他们可能喜欢做的一件事就是搜索数据库。现在,对你这个 SQL 大师来说,这没什么大不了的。当你希望让其他人也能做到这一点时,你真的必须确保你已经考虑到了所有方面,并且理清了所有细节(并使用了太多行话)。我知道到目前为止这有点含糊,那么,我们来举个例子吧?!?

最后,举个例子

让我们假设你是 IT 部门的内部开发人员。你有一个大经理,人力资源的 Sallie,她希望能够根据公司数据库(恰好看起来很像 SQL Server 附带的 Northwind 数据库)中的任何可用标准来搜索所有员工。

起初,这听起来并不难。你有一个 Employees 表,只需要搜索其中的字段。没问题……所以你在 Visual Studio Designer 中制作了一个样式精美、运转良好(并带颜色)的应用程序,在其中你选择了要搜索的 18 个字段中的哪些。你已经将一个文本框连接起来,用于提供一个值,该值作为 SQL Select 中的参数,将结果网格数据绑定到 18 个 SqlDataSource 中的一个(每个字段一个)。

Sallie 在你提出这个想法时很喜欢,但她说她确实需要能够根据多个列进行查询——顺序不限,并且可能使用所有列。

好吧,这会让数据绑定解决方案功亏一篑。看起来你必须用命令模式来编写这个……所以你允许用户从复选框列表中选择将在查询中包含的任何字段,并指定标准。你可以做很多 switch-ing if 逻辑来弄清楚要呈现哪种查询,一次添加一部分,或者……(当然,你知道我会说到这里)你可以使用 lambda 和 PredicateBuilder 将多个谓词注入表达式树!

现在,说清楚点。我没有发明 PredicateBuilder;事实上,它是由 Joseph Albahari 带来世界的一个非常流行的小工具。你可以在这里免费看到它,或者在它的自然栖息地LINQKit中查看它,以及一些其他类似的类似小工具的实用类。

没有 LINQ 和 PredicateBuilder 来解决这个问题,基本上需要为每种可能的列组合编写查询变体,或者一种构建能达到相同效果的查询的方法。编译器编写者对认为这很困难的想法嗤之以鼻,但对我们其他人来说,只有一种方法可以构建和遍历编译成其他类型代码(在此情况下为 SQL)的树——让别人去做。

Using the Code

这个示例的第一个显著特点是用户界面。用户会坚持认为用户界面是应用程序中一切的全部。他们经常将“应用程序的功能”称为“屏幕”,所以合理地设置这部分非常重要。

由于此解决方案要求用户能够选择一个或所有列,我们喜欢使用复选框来过滤。这里的大多数字段都是 nvarchar,所以我们可以为它们使用 String.Contains() lambda。DateTime 字段,即出生日期,需要使用值范围比较,因此该过滤器必须包含一个测试日期是否在范围内的 lambda(以及两个有效的日期输入)。

所以单个过滤器 lambda 看起来像这样

// This kind of lambda, when evaluated,
// will generate a LIKE clause with wildcards on both sides 
e => e.FirstName.Contains(filterFirstName.Text)

// This kind of lambda, when evaluated,
// will generate two value comparisons, just like it looks here
e => e.BirthDate.Value >= startDateRange && e.BirthDate.Value <= endDateRange

这很棒,你可能会说,但我该如何组合它们呢?

有两种方法可以做到这一点:你可以在客户端进行,这意味着你从 SQL 加载所有数据,然后在服务器端进行过滤。你创建一个 lambda 数组,循环遍历每个 lambda,并将它作为过滤器应用于 IQueryable,作为 Where 条件。这在服务器内存方面可能非常昂贵。或者,你可以采用正确的方式,使用 PredicateBuilder。基本上,LINQ 对 IEnumerable<T>Where 扩展接受一个条件表达式作为参数。这可以是一个简单的 lambda,也可以是多个 lambda 或其他有效 LINQ 表达式的复杂组合。PredicateBuilder 只是使组合不定数量的 lambda 变得容易。

从示例代码中

public IQueryable<Employee> PrepareDataSource() {

    // we start with .True, as in, no filters, get all of them.
    var predicate = PredicateBuilder.True<Employee>();


    // just inspect the 'checks'... throw in some lambdas

    int emplId = -1;

    // use tryparse to make sure we don't run a bogus query.
    if (cbxUseEmployeeID.Checked &&
        int.TryParse(filterEmployeeId.Text, out emplId) &&
        emplId > 0) {

        // here's how simple it is to add a condition to the query.
        // Still not executing yet, just building a tree.
        predicate = predicate.And(e => e.EmployeeID == emplId);
    }

    if (cbxUseLastName.Checked &&
        !string.IsNullOrEmpty(filterLastName.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.LastName.Contains(filterLastName.Text));
    }

    if (cbxUseFirstName.Checked &&
        !string.IsNullOrEmpty(filterFirstName.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.FirstName.Contains(filterFirstName.Text));
    }

    if (cbxUseTitle.Checked &&
        !string.IsNullOrEmpty(filterTitle.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.Title.Contains(filterTitle.Text));
    }

    // default value to avoid 'unassigned use' errors. 
    DateTime startDateRange = new DateTime();
    DateTime endDateRange = new DateTime(); 

    if (cbxUseBirthDate.Checked &&
        DateTime.TryParse(filterBirthDateStart.Text, out startDateRange) &&
        DateTime.TryParse(filterBirthDateEnd.Text, out endDateRange)) {

        // tack on some numeric range testing. I'd have
        // liked to do a between query, but I don't know if
        // there is one that translates in such a way, so we'll just do this:

        predicate = predicate.And(e => e.BirthDate.Value >= 
            startDateRange && e.BirthDate.Value <= endDateRange);
    }

    if (cbxUseAddress.Checked &&
        !string.IsNullOrEmpty(filterAddress.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.Address.Contains(filterAddress.Text));
    }

    if (cbxUseCity.Checked &&
        !string.IsNullOrEmpty(filterCity.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.City.Contains(filterCity.Text));
    }

    if (cbxUseState.Checked &&
        !string.IsNullOrEmpty(filterState.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.Region.Contains(filterState.Text));
    }
    

    if (cbxUsePostalCode.Checked &&
        !string.IsNullOrEmpty(filterPostalCode.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.PostalCode.Contains(filterPostalCode.Text));
    }

    if (cbxUseCountry.Checked &&
        !string.IsNullOrEmpty(filterCountry.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.Country.Contains(filterCountry.Text));
    }

    if (cbxUseHomePhone.Checked &&
        !string.IsNullOrEmpty(filterHomePhone.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.HomePhone.Contains(filterHomePhone.Text));
    }

    if (cbxUseNotes.Checked &&
        !string.IsNullOrEmpty(filterNotes.Text)) {

        // this translates into a LIKE query.
        predicate = predicate.And(e => e.Notes.Contains(filterNotes.Text));
    }

    var results = Config.GetCurrentContext().Employees.Where(predicate);

    // If you are debugging, you can put a breakpoint
    // up there and see the Query by hovering over 'results'.

    // this query is constructed but not yet exectued
    return results;
}

使用 PredicateBuilder 非常简单,我认为他们正试图在几个州将其列为非法。(我觉得这几乎相当于所谓的“机械工程师”对程序员被称为“软件工程师”感到不满。)

关注点

如果你查看下载中的 Employee 类,你会注意到我利用了 LINQ-to-SQL 创建的对象partial 的特性。我添加了一个静态方法,该方法返回数据库中的整个 IQueryable<Employee>。请注意,我还为部分类添加了 System.ComponentModel.DataObjectAttribute。我这样做是为了能够通过将 GridView 临时绑定到 ObjectDataSource 并使用 GetAll() 作为 *Select* 方法来填充 GridView 中的列。所有列都会出现,我删除 ObjectDataSource,然后继续进行我的愉快工作。节省了我一些打字时间。

你可能不知道的一件事是,LINQ-to-SQL 非常智能。是的。你是否曾想过,“嗯,这最好用一个SELECT Foobar where Baz in ('some', 'list', 'of', 'elements')”,然后又打消了这个念头,因为显然 LINQ 不能生成这个?如果你有,你就错了(!),因为事实证明,它几乎可以和你一样增长。我没有在代码下载中使用它……但看这里

string [] values = new string[] { "Leverling", "Davolio", 
             "Callahan", "Dodsworth" };

// No WAY that'll work! 
predicate = predicate.And(e => values.Contains(e.LastName));

// Yes WAY! It gets translated to  AND Employees.LastName
// in ('Leverling', 'Davolio', 'Callahan', 'Dodsworth')

我做了一些数学计算,我计算出,如果我要组合 Employees 表中的 18 个字段的所有可能组合,我将不得不构建 262,142 个唯一的 SQL 语句来适应每个组合。这还不包括每个组合的 if 语句和条件块!(我知道,没有人真的那样做,我希望。)

如果你要自己编写一个查询生成器,你将不得不进行一些类似编译器的翻译。我知道,从编写执行自定义基于树的语法翻译的软件的丰富经验来看,存在你意想不到的角落情况,而这些情况是需要最长时间来查找和修复的。

这个故事的寓意是,当你绝对需要行为的灵活性(不一定是速度)时,运行时翻译是最佳选择。LINQ-to-SQL 是一个不错的、易于访问、可用、现成的解决方案。当别人已经为你写了做这件事的东西时,就用它。(作者看着你,同时指向“LINQ-to-SQL”。)如果你不必自己动手,就不要自己动手。

历史

  • 2008 年 8 月 14 日 - 首次发布。(Northwind DB 未包含在代码中。)
  • 当天晚些时候,修复了一些“深夜”的语法错误和拼写错误。
© . All rights reserved.