[料理佳餚] 在 Entity Framework 使用 Expression 黑魔法做動態條件查詢

使用 Entity Framework 大都會搭配 Lambda Expression 到資料庫去 Query 資料出來,但是通常我們只能在程式碼中先寫好查詢的條件,當遇到不同條件的時候就要再寫一組查詢的條件,那我們能不能寫一個查詢條件的產生器,可以依照我丟進去的條件參數,幫我產生不同組合的查詢條件?

當然是可以的,這時候我們就必須動用到 Expression 這個黑魔法,我們熟知的 Lambda Expression 也繼承自 Expression 這個類別,個人覺得 Expression 是個蠻神奇的東西,從 .Net Framework 3.5 就有了,它稍微顛覆了我們寫程式的方式。

Expression 是什麼?能吃嗎?

舉個例子來說明比較容易懂,假設我要做一個可以傳入 2 個 int 數值,並回傳相加結果的 function,通常這樣寫就好了。

public int Add(int a, int b)
{
    return a + b;
}

不過,這個兩數相加的方法還能像下面這樣寫,而這種寫法我想各位應該不陌生,我們用 Lambda Expression 陳述一個 Func<int, int, int>,內容是可以傳入兩個 int 參數,回傳值是 int 的方法。

Func<int, int, int> 本身已經是個方法了,裡面已經看不到陳述句的內容,但是我們可以用 Expression<TDelegate> 來讓陳述句現身。

我們可以看到陳述句的本體是 (a + b),參數為 ab,此外還有一些關於這個陳述句的資訊。

這時候我們來施一點魔法,我要在 Runtime 的時候把原本回傳 (a + b) 的結果變成回傳 (a + b) + b 的結果,讓原本 (1 + 2) = 3 變成 (1 + 2) + 2 = 5。

是不是從沒想過我原本寫好的方法,會在 Runtime 的時候被改變運算的邏輯?

我們來看一下它做了什麼事情,原本陳述句的本體是 (a + b),在執行 Expression.Add() 之後變成了 (a + b) + b。

Expression.Add() 方法的左邊參數傳入原本陳述句的本體,右邊參數則傳入原本陳述句的第二個 Parameter,Expression.Add() 方法就會把左右兩邊用加號連接起來,我們移至 Expression 的定義就會發現,其實 Expression 還有一堆靜態方法,都是與運算相關的陳述語句,Expression.Add() 只是其中一個。

從上面的例子可以得知 Expression 是可以讓我們在 Runtime 的時候,藉由它所提供的陳述語句,依照我們的需求來自行組合運算資料的方式。

動態條件查詢

我們回到 Entity Framework,我這邊做了一個例子,我有一個 Customer 的資料表,裡面的資料就跟下圖一樣。

我有兩個查詢需求,一個是撈出住在台北市的人,一個是撈出住在台北市大安區的人,以往的直覺跟習慣這樣寫應該是沒有什麼問題的。

程式寫好上線了,這時候需求來了,使用者要撈出住在台北市大安區姓蔡的人,這樣以後不就多一個查詢組合就再寫一個查詢方法,感覺有點不太 Smart。

我們看到 Where 擴充方法裡面要給入 Expression<Func<TSource, bool>> 參數當查詢條件,經過剛剛的例子我想應該對這個參數不陌生了吧。

既然已經知道我們可以對 Expression<Func<TSource, bool>> 動手腳了,我們就可以來開始進行改造,我們把我們的查詢條件做個設計,我另外多建立幾個類別,用來表達從使用者端丟下來的查詢條件,CustomerQueryConditions 是由 Customer 中挑選幾個屬性,開放出來提供查詢的條件。

再來我建立一個抽象類別 QueryConditionsResolver<TEntry, TConditions>,裡面提供了 And 方法,用來組 And 條件,還定義了一個派生類別必須要實作的方法 Resolve(),讓派生類別自己去實作查詢條件。

public abstract class QueryConditionsResolver<TEntry, TConditions>
{
    private Expression predicate;
    private ParameterExpression parameter;

    public QueryConditionsResolver(TConditions queryConditions)
    {
        this.parameter = Expression.Parameter(typeof(TEntry));
        this.predicate = Expression.Constant(true);
        this.QueryConditions = queryConditions;
    }

    protected TConditions QueryConditions { get; set; }

    public abstract Expression<Func<TEntry, bool>> Resolve();

    protected Expression<Func<TEntry, bool>> GenerateLambdaExpression()
    {
        return Expression.Lambda<Func<TEntry, bool>>(this.predicate, this.parameter);
    }

    protected void And<TValue>(QueryCondition<TValue> queryCondition, string entryFieldName)
    {
        if (queryCondition != null)
        {
            Expression expression = null;
            Expression property = Expression.Property(this.parameter, entryFieldName);
            Expression constant = Expression.Constant(queryCondition.Value, typeof(TValue));

            switch (queryCondition.Comparsion)
            {
                case QueryComparsion.GreaterThan:
                    expression = Expression.GreaterThan(property, constant);
                    break;

                case QueryComparsion.LessThan:
                    expression = Expression.LessThan(property, constant);
                    break;

                case QueryComparsion.Equal:
                    expression = Expression.Equal(property, constant);
                    break;

                case QueryComparsion.NotEqual:
                    expression = Expression.NotEqual(property, constant);
                    break;

                case QueryComparsion.LessThanOrEqual:
                    expression = Expression.LessThanOrEqual(property, constant);
                    break;

                case QueryComparsion.GreaterThanOrEqual:
                    expression = Expression.GreaterThanOrEqual(property, constant);
                    break;

                case QueryComparsion.StartsWith:
                    expression = Expression.Call(property, typeof(string).GetMethod("StartsWith", new Type[] { typeof(String) }), constant);
                    break;

                default:
                    throw new NotSupportedException("不支援此類型");
            }

            this.predicate = Expression.And(this.predicate, expression);
        }
    }
}

接著我就建立一個類別 CustomerQueryConditionsResolver 繼承 QueryConditionsResolver<Customer, CustomerQueryConditions>,並實作 Resolve() 方法,兜完查詢條件後,呼叫 GenerateLambdaExpression() 將查詢條件的 Lambda 陳述句產生出來。

public class CustomerQueryConditionsResolver : QueryConditionsResolver<Customer, CustomerQueryConditions>
{
    public CustomerQueryConditionsResolver(CustomerQueryConditions customerQueryConditions)
        : base(customerQueryConditions)
    {
    }

    public override Expression<Func<Customer, bool>> Resolve()
    {
        this.And(this.QueryConditions.City, nameof(Customer.City));
        this.And(this.QueryConditions.District, nameof(Customer.District));
        this.And(this.QueryConditions.Name, nameof(Customer.Name));

        return this.GenerateLambdaExpression();
    }
}

準備工作完畢,這時候我們去改寫原來的查詢方法,並且把撈出住在台北市大安區姓蔡的人的需求也實作起來。

這樣子改完之後,我們的查詢條件就可以依照傳進來的 QueryConditions 去動態地組合,這樣子做我們還可以減少存取資料庫程式的變動,雖然無法徹底符合所有的查詢條件,像有些花式查詢還是沒辦法靠這樣兜出來,但是已經可以滿足大部分的需求,就算遇到需要花式查詢的時候,至少我們還有 Dapper 可以用。

參考資料

 < Source Code >

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學