【Ruby on Rails】スコープとクエリメソッドで検索条件を整理する

Ruby

こんにちは、かつコーチです。

コントローラのアクションが増えてくると、whereorderを使った検索条件があちこちに散らばりがちです。

同じような条件を何度も書いていたり、条件が複雑になって可読性が落ちていたりする経験はありませんか。

Railsには、こうした検索条件をモデルにまとめて定義できるスコープ(よく使う検索条件に名前をつけて再利用できるようにする仕組み)という機能があります。

今回は、記事一覧を例に、whereorderlimitといったクエリメソッドの組み合わせ方と、スコープを使って可読性を上げるコツを解説します。

スコープとクエリメソッドの基本

where・order・limitの基本の組み合わせ方

Articleモデルにpublished(真偽値)とpublished_at(日時)というカラムがある前提で考えます。

公開済みの記事を新しい順に3件取得したい場合、以下のように書けます。

Article.where(published: true).order(published_at: :desc).limit(3)

クエリメソッド(条件を指定してデータベースへの問い合わせを組み立てるメソッド)はメソッドチェーンでつなげられるため、条件を後から追加していく形で読みやすく書けます。

# タイトルに「Rails」を含む記事を検索する
Article.where("title LIKE ?", "%Rails%")

# 複数条件をハッシュでまとめて指定する
Article.where(published: true, category: "tech")

# NOT条件
Article.where.not(published: false)

このままコントローラに直接書いてもエラーにはなりませんが、同じ条件を複数のアクションで使い回したくなった瞬間に、コピペが増えてメンテナンス性が落ちていきます。

scopeメソッドで検索条件に名前をつける

よく使う条件は、モデルにscopeとして定義しておくと便利です。

# app/models/article.rb
class Article < ApplicationRecord
  scope :published, -> { where(published: true) }
  scope :recent, -> { order(published_at: :desc) }
  scope :latest, ->(count) { recent.limit(count) }
end

scopeの第1引数がスコープ名、第2引数がラムダ(実行する処理)です。

コントローラ側では、以下のようにメソッドを呼ぶ感覚で使えます。

# app/controllers/articles_controller.rb
def index
  @articles = Article.published.latest(3)
end

Article.where(published: true).order(published_at: :desc).limit(3)と書くよりも、「公開済みの記事を、最新3件」という意図がコード上ではっきり読み取れるようになります。

クラスメソッドとして定義する方法

スコープはscopeを使う書き方のほかに、クラスメソッドとして定義する方法もあります。

class Article < ApplicationRecord
  def self.published
    where(published: true)
  end

  def self.latest(count)
    order(published_at: :desc).limit(count)
  end
end

どちらの書き方でも動作はほぼ同じですが、scopeの方が「検索条件である」という意図が読み手に伝わりやすい点がメリットです。

条件分岐など複雑なロジックを含む場合は、クラスメソッドの方が読みやすくなることもあるため、内容に応じて使い分けるとよいでしょう。

よくあるつまずきポイント・エラー対処

scope内でnilを返してエラーになる

私が実際に遭遇したのが、条件分岐を含むスコープでうっかりnilを返してしまうケースです。

❌ Before:三項演算子の分岐でnilを返してしまう

class Article < ApplicationRecord
  scope :by_category, ->(category) {
    category.present? ? where(category: category) : nil
  }
end

このスコープをメソッドチェーンでつなげると、以下のようなエラーが発生しました。

NoMethodError: undefined method 'order' for nil:NilClass

categoryが空だった場合にスコープがnilを返してしまい、後続でつなげた.orderが「nilに対してorderを呼ぼうとしている」状態になってエラーになったのです。

✅ After:allを返して常にActiveRecord::Relationを返す

class Article < ApplicationRecord
  scope :by_category, ->(category) {
    category.present? ? where(category: category) : all
  }
end

nilの代わりにall(条件なしで全件を対象にする)を返すようにすることで、常にActiveRecord::Relation(クエリメソッドをつなげられるオブジェクト)が返るようになり、後ろに.order.limitをつなげてもエラーになりません。

スコープを定義するときは、「どんな条件分岐でも、必ずActiveRecord::Relationを返す」ことを意識すると、この手のエラーを防げます。

N+1問題を招く条件の書き方

スコープの中で関連モデルを参照する条件を書くと、意図せずN+1問題(関連データを1件ずつ取得してしまい、クエリが大量発行される問題)を招くことがあります。

# ❌ コメントが1件以上ある記事だけを取得したいが、非効率
scope :with_comments, -> { select { |article| article.comments.any? } }

selectにブロックを渡すRubyの配列操作は、一度全件をメモリに読み込んでからArticleごとにcommentsを都度取得するため、記事数分のクエリが発行されてしまいます。

# ✅ joinsとdistinctでSQLレベルに条件を落とし込む
scope :with_comments, -> { joins(:comments).distinct }

joinsを使えばSQLのJOINとして1回のクエリで完結するため、件数が増えても速度が落ちません。

N+1問題については、次回の記事でincludesを使った解決方法を詳しく解説します。

まとめ

この記事のポイント

  • whereorderlimitはメソッドチェーンでつなげて条件を組み立てられる
  • よく使う条件はscopeとして名前をつけると、コードの意図が読みやすくなる
  • スコープは条件分岐があっても、必ずActiveRecord::Relationを返すようにする(nilではなくallを返す)
  • 関連モデルの条件はRubyの配列操作ではなくjoinsでSQLレベルに落とし込む

次に読むべき記事

→ 次の記事:N+1問題とincludesでの解決方法

タグ: Ruby on Rails, 中級者向け, データベース

タイトルとURLをコピーしました