Edited at

Ruby on Rails の ActiveRecord で結合先のテーブルを COUNT する場合は joins/left_joins が良い

More than 1 year has passed since last update.

Ruby on Rails の Active Record って便利ですよね。

SQL から解放されたかのようにデータアクセスできるので、心穏やかに DB と向き合えます。

でも、以下のような SQL をイメージしてデータを取得したい場合はどうでしょうか。

SELECT

users.*,
COUNT(thanks.id) AS thanks_count
FROM
users
LEFT OUTER JOIN
thanks
ON users.id = thanks.user_id
GROUP BY
users.id
;

usersthanks は親子関係のテーブルで、 1:n の関係性を持っているケースです。

users に紐づく thanks の件数を取得したいと考え、例えば以下のようなコードを書いてしまうと、よく聞く n+1 問題が発生してしまいます。

User.all.thanks.each { |thank| thank.count }

この対処法としてよく聞くのが eager_loadjoins などです。

eager_load とか joins とか includes とか、いろいろあって何がどうだったかよく分からなくなっちゃいますよね。

それぞれの特徴に関して、私はよく以下の記事を参考にしております。お世話になっております。

ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い

結論としては、結合先のテーブルで COUNT をしたい場合は joins が有効です。

eager_load では、結合先テーブルの全カラムを select に入れてしまい、集計関数が使えなくなってしまうためです。

なので、上記の SQL 的なデータアクセスをする場合は、以下のように書きます。

User.all.left_joins(:thanks).group(:id).select('users.*, COUNT(`thanks`.`id`) AS thanks_count')

もし、これを複数箇所で使う場合、さらに他のテーブルなどにも汎用性をもたせたい場合、以下のように Model にメソッド化しておきたいですね。

class User < ActiveRecord::Base

scope :add_count_column, -> (join_table_name) do
scope = current_scope || relation
scope = scope.select("`#{table_name}`.#{Arel.star}") if scope.select_values.blank?
scope.left_joins(join_table_name).group(:id).select('COUNT(`#{join_table_name}`.`id`) AS #{join_table_name}_count')
end
end

上記の書き方は、以下の記事を参考にさせていただきました。ありがとうございます。

Rails: SELECTするカラムを追加するscopeを定義する


おわりに

Ruby on Rails の Active Record は良いツールではあるのですが、それだけで完結できないこともあります。

どこかで SQL を考え、パフォーマンスを意識しておかないといけないのかもしれません。

今回紹介した joins や、eager_loadincludes を使いこなせると、こうした問題に立ち向かえる力が増すと思いますよ。