動態構建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幫助下進行了探索。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章