Rails で結合先のテーブルで条件つけたいけど結合元のレコードは全部欲しいってときは Scoped Association

2019-02-04

はじめに

本記事は こちらのページ を参考にしています。

また、SQL や ActiveRecord のクエリメソッドについて大体の理解を仮定して進めていきます(僕自身 Rails を使っているので SQL はそんなに書いたことないですけれど)。

以下で登場する問題設定は、こちら に用意してあります。クローンしてくれば手元で実験が行えます。実験のやり方等は README に書いてあるのでご参照ください。

ではやっていきましょう。

問題設定

まずは具体的な状況を設定しておきましょう。今から設定するのは、「結合先のテーブルで条件つけたいけど、結合元のレコードは全部欲しい」というものです。タイトルにも書いているやつですね。

例えばチームモデルとプレーヤーモデルがあり、関係が 1 対 0 以上だったとしましょう。さらにプレーヤーモデルは deleted という論理削除用の属性を持っているとします。例えばこんな感じです。

チームプレーヤー論理削除
チームA重田しげるfalse
佐藤里子false
チームB鈴木すずfalse
高橋たか子false
チームC神崎かん太true

この例ではチーム C の神崎かん太が論理削除されている状態です。

このような場合に、各チームのプレーヤー一覧を表示させたいとします。

チームA

  • 重田しげる
  • 佐藤里子

チームB

  • 鈴木すず
  • 高橋たか子

チームC

このチームにプレーヤーはいません。

プレーヤーがいないチーム C も一覧に表示されているのがポイントです。

以上の設定が、「結合先のプレーヤーテーブルにおいて論理削除されていないという条件をつけたいけど、チームのレコードは全部欲しい」という状況になっているのがお分かりいただけるでしょうか。

どうクエリメソッドを書けばよいか?

まずパッと思いつくもの

例えばこんなのはどうでしょうかね。

Team.eager_load(:players).where(players: {deleted: false})

すると以下のようなSQLが発行されます。

SELECT 
  "teams"."id" AS t0_r0,
  "teams"."name" AS t0_r1,
  "teams"."created_at" AS t0_r2,
  "teams"."updated_at" AS t0_r3,
  "players"."id" AS t1_r0,
  "players"."name" AS t1_r1,
  "players"."deleted" AS t1_r2,
  "players"."team_id" AS t1_r3,
  "players"."created_at" AS t1_r4,
  "players"."updated_at" AS t1_r5 
FROM
  "teams" 
  LEFT OUTER JOIN "players" 
    ON "players"."team_id" = "teams"."id" 
WHERE
  "players"."deleted" = 0

この場合だと、チーム C は取ってこれません...。実際チーム数を数えてみると 2 つです。

teams = Team.eager_load(:players).where(players: {deleted: false})
teams.size #=> 2

(ちなみに t.count は追加で SQL を発行してしまうので注意です。)

このSQLのなにがまずいのか

上のクエリメソッドで発行されるSQLの問題点は、WHERE 句に論理削除条件が入っていることです。これによって、LEFT OUTER JOIN であるにもかかわらず、チームCのレコードが取ってこれませんでした。

ではどうなっていたらよかったかというと、以下のように論理削除条件は ON 句にあるべきだったのです。

SELECT 
  "teams"."id" AS t0_r0,
  -- 中略
  "players"."updated_at" AS t1_r5 
FROM
  "teams" 
  LEFT OUTER JOIN "players" 
    ON "players"."team_id" = "teams"."id" 
      AND "players"."deleted" = 0

この SQL 文が発行できれば、チーム C も逃すことはありません。

解決策

ここからが本題ですね。これまで見てきた問題を解決するには、Scoped Associations というものを使います。

言葉で説明するよりコードを見てもらったほうが早いでしょう。

# モデルクラス定義

class Team
  has_many :players, -> { where(deleted: false) }
end
# クエリメソッド

Team.eager_load(:players)

これで、ON 句に論理削除条件が入った SQL を発行することができます。

ポイントはなんといっても Team モデルクラスの定義ですね。has_many メソッドにラムダを渡しています。ここで論理削除条件を指定しています。

これはつまり、Team から Players を参照する際は必ず deleted: false という条件がつくということです。

例えば以下のような使い方をしたときもデフォルトで論理削除条件がつくようになっています。

team = Team.first
team.players
-- t.players で発行されるSQL

SELECT
  "players".*
FROM
  "players"
WHERE
  "players"."team_id" = ?
  AND "players"."deleted" = 0

デフォルトで設定されるのは困るなーってときは?

今回の論理削除されているかどうかという条件は、デフォルトになっていてもいいくらいの条件でした。しかし絞り込み条件を毎回使うとは限らない場合はどうしたらよいでしょうか。

それは簡単です。以下のようにすれば解決できます。

class Team
  has_many :players
  has_many :existing_players, -> { where(deleted: false) }, class_name: 'Player`
end

こうすれば、Team.eager_load(:existing_players) とすることで、チームCも取得できます。ただし注意してほしいのは、この場合 players ではなく existing_players で参照する必要があります。

teams = Team.eager_load(:existing_players)
teams.each do |team|
  team.existing_players  # team.players としないように注意
  # その他処理
end

まとめ

「関連テーブルの条件を ON 句に書きたい!」という問題は、実際にRailsアプリを構築している際にぶち当たったものです。その時はなかなか検索しても出てこなかったので苦労しました...。

冒頭で紹介した参考記事を見つけたときはそれはもう感動しましたね。

今回説明した Scoped Associations は使えるケースが他にもありそうです。どちらにせよコントローラに長々とクエリメソッドを書くのはあまり美しくないですしね。よく使うクエリはモデルにまとめておくのがよさそうです。