ActiveRecord

【Rails】リレーション先のカラムで並び替えする方法

Railsでリレーション先のカラムの値で並び替えをしたいケースがあるかと思いますが、今回はそれについて書いていきます。

今回使う例では、記事とコメントが1対多で紐付いていて、そのコメントの数や内容によって記事の並び変えをするケースを考えます。

サブクエリを使う方法

まずは、サブクエリを使う方法です。サブクエリで記事に紐づくコメントの数を数えて、それによって降順に並び替えています。

  def self.sort_by_comment_count
    self
      .includes(:comments)
      .select('*', '(SELECT COUNT(comments.id) FROM comments WHERE articles.id = comments.article_id) AS comment_count')
      .order(comment_count: :desc)
  end

ただし、この方法だと生のSQLをそのまま書いているため、一般的にはアンチパターンと呼ばれます。

ではなぜ生のSQLを書くのがよくないのでしょうか。その理由はネットで検索すると色々出てくるのですが、自分なりにまとめると以下になると思っています。

  • ユーザの入力値をそのまま使ってしまい、SQLインジェクションに対する脆弱性を生む原因になりうる
  • 書く量が多くなることが多く、読みにくくなる
  • タイポに気づけない
  • DBを変更する際(MySQL→PostgreSQLなど)、書いたSQLが壊れる危険性がある

あくまで主観ですが、重要度の高いものから順に書いています。

1つ目は脆弱性を生む危険性が高まることだと思っています。これに関しては、気をつけたりコードレビューなどで指摘し合えば済む話じゃないかと言われればそうなのですが、コードを書いている側も人間なので、長く開発していればうっかり入れてしまうことが絶対にないとは言い切れないと思います。もし危険なコードがシステムに混ざっていると、重大なセキュリティホールになりうるので、できる限り書くべきでないというのが持論です。

2つ目は可読性です。今回の例のように生SQLは長くなりがちです。そのため読みづらく、SQLが読めない人がいる現場などでは使わないほうがプロダクトにとってプラスになると考えています。また、先ほどの話しに関連して、まだ慣れていない人が上級者のコードを読んで真似して脆弱性を生んでしまうということにも繋がりかねません。こうした理由から、特に慣れていない人が多い現場ではあまり書くべきでないと思っています。

3つ目はタイポの起こしやすさです。SQL文は長くなりますし、文字列なのでVSCodeの補完も効きません。

最後はDBの移植性ですが、急にMySQLからPostgreSQLなどに変えることはそうそうないケースかと思うので最後に書きました。

とはいえ、位置情報の検索やパーティションなどデータベースの機能を使って要件を達成することもある話だと思うので、SQLを使うことが絶対に悪というわけではなく、Active RecordでできることはActive Recordでやろうねという話でした。

話が少し反れましたが、サブクエリを使わず並び替えをする方法を紹介します。

並び替えに使うModelからチェーンする

並び替えに使うModelからチェーンを繋ぐことで要件を達成できます。

①   @articles = Comment
②      .group(:article_id)
         .includes(:article)
③      .select(:article_id, 'count(*) as c')
④      .order(c: :desc)
⑤      .map{_1.article}

一つ一つ順番に見ていきます。

  1. Commentモデルからチェーンを繋ぎます。
  2. groupメソッドを使って、記事ごとのコメントとしてグループ化します
  3. グループ化したコメントの数を数えて、SELECTします。
  4. コメントの数で降順に並び替えます。
  5. その後、それぞれ紐づく記事を取り出します

色々チェーンしてあるので、最初うわっと思うかもしれないですが、一つ一つ見ていくと大したことはないです。
これによって発行されるSQLを確認してみます。

Comment Load (1.5ms) SELECT `comments`.`article_id`, count(*) as c FROM `comments`  GROUP BY `comments`.`article_id` ORDER BY `c` DESC
Article Load (4.3ms)  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` IN (1, 2, 3)

ちなみにややこしくなるので書かなかったですが、3行目でincludesしているのは記事をロードするときにN+1が発生するのを防ぐためです。includesしない場合に発行されるSQLは以下です。

  Comment Load (1.5ms)  SELECT `comments`.`article_id`, count(*) as c FROM `comments`  GROUP BY `comments`.`article_id` ORDER BY `c` DESC
  Article Load (1.8ms)  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 1 LIMIT 1
  Article Load (1.3ms)  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 2 LIMIT 1
  Article Load (1.1ms)  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` = 3 LIMIT 1

記事を複数回ロードしてますね。

また、数えるコメントを絞り込みたい場合はwhereメソッドを入れるだけで簡単にできます。SQL文と合わせて下に記載します。

   @articles = Comment
      .where("created_at > '#{30.days.ago}'") # 追記
      .group(:article_id)
      .includes(:article)
      .select(:article_id, 'count(*) as c')
      .order(c: :desc)
      .map{_1.article}
  Comment Load (1.6ms)  SELECT `comments`.`article_id`, count(*) as c FROM `comments` WHERE (created_at > '2022-03-07 08:18:44 +0900') GROUP BY `comments`.`article_id` ORDER BY `c` DESC
  Article Load (1.3ms)  SELECT `articles`.* FROM `articles` WHERE `articles`.`id` IN (1, 2, 3)

whereメソッドの代わりにモデルのscopeを入れることもできます。

# comment.rb
class Comment < ApplicationRecord
  belongs_to :article

  scope :recent, -> { where("created_at > '#{30.days.ago}'") }
  scope :public_comments, -> { where(is_public: true) }
  scope :default_comments, -> { recent.public_comments }
end

# comments_controller.rb
def index
    @articles = Comment
      .default_comments # 追記
      .group(:article_id)
      .includes(:article)
      .select(:article_id, 'count(*) as c')
      .order(c: :desc)
      .map{_1.article}
end

サブクエリを使う方法だと、scopeを利用できずに生SQLをベタ書きすることになるので、scopeを使いたいというケースでもこちらの方法がよさそうですね。かなり限定的ではありますが。
サブクエリを使って同じことをしようとするとこのようになります。

  def self.sort_by_comment_count
    self
      .includes(:comments)
      .select(
        '*',
        "(SELECT
            COUNT(comments.id)
          FROM
            comments
          WHERE
            articles.id = comments.article_id
          AND
            created_at > '#{30.days.ago}'
          AND
            comments.is_public = TRUE)
          AS comment_count)"
      )
      .order(comment_count: :desc)
  end

モデルのscopeを使えずにSQLベタ書きになっているのが分かると思います。これだとタイポがありえますし、条件が変更になるとscopeの条件とSQLの部分で変更箇所が複数になります。そのときのタイポもありえます。

このようにメンテナンスの観点からもActive Recordのメソッドを使ったほうがいいことが示せたかと思います。

まとめ

簡単にまとめると以下です。

  • Active Recordのメソッドが使える場合はなるべくActive Recordのメソッドを使う

ここまで読んでいただきありがとうございました。

ABOUT ME
sakai
東京在住の30歳。元々は車部品メーカーで働いていてましたが、プログラミングに興味を持ちスクールに通ってエンジニアになりました。 そこからベンチャー → メガベンチャー → 個人事業主になりました。 最近は生成 AI 関連の業務を中心にやっています。 ヒカルチャンネル(Youtube)とワンピースが大好きです!