动态构建LINQ表达式

目录

基础

挑战

解决方案:动态表达式

Transaction介绍

参数表达式

逻辑表达式

属性表达式

常量和调用表达式

比较表达式

Lambda表达式和编译

从内存到数据库

结论


LINQ意为语言集成查询。它提供了一种一致的强类型机制,用于跨各种源查询数据。LINQ基于表达式。本文通过在参考应用程序中构建自定义表达式树来探索LINQ和基础表达式。

LINQ意为语言集成查询,是我最喜欢的.NETC#技术之一。使用LINQ,开发人员可以直接在强类型代码中编写查询。LINQ提供了跨数据源一致的标准语言和语法。

基础

考虑以下LINQ查询(您可以将其粘贴到控制台应用程序中并自己运行):

using System;
using System.Linq;
public class Program
{
    public static void Main()
    {
        var someNumbers = new int[]{4, 8, 15, 16, 23, 42};
        var query = from num in someNumbers
                    where num > 10
                    orderby num descending
                    select num.ToString();
        Console.WriteLine(string.Join('-', query.ToArray()));
        // 42-23-16-15
    }
}

因为someNumbers是一个IEnumerable<int>,所以查询由LINQ to Objects解析。相同的查询语法可以与诸如Entity Framework Core类的工具一起使用,以生成针对关系数据库运行的T-SQL可以使用以下两种语法之一来编写LINQ查询语法(如上所示)或方法语法。两种语法在语义上是相同的,您使用哪种语法取决于您的偏好。可以使用如下方法语法编写上面的相同查询:

var secondQuery = someNumbers.Where(n => n > 10)
                             .OrderByDescending(n => n)
                             .Select(n => n.ToString());

每个LINQ查询都有三个阶段:

  1. 设置了一个数据源,称为提供程序,以使查询根据该数据源进行操作。例如,到目前为止显示的代码使用内置的LINQ to Objects提供程序。EF Core项目使用映射到数据库的EF Core提供程序
  2. 定义查询并将其转换为表达式树。一会儿我将介绍更多表达式。
  3. 执行查询,并返回数据。

步骤3很重要,因为LINQ使用了所谓的延迟执行。在上面的示例中,secondQuery定义了一个表达式树,但尚未返回任何数据。实际上,在开始迭代数据之前,实际上什么也没有发生。这很重要,因为它允许提供商仅通过传递请求的数据来管理资源。例如,假设您要使用secondQuery来查找特定的字符串,那么您可以执行以下操作:

var found = false;
foreach(var item in secondQuery.AsEnumerable())
{
    if (item == "23")
    {
        found = true;
        break;
    }
}

提供程序可以处理枚举数,以便它一次将一个取出数据元素。如果在第三次迭代中找到该值,则可能实际上只从数据库返回了三项。另一方面,使用.ToList()扩展方法时,将立即获取所有数据以填充列表。

挑战

作为.NET DataPM,我经常与客户交谈以了解他们的需求。最近,我与一个客户进行了讨论,该客户希望在其网站中使用第三方控件来建立业务规则。更具体地说,业务规则是谓词或一组可解析为truefalse的条件。该工具可以生成JSONSQL格式的规则。SQL很想传递给数据库,但是它们的要求是将谓词也作为服务器上的筛选器应用到内存中对象。他们正在考虑将SQL转换为表达式的工具(称为动态LINQ如果您有兴趣)。我建议JSON格式可能很好,因为它可以解析为LINQ表达式,该表达式针对内存中的对象运行,或者可以轻松地应用于Entity Framework Core集合以针对数据库运行。

我写的spike只处理默认JSON产生的工具:

{
   "condition":"and",
   "rules":[
      {
         "label":"Category",
         "field":"Category",
         "operator":"in",
         "type":"string",
         "value":[
            "Clothing"
         ]
      },
      {
         "condition":"or",
         "rules":[
            {
               "label":"TransactionType",
               "field":"TransactionType",
               "operator":"equal",
               "type":"boolean",
               "value":"income"
            },
            {
               "label":"PaymentMode",
               "field":"PaymentMode",
               "operator":"equal",
               "type":"string",
               "value":"Cash"
            }
         ]
      },
      {
         "label":"Amount",
         "field":"Amount",
         "operator":"equal",
         "type":"number",
         "value":10
      }
   ]
}

结构很简单:存在一个ANDOR 条件,其中包含一组比较或嵌套条件的规则。我的目标是双重的:了解有关LINQ表达式的更多信息,以更好地帮助我理解EF Core和相关技术,并提供一个简单的示例来说明如何在不依赖第三方工具的情况下使用JSON

我最早的开源贡献之一是命名为SterlingNoSQL数据库引擎,因为我将其编写为Silverlight的本地数据库。后来,当Windows PhoneSilverlight作为运行时一起发布时,它开始流行,并被用于一些流行的食谱和健身应用程序中。Sterling 遭受了一些限制,而这些限制可以通过适当的LINQ提供程序轻松缓解。我的目标是最终掌握足够的LINQ,以便在需要时编写自己的EF Core提供程序。

解决方案:动态表达式

我创建了一个简单的控制台应用程序来检验我的假设,即从JSON实现LINQ相对简单。

JeremyLikness/ExpressionGenerator

在本文的第一部分,将启动项目设置为ExpressionGenerator。如果从命令行运行它,请确保该rules.json文件位于当前目录中。

我将示例JSON嵌入为rules.json。使用System.Text.Json解析文件非常简单:

var jsonStr = File.ReadAllText("rules.json");
var jsonDocument = JsonDocument.Parse(jsonStr);

然后,我创建了一个JsonExpressionParser以解析JSON并创建表达式树。因为解决方案是谓词,所以表达式树是根据评估左表达式和右表达式的BinaryExpression实例构建的。该评估可能是逻辑门(ANDOR),或比较(equalgreaterThan)或方法调用。对于In等的情况,我们希望属性Category位于列表中的多个项目之一中,我翻转脚本并使用Contains。从概念上讲,引用的JSON如下所示:

                        /-----------AND-----------\
                         |                         |
                      /-AND-\                      |
Category IN ['Clothing']   Amount eq 10.0        /-OR-\
                        TransactionType EQ 'income'  PaymentMode EQ 'Cash'

请注意,每个节点都是二进制的。让我们开始解析!

Transaction介绍

不,不是System.Transaction。这是示例项目中使用的自定义类。我没有在供应商的网站上花费太多时间,因此我根据规则猜测该实体的外观。我想出了这个:

public class Transaction
{
  public int Id { get; set; }
  public string Category { get; set; }
  public string TransactionType { get; set; }
  public string PaymentMode { get; set; }
  public decimal Amount { get; set; }
}

然后,我添加了一些其他方法来简化生成随机实例的过程。您可以自己在代码中看到这些内容。

参数表达式

main方法返回一个谓词函数。这是开始的代码:

public Func<T, bool> ParsePredicateOf<T>(JsonDocument doc)
{
   var itemExpression = Expression.Parameter(typeof(T));
   var conditions = ParseTree<T>(doc.RootElement, itemExpression);
}

第一步是创建谓词参数。可以将谓词传递给Where子句,如果我们自己编写它,它将看起来像这样:

var query = ListOfThings.Where(t => t.Id > 2);

t =>是所述第一参数和表示一个项目的列表中的类型。因此,我们为该类型创建一个参数。然后,我们递归地遍历JSON节点以构建树。

逻辑表达式

解析器的开始看起来像这样:

private Expression ParseTree<T>(
    JsonElement condition,
    ParameterExpression parm)
    {
        Expression left = null;
        var gate = condition.GetProperty(nameof(condition)).GetString();

        JsonElement rules = condition.GetProperty(nameof(rules));

        Binder binder = gate == And ? (Binder)Expression.And : Expression.Or;

        Expression bind(Expression left, Expression right) =>
            left == null ? right : binder(left, right);

有一点需要消化。gate变量是状态,即,“and”“or”rules语句获取一个节点,该节点是相关规则的列表。我们一直在跟踪表达式的左侧和右侧。该Binder签名是一个二进制表达式的简写,并定义如下:

private delegate Expression Binder(Expression left, Expression right);

binder变量仅设置顶级表达式:Expression.AndExpression.Or。两者都采用左右表达式来求值。

bind函数更加有趣。遍历树时,我们需要构建各个节点。如果尚未创建表达式(leftnull),则从创建的第一个表达式开始。如果我们有一个现有的表达式,则可以使用该表达式合并这两个方面。

现在leftnull,然后我们开始枚举属于该条件的规则:

foreach (var rule in rules.EnumerateArray())

属性表达式

第一条规则是相等规则,因此我现在将跳过条件部分。这是发生了什么:

string @operator = rule.GetProperty(nameof(@operator)).GetString();
string type = rule.GetProperty(nameof(type)).GetString();
string field = rule.GetProperty(nameof(field)).GetString();
JsonElement value = rule.GetProperty(nameof(value));
var property = Expression.Property(parm, field);

首先,我们得到运算符(in),类型(string),字段(Category)和值(以Clothing为唯一元素的数组)。请注意对Expression.Property的调用。该规则的LINQ如下所示:

var filter = new List<string> { "Clothing" };
Transactions.Where(t => filter.Contains(t.Category));

该属性是t.Category组成部分,因此我们基于父属性(t)和字段名称创建它。

常量和调用表达式

接下来,我们需要构建对Contains的调用。为简化起见,我在这里创建了对该方法的引用:

private readonly MethodInfo MethodContains = typeof(Enumerable).GetMethods(
  BindingFlags.Static | BindingFlags.Public)
  .Single(m => m.Name == nameof(Enumerable.Contains)
      && m.GetParameters().Length == 2);

它获取了Enumerable上的方法,该方法有两个参数:要枚举的值和要检查的值。接下来的逻辑如下所示:

if (@operator == In)
{
    var contains = MethodContains.MakeGenericMethod(typeof(string));
    object val = value.EnumerateArray().Select(e => e.GetString())
        .ToList();
    var right = Expression.Call(
        contains,
        Expression.Constant(val),
        property);
    left = bind(left, right);
}

首先,我们使用Enumerable.Contains模板来创建一个Enumerable<string>,因为这是我们要查找的类型。接下来,我们获取值列表并将其转换为List<string>。最后,我们构建调用,并传递它:

  • 调用方法(contains
  • 作为要检查的参数的值(带有ClothingExpression.Constant(val)的列表)
  • 要针对(t.Category)进行检查的属性。

我们的表达式树已经相当深,带有参数,属性,调用和常量。请记住,left仍然是null,因此绑定调用仅设置left为我们刚刚创建的调用表达式。到目前为止,我们看起来像这样:

Transactions.Where(t => (new List<string> { "Clothing" }).Contains(t.Category));

反复循环,下一个规则是嵌套条件。我们点击以下代码:

if (rule.TryGetProperty(nameof(condition), out JsonElement check))
{
    var right = ParseTree<T>(rule, parm);
    left = bind(left, right);
    continue;
}

当前,left已分配给in表达式。right将被分配为解析新条件的结果。我碰巧知道这是一个OR条件。现在,我们的binder设置为Expression.And,以便当函数返回时,bind调用留给我们的是:

Transactions.Where(t => (new List<string> { "Clothing" }).Contains(t.Category) && <something>);

让我们看一下something

比较表达式

首先,递归调用确定存在一个新条件,这次是一个逻辑ORbinder设置为Expression.Or,规则开始评估。第一条规则是TransactionType。设置为boolean,但据我推断,这意味着界面中的用户可以检查选择一个值或切换到另一个值。因此,我将其实现为简单的字符串比较。这是构建比较的代码:

object val = (type == StringStr || type == BooleanStr) ?
    (object)value.GetString() : value.GetDecimal();
var toCompare = Expression.Constant(val);
var right = Expression.Equal(property, toCompare);
left = bind(left, right);

该值被解构为字符串或十进制(以后的规则将使用十进制格式)。然后将值转换为常数,然后创建比较。注意它是传递给属性的。变量right现在看起来像这样:

Transactions.Where(t => t.TransactionType == "income");

在此嵌套循环中,left仍为空。解析器评估下一条规则,即付款方式。该bind函数将其转换为以下or语句:

Transactions.Where(t => t.TransactionType == "income" || t.PaymentMode == "Cash");

其余的应该是不言自明的。表达式的一个不错的功能是它们会重载ToString()以生成表示形式。这是我们的表达形式(为了方便查看,我采取了格式化的自由):

(
  (value(System.Collections.Generic.List`1[System.String]).Contains(Param_0.Category)
      And (
          (Param_0.TransactionType == "income")
          Or
          (Param_0.PaymentMode == "Cash"))
      )
  And
  (Param_0.Amount == 10)
)

看起来不错但是我们还没有完成!

Lambda表达式和编译

表达式树表示一个想法。它需要变成某种物质。如果可以简化表达式,请减少它。接下来,我创建一个lambda表达式。这定义了解析表达式的形状,它将是一个谓词(Func<T,bool>)。最后,我返回编译后的委托。

var conditions = ParseTree<T>(doc.RootElement, itemExpression);
if (conditions.CanReduce)
{
    conditions = conditions.ReduceAndCheck();
}
var query = Expression.Lambda<Func<T, bool>>(conditions, itemExpression);
return query.Compile();

为了检查我的数学,我生成了1000 transactions (加权后包括应该匹配的几笔交易)。然后,我应用过滤器并迭代结果,以便可以手动测试是否满足条件。

var predicate = jsonExpressionParser
                .ParsePredicateOf<Transaction>(jsonDocument);
var transactionList = Transaction.GetList(1000);
var filteredTransactions = transactionList.Where(predicate).ToList();
filteredTransactions.ForEach(Console.WriteLine);

如您所见,结果全部签出(我平均每次运行约70匹配

从内存到数据库

生成的委托不仅用于对象。我们也可以将其用于数据库访问。

在本文的其余部分,将启动项目设置为DatabaseTest。如果从命令行运行它,请确保该databaseRules.json文件位于当前目录中。

首先,我重构了代码。还记得表达式如何需要数据源吗?在前面的示例中,我们编译表达式并最终得到对对象起作用的委托。要使用其他数据源,我们需要在编译表达式之前传递它。这样就可以对数据源进行编译。如果我们传递已编译的数据源,则将强制数据库提供程序从数据库中获取所有行,然后解析返回的列表。我们希望数据库完成这项工作。我将大量代码移到了一个名为ParseExpressionOf<T>方法中,该方法返回了lambda。我将原始方法重构为:

public Func<T, bool> ParsePredicateOf<T>(JsonDocument doc)
{
    var query = ParseExpressionOf<T>(doc);
    return query.Compile();
}

ExpressionGenerator程序使用编译后的查询。DatabaseTest使用原始λ表达式。它将其应用于本地SQLite数据库,以演示EF Core如何解析该表达式。在将1000transactions创建并插入数据库后,代码将检索count

var count = await context.DbTransactions.CountAsync();
Console.WriteLine($"Verified insert count: {count}.");

这将导致以下SQL

SELECT COUNT(*)
FROM "DbTransactions" AS "d"

如果您想知道为什么有两个上下文,那是由于日志。第一个上下文插入1000条记录,如果打开了日志记录,则在将插入内容写入控制台时它将运行非常慢。第二个上下文打开日志记录,因此您可以查看评估后的语句。

对该谓词进行解析(这次是从databaseRules.json中的一组新规则),然后传递给Entity Framework Core提供程序。

var parser = new JsonExpressionParser();
var predicate = parser.ParseExpressionOf<Transaction>(
    JsonDocument.Parse(
        await File.ReadAllTextAsync("databaseRules.json")));
  var query = context.DbTransactions.Where(predicate)
      .OrderBy(t => t.Id);
  var results = await query.ToListAsync();

启用Entity Framework Core日志记录后,我们能够检索SQL并一目了然地获取项目并在数据库引擎中进行评估。请注意,PaymentMode已选中Credit而不是Cash

SELECT "d"."Id", "d"."Amount", "d"."Category", "d"."PaymentMode", "d"."TransactionType"
FROM "DbTransactions" AS "d"
WHERE ("d"."Category" IN ('Clothing') &
        ((("d"."TransactionType" = 'income') AND "d"."TransactionType" IS NOT NULL) |
          (("d"."PaymentMode" = 'Credit') AND "d"."PaymentMode" IS NOT NULL))) &
      ("d"."Amount" = '10.0')
ORDER BY "d"."Id"

该示例应用程序还将打印所选实体之一以进行抽查。

结论

LINQ表达式是过滤和转换数据的非常强大的工具。我希望该示例有助于揭开表达式树的构建方式。当然,解析表达式树感觉有点像魔术。Entity Framework Core如何遍历表达式树以产生有意义的SQL?我正在自己探索这个问题,并在我的朋友ExpressionVisitor帮助下进行了探索。

  • 0
    点赞
  • 1
    收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:酷酷鲨 设计师:CSDN官方博客 返回首页
评论
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值