【Rails5】関連レコードのプロパティをwhereに含める方法【絞り込み検索】

Railsで絞り込み検索を実装する際に、親モデルのプロパティだけでなく、関連モデルのプロパティも検索対象に含めるのに試行錯誤したのでそれの残しがきです。

タグ、カテゴリー、価格帯などからレストランを絞り込めるようなポータルサイトをイメージしてもらうとわかりやすいかもしれないです。

この記事では関連レコードのプロパティを含めた絞り込み検索を以下の二つの角度から行う方法を紹介します:

  1. いずれかの関連レコードが紐づいている親レコードを取得(イメージとしてはOR検索)
  2. 全ての関連レコードが紐づいている親レコードを取得(イメージとしてはAND検索)

具体的には「ぐるなび」のようなレストランを検索できるポータルサイトの簡略版を例として進めていきます。

親のモデルとして「レストラン」モデルがあり、関連モデルとして「カテゴリー」と「タグ」が紐づいているシンプルな構成です。
クラスや関連性は以下のようなよくある形を想定します:

まずはいずれかの関連レコードが紐づいている親レコードを取得(イメージとしてはOR検索)から見ていきましょう。

1.いずれかの関連レコードが紐づいている親レコードを取得

「いずれかの関連レコードが紐づいている親レコードの取得」というのはいまいちわかりづらいので具体的な例を出します。

以下のようなデータがあるとします。

Restaurant Table

id name
1 レストランほげ
2 ふが屋
3 ほげふが亭

Category Table

id name
1 和食
2 洋食
3 中華
4 イタリアン

Restaurant_Category Table

id restaurant_id category_id
1 1 2
2 2 1
3 3 3
4 1 4
サイトの検索フィルターから「イタリアン、中華」にチェックを入れたとします。
「いずれかの関連レコードが含まれる」なので、「イタリアン」もしくは「中華」カテゴリーが紐づいているレストランが取得されます。この場合は
  1. レストランほげ
  2. ふげほが亭

が取得される形となります。

それでは早速取得する方法に進んで行きましょう。

1-1.関連テーブルをleft_joinsする

この方法は比較的シンプルです。

親のテーブルに関連テーブルを結合してしまえば一緒にデータを取得することが可能になります。

この場合は親のテーブルを主軸とするので、Rails5から導入されたleft_joinsメソッドを使います。

left_joinsメソッドを掻い摘んで説明するとRestaurant Tableのデータを取得し、RestaurantCategory Tableからrestaurant_idにマッチするレコードを結合します。
詳しくは以下の記事をご覧ください。

ちなみにLEFT OUTER JOINはテーブルがものすごく大きい場合はパフォーマンスが落ちます。クエリ時間が長い場合はデータベースデザインを検討しなおすか他の方法を考えるといいかもしれません。

以下の記事(英語)が大きなテーブルでLEFT OUTER JOINした際のパフォーマンスについて言及しています。

1-2.whereメソッド用の条件を動的に作る

left_joinsメソッドで関連テーブルを結合したら、以下のようにwhereメソッドを使って関連レコードのプロパティも検索の対象に含めることが出来ます。

ただこのように直書きしてしまうと、カテゴリーで絞り込みを行なっていない場合に、カテゴリーのID配列が空の状態で渡されることになります。

その場合、当てはまるレコードがないため、結果何も返って来ません。

ですので、ユーザーがカテゴリーの絞り込みを行い検索フォームからカテゴリーの配列が渡された時のみwhereメソッドに上記の条件を含めるようにしないといけません。

以下のように変更します:

同じ要領でタグ用にも作ります

これで例えタグのみで絞り込み検索を行なっても、whereの条件にはカテゴリーの部分が含まれることはありません。

また、カテゴリーとタグ両方で絞り込み検索を行なった場合は絞り込んだカテゴリーのいずれかが含まれている、かつ絞り込んだタグのいずれかが含まれているレストランを取得します。

2.全ての関連レコードが紐づいている親レコードを取得

①でやったように、同じデータを使い具体的な例を出します。

Restaurant Table

id name
1 レストランほげ
2 ふが屋
3 ほげふが亭

Category Tabel

id name
1 和食
2 洋食
3 中華
4 イタリアン

Restaurant_Category Table

id restaurant_id category_id
1 1 2
2 2 1
3 3 3
4 1 4
サイトの検索フィルターから「洋食、イタリアン」にチェックを入れたとします。
「全ての関連レコードが含まれる」なので、「洋食」と「イタリアン」両方のカテゴリーが紐づいているレストランが取得されます。この場合は
  1. レストランほげ

のみが取得される形となります。

それでは進めていきましょう。

2-1.全ての関連レコードが紐づいているという事とは

①のように、検索フォームから渡されるのはIDの配列です。それをwhereメソッドにかけると、自動的に配列の中のIDのいずれかに該当するレコードが取得されます。

なので別の方法を考える必要があります。

こういう時は具体的に状況を紙などに書いて整理してみましょう。
仮に以下のような状況があったとします。

これを言葉にしてみましょう。

検索フォームから渡されるIDの配列の値が全て該当するということは、レストランとカテゴリーを紐づけている中間モデルをレストランのIDでグループ分けした時に、中間モデルのレコード数がカテゴリーIDの配列の値の和と同じになる

という事になります。

つまり上の状況ですと、RestaurantCategoryのレコードの数とcategories配列の値の和が同じになります。
方向性が見えて来ました。手順は以下です。
  1. 検索フォームから渡されたカテゴリーIDの配列に含まれるIDが該当する中間モデルを全て取得する
  2. 取得された中間モデルをレストランのIDでグループ分けする
  3. グループ分けされたレコード数が、渡されたカテゴリーID配列の値の和と同じものをさらに取得する
  4. 取得したレコードのレストランIDを配列にマッピングする
ひとまずここまでです。
上記をコードにしたものが下記です。

これで4つのカテゴリーが紐づいているレストランのID配列ができました。
あとはwhereメソッドで配列のIDに該当するレストランのレコードを取得するだけです。

2-2.関連モデルが複数ある場合

関連モデルがカテゴリーのみの場合は先ほどのように取得したrestaurant_idの配列をwhere検索すればいいだけです。しかし、関連モデルがカテゴリーだけでなくタグもあった場合は両方の条件を満たすレコードを取得しないといけません。

2-1でやったことをタグにもやりましょう。

カテゴリーの条件にあうレストランIDの配列と、タグの条件にあうレストランIDの配列が出来上がりました。

ここでもう一度求める結果を言葉にしてみましょう。

検索フォームでチェックを入れたカテゴリー全てに紐づいていて、かつチェックを入れたタグにも全て紐づいているレストランのみを検索する

つまり、カテゴリーの条件にあうレストランIDの配列と、タグの条件にあうレストランIDの配列を比べて、重複しているIDが全ての条件を満たしている事になります。

それでは空の配列を作り、その中にレストランIDの配列を入れていきましょう。

仮に、それぞれの条件に当てはまるレストランのID配列を以下と仮定しましょう。

結果としてpushIdArraysの中身はこうなるはずです:

以下の方法でこの中から重複しているIDを取得します。

まず、flattenメソッドを使い配列の配列を平坦化します。
そこからgroup_byメソッドを使って、同じ値をグループ化し、重複しているIDをマッピングします。

最終的にfilteredIdArrayの中身は以下のようになります:

あとはwhere検索で上記のIDに当てはまるカードを取得するだけです。

2-3.二つ以上の配列から重複している値を探すときにのみに有効

上記の例は二つ以上の配列から重複している値を探す時にのみ有効です。
配列が一つしか無い場合はIDが重複するはずが無いので何も返ってきません。

なので分岐させます。

pushIdArraysの中に1つ以上の配列が入ってる場合、重複を取得し、配列が1つだけの場合は平坦化させるだけです。

これで複数の関連モデルにも対応出来ます!

2-4.Ransackと併用する場合

キーワード検索を実装する際によく使われるRansack というgemがあります。このgemと併用する場合、キーワードのみを使って絞り込みが行われるケースが想定されます。そうすると、カテゴリーとタグのID配列が空になり、最終的なレストランID配列も空になります。whereメソッドに空のID配列を入れると、何も返ってきません。

解決策も検索条件の内容によって変わります。

パターン①:whereメソッドに必ず含める項目がある場合

例えば、レストランモデルに「公開ステータス」というプロパティがあったとしましょう。取得するのは必ず「公開中」のレストランと決まっていたらwhereメソッドには必ずこのプロパティが含まれることになります。

この場合、1-2.でやったように条件を動的に変えることによって解決できます。

 

パターン②:whereメソッドに必ず含める項目がない場合

必ず含める項目がない場合はwhereメソッドを使えないので、レストランの取得方法を分岐させる必要があります。

3.おまけ:配列で重複しているものを探す方法

ちなみに、配列で重複している値を探す方法を検索すると、以下の方法をよくみます。

配列の中の全ての値にcountメソッドを適用しているため、配列が大きくなるとパフォーマンスが落ちます。
これをハッシュ化すると、繰り返し処理がなくなるので速さが向上します。

 
以上、Railsで関連レコードのプロパティを含めた絞り込み検索を二つの角度から行う方法でした。

参考記事