18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Rails】ブログに付いたコメントの件数順にブログを並び替える方法

Last updated at Posted at 2023-05-01

はじめに

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.cntNULLなら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アプリケーションでブログに付いたコメントの件数順にブログを並び替える方法を説明しました。

この記事ではコメント数で並び替えましたが、「いいね!」の件数で並び替えたりする場合も考え方は同じです。

関連先のテーブルのレコード件数で並び替えをする必要が出てきたら、この記事を参考にしてみてください。

18
13
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?