LINQ 進階練習 - 動態組裝 Expression 產生 WHERE 條件
2 |
上回研究過 ExpressionTree,這回再用它玩些有趣應用。
在 EF Core Model 要宣告複合索引,Fluent API 做法是在 OnModelCreating() 宣告 modelBuilder.Entity<Person>().HasIndex(p => new { p.FirstName, p.LastName });
(註:EF Core 5.0 起支援用 Attribute 宣告),EF Core 便能依據匿名物件屬性 p => new { p.FirstName, p.LastName }
得知索引的欄位組成,跟上回從 o => o.PropName 拿到屬性相關資訊一樣讓我覺得很酷,想用它對映成 WHERE 比對條件。
先佈置測試環境,宣告 Product 模型類別及 DbContext 物件,CreateDbContextForTest() 方法建立一個以 SQLite 記憶體暫存資料庫儲存資料,包含五筆測試資料的 DbContext 提供測試:
class Product
{
[Key]
public int ProductId { get; set; }
[Required]
public string Brand {get; set;}
[Required]
public string Category {get; set;}
[Required]
public string ModelId {get; set;}
[Required]
public string Name {get; set;}
}
class MyDbContext : DbContext
{
public DbSet<Product> Products { get; set; }
public MyDbContext(DbContextOptions options) : base(options) { }
public static bool LogEnabled = false;
public static MyDbContext CreateDbContextForTest()
{
var cn = new SqliteConnection("Data Source=:memory:");
cn.Open();
var opt = new DbContextOptionsBuilder<MyDbContext>()
.UseSqlite(cn)
.LogTo(s =>
{
if (LogEnabled && s.Contains("Microsoft.EntityFrameworkCore.Database.Command"))
Console.WriteLine(s);
}, Microsoft.Extensions.Logging.LogLevel.Information)
// 連同寫入資料庫的參數一起顯示,正式環境需留意個資或敏感資料寫入Log
.EnableSensitiveDataLogging()
.Options;
var dbctx = new MyDbContext(opt);
dbctx.Database.EnsureCreated();
dbctx.Products.Add(new Product
{
Brand = "ThinkPad",
Category = "Laptop",
ModelId = "T470p",
Name = "小黑 T470p"
});
dbctx.Products.Add(new Product
{
Brand = "ThinkPad",
Category = "Laptop",
ModelId = "T43",
Name = "小黑 T43"
});
dbctx.Products.Add(new Product
{
Brand = "ThinkPad",
Category = "Laptop",
ModelId = "X21",
Name = "小黑 X21"
});
dbctx.Products.Add(new Product
{
Brand = "Sony",
Category = "Laptop",
ModelId = "VAIO T13",
Name = "小銀 VAIO T13"
});
dbctx.Products.Add(new Product
{
Brand = "Sony",
Category = "Camera",
ModelId = "RX-100",
Name = "RX100"
});
dbctx.SaveChanges();
return dbctx;
}
}
假想情境一,有三組預先設好篩選條件組合 Brand、Category、Brand+Category,用 o => new { o.Brand, o.Category }
定義篩選欄位,寫一個函式 Expression<Func<TEntity, bool>> GetFindExpression<TEntity>( Expression<Func<TEntity, object>> compositeKeys, params object[] args)
使 dbctx.Products.Where(GetFindExpression(filterBrandAndCategory, "ThinkPad", "Laptop")) 具有 dbctx.Products.Where(o => o.Brand == "ThinkPad" && o.Category == "Laptop") 相同結果:
using System.Linq.Expressions;
using FlexDataAccess;
using Microsoft.Extensions.Options;
var dbctx = MyDbContext.CreateDbContextForTest();
MyDbContext.LogEnabled = true;
// 預先定義好篩選條件
Dictionary<string, Expression<Func<Product, object>>> predefinedFilters = new();
predefinedFilters.Add("Category", o => new { o.Category });
predefinedFilters.Add("Brand", o => new { o.Brand });
predefinedFilters.Add("Brand-Category", o => new { o.Brand, o.Category });
Test("Brand", 2, "Sony");
Test("Category", 4, "Laptop");
Test("Brand-Category", 0, "ThinkPad", "Camera");
Console.ReadLine();
void Test(string filterName, int expectedRowCount, params object[] args)
{
var res = dbctx.Products.Where(
GetFindExpression(predefinedFilters[filterName], args)).ToList();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"{filterName}: {string.Join(", ", args.Select(o => o.ToString()))}");
Console.WriteLine($"Result Count = {res.Count()} (Expected: {expectedRowCount})");
Console.ResetColor();
}
Expression<Func<TEntity, bool>> GetFindExpression<TEntity>(
Expression<Func<TEntity, object>> compositeKeys,
params object[] args) where TEntity : class
{
var exp = compositeKeys;
// 偵測表示式是否為 o => new { ... }
if (exp.NodeType == ExpressionType.Lambda && exp.Body.NodeType == ExpressionType.New)
{
var newExp = exp.Body as NewExpression;
// 檢查 new { ... } 屬性個數與參數個數必須相等
var argList = ((NewExpression)exp.Body).Arguments.Cast<MemberExpression>().ToList();
if (argList.Count != args.Length)
{
throw new ArgumentException("Invalid argument number.");
}
var param = Expression.Parameter(typeof(TEntity), "o");
Expression body = null;
for (int i = 0; i < argList.Count; i++)
{
// 建立等於判斷式
var eqExp = Expression.Equal(
Expression.Property(param, argList[i].Member.Name), // 屬性
Expression.Constant(args[i])); // 常數
// 第二筆開始用 && 串接
body = i == 0 ? eqExp : Expression.AndAlso(body, eqExp);
}
return Expression.Lambda<Func<TEntity, bool>>(body, param);
}
else
{
throw new NotSupportedException("Invalid PrimaryKeyProperties.");
}
}
那麼,有沒有可能傳入 new Dictionary<string, object>() { ["Brand"] = "ThinkPad", ["Category"] = "Laptop" }
展開成 dbctx.Products.Where(o => o.Brand == "ThinkPad" && o.Category == "Laptop")
呢? 這樣好像更有彈性更好用! 來試試:
using System.Linq.Expressions;
using System.Xml;
using FlexDataAccess;
using Microsoft.Extensions.Options;
var dbctx = MyDbContext.CreateDbContextForTest();
MyDbContext.LogEnabled = true;
// 預先定義好篩選條件
Dictionary<string, Expression<Func<Product, object>>> predefinedFilters = new();
predefinedFilters.Add("Category", o => new { o.Category });
predefinedFilters.Add("Brand", o => new { o.Brand });
predefinedFilters.Add("Brand-Category", o => new { o.Brand, o.Category });
Test(new Dictionary<string, object> { ["Brand"] = "Sony" }, 2);
Test(new Dictionary<string, object> { ["Category"] = "Laptop" }, 4);
Test(new Dictionary<string, object> { ["Brand"] = "ThinkPad", ["Category"] = "Camera" }, 0);
Console.ReadLine();
void Test(Dictionary<string, object> parameters, int expectedRowCount)
{
var res = dbctx.Products.Where(
GetFindExpression<Product>(parameters)).ToList();
Console.ForegroundColor = ConsoleColor.Yellow;
var condText = string.Join(" && ",
parameters.Select(o => $"{o.Key}={o.Value}").ToArray());
Console.WriteLine($"Filter: {condText}");
Console.WriteLine($"Result Count = {res.Count()} (Expected: {expectedRowCount})");
Console.ResetColor();
}
Expression<Func<TEntity, bool>> GetFindExpression<TEntity>(
Dictionary<string, object> parameters) where TEntity : class
{
var param = Expression.Parameter(typeof(TEntity), "o");
Expression body = null;
foreach (var kv in parameters)
{
var eqExp = Expression.Equal(
Expression.Property(param, kv.Key),
Expression.Constant(kv.Value));
body = body == null ? eqExp : Expression.AndAlso(body, eqExp);
}
return Expression.Lambda<Func<TEntity, bool>>(body, param);
}
輕鬆秒殺!
學會以上技巧,我們就有能力動態組裝 WHERE 條件丟給 EF Core 執行,雖然比用 Dapper / FromSql / ExecuteSql 來得複雜,但可以跨資料庫甚至用在物件集合查詢是無可取代的好處,解決問題時再多一種選擇。
Example of how to use expression tree to generate mapped Where() condition.
Comments
# by ryan
這種寫法如果是對兩個表JOIN完畢之後的結果來組合where 是不是就沒辦法了
# by Jeffrey
to ryan, 給個可執行範例重現問題,大家可以幫忙研究看看。