EntityFramework を使用するとデータベースに対する検索処理を LINQ で記述することができるためとても便利です。しかし LINQ では動的な(可変の)検索条件を記述することができず、複雑な検索処理では SQL を利用せざるを得ません。そこで今回は式木( Expression Tree )を利用して動的に LINQ で検索条件を組み立てる方法について紹介します。
はじめに
以前 .NET Core 2.1 / 2.2 から .NET Core 3.1 に更新した際に、 EntityFramework Core (以下 EFCore ) の破壊的変更にぶつかってしまい予定外の工数を割いてしまった、という記事を投稿しました。今回の記事の内容も .NET Core 3.1 (EFCore 3.1) に更新した際に別の破壊的変更によって実装変更を余儀なくされたことがきっかけとなっています。 C# の式木( Expression )の機能を活用し動的に LINQ の WHERE 句の中身を組み立てます。
発生事象と具体的な対策(クリックで詳細を表示)
EFCore 3.x の破壊的変更の内容の1つに「関連エンティティの一括読み込みが 1 つのクエリで行われるようになった」という項目があります。これはどういうことかというと、 EntityFramework では外部キーで繋がったテーブルを Include
や ThenInclude
を使用して一括で取得する機能がありまして、 2.x ではこの機能を利用すると各テーブル毎に SQL が生成されてデータが取得されますが、 3.x 以降では全てのテーブルを結合して1本の SQL を生成・実行する様になりました。テーブルを数個繋げるだけならばパフォーマンス上そこまで問題にはなりません。しかしある Web アプリでは20近いテーブルを最大5ネストで一括取得していたため、多数のレコードが登録されているデータを取得する際に1秒もかかっていなかったクエリが数分かかるようになりました。タイムアウト事故必至です。
この問題に対応するため筆者は手動で Include
や ThenInclude
を分解し、数本の適度な大きさのクエリ( LINQ )に分割しました。各分割クエリでは親テーブルのキーを動的に指定する必要がありますので、 Expression で WHERE 句の中身を動的に生成した、というのがこの記事の経緯です。
docs.microsoft.com
試した環境
要素 | バージョン |
---|---|
Visual Studio | 2022 (17.3.6) |
.NET SDK | 6.0.402 |
本記事に記載のコードの一部は GitHub 上に置いてあります。
式木( Expression Tree )について
式木とは「式(数式)を木構造で表したもの」です。たとえば a + b
(a + b) * c + d
は次の図の様に表されます。
C# において式木は Expression
として表され、コンパイルおよび実行することができます。ただし式木として表現可能な C# のコードには制限があり、分岐を持たない1行で記述できるラムダ式となります。式木で表すことができるラムダ式は次の様に Expression
に代入可能です。
// 変換できる Expression<Func<string, bool>> e1 = (string x) => x == "1"; // 変換できない // CS0834: ステートメント本体を含むラムダ式は、式ツリーに変換できません。 Expression<Func<string, bool>> e2 = (string x) => { if (x == "1") return true; else return false; };
C# の Expression にはラムダ式を表す LambdaExpression や2項演算子を表す BinaryExpression などいくつかの種類がありますが、これらは全て Expression クラスを継承しています。またこれらの Expression は直接インスタンス生成をすることはできず、 Expression クラスの static メソッドを通じた操作が必要です。本記事の主題は Expression の詳細な解説ではありませんので、個々の説明は割愛いたします。
動的に検索条件を構築する
本記事の目的は EFCore を利用する際に動的に LINQ の検索条件を構築することです。例えば以下のように、複数の Expression から1つの Expression を生成することが目標です。
// x.Name == "Hoge" && x.Age == 10 を作りたい var data = context.Data .Where(ExpressionHelper.AndAlso<Data>(x => x.Name == "Hoge", x => x.Age == 10)) .ToList(); // x.Name == "Hoge" || x.Age == 10 を作りたい var data = context.Data .Where(ExpressionHelper.OrElse<Data>(x => x.Name == "Hoge", x => x.Age == 10)) .ToList();
ところでラムダ式を Expression で表す場合、Expression の構造は例えば (string x) => x == "1"
であれば下の画像のようなイメージとなります(※あくまでもイメージ、雰囲気です)。
検索条件とはこの図の Body の箇所を指しますので、複数の Expression を1本にまとめる場合は Body 部分を結合すれば良さそうです。 &&
で結合するには AndAlso
を、 ||
で結合するには OrElse
を使用します。
Expression<Func<Data, bool>> e1 = x => x.Name == "Hoge"; Expression<Func<Data, bool>> e2 = x => x.Age == 10; BinaryExpression andAlsoBody = Expression.AndAlso(e1.Body, e2.Body); // && BinaryExpression orElseBody = Expression.OrElse(e1.Body, e2.Body); // ||
これで Body 部分が作成されました。続いてこの Body 部分を Lambda に戻しましょう。先の図では LambdaExpression は Parameter と Body から構成されていますので、残り必要なものは Parameter だけですね。 Parameter の生成はその名の通り Parameter
を、 Lambda の生成も Lambda
を使用します。
ParameterExpression parameter = Expression.Parameter(typeof(Data), "x"); // (Data x) => x.Name == "Hoge" && x.Age == 10 Expression<Func<Data, bool>> andAlsoLambda = Expression.Lambda<Func<Data, bool>>(andAlsoBody, parameter); // (Data x) => x.Name == "Hoge" || x.Age == 10 Expression<Func<Data, bool>> orElseLambda = Expression.Lambda<Func<Data, bool>>(orElseBody, parameter);
ただしこのままでは式の中で利用している Parameter が名前こそすべて「x」で同じですが、その参照は異なりますのでうまく動作しません。このラムダ式を正しく動かすにはすべての Parameter を同じ参照に差し替える必要があります。
すべての Parameter を手動で差し替えるのはとても大変ですが、 C# の Expression には ExpressionVisitor
という Expression 中に存在するすべての要素に対して処理を差し込めるクラスがあります。このクラスを継承したクラスを作成し、 Body 部分の Parameter をすべて同じ参照に差し替えましょう。 Parameter を固定で差し替えるだけですので、このような感じで大丈夫でしょう。
/// <summary> /// Expressionの引数を固定値に変換するクラス /// </summary> /// <typeparam name="T">引数の型</typeparam> class ParameterReplacer<T> : ExpressionVisitor { public ParameterExpression Parameter { get; } public ParameterReplacer(ParameterExpression? p = null) { Parameter = p ?? Expression.Parameter(typeof(T), "x"); } protected override Expression VisitParameter(ParameterExpression node) { return Parameter; } }
この Parameter を差し替える ExpressionVisitor を利用して Expression を組み立てれば完成です。
ParameterReplacer<Data> parameter = new ParameterReplacer<Data>(); // (Data x) => x.Name == "Hoge" && x.Age == 10 Expression<Func<Data, bool>> andAlsoLambda = Expression.Lambda<Func<Data, bool>>(parameter.Visit(andAlsoBody)!, parameter.Parameter); // (Data x) => x.Name == "Hoge" || x.Age == 10 Expression<Func<Data, bool>> orElseLambda = Expression.Lambda<Func<Data, bool>>(parameter.Visit(orElseBody)!, parameter.Parameter);
これらの処理を動的に組み立てるには検索条件となる Expression を引数とする関数を作成します。可変部分は Body を結合する箇所だけですので、例えば &&
で結合するなら以下のような関数が利用できると思います。
/// <summary> /// 複数の <c>(x: T) => bool</c> 形式の Lambda の条件部分を <c>AND (&&)</c> で結合した新しい Lambda に変換します。 /// </summary> public static Expression<Func<T, bool>> AndAlso<T>( Expression<Func<T, bool>> e1, Expression<Func<T, bool>> e2, params Expression<Func<T, bool>>[] expressions) { var parameter = new ParameterReplacer<T>(); var body = Expression.AndAlso(e1.Body, e2.Body); foreach (var e in expressions) { body = Expression.AndAlso(body, e.Body); } return Expression.Lambda<Func<T, bool>>(parameter.Visit(body)!, parameter.Parameter); }
おわりに
Expression を利用して動的に LINQ の検索条件を組み立てる方法の紹介でした。 SQL を書いてしまえば良い場面も多いかとは思いますが、 Include
を使用する等今回の筆者の様に SQL では実装できない箇所もあります。 Expression は今回以外の用途だと例えば EFCore の Fluent API 等で使用されており、リフレクションのお供の様に使用することもできます。多様は禁物ですが知っていると便利な技術ですので、必要性や読みやすさ等を考慮しつつ、用法用量守った実装を心掛けましょう。