Railsで eager_load
を使う中で、 eager_load
についてもうちょっと理解を深めたいと思いました。
eager_load
についてはWeb上にいろいろな記事はあるものの、自分の理解を深めるためにためしたときのメモを残します。
今回は eager_load
のLEFT JOINにて、WHERE句とON句のそれぞれで絞り込みをしてみたときのメモです。
目次
- 環境
- 記事の中で使うモデル
- そもそも、eager_load を使うことについて
- ActiveRecordの where() により、WHERE句で絞り込む
- ActiveRecordの関連付けでのスコープにより、ON句で絞り込む
- まとめ
- ソースコード
環境
- Rails 7.0.2.3
記事の中で使うモデル
以前の記事で使った、 Food > Fruit > Cultivar
というモデルを再利用します。
Railsで、関連付けの基本や関連付けにおけるNOT NULL制約まわりをさわってみた - メモ的な思考的な
ER図的にはこんな感じです。各テーブルには name
と親に対する外部キーがあります。
Railsのモデルは以下とします。
Cultivarの外部キーに NOT NULL 制約はありませんが、今回試す範囲では問題ないです。
# Food class Food < ApplicationRecord end # Fruit class Fruit < ApplicationRecord belongs_to :food has_many :cultivars # Fruit から Cultivar をたぐるときに使う end # Cultivar class Cultivar < ApplicationRecord belongs_to :fruit, required: false end
また、Railsのfixtureとして以下を用意します。
foods.yml
kudamono: name: 果物
fruits.yml
apple: name: りんご food: kudamono mandarin: name: みかん food: kudamono
cultivar.yml
fuji: name: ふじ fruit: apple shinano_gold: name: シナノゴールド fruit: apple shinano_dolce: name: シナノドルチェ fruit: apple
このfixtureを投入すると、各テーブルはこんな感じになります。
なお、各テーブルの id
は、fixtureを投入したときに自動で採番したものになります。
foods
id | name |
---|---|
225449629 | 果物 |
fruits
id | name | food_id |
---|---|---|
396042782 | みかん | 225449629 |
690933842 | りんご | 225449629 |
cultivar
このテーブルには みかん
を親に持つレコードはありません
id | name | fruit_id |
---|---|---|
105875372 | ふじ | 690933842 |
427894753 | シナノゴールド | 690933842 |
835639346 | シナノドルチェ | 690933842 |
そもそも、eager_load を使うことについて
N+1 問題の発生について
よくあるケースとして、いわゆる N+1 問題が発生するときに使います。
そこで、「fruitsに紐づくcultivarの名前を表示したい」を例にしてためしてみます。
まずは、RailsでN+1問題が発生するか気づきやすくなる bullet
gem をセットアップします。
flyerhzm/bullet: help to kill N+1 queries and unused eager loading
Gemfileに追記します。
group :development do gem "bullet" end
セットアップします。
# gemのインストール % bundle install # セットアップ % bin/rails g bullet:install
Railsコンソールで以下を入力・実行します。なお、Railsコンソールでも bullet が動作するよう、 Bullet.profile
ブロックの中で実行します。
mysql - Bullet gem not logging from Rails console - Stack Overflow
Bullet.profile do Fruit.all.map do | fruit | puts '=== fruit ============>' puts fruit.name fruit.cultivars.map do | cultivar | puts '=== cultivar ===>' puts cultivar.name cultivar.name end end end
実行すると以下の結果が表示されます。SQLが3回実行されています。
Fruit Load (0.2ms) SELECT "fruits".* FROM "fruits" === fruit ============> みかん Cultivar Load (0.2ms) SELECT "cultivars".* FROM "cultivars" WHERE "cultivars"."fruit_id" = ? [["fruit_id", 396042782]] === fruit ============> りんご Cultivar Load (0.1ms) SELECT "cultivars".* FROM "cultivars" WHERE "cultivars"."fruit_id" = ? [["fruit_id", 690933842]] === cultivar ===> ふじ === cultivar ===> シナノゴールド === cultivar ===> シナノドルチェ => [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]
bulletのログにも N+1 の発生が記録されます。
USE eager loading detected Fruit => [:cultivars] Add to your query: .includes([:cultivars])
eager_load を使って、 N+1 問題に対応する
次に、 eager_load
を使い、 N+1 問題に対応してみます。
同じく、以下をRailsコンソールで実行します。
Bullet.profile do Fruit.eager_load(:cultivars).map do | fruit | puts '=== fruit ============>' puts fruit.name fruit.cultivars.map do | cultivar | puts '=== cultivar ===>' puts cultivar.name cultivar.name end end end
実行結果です。なお、読みやすくするためSQLはフォーマットしています。
LEFT OUTER JOIN
のSQLを1回実行し、結果をキャッシュして使っているようです。
SQL (0.3ms) SELECT "fruits"."id" AS t0_r0, "fruits"."name" AS t0_r1, "fruits"."food_id" AS t0_r2, "fruits"."created_at" AS t0_r3, "fruits"."updated_at" AS t0_r4, "cultivars"."id" AS t1_r0, "cultivars"."name" AS t1_r1, "cultivars"."created_at" AS t1_r2, "cultivars"."updated_at" AS t1_r3, "cultivars"."fruit_id" AS t1_r4 FROM "fruits" LEFT OUTER JOIN "cultivars" ON "cultivars"."fruit_id" = "fruits"."id" === fruit ============> みかん === fruit ============> りんご === cultivar ===> ふじ === cultivar ===> シナノゴールド === cultivar ===> シナノドルチェ => [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]
なお、LEFT OUT JOIN を使っているため、上記のSQLの実行結果は
と、子が存在しない みかん
のデータも取得して NULL になっています。
ActiveRecordの where() により、WHERE句で絞り込む
上記の例では eager_load
を使って全件を取得していました。ただ、 eager_load
を使ったときに絞り込みを行いたいときもあると思います。
そこで、eager_load
と where
を使って
- 親側 (外部表・駆動表)
- 子側 (内部表)
で絞り込むときのケースをそれぞれ見ていきます。
親側(外部表・駆動表) での絞り込み
まずは、 eager_load
の結果に対し where('fruits.name LIKE "%ん%"')
と、親( fruits
)側で絞り込みをためしてみます。
Fruit.eager_load(:cultivars).where('fruits.name LIKE "%ん%"').map do | fruit | puts '=== fruit ============>' puts fruit.name fruit.cultivars.map do | cultivar | puts '=== cultivar ===>' puts cultivar.name cultivar.name end end
この場合、
SQL (0.2ms) SELECT "fruits"."id" AS t0_r0, "fruits"."name" AS t0_r1, "fruits"."food_id" AS t0_r2, "fruits"."created_at" AS t0_r3, "fruits"."updated_at" AS t0_r4, "cultivars"."id" AS t1_r0, "cultivars"."name" AS t1_r1, "cultivars"."created_at" AS t1_r2, "cultivars"."updated_at" AS t1_r3, "cultivars"."fruit_id" AS t1_r4 FROM "fruits" LEFT OUTER JOIN "cultivars" ON "cultivars"."fruit_id" = "fruits"."id" WHERE (fruits.name LIKE "%ん%") === fruit ============> みかん === fruit ============> りんご === cultivar ===> ふじ === cultivar ===> シナノゴールド === cultivar ===> シナノドルチェ => [[], ["ふじ", "シナノゴールド", "シナノドルチェ"]]
と、子が存在しないみかんのデータも取得できています。
SQLの実行結果でもNULLを持つ行が存在しています。
子側(内部表) での絞り込み
続いて、 where('cultivars.name LIKE "シナノ%"')
と、子側での絞り込みを行います。
Fruit.eager_load(:cultivars).where('cultivars.name LIKE "シナノ%"').map do | fruit | puts '=== fruit ============>' puts fruit.name fruit.cultivars.map do | cultivar | puts '=== cultivar ===>' puts cultivar.name cultivar.name end end
すると、みかんのデータが取得できなくなりました。
SQL (0.9ms) SELECT "fruits"."id" AS t0_r0, "fruits"."name" AS t0_r1, "fruits"."food_id" AS t0_r2, "fruits"."created_at" AS t0_r3, "fruits"."updated_at" AS t0_r4, "cultivars"."id" AS t1_r0, "cultivars"."name" AS t1_r1, "cultivars"."created_at" AS t1_r2, "cultivars"."updated_at" AS t1_r3, "cultivars"."fruit_id" AS t1_r4 FROM "fruits" LEFT OUTER JOIN "cultivars" ON "cultivars"."fruit_id" = "fruits"."id" WHERE (cultivars.name LIKE "シナノ%") === fruit ============> りんご === cultivar ===> シナノゴールド === cultivar ===> シナノドルチェ => [["シナノゴールド", "シナノドルチェ"]]
SQLを実行してみても、みかんの行が存在しません。
これは、 eager_load
は LEFT OUTER JOIN なことから、 where
による WHERE句での絞り込みを行うことで、子側が存在しないデータは抽出されなくなります。
- JOIN ON で絞り込み条件を入れるのと、JOIN ONの後WHERE句で絞り込み条件を入れるのとでは、結果が違う件 - なからなLife
- SQLの結合条件と抽出条件の解説をリライトした例:技術屋のためのドキュメント相談所:オルタナティブ・ブログ
そのため、「子側が存在しない場合は結果に含めなくて良い」場合は問題ないですが、「子側が存在しなくても、結果に含めたい」場合には where
は使えません。
ActiveRecordの関連付けでのスコープにより、ON句で絞り込む
上記の通り、存在しない子側も取得したい場合は where()
によるWHERE句での絞り込みは使えないため、ON句で絞り込めるような方法を探します。
すると、ActiveRecordの関連付けでのスコープを使うことで、ON句での絞り込みができることがわかりました。
- 【Rails】結合先のテーブルで条件つけたいけど, 結合元のレコードは全部欲しいってときはScoped Association | Blogicoffee
- Conditional Eager Loading in Rails | by Alexandre Gonçalves | Runtime Revolution
そこで実際に試してみます。
まずは、親側のモデル Fruit
に has_many
を追加で定義します。
4.3.2 has_manyのオプション | Active Record の関連付け - Railsガイド
その際、スコープで結合条件を追加します。
4.3.3 has_manyのスコープについて | Active Record の関連付け - Railsガイド
また、追加した関連付け名 full_cultivars
から対象のモデルを推測できないため、 class_name
を使い Cultivar
を指定します。
4.3.2.3 :class_name | Active Record の関連付け - Railsガイド
それらをまとめると、モデルは以下となります。
class Fruit < ApplicationRecord belongs_to :food has_many :cultivars # スコープを持つ has_many を追加 has_many :full_cultivars, -> { where('cultivars.name LIKE "シナノ%"') }, class_name: 'Cultivar' end
準備ができたので、ためしてみます。
eager_load
にわたす関連付け名は、追加した full_cultivars
とします。
また、念のため N+1 が発生していないかどうかを見るため、Bullet.profile も使っておきます。
Bullet.profile do # 追加した has_many の関連付け名を指定 Fruit.eager_load(:full_cultivars).map do | fruit | puts '=== fruit ============>' puts fruit.name fruit.full_cultivars.map do | cultivar | puts '=== cultivar ===>' puts cultivar.name cultivar.name end end end
実行結果です。 ON句で絞り込むSQLが発行されました。
SQL (0.2ms) SELECT "fruits"."id" AS t0_r0, "fruits"."name" AS t0_r1, "fruits"."food_id" AS t0_r2, "fruits"."created_at" AS t0_r3, "fruits"."updated_at" AS t0_r4, "cultivars"."id" AS t1_r0, "cultivars"."name" AS t1_r1, "cultivars"."created_at" AS t1_r2, "cultivars"."updated_at" AS t1_r3, "cultivars"."fruit_id" AS t1_r4 FROM "fruits" LEFT OUTER JOIN "cultivars" ON "cultivars"."fruit_id" = "fruits"."id" AND (cultivars.name LIKE "シナノ%") === fruit ============> みかん === fruit ============> りんご === cultivar ===> シナノゴールド === cultivar ===> シナノドルチェ => [[], ["シナノゴールド", "シナノドルチェ"]]
SQLを実行してみると、みかんの行も取得できていました。
まとめ
以上より、 eager_load
で絞り込みを行う場合、
- NULLな行も取得したい場合は、ON句で絞り込むスコープ
- NULLな行が不要な場合は、WHERE句で絞り込む
where()
を使えば良さそうでした。
ソースコード
Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample
今回のプルリクは以下となります。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/1