Railsで、eager_loadのLEFT OUTER JOINにて、WHERE句とON句のそれぞれで絞り込みしてみた

Railseager_load を使う中で、 eager_load についてもうちょっと理解を深めたいと思いました。

eager_load についてはWeb上にいろいろな記事はあるものの、自分の理解を深めるためにためしたときのメモを残します。

今回は eager_load のLEFT JOINにて、WHERE句とON句のそれぞれで絞り込みをしてみたときのメモです。

 
目次

 

環境

 

記事の中で使うモデル

以前の記事で使った、 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 JOINSQLを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_loadwhere を使って

  • 親側 (外部表・駆動表)
  • 子側 (内部表)

で絞り込むときのケースをそれぞれ見ていきます。

 

親側(外部表・駆動表) での絞り込み

まずは、 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句での絞り込みを行うことで、子側が存在しないデータは抽出されなくなります。

 
そのため、「子側が存在しない場合は結果に含めなくて良い」場合は問題ないですが、「子側が存在しなくても、結果に含めたい」場合には where は使えません。

 

ActiveRecordの関連付けでのスコープにより、ON句で絞り込む

上記の通り、存在しない子側も取得したい場合は where() によるWHERE句での絞り込みは使えないため、ON句で絞り込めるような方法を探します。

すると、ActiveRecordの関連付けでのスコープを使うことで、ON句での絞り込みができることがわかりました。

 
そこで実際に試してみます。

まずは、親側のモデル Fruithas_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