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

## はじめに
Railsアプリケーションで以下のようなBlogモデルとCommentモデルがあったとします。

```ruby 
class Blog < ApplicationRecord
  has_many :comments
end
```

```ruby 
class Comment < ApplicationRecord
  belongs_to :blog
end
```

この記事では、このBlogクラスに「コメントの件数が多い順に並び替える」scopeを追加する方法を解説します。

```ruby 
Blog.order_by_comment_count
```

上のscopeの実行例です。

```ruby 
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）

### サンプルコード

この記事で使ったサンプルコードは以下に置いてあります。

https://github.com/JunichiIto/order-by-children-sandbox

## scopeの実装例

今回は次のように実装しました。

```ruby 
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が生成されます。

```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 ..."というエラーが出ます。詳しくは[こちらの記事](https://qiita.com/jnchito/items/5f2f00c93c0ba68e4d31)をどうぞ。

:::note warn
②のサブクエリは全ブログを対象にコメントの件数を取得しようとしているため、コメントが何万〜何百万もある場合はパフォーマンスが悪くなる可能性があります。
その場合はさらにSQLを一工夫する必要がありますが、具体的な方法はここでは割愛します。
:::

:::note warn
ORDER BY句は本来、並び順がユニークになるように指定すべきです。
今回の例ではコメントの件数が同じだった場合を考慮して、たとえば`blogs.id`もORDER BY句に追加する、というアプローチが考えられます（[参考](https://qiita.com/jnchito/items/90c28c3f3e856add0a82)）。

```ruby
joins(sql)
  .order(Arel.sql("COALESCE(comment_counts.cnt, 0) DESC"))
  .order(id: :desc) # コメントの件数が同じならblogs.idの降順で並び替える
```

ただし、本記事ではコードの簡潔さを優先して、あえて「コメントの件数のみ」で並び替えています。
:::


### 応用：コメントの件数も同時に取得したい場合

並び替えるだけでなく、コメントの件数も同時に取得したい場合は以下のように`select`メソッドでSELECT句のSQLを指定します。
こうすると、Blogモデルに`comment_count`というメソッドが追加され、このメソッドを呼ぶとコメントの件数が取得できます。

```ruby 
Blog
  .order_by_comment_count
  .select("blogs.*, COALESCE(comment_counts.cnt, 0) AS comment_count")
  .map(&:comment_count)
#=> [3, 2, 1, 0]
```

この場合、以下のようなSQLが生成されます。

```ruby 
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 
# 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を追加したとします。

```ruby 
# 今日作成されたブログだけを抽出するscope
scope :created_today, -> { where(created_at: Date.current.all_day) }
```

`order_by_comment_count`のあとに`created_today`を連結して呼び出そうとすると例外が発生します。

```ruby 
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は再利用性が低くなります。

```ruby
# 順番を入れ替えればいちおう呼び出せるが、呼び出し方に制限があるため再利用性が低い 
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`は以下のように実装する案もあります。

```ruby 
# サブクエリを使わない方法（ただしあまりお勧めしない）
scope :order_by_comment_count, -> do
  left_joins(:comments)
    .group(:id)
    .order("COUNT(comments.id) DESC")
end
```

この場合、以下のようなSQLが生成されます。

```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しようとすると例外が発生します。

```ruby 
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という機能もあります。

https://railsguides.jp/association_basics.html#belongs-to%E3%81%AE%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3-counter-cache

これを使えば`blogs`テーブルの`comments_count`カラムにコメントの件数が自動更新されていきます。

```ruby 
class Comment < ApplicationRecord
  # コメントが増減するとblogsテーブルのカウンタ値が更新される
  belongs_to :blog, counter_cache: true
end
```

そして、`order_by_comment_count`は以下のように実装できます。

```ruby 
scope :order_by_comment_count, -> { order(comments_count: :desc) }
```

この場合、以下のようなSQLが生成されます。

```sql 
SELECT 
  "blogs".* 
FROM "blogs" 
ORDER BY 
  "blogs"."comments_count" DESC
```

一見すると、とてもシンプルで便利ですが、counter_cache はメリットだけではなく、以下のような考慮点もあります。

- 事前準備の必要性
    - 追加のマイグレーション（DBスキーマの変更）やモデルの修正なしでは実現できない
    - 既存のテーブルにcounter_cache用のカラムを追加したときは適切な初期値を更新しなければならない
- 仕様上の制約
    - 単純な件数カウントしか対応できない（閲覧権限のあるコメントの件数をログインユーザーごとに出し分けたい、というようなことはできない）
- 非正規化による不正確なカウンタ値が発生するリスク
    - 生のSQLを使ったデータの一括更新、一括削除などを実行するとカウンタが更新されない（もしくは自力で更新する必要がある）
    - データが非正規化されるので予期せぬ理由でデータの不整合（不正確なカウンタ値）が発生する可能性がある
    - データの不整合が発生していても問題に気付きにくい。もし気付いたとしても、いつからどういう原因で不整合が発生したのか調査するのが難しい
- パフォーマンス問題
    - 大量のデータを登録すると、そのたびにカウンタの更新が走ってパフォーマンスが悪くなる

このようにcounter_cacheを使う場合は、メリットとデメリットを天秤にかけて、デメリットが深刻な問題にならない（もしくはメリットの方が明らかに大きい）ときにだけ採用することをお勧めします。

ちなみに僕はcounter_cacheは滅多に使いません（ただの食わず嫌い？）。

## まとめ
というわけで、この記事ではRailsアプリケーションでブログに付いたコメントの件数順にブログを並び替える方法を説明しました。

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

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