「eager_loadからのあらかじめHash」で発行クエリ数を最小限に抑えて高速で検索できる

 ループ内でアソシエーション以外の方法で参照クエリを実行しなければいけない場合に、
クエリ回数を最小限に抑える方法について記す。

状況

 以下のような関係性の時、ユーザーが所有する全てのアイテムの名前を取得したい。

# アソシエーション
User
 has_many: user_items

UserItem
# ### Columns
# Name                               |
# ---------------------------------- |
# **`user_id`**                      |
# **`item_id`**                      |

belongs_to: item

Item
# ### Columns
# Name                               |
# ---------------------------------- |
# **`name`**                      |

問題

 以下のようなコードでは、ループ内でfind_byを行なっているためループ回数分クエリが実行される。取得するItemの件数が多い場合や、Item.allの数が多い場合には大量のクエリが発行されるので、処理が重くなる、という問題が起こる。

user = User.find(?)
user_items = Item.all.map do |item|
  user.user_items.find_by(item: item)
end
# 以降はuser_itemsを使ってアイテム名を返す

解決

 これを回避するにはループ外でまとめてUserItemを取得するようにする。

user = User.find(?)
# ループ外でUserItemを取得しておく
user_items = user.user_items.preload(:item)

Item.all.map do |item|
  # ループ内ではRubyのメソッドで検索する
  user_item = user_items.detect {|user_item| item.id == user_item.item_id }
  user_item.name
end

 これで発行クエリ数は少なくできたが、Item.all.mapループの中でuser_items.detectループを回しているのが問題。

 user_items.detectループはItemの数が多くなるとループの数も増えるので、検索にかかる時間が長くなる。

 以下のように、あらかじめ検索対象であるuser_itemsをhashにしておけば、user_items.detectループは不要になる。

user = User.find(?)
# { item_id => user_item }形式のHashを用意する
user_item_hash = user.user_items.includes(:item).index_by(&:item_id) 

# hashから目当てのItemを探す
Item.all.map do |item|
  user_item_hash[item.id].name
end

まとめ

1、ループ内でアソシエーションを参照する場合はActiveRecordのeager loadingメソッドを利用する
2、ループ内でアソシエーション以外の参照を行う場合はループ外で自前で必要なデータをまとめて取得する
3、2で取得したデータをループ内で検索に使用する場合は、探索効率を良くするためにHashにする

コメント