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




