Rails 検索条件に名前をつける(Scopeとクラスメソッド)

『パーフェクト Ruby on Rails 【増補改訂版】』を読んでいたら、検索条件を Scope で定義した場合とクラスメソッドで定義した場合の違いが書かれていた。クラスメソッドで定義するというのがよくわからなかったのでまとめてみる。

Scope とは

Scope は、モデルで検索条件をひとまとめにして名前をつけられる仕組み。

Scope の定義方法

書き方

class モデル名 < ApplicationRecord
  scope :スコープ名, -> { 検索条件 }
end

例(パーフェクト Ruby on Rails 2-2-1 より抜粋)

class Book < ApplicationRecord
  scope :costly, -> { where("price > ?", 3000) }
end

値段が 3000円より高い本を検索するcostlyという Scope を定義。

コントローラーなどからは、以下のようにして呼び出せる。

Book.costly

条件に該当するレコードがあれば、 ActiveRecord::Relation クラスの配列のようなオブジェクトが返ってくる。

rails コンソールで確かめてみる。

irb(main):001:0> Book.costly
  Book Load (0.2ms)  SELECT "books".* FROM "books" WHERE (price > 3000) LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Book id: 4, name: "Book 4", published_on: "2019-08-24", price: 4000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 5, name: "Book 5", published_on: "2019-07-24", price: 5000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">]>

Book.where("price > ?", 3000)と同じ結果になる。

Scope は、以下のように引数を渡すことも可能。

class Book < ApplicationRecord
  scope :lower, ->(price) { where("price < ?", price) }
end

引数priceより価格が低い本を検索する。呼び出すときは、Book.lower(3000)のように引数を指定する。

irb(main):001:0> Book.lower(3000)
   (1.1ms)  SELECT sqlite_version(*)
  Book Load (0.3ms)  SELECT "books".* FROM "books" WHERE (price < 3000) LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Book id: 1, name: "Book 1", published_on: "2019-11-24", price: 1000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 2, name: "Book 2", published_on: "2019-10-24", price: 2000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">]>

クラスメソッドでの定義方法

上に書いた Scope lowerを、クラスメソッドで定義すると以下のようになる。

class Book < ApplicationRecord
  def self.lower(price)
    where("price < ?", price)
  end
end

呼び出し方は、Scope のときと同じ。結果も同じ。

irb(main):001:0> Book.lower(3000)
   (1.1ms)  SELECT sqlite_version(*)
  Book Load (0.4ms)  SELECT "books".* FROM "books" WHERE (price < 3000) LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Book id: 1, name: "Book 1", published_on: "2019-11-24", price: 1000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 2, name: "Book 2", published_on: "2019-10-24", price: 2000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">]>

Scope とクラスメソッドの違い

スコープでのメソッドの設定は、クラスメソッドの定義と完全に同じ (というよりクラスメソッドの定義そのもの) です。どちらの形式を使用するかは好みの問題です。
〜 中略 〜
ただし1つ注意点があります。それは条件文を評価した結果がfalseになった場合であっても、スコープは常にActiveRecord::Relationオブジェクトを返すという点です。クラスメソッドの場合はnilを返すので、この振る舞いが異なります。したがって、条件文を使ってクラスメソッドをチェインさせていて、かつ、条件文のいずれかがfalseを返す場合、NoMethodErrorを発生することがあります。
Active Record クエリインターフェイス - Railsガイド

Scope の方が短く書けるため、複雑でない条件であれば Scope が使われることが多そう。

Scope とクラスメソッドは基本的に同じだが、Scope は必ず ActiveRecord::Relation クラスのオブジェクトを返す点に注意が必要。nilを返すことはない。

例(パーフェクト Ruby on Rails 2-2-1 より抜粋)

class Book < ApplicationRecord
  scope :find_price, ->(price) { find_by(price: price) }
end

指定した価格の本を検索する Scope。

この Scope と同じ動きをする(はずの)クラスメソッドfind_by_priceを定義して、比べてみる。

class Book < ApplicationRecord
  scope :find_price, ->(price) { find_by(price: price) }

  def self.find_by_price(price)
    find_by(price: price)
  end
end

該当するレコードがあるとき

該当するレコードが存在するときは、以下のように、Scope でもクラスメソッドでも同じ結果となる。

Scopeの場合

irb(main):001:0> Book.find_price(2000)
   (1.1ms)  SELECT sqlite_version(*)
  Book Load (0.5ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 2000], ["LIMIT", 1]]
=> #<Book id: 2, name: "Book 2", published_on: "2019-10-24", price: 2000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">

クラスメソッドの場合

irb(main):002:0> Book.find_by_price(2000)
  Book Load (0.2ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 2000], ["LIMIT", 1]]
=> #<Book id: 2, name: "Book 2", published_on: "2019-10-24", price: 2000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">

該当するレコードがないとき

該当するレコードが存在しないときは、以下のように結果が異なる。

Scopeの場合

irb(main):001:0> Book.find_price(1500)
   (1.2ms)  SELECT sqlite_version(*)
  Book Load (0.3ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 1500], ["LIMIT", 1]]
  Book Load (0.1ms)  SELECT "books".* FROM "books" LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Book id: 1, name: "Book 1", published_on: "2019-11-24", price: 1000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 2, name: "Book 2", published_on: "2019-10-24", price: 2000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 3, name: "Book 3", published_on: "2019-09-24", price: 3000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 4, name: "Book 4", published_on: "2019-08-24", price: 4000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">, #<Book id: 5, name: "Book 5", published_on: "2019-07-24", price: 5000, created_at: "2020-08-30 00:09:21", updated_at: "2020-08-30 00:09:21">]>

クラスメソッドの場合

irb(main):002:0> Book.find_by_price(1500)
  Book Load (0.2ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 1500], ["LIMIT", 1]]
=> nil

クラスメソッドは nil を返すが、Scope の場合はメソッドチェーンを続けられる。

結果が nil となった場合は該当 Scope の検索条件を除外したクエリを発行し、必ず ActiveRecord::Relation を返すという動作をします。
パーフェクト Ruby on Rails 【増補改訂版】

コントローラーで検索しない

コントローラーやビューに検索条件を書くことも可能ではあるが、望ましくないようだ。詳しくはこちらの記事。

techracho.bpsinc.jp

(その他参考サイト)