動態查詢最危險的錯誤,往往不是 SQL 寫錯,而是運算式樹在執行前就因型別不相容而失敗,或把無法轉譯的方法帶進查詢。本文聚焦 EF Core Expression 型別對齊,用固定版本的最小測試說明如何檢查方法參數、何時加入轉型、如何處理字串比對,以及怎樣讓失敗保持可觀察。
核心結論:以方法參數型別為準,不要只看 nullable
建立 Contains 呼叫時,應比較屬性運算式與目標方法參數的實際型別;兩者不同才加入明確轉型,不能只因屬性可為空就一律拆掉 nullable。
Expression.Call 組合的是具型別的呼叫節點,不是會自動套用所有 C# 語言轉換規則的文字產生器。若集合是 List,被呼叫的 Contains 參數就是 int;若資料模型屬性是 int?,把該屬性節點直接塞進去會在建立運算式時收到 ArgumentException。相反地,集合若是 List,參數本來就是 int?,此時把屬性強制拆成 int 反而破壞正確語意。
因此,可靠的判斷基準是 MethodInfo.GetParameters()[0].ParameterType。先取得被選定多載的參數型別,再與 property.Type 比較。完全相同就保留原節點;不同時,必須先確認轉換是否合法以及空值要採用什麼規則,然後才建立 Expression.Convert。這個順序也避免程式僅看到 Nullable.GetUnderlyingType 不為空,就做出過度簡化的決定。
還要特別處理 null。把 int? 轉成 int 時,空值會在執行委派時失敗。若查詢規格將 null 定義為「不在非空集合中」,應先加入 property.HasValue,再對 property.Value 或轉型後節點執行集合比對。若集合允許 null,則應選取參數為 int? 的方法,不應移除 nullable。型別對齊與業務空值語意是兩個步驟,不能用同一個 Convert 掩蓋。
為何錯誤會偽裝成查無資料
若資料層用空白 catch 把運算式建構或 EF Core 執行例外轉成空集合,上層只會看到零筆結果,真正的型別錯誤與轉譯失敗就會被誤判成正常查詢結果。
這類問題常具有條件性:只有資料存在、程式真正建立篩選條件時才會觸發;空資料庫或錯誤測試樣本可能讓流程提前返回,看起來完全正常。換到有資料的測試資料集後才失敗,容易讓人把原因歸咎於資料同步、快取或時間條件,而忽略查詢本身從未成功。
處理方式不是增加更多模糊重試,而是保存錯誤邊界。運算式建構失敗時應留下例外類型、條件名稱、屬性 CLR 型別與方法參數型別;不要記錄資料內容。查詢執行失敗時則要區分「查詢成功且為零筆」與「查詢未完成」。回傳模型可使用明確結果型別,至少也要讓例外沿呼叫鏈傳到能統一記錄與轉換的邊界。
測試資料也必須覆蓋三種狀態:有符合資料、有資料但不符合、完全沒有資料。第一種能迫使條件建構及執行;第二種證明零筆是合法結果;第三種檢查提前返回。再加入 nullable 欄位為 null、為集合內數值、為集合外數值三組案例,才能防止單一路徑通過後就誤認修正完整。
建構可審查的 Contains 運算式
安全的產生器會先鎖定集合型別與正確多載,再驗證參數數量、參數型別及空值策略,最後才組合呼叫;每個決策都能由小型單元測試獨立檢查。
以下範例示意非空 List 對 int? 屬性的策略。它先要求屬性有值,再將屬性轉為方法期待的 int。實際共用函式還應拒絕不支援的轉換,而不是對任何不同型別都盲目呼叫 Expression.Convert。
var entity = Expression.Parameter(typeof(Row), "row");
var property = Expression.Property(entity, nameof(Row.Type)); // int?
var values = Expression.Constant(new List { 1, 2 });
var method = typeof(List).GetMethod("Contains", new[] { typeof(int) })!;
var expectedType = method.GetParameters()[0].ParameterType;
var hasValue = Expression.Property(property, nameof(Nullable.HasValue));
Expression argument = property.Type == expectedType
? property
: Expression.Convert(property, expectedType);
var call = Expression.Call(values, method, argument);
var body = Expression.AndAlso(hasValue, call);
var predicate = Expression.Lambda>(body, entity);
若要支援泛型集合,建議由集合元素型別決定目標方法,不要只用方法名稱抓取第一個多載。使用反射選擇方法時,逐一比對名稱、參數數量與參數型別;若找不到唯一方法就立即失敗。這比在錯誤多載上補轉型更容易維護,也能防止套件或模型變更後靜默改變行為。
NotContains 也不需要另一套型別規則。先建立已驗證的 Contains 節點,再以 Expression.Not 反轉最終布林結果即可;但 null 語意要先寫成測試。null 對非空集合究竟視為不包含,還是應排除在結果外,必須由查詢契約決定,不能任由轉型例外替系統作答。
字串 Contains 與 EF Core 轉譯邊界
帶有 StringComparison 的字串 Contains 多載不應假設可由關聯式提供者轉譯;在篩選條件中無法轉譯時,EF Core 會丟出執行例外,而不是悄悄把整批資料搬到記憶體。
Microsoft 的 client-versus-server evaluation 文件說明:EF Core 僅允許頂層投影中部分無法轉譯的內容在用戶端計算;若無法轉譯的運算式出現在其他位置,例如 Where,會直接丟出執行階段例外。這項行為可避免篩選在資料端失效後,把大量資料讀入應用程式才處理。動態產生器因此不能只確認 Lambda 可編譯,還要透過實際 provider 執行查詢。
若需求是資料庫樣式比對,可使用 EF.Functions.Like,並明確理解大小寫行為由資料庫提供者與 collation 決定。不要把 Like 宣稱為所有資料庫都等同 OrdinalIgnoreCase;兩者對文化、大小寫、重音與萬用字元的規則不同。需要精確序位語意時,應選擇與需求相符的資料欄位正規化及 collation,並用目標提供者的整合測試鎖定結果。
也不要在 Where 前隨意加入 AsEnumerable 來讓方法在記憶體執行。這會改變查詢邊界,使後續篩選不再轉譯成 SQL,資料量與傳輸成本可能顯著增加。只有資料集合已知很小、界線明確且經審查時,才可刻意切到用戶端;預設策略應保持篩選可轉譯並檢查產生的 SQL 或執行結果。
驗證與重現:.NET 9、EF Core SQLite 9.0.9
本次在 Windows sandbox 使用 .NET SDK 9.0.305 與 Microsoft.EntityFrameworkCore.Sqlite 9.0.9 實際執行;四項檢查皆通過,未連線外部資料庫,也未寫入既有資料。
- 建立
net9.0Console 測試專案,固定 SDK 為9.0.305,加入Microsoft.EntityFrameworkCore.Sqlite9.0.9,並使用記憶體內 SQLite 連線。 - 建立
List.Contains(int)方法資訊,直接傳入int?屬性節點;通過條件是捕捉到ArgumentException,若節點建立成功則失敗。 - 依方法參數型別加入
Expression.Convert,編譯委派後以集合內外數值測試;通過條件依序為true與false。 - 對 SQLite 查詢執行
string.Contains的StringComparison多載;通過條件是收到InvalidOperationException,表示篩選無法轉譯。 - 改用
EF.Functions.Like查詢單筆 sandbox 資料;通過條件是計數恰為一,結束後由程序釋放記憶體資料庫。
dotnet --version
dotnet restore ExpressionVerification.csproj
dotnet run --project ExpressionVerification.csproj
實際可觀察結果為:不相容呼叫測試回報 MISMATCH_PASS: ArgumentException;轉型委派回報 CONVERT_PASS: true,false;不可轉譯多載回報 TRANSLATION_PASS: InvalidOperationException;Like 查詢回報 LIKE_PASS: count=1。這些是正確性檢查,不是效能 benchmark,也不代表其他 provider 的 collation 行為。
若讀者使用不同版本,先調整 global.json 與套件版本,再完整重跑,不要混用 SDK 與 EF Core 主版本後直接比較結果。若目標為其他關聯式 provider,保留純 Expression 單元測試,並另加該 provider 的暫時測試資料庫整合測試;SQLite 的結果只能證明本次指定組合。
回歸測試矩陣與程式碼審查清單
完整回歸範圍應同時覆蓋 CLR 型別、集合元素型別、null 語意、EF Core provider 與資料狀態,不能只驗證單一正常值通過。
型別矩陣至少包含 int 對 List、int? 對 List、int? 對 List,以及明確拒絕的無關型別。每列都要記錄預期是直接使用、合法轉型、加入 null guard,或在建構階段拒絕。這能防止修正 nullable 案例後,反而破壞原本合法的 nullable 集合。
查詢矩陣要區分純委派測試與 provider 整合測試。純委派測試確認運算式樹的 CLR 行為,執行快速且容易定位;整合測試確認 EF Core 是否能將同一條件送給 provider。兩者不能互相替代,因為可編譯的委派未必可轉譯,而可轉譯查詢也可能因資料庫 collation 得到不同字串結果。
程式碼審查時依序問:是否選到唯一正確多載?屬性型別與參數型別是否完全列出?轉型前是否證明合法?null 的真值表是否寫進測試?查詢例外是否仍可觀察?是否有人用 AsEnumerable 隱藏轉譯錯誤?字串比較是否說明 provider 與 collation?固定版本測試是否可由乾淨 sandbox 重跑?只要其中一項沒有答案,就不應把動態條件產生器視為完成。
結語
EF Core 動態查詢的可靠性來自明確型別與可驗證邊界:按方法參數對齊 Expression、獨立定義 null 語意、用實際 provider 驗證轉譯,並讓失敗與零筆結果保持不同狀態。
最小修補可能只是一個 Expression.Convert,但可長期維護的解法必須包含多載選擇、型別檢查、null guard、轉譯整合測試與可觀察錯誤。採用這套流程後,當模型、集合或 provider 改變時,測試會指出是哪一層契約被破壞,而不是讓上層只看到難以追查的空結果。