はじめに
Railsアプリケーションで以下のようなBlogモデルとCommentモデルがあったとします。
class Blog < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :blog
end
この記事では、このBlogクラスに「コメントの件数が多い順に並び替える」scopeを追加する方法を解説します。
Blog.order_by_comment_count
上のscopeの実行例です。
blog_without_comments = Blog.create!(title: "Chao!")
blog_with_one_comment = Blog.create!(title: "Hello")
blog_with_one_comment.comments.create!(content: "Hi")
blog_with_two_comments = Blog.create!(title: "こんにちは")
blog_with_two_comments.comments.create!(content: "やあ")
blog_with_two_comments.comments.create!(content: "やあやあ")
blog_with_three_comments = Blog.create!(title: "にーはお")
blog_with_three_comments.comments.create!(content: "はお")
blog_with_three_comments.comments.create!(content: "はおはお")
blog_with_three_comments.comments.create!(content: "はおはおはお")
Blog.order_by_comment_count.map(&:title)
#=> ["にーはお", "こんにちは", "Hello", "Chao!"]
実行環境
この記事は以下の実行環境で動作確認しています。
- Rails 7.0.4
- Ruby 3.2.1
- SQLite3 (一部PostgreSQL)
サンプルコード
この記事で使ったサンプルコードは以下に置いてあります。
scopeの実装例
今回は次のように実装しました。
scope :order_by_comment_count, -> do
sql = <<~SQL
LEFT OUTER JOIN (
-- ②
SELECT c.blog_id, COUNT(*) AS cnt
FROM comments c
GROUP BY c.blog_id
) comment_counts -- ①
ON comment_counts.blog_id = blogs.id -- ③
SQL
joins(sql)
.order(Arel.sql("COALESCE(comment_counts.cnt, 0) DESC")) # ④
end
このスコープは、以下の処理を行っています。
-
LEFT OUTER JOIN
を使って、ブログ記事のコメント数をcomment_counts
という名前の仮想テーブルとして作成します - ① -
comments
テーブルをグループ化し、各ブログ記事に対してのコメント数をカウントしています - ② -
comment_counts
仮想テーブルとblogs
テーブルをblog_id
で結合します - ③ -
comment_counts
仮想テーブルのcnt
カラムを使って、ブログ記事をコメント数の多い順に並べ替えます - ④
この場合、以下のようなSQLが生成されます。
SELECT
"blogs".*
FROM "blogs"
LEFT OUTER JOIN (
SELECT c.blog_id, COUNT(*) AS cnt
FROM comments c
GROUP BY c.blog_id
) comment_counts ON comment_counts.blog_id = blogs.id
ORDER BY
COALESCE(comment_counts.cnt, 0) DESC
備考
-
INNER JOIN
で結合しようとするとコメントが0件のBlogが引けなくなってしまうので、LFET OUTER JOIN
にする必要があります。 -
COALESCE
関数は引数として指定された複数の式の中から、最初にNULL
以外の値を返す式を評価し、その値を返す関数です。簡単に言えば、comment_counts.cnt
がNULL
なら0を返し、それ以外ならcomment_counts.cnt
の値を返します。 -
Arel.sql
は「このSQLは安全ですよ」とRailsにお知らせするためのメソッドです。これがないと"ActiveRecord::UnknownAttributeReference: Dangerous query method ..."というエラーが出ます。詳しくはこちらの記事をどうぞ。
②のサブクエリは全ブログを対象にコメントの件数を取得しようとしているため、コメントが何万〜何百万もある場合はパフォーマンスが悪くなる可能性があります。
その場合はさらにSQLを一工夫する必要がありますが、具体的な方法はここでは割愛します。
ORDER BY句は本来、並び順がユニークになるように指定すべきです。
今回の例ではコメントの件数が同じだった場合を考慮して、たとえばblogs.id
もORDER BY句に追加する、というアプローチが考えられます(参考)。
joins(sql)
.order(Arel.sql("COALESCE(comment_counts.cnt, 0) DESC"))
.order(id: :desc) # コメントの件数が同じならblogs.idの降順で並び替える
ただし、本記事ではコードの簡潔さを優先して、あえて「コメントの件数のみ」で並び替えています。
応用:コメントの件数も同時に取得したい場合
並び替えるだけでなく、コメントの件数も同時に取得したい場合は以下のようにselect
メソッドでSELECT句のSQLを指定します。
こうすると、Blogモデルにcomment_count
というメソッドが追加され、このメソッドを呼ぶとコメントの件数が取得できます。
Blog
.order_by_comment_count
.select("blogs.*, COALESCE(comment_counts.cnt, 0) AS comment_count")
.map(&:comment_count)
#=> [3, 2, 1, 0]
この場合、以下のようなSQLが生成されます。
SELECT
blogs.*,
COALESCE(comment_counts.cnt, 0) AS comment_count
FROM "blogs"
LEFT OUTER JOIN (
SELECT c.blog_id, COUNT(*) AS cnt
FROM comments c
GROUP BY c.blog_id
) comment_counts ON comment_counts.blog_id = blogs.id
ORDER BY
COALESCE(comment_counts.cnt, 0) DESC
なお、scope内でselectメソッドを呼ぶこともできますが、scopeの再利用性が悪くなる(利用場面が制限される)ので、あまりおすすめできません。
こういうscopeはNG
SQLに不慣れな方は以下のようにscopeの中にRubyのメソッドを使って並び替えようとするかもしれません。
# Rubyのsort_byメソッドで並び替えるのはNG
scope :order_by_comment_count, -> do
includes(:comments)
.sort_by { |blog| blog.comments.size }
.reverse
end
一見、この方法でもうまくいくように見えますが、この方法には以下のような問題があります。
まず、この方法だとブログの取得とコメント取得で2回SQLが発行されます。
Blog Load (0.0ms) SELECT "blogs".* FROM "blogs"
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."blog_id" IN (?, ?, ?, ?) [["blog_id", 1], ["blog_id", 2], ["blog_id", 3], ["blog_id", 4]]
単純に2回発行されるのは非効率ですし、それだけでなく、コメントの件数分だけCommentクラスのインスタンスが生成されるのも非効率です。
コメントが大量にDBに登録されていると、データの読み出しに時間がかかりますし、メモリも圧迫します。
次に、この書き方だとこの後ろに他のscopeを連結させることができません。
たとえばBlogクラスに以下のようなscopeを追加したとします。
# 今日作成されたブログだけを抽出するscope
scope :created_today, -> { where(created_at: Date.current.all_day) }
order_by_comment_count
のあとにcreated_today
を連結して呼び出そうとすると例外が発生します。
Blog.order_by_comment_count.created_today
#=> NoMethodError: undefined method `created_today' for [#<Blog id: 4, title: "にーはお", ...(省略)]:Array
これはorder_by_comment_count
がリレーションではなく、配列を返すためです。
order_by_comment_count
が最後に呼び出されるように変更すれば例外を回避できますが、このような制約を持つscopeは再利用性が低くなります。
# 順番を入れ替えればいちおう呼び出せるが、呼び出し方に制限があるため再利用性が低い
Blog.created_today.order_by_comment_count
# たとえば kaminari gem の page メソッドを呼び出そうとすると結局エラーになる
Blog.created_today.order_by_comment_count.page(2)
#=> NoMethodError: undefined method `page' for [#<Blog id: 4, title: "にーはお", ...(省略)]:Array
サブクエリ化しない方法もあるがちょっと微妙
order_by_comment_count
は以下のように実装する案もあります。
# サブクエリを使わない方法(ただしあまりお勧めしない)
scope :order_by_comment_count, -> do
left_joins(:comments)
.group(:id)
.order("COUNT(comments.id) DESC")
end
この場合、以下のようなSQLが生成されます。
SELECT
"blogs".*
FROM "blogs"
LEFT OUTER JOIN "comments"
ON "comments"."blog_id" = "blogs"."id"
GROUP BY
"blogs"."id"
ORDER BY
COUNT(comments.id) DESC
一見、こちらの方がシンプルに見えますが、GROUP BY句が固定されてしまうため、RDBMSによっては再利用性が低下します。たとえば、PostgreSQL環境で以下のようにブログと同時にコメントをeager loadしようとすると例外が発生します。
Blog.eager_load(:comments).order_by_comment_count
# ActiveRecord::StatementInvalid: PG::GroupingError: ERROR: column "comments_blogs.id" must appear in the GROUP BY clause or be used in an aggregate function
SQLite3では例外は出ませんが、クエリによっては思いがけない結果が返ってくるかもしれないので、この実装方法もあまりお勧めしません。
counter_cache はどうなの?(2023.5.2追記)
Railsにはcounter_cacheという機能もあります。
これを使えばblogs
テーブルのcomments_count
カラムにコメントの件数が自動更新されていきます。
class Comment < ApplicationRecord
# コメントが増減するとblogsテーブルのカウンタ値が更新される
belongs_to :blog, counter_cache: true
end
そして、order_by_comment_count
は以下のように実装できます。
scope :order_by_comment_count, -> { order(comments_count: :desc) }
この場合、以下のようなSQLが生成されます。
SELECT
"blogs".*
FROM "blogs"
ORDER BY
"blogs"."comments_count" DESC
一見すると、とてもシンプルで便利ですが、counter_cache はメリットだけではなく、以下のような考慮点もあります。
- 事前準備の必要性
- 追加のマイグレーション(DBスキーマの変更)やモデルの修正なしでは実現できない
- 既存のテーブルにcounter_cache用のカラムを追加したときは適切な初期値を更新しなければならない
- 仕様上の制約
- 単純な件数カウントしか対応できない(閲覧権限のあるコメントの件数をログインユーザーごとに出し分けたい、というようなことはできない)
- 非正規化による不正確なカウンタ値が発生するリスク
- 生のSQLを使ったデータの一括更新、一括削除などを実行するとカウンタが更新されない(もしくは自力で更新する必要がある)
- データが非正規化されるので予期せぬ理由でデータの不整合(不正確なカウンタ値)が発生する可能性がある
- データの不整合が発生していても問題に気付きにくい。もし気付いたとしても、いつからどういう原因で不整合が発生したのか調査するのが難しい
- パフォーマンス問題
- 大量のデータを登録すると、そのたびにカウンタの更新が走ってパフォーマンスが悪くなる
このようにcounter_cacheを使う場合は、メリットとデメリットを天秤にかけて、デメリットが深刻な問題にならない(もしくはメリットの方が明らかに大きい)ときにだけ採用することをお勧めします。
ちなみに僕はcounter_cacheは滅多に使いません(ただの食わず嫌い?)。
まとめ
というわけで、この記事ではRailsアプリケーションでブログに付いたコメントの件数順にブログを並び替える方法を説明しました。
この記事ではコメント数で並び替えましたが、「いいね!」の件数で並び替えたりする場合も考え方は同じです。
関連先のテーブルのレコード件数で並び替えをする必要が出てきたら、この記事を参考にしてみてください。