【Rails】なぜクラスメソッドよりもスコープを使うべきなのか?

Active Record scopes vs class methods « Plataformatec Blog

以前気になって、上のリンクをブックマークしておいたのですが、当時はよく理解できておらず消化不良感を感じていたので改めて読んでみました。要点のみを抜粋してみます。元記事にはより詳しい解説があり、コメント欄には、スコープではなくクラスメソッドを使うべきと言った意見も上がっていますので、是非ご一読ください。


スコープは内部実装としては単にクラスメソッドである

スコープはRails内部ではクラスメソッドを動的に定義することで実現されています。

def self.scope(name, body)
  singleton_class.send(:define_method, name, &body)
end

これにより、スコープは次のようなクラスメソッドに展開されます。

def self.published
  where(status: 'published')
end

スコープの本質がクラスメソッドなら、どうしてクラスメソッドではなくスコープを使うべきなのでしょう?

スコープは常にメソッドチェーン可能であることが保証される

class Post < ActiveRecord::Base
  scope :by_status, -> status { where(status: status) }
  scope :recent, -> { order("posts.updated_at DESC") }
end

このようなモデルを考えた時、メソッドチェーンはクラスメソッドであっても可能です。しかし、スコープに与える引数がnilblankであった場合はどうでしょうか?

Post.by_status(nil).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" IS NULL 
#   ORDER BY posts.updated_at DESC

Post.by_status('').recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" = '' 
#   ORDER BY posts.updated_at DESC

普通、このようなクエリは発行したくないのでこのようにします。

scope :by_status, -> status { where(status: status) if status.present? }

このようなスコープは問題なくチェーンできます。しかし、クラスメソッドにしてしまうとそうは行きません。

class Post < ActiveRecord::Base
  def self.by_status(status)
    where(status: status) if status.present?
  end
end

Post.by_status('').recent
NoMethodError: undefined method `recent' for nil:NilClass

スコープの中で発行されたクエリがnilになる場合は、.allを返すように設計されているので、スコープの返り値を気にせずチェーンを繋げることができます。

スコープは拡張できる

ページネーションライブラリなどで、次のような使い方を目にすることがあります。

Post.page(2).per(15)

posts = Post.page(2)
posts.total_pages # => 2
posts.first_page? # => false
posts.last_page?  # => true

.per.total_pagesのようなメソッドはページネーションをしないコレクションで呼べるようにはしたくないので、内部ではscope extensionsを使って次のように実装されます。

scope :page, -> num { # some limit + offset logic here for pagination } do
  def per(num)
    # more logic here
  end

  def total_pages
    # some more here
  end

  def first_page?
    # and a bit more
  end

  def last_page?
    # and so on
  end
end

これらのextensionをクラスメソッドを使って以下のように実装することもできますが、煩雑です。

def self.page(num)
  scope = # some limit + offset logic here for pagination
  scope.extend PaginationExtensions
  scope
end

module PaginationExtensions
  def per(num)
    # more logic here
  end

  def total_pages
    # some more here
  end

  def first_page?
    # and a bit more
  end

  def last_page?
    # and so on
  end
end

まとめ

常にクラスメソッドではなくスコープを使うべきというわけではなくて、「仕事に適したツール™」を選ぶことが重要で、場合によって使い分ければよく、アプリケーションコードの中で使い方が一貫していれば問題ないのではないか?、という結論でまとめられています。

Active Record scopes vs class methods « Plataformatec Blog