LINQ で動的に OR 検索するやつ

Entity Framework なんかを使っていて、データを検索したいとき。
検索フォームからユーザーに検索条件を入力してもらって、それで OR 検索をしたいとなると、ちょっと面倒です。
もちろん、何も入力しなかった項目ではフィルタリングを行いません。

AND 検索だけなら、Where メソッドをチェーンさせて行けばいいのですが、OR 検索が絡むと、そう簡単にはいきません。
こういう場合は Expression Tree を頑張って組み立てなければいけないわけですが、やり方はこのへんを見て頂いてですね…

LINQ文で動的にWhere句を組み立てるには?[3.5、C#、VB] − @IT
Expressionを使った動的なOR文の生成 - Architect Life

いや、いやいやいや。わけがわからないし面倒くさい。そんなのやってらんない。
もうちょっと簡単に、こんな感じでやりたいわけですよ。*1

var expressions = new List<Expression<Func<Item, bool>>>();

if (searchCriteria.Keywords.Any()) {
  // 検索条件にキーワードが指定されていた場合
  expressions.Add(x => searchCriteria.Keywords.Contains(x.Keyword));
}

if (searchCriteria.Categories.Any()) {
  // 検索条件にカテゴリーが指定されていた場合
  expressions.Add(x => searchCriteria.Categories.Contains(x.Category));
}

// 複数の条件で OR 検索したい
var results = data.Where(expressions.OrElse());

Keyword とか Category といったプロパティの参照や、Contains メソッドの呼び出しまで動的に組み立てるのではなく*2、そのあたりはラムダ式で作っておいて、条件の指定の有無に応じて OR 条件を組み立てるところ*3だけ動的にできれば十分です。

Expression.OrElse

先程のサンプルでは、Expression を List に入れて個数を可変にしていましたが、ここからは簡略化のために 2 つだけとします。
以下のような例で、expression1 と expression2 を合成して、単一の Expression を作れればよいわけです。

Expression<Func<Item, bool>> expression1 = x => searchCriteria.Keywords.Contains(x.Keyword));
Expression<Func<Item, bool>> expression2 = x => searchCriteria.Categories.Contains(x.Category));

↑これから、↓これを作りたい。

Expression<Func<Item, bool>> expression = x =>
  searchCriteria.Keywords.Contains(x.Keyword) ||
  searchCriteria.Categories.Contains(x.Category);

C# でいう || 演算子に対応する Expression を作るのは、Expression.OrElse メソッドです。

というわけで何も考えずにこう書くと、コンパイルが通りません。

var expression = Expression.OrElse(expression1, expression2);
var results = data.Where(expression);

OrElse は BinaryExpression オブジェクトを返しますが、Where は Expression<Func<Item, bool>> オブジェクトを要求するので、型が合いません。
また、2 行目を削除するとコンパイルはできますが、実行時エラーになります。

ハンドルされていない例外: System.InvalidOperationException: バイナリ演算子 OrElse が型 'System.Func`2[Item,System.Boolean]' と 'System.Func`2[Item,System.Boolean]' に対して定義されていません。

なぜこうなるかと言うと、先のコードは、C# でハード コーディングすれば、以下のようなものに相当するからです。
これがコンパイルできないのは当然ですよね。

Expression<Func<Item, bool>> expression =
  x => searchCriteria.Keywords.Contains(x.Keyword) ||
  x => searchCriteria.Categories.Contains(x.Category);

再掲しますと、実現したいのは、こうです。ちょっと違いますね。

Expression<Func<Item, bool>> expression = x =>
  searchCriteria.Keywords.Contains(x.Keyword) ||
  searchCriteria.Categories.Contains(x.Category);

ラムダ式は、引数部と本体部から成ります。
f:id:aetos382:20150925034432p:plain
|| 演算子を OrElse メソッドに読み替えれば、OrElse メソッドには、本体部のみを渡せばよいのがわかるでしょうか。
ラムダ式の本体部は LambdaExpression.Body プロパティで取れますから、以下のように呼び出すことができます。これなら実行時エラーにはなりません。

var expression = Expression.OrElse(expression1.Body, expression2.Body);

ただし、これで得られるのは、C# でいうと、この部分(本体部)だけです。

searchCriteria.Keywords.Contains(x.Keyword) ||
searchCriteria.Categories.Contains(x.Category);

引数部がないので、これだけではラムダ式として成り立たず、Where メソッドに渡すことができません。

Expression.Lambda

引数部と本体部からラムダ式を生成するのは、Expression.Lambda メソッドです。
引数部に相当するのは ParameterExpression オブジェクトで、これは Expression.Parameter メソッドで生成することができます。

というわけで、適当に引数部を生成してくっつけてやりましょう。

var expressionBody = Expression.OrElse(expression1.Body, expression2.Body);
var parameter = Expression.Parameter(typeof(Item), "x");
var lambdaExpression = Expression.Lambda<Func<Item, bool>>(expressionBody, parameter);

var results = data.Where(lambdaExpression);

コンパイルはできますね。
しかし…

ハンドルされていない例外: System.InvalidOperationException: 型 'Item' の変数 'x' がスコープ '' から参照されましたが、これは定義されていません

ダメでしたー。

なぜダメか。
組み合わせたい 2 つのラムダ式は、こう書き換えてもよいはずです。

Expression<Func<Item, bool>> expression1 = x1 => searchCriteria.Keywords.Contains(x1.Keyword));
Expression<Func<Item, bool>> expression2 = x2 => searchCriteria.Categories.Contains(x2.Category));

これに対して、先ほどのコードで生成されたものを、C# で書き下せば、こうなります。

Expression<Func<Item, bool>> expression = x =>
  searchCriteria.Keywords.Contains(x1.Keyword) ||
  searchCriteria.Categories.Contains(x2.Category);

そりゃエラーになりますよね。

繰り返しになりますが、ラムダ式は引数部と本体部から成ります。
そして、本体部は引数部への参照を持っています。
f:id:aetos382:20150925034442p:plain
この参照元と参照先は同じ名前であるだけではダメで、同じ ParameterExpression インスタンスでなければならないのです。

さて、そうなると、ラムダ式の本体部に分け入って、その中にある ParameterExpression オブジェクトを、用意したもので置き換えてやる必要があります。
どうすればいいでしょうか。

ExpressionVisitor

そんなときのためのクラスが、.NET Framework にはちゃんと用意されています。
それが ExpressionVisitor クラス です。
このクラスの派生クラスを作って、Visit メソッドに Expression を渡してやると、その中の個々の構成要素(パラメーターとかプロパティ参照とかメソッド呼び出しとか…)に応じて、適切なメソッドを呼び出してくれます。
オーバーライドしたメソッド中では、引数に渡されてきたオリジナルの構成要素を元に、新しい構成要素を表す Expression を返すことで、式を作り替えることができます。
今回はパラメーターを置き換えたいので、VisitParameter メソッドをオーバーライドしてやればいいわけです。

このサンプルは文末をご覧ください。

まとめ

つまり、今回のようなラムダ式の合成をするためには、

  1. 結果のラムダ式の引数部となる ParameterExpression を用意し
  2. Body プロパティで元のラムダ式の本体部を取り出し
  3. ExpressionVisitor の派生クラスを使って、本体部の中のパラメーター参照を置き換え
  4. OrElse で本体部を組み合わせ
  5. Expression.Lambda で引数部をくっつけてラムダ式を組み立てる

という手順が必要になるわけです。

2 引数ではなく任意個数の引数にするには、OrElse を繰り返し呼べばよいだけです。

というわけで、完成品がこちら。


giste5529fc5c9f32302adfb

*1:この仕様だと、一つの Item は Keyword も Category も 1 つずつしか持てないので使いづらそうですが、そんなことはこの際どうでもいいことです。と逃げる。

*2:独自のクエリ言語を持つシステムでもない限り、そこまで柔軟にする必要はないでしょう。

*3:上記コードの expressions.OrElse の部分