【Rails5】関連レコードのプロパティをwhereに含める方法【絞り込み検索】
Railsで絞り込み検索を実装する際に、親モデルのプロパティだけでなく、関連モデルのプロパティも検索対象に含めるのに試行錯誤したのでそれの残しがきです。
タグ、カテゴリー、価格帯などからレストランを絞り込めるようなポータルサイトをイメージしてもらうとわかりやすいかもしれないです。
この記事では関連レコードのプロパティを含めた絞り込み検索を以下の二つの角度から行う方法を紹介します:
- いずれかの関連レコードが紐づいている親レコードを取得(イメージとしてはOR検索)
- 全ての関連レコードが紐づいている親レコードを取得(イメージとしてはAND検索)
具体的には「ぐるなび」のようなレストランを検索できるポータルサイトの簡略版を例として進めていきます。
親のモデルとして「レストラン」モデルがあり、関連モデルとして「カテゴリー」と「タグ」が紐づいているシンプルな構成です。
クラスや関連性は以下のようなよくある形を想定します:
1 2 3 4 5 6 |
class Restaurant < ApplicationRecord has_many :restaurant_categories has_many :categories through: :restaurant_categories has_many :restaurant_tags has_many :tags through: :restaurant_tags end |
1 2 3 4 |
class RestaurantCategory < ApplicationRecord belongs_to :restaurant belongs_to :category end |
1 2 3 4 |
class Category < ApplicationRecord has_many :restaurant_categories has_many :restaurants through: :restaurant_categories end |
1 2 3 4 |
class RestaurantTag < ApplicationRecord belongs_to :restaurant belongs_to :tag end |
1 2 3 4 |
class Tag < ApplicationRecord has_many :restaurant_tags has_many :restaurants through: :restaurant_tags end |
まずはいずれかの関連レコードが紐づいている親レコードを取得(イメージとしては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-1.関連テーブルをleft_joinsする
この方法は比較的シンプルです。
親のテーブルに関連テーブルを結合してしまえば一緒にデータを取得することが可能になります。
この場合は親のテーブルを主軸とするので、Rails5から導入されたleft_joinsメソッドを使います。
1 |
Restaurant.left_joins(:restaurant_categories) |
詳しくは以下の記事をご覧ください。
ちなみにLEFT OUTER JOINはテーブルがものすごく大きい場合はパフォーマンスが落ちます。クエリ時間が長い場合はデータベースデザインを検討しなおすか他の方法を考えるといいかもしれません。
以下の記事(英語)が大きなテーブルでLEFT OUTER JOINした際のパフォーマンスについて言及しています。
1-2.whereメソッド用の条件を動的に作る
left_joinsメソッドで関連テーブルを結合したら、以下のようにwhereメソッドを使って関連レコードのプロパティも検索の対象に含めることが出来ます。
1 |
Restaurant.left_joins( :restaurant_categories ).where( :restaurant_categories => { :category_id => [カテゴリのID配列] } ) |
ただこのように直書きしてしまうと、カテゴリーで絞り込みを行なっていない場合に、カテゴリーのID配列が空の状態で渡されることになります。
その場合、当てはまるレコードがないため、結果何も返って来ません。
ですので、ユーザーがカテゴリーの絞り込みを行い検索フォームからカテゴリーの配列が渡された時のみwhereメソッドに上記の条件を含めるようにしないといけません。
以下のように変更します:
1 2 3 4 5 6 7 8 9 10 |
categories = [カテゴリーID] //検索フォームから受け取ったカテゴリーのID配列 conditions = {} //新しくハッシュを作ります unless categories.empty? //ハッシュが入れ子になってるのでまた新しくハッシュを作ります conditions[:restaurant_categories] = {} //:restaurant_categories => { :category_id => [カテゴリのID配列] }と同じ conditions[:restaurant_categories][:category_id] = categories.map(&:to_i) end //uniqメソッドを使い、重複の削除 @query = Restaurant.left_joins( :restaurant_categories ).where(conditions).uniq |
同じ要領でタグ用にも作ります
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
categories = [カテゴリーID] //検索フォームから受け取ったカテゴリーのID配列 tags = [タグID] //検索フォームから受け取ったタグのID配列 conditions = {} //新しくハッシュを作ります unless categories.empty? //ハッシュが入れ子になってるのでまた新しくハッシュを作ります conditions[:restaurant_categories] = {} //:restaurant_categories => { :category_id => [カテゴリのID配列] }と同じ conditions[:restaurant_categories][:category_id] = categories.map(&:to_i) end unless tags.empty? conditions[:restaurant_tags] = {} conditions[:retaurant_tags][:tag_id] = tags.map(&:to_i) end //uniqメソッドを使い、重複の削除 @query = Restaurant.left_joins( :restaurant_categories, :restaurant_tags ).where(conditions).uniq |
これで例えタグのみで絞り込み検索を行なっても、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 |
「全ての関連レコードが含まれる」なので、「洋食」と「イタリアン」両方のカテゴリーが紐づいているレストランが取得されます。この場合は
- レストランほげ
のみが取得される形となります。
それでは進めていきましょう。
2-1.全ての関連レコードが紐づいているという事とは
①のように、検索フォームから渡されるのはIDの配列です。それをwhereメソッドにかけると、自動的に配列の中のIDのいずれかに該当するレコードが取得されます。
なので別の方法を考える必要があります。
仮に以下のような状況があったとします。
1 2 3 4 5 6 7 |
categories = [1,5,6,7] //検索フォームから渡されたカテゴリーIDの配列 //実際に4つのカテゴリーが紐づいているレストランがあった場合、RestaurantCategoryのレコード数は計4つあることになります。 RestaurantCategory{category_id: 1, restaurant_id: 51} RestaurantCategory{category_id: 5, restaurant_id: 51} RestaurantCategory{category_id: 6, restaurant_id: 51} RestaurantCategory{category_id: 7, restaurant_id: 51} |
これを言葉にしてみましょう。
検索フォームから渡されるIDの配列の値が全て該当するということは、レストランとカテゴリーを紐づけている中間モデルをレストランのIDでグループ分けした時に、中間モデルのレコード数がカテゴリーIDの配列の値の和と同じになる
という事になります。
方向性が見えて来ました。手順は以下です。
- 検索フォームから渡されたカテゴリーIDの配列に含まれるIDが該当する中間モデルを全て取得する
- 取得された中間モデルをレストランのIDでグループ分けする
- グループ分けされたレコード数が、渡されたカテゴリーID配列の値の和と同じものをさらに取得する
- 取得したレコードのレストランIDを配列にマッピングする
上記をコードにしたものが下記です。
1 2 3 4 |
categories = [1,5,6,7] //検索フォームから渡されたカテゴリーIDの配列 matchAllCategories = RestaurantCategory.where(category_id: categories).group(:restaurant_id).having('count(restaurant_id) = ?', categories.length) restaurantIds = matchAllCategories.map(&:restaurant_id) |
あとはwhereメソッドで配列のIDに該当するレストランのレコードを取得するだけです。
1 |
@query = Restaurant.where(id: restaurantIds) |
2-2.関連モデルが複数ある場合
関連モデルがカテゴリーのみの場合は先ほどのように取得したrestaurant_idの配列をwhere検索すればいいだけです。しかし、関連モデルがカテゴリーだけでなくタグもあった場合は両方の条件を満たすレコードを取得しないといけません。
1 2 3 4 |
categories = [1,5,6,7] tags = [10,20,30,40,50] ↑全てのカテゴリーと全てのタグが紐づいているレコードを検索 |
2-1でやったことをタグにもやりましょう。
1 2 3 4 5 6 7 8 |
categories = [1,5,6,7] //検索フォームから渡されたカテゴリーIDの配列 tas = [10,20,30,40,50] //検索フォームから渡されたタグIDの配列 matchAllCategories = RestaurantCategory.where(category_id: categories).group(:restaurant_id).having('count(restaurant_id) = ?', categories.length) categoryRestaurantIds = matchAllCategories.map(&:restaurant_id) matchAllTags = RestaurantTag.where(category_id: tags).group(:restaurant_id).having('count(restaurant_id) = ?', tags.length) tagRestaurantIds = matchAllTags.map(&:restaurant_id) |
カテゴリーの条件にあうレストランIDの配列と、タグの条件にあうレストランIDの配列が出来上がりました。
ここでもう一度求める結果を言葉にしてみましょう。
検索フォームでチェックを入れたカテゴリー全てに紐づいていて、かつチェックを入れたタグにも全て紐づいているレストランのみを検索する
つまり、カテゴリーの条件にあうレストランIDの配列と、タグの条件にあうレストランIDの配列を比べて、重複しているIDが全ての条件を満たしている事になります。
それでは空の配列を作り、その中にレストランIDの配列を入れていきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
categories = [1,5,6,7] //検索フォームから渡されたカテゴリーIDの配列 tas = [10,20,30,40,50] //検索フォームから渡されたタグIDの配列 pushIdArrays = [] unless categories.empty? matchAllCategories = RestaurantCategory.where(category_id: categories).group(:restaurant_id).having('count(restaurant_id) = ?', categories.length) categoryRestaurantIds = matchAllCategories.map(&:restaurant_id) pushIdArrays.push(categoryRestaurantIds) end unless aspect_ids.empty? matchAllTags = RestaurantTag.where(category_id: tags).group(:restaurant_id).having('count(restaurant_id) = ?', tags.length) tagRestaurantIds = matchAllTags.map(&:restaurant_id) pushIdArrays.push(tagRestaurantIds) end |
仮に、それぞれの条件に当てはまるレストランのID配列を以下と仮定しましょう。
1 2 |
categoryRestaurantIds = [5,23,32,44,82,94,95] //[1,5,6,7]のカテゴリーID全てが紐づいているレストランのID配列 tagRestaurantIds = [9,32,37,44,53,67,88,95] //[10,20,30,40,50]のタグID全てが紐づいているレストランのID配列 |
結果としてpushIdArraysの中身はこうなるはずです:
1 |
pushIdArrays = [ [5,23,32,44,82,94,95],[9,32,37,44,53,67,88,95] ] |
以下の方法でこの中から重複しているIDを取得します。
1 |
filteredIdArray = pushIdArrays.flatten.group_by{|e| e}.select{|k,v| v.size > 1}.map(&:first) |
そこからgroup_byメソッドを使って、同じ値をグループ化し、重複しているIDをマッピングします。
最終的にfilteredIdArrayの中身は以下のようになります:
1 |
filteredIdArray = [32,44,95] //全ての条件を満たすカードのID配列 |
あとはwhere検索で上記のIDに当てはまるカードを取得するだけです。
1 |
@query = Restaurant.where(id: filteredIdArray) |
2-3.二つ以上の配列から重複している値を探すときにのみに有効
配列が一つしか無い場合はIDが重複するはずが無いので何も返ってきません。
なので分岐させます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
pushIdArrays = [] unless categories.empty? matchAllCategories = RestaurantCategory.where(category_id: categories).group(:restaurant_id).having('count(restaurant_id) = ?', categories.length) categoryRestaurantIds = matchAllCategories.map(&:restaurant_id) pushIdArrays.push(categoryRestaurantIds) end unless aspect_ids.empty? matchAllTags = RestaurantTag.where(category_id: tags).group(:restaurant_id).having('count(restaurant_id) = ?', tags.length) tagRestaurantIds = matchAllTags.map(&:restaurant_id) pushIdArrays.push(tagRestaurantIds) end if pushIdArrays.length > 1 filteredIdArray = pushIdArrays.flatten.group_by{|e| e}.select{|k,v| v.size > 1}.map(&:first) else filteredIdArray = pushIdArrays.flatten end @query = Restaurant.where(id: filteredIdArray) |
pushIdArraysの中に1つ以上の配列が入ってる場合、重複を取得し、配列が1つだけの場合は平坦化させるだけです。
これで複数の関連モデルにも対応出来ます!
2-4.Ransackと併用する場合
キーワード検索を実装する際によく使われるRansack というgemがあります。このgemと併用する場合、キーワードのみを使って絞り込みが行われるケースが想定されます。そうすると、カテゴリーとタグのID配列が空になり、最終的なレストランID配列も空になります。whereメソッドに空のID配列を入れると、何も返ってきません。
解決策も検索条件の内容によって変わります。
パターン①:whereメソッドに必ず含める項目がある場合
例えば、レストランモデルに「公開ステータス」というプロパティがあったとしましょう。取得するのは必ず「公開中」のレストランと決まっていたらwhereメソッドには必ずこのプロパティが含まれることになります。
この場合、1-2.でやったように条件を動的に変えることによって解決できます。
1 2 3 4 5 6 7 8 |
conditions = {} conditions[:status] = public //カテゴリーとタグで絞り込みを行なっていない場合は検索条件に含めない unless categories.empty? && tags.empty? conditions[:id] = filteredIdArray end @query = Restaurant.where(conditions).ransack(:name_cont_any => keyword).result() |
パターン②:whereメソッドに必ず含める項目がない場合
必ず含める項目がない場合はwhereメソッドを使えないので、レストランの取得方法を分岐させる必要があります。
1 2 3 4 5 |
if categories.empty? && tags.empty? @query = Restaurant.ransack(:name_cont_any => keyword ).result() else @query = Restaurant.where(id: filteredIdArray).ransack(:name_cont_any => keyword).result() end |
3.おまけ:配列で重複しているものを探す方法
ちなみに、配列で重複している値を探す方法を検索すると、以下の方法をよくみます。
1 2 3 4 |
sample_array = [1,1,2,3,4,4,5,6,7,7,8] sample_array.select{|item| sample_array.count(item) > 1}.uniq => [1,4,7] |
これをハッシュ化すると、繰り返し処理がなくなるので速さが向上します。
以上、Railsで関連レコードのプロパティを含めた絞り込み検索を二つの角度から行う方法でした。
参考記事