【Rails】scopeの注意点

# scopeのメリット
:::note
- 繰り返し利用するクエリの再利用性が上がる
- クエリに名前を付けることで、可読性が向上する
:::

# scopeの注意点
:::note warn
scopeの結果が`nil`となった場合は、<INS>`nil`を返す該当scopeの検索条件を除外したクエリを発行し、必ず***ActiveRecord::Relation***を返す</INS>という動作が行われる
:::

本記事は、こちらの注意点にフォーカスします。

# （先に）結論

:::note
`nil`を返す可能性がある場合にはscopeで定義せず、クラスメソッドで定義する
:::


# 動作確認のための準備

## 環境
- Rubyバージョン：`2.6.6`
- Railsバージョン：`6.0.3`

## モデルの作成
```
rails g model Book book_title:string publication_date:date price:integer
```

## マイグレーション
```
rails db:migrate
```

## scopeを定義
```app/models/book.rb
class Book < ApplicationRecord
  scope :search_by_title, ->(book_title) { where('book_title like ?', "%#{book_title}%") } # 通常のscope
  scope :find_by_price, ->(price) { find_by(price: price) } # nilを返す可能性のあるscope
end
```

## 任意のレコードを作成
```rb:rails c
(base) sample@SamplenoMBP practice % rails c
Loading development environment (Rails 6.0.3)
irb(main):001:0> (1..5).each { |i| Book.create(book_title: "Book #{i}", publication_date: Time.parse('20240901').ago(i.months), price: (i * 1000)) }
   (0.8ms)  SELECT sqlite_version(*)
   (0.0ms)  begin transaction
  Book Create (1.0ms)  INSERT INTO "books" ("book_title", "publication_date", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_title", "Book 1"], ["publication_date", "2024-08-01"], ["price", 1000], ["created_at", "2024-08-31 09:10:12.130910"], ["updated_at", "2024-08-31 09:10:12.130910"]]
   (0.6ms)  commit transaction
   (0.0ms)  begin transaction
  Book Create (0.2ms)  INSERT INTO "books" ("book_title", "publication_date", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_title", "Book 2"], ["publication_date", "2024-07-01"], ["price", 2000], ["created_at", "2024-08-31 09:10:12.138869"], ["updated_at", "2024-08-31 09:10:12.138869"]]
   (0.3ms)  commit transaction
   (0.0ms)  begin transaction
  Book Create (0.2ms)  INSERT INTO "books" ("book_title", "publication_date", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_title", "Book 3"], ["publication_date", "2024-06-01"], ["price", 3000], ["created_at", "2024-08-31 09:10:12.139852"], ["updated_at", "2024-08-31 09:10:12.139852"]]
   (0.3ms)  commit transaction
   (0.0ms)  begin transaction
  Book Create (0.2ms)  INSERT INTO "books" ("book_title", "publication_date", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_title", "Book 4"], ["publication_date", "2024-05-01"], ["price", 4000], ["created_at", "2024-08-31 09:10:12.140731"], ["updated_at", "2024-08-31 09:10:12.140731"]]
   (0.3ms)  commit transaction
   (0.0ms)  begin transaction
  Book Create (0.2ms)  INSERT INTO "books" ("book_title", "publication_date", "price", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_title", "Book 5"], ["publication_date", "2024-04-01"], ["price", 5000], ["created_at", "2024-08-31 09:10:12.141575"], ["updated_at", "2024-08-31 09:10:12.141575"]]
   (0.3ms)  commit transaction
```


# 敢えてnilが返る条件でscopeを実行

## nilを返す(可能性のある)scopeのみの場合
```rb:rails c
irb(main):001:0> Book.find_by_price(10000)
  Book Load (0.3ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 10000], ["LIMIT", 1]]
  Book Load (0.1ms)  SELECT "books".* FROM "books" LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Book id: 1, book_title: "Book 1", publication_date: "2024-08-01", price: 1000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">, #<Book id: 2, book_title: "Book 2", publication_date: "2024-07-01", price: 2000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">, #<Book id: 3, book_title: "Book 3", publication_date: "2024-06-01", price: 3000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">, #<Book id: 4, book_title: "Book 4", publication_date: "2024-05-01", price: 4000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">, #<Book id: 5, book_title: "Book 5", publication_date: "2024-04-01", price: 5000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">]>
```

:::note warn
該当scopeの条件によって、取得された結果が本来`nil`となる場合
→<INS>`nil`になる該当scopeの条件を除いたクエリが発行される</INS>
:::

:::note alert
該当scopeの条件を除いた場合、このケースだと`Book.all`と同等になる為、全てのレコードが返る
:::


## 通常のscope + nilを返す可能性のあるscope
```rb:rails c
irb(main):002:0> Book.search_by_title('Book 1').find_by_price(10000)
    (1.3ms)  SELECT sqlite_version(*)
   Book Load (0.3ms)  SELECT "books".* FROM "books" WHERE (book_title like '%Book 1%') AND "books"."price" = ? LIMIT ?  [["price", 10000], ["LIMIT", 1]]
   Book Load (0.1ms)  SELECT "books".* FROM "books" WHERE (book_title like '%Book 1%') LIMIT ?  [["LIMIT", 11]]
 => #<ActiveRecord::Relation [#<Book id: 1, book_title: "Book 1", publication_date: "2024-08-01", price: 1000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">]>
```
:::note warn
該当scopeの条件を除いた、
`SELECT books.* FROM books WHERE (book_title like '%Book 1%')`
の結果（今回の場合だと1件）が返る
:::

:::note alert
本来`nil`を期待しているとすると、想定しない結果が返ることになる
:::


## 通常scope(検索結果なし) + nilを返すscopeの場合
```rb:rails c
irb(main):004:0> Book.search_by_title('zzz').find_by_price(10000)
  Book Load (0.7ms)  SELECT "books".* FROM "books" WHERE (book_title like '%zz%') AND "books"."price" = ? LIMIT ?  [["price", 10000], ["LIMIT", 1]]
  Book Load (0.3ms)  SELECT "books".* FROM "books" WHERE (book_title like '%zz%') LIMIT ?  [["LIMIT", 11]]
=> #<ActiveRecord::Relation []>
```
:::note
該当のscopeの条件を除いた、
`SELECT books.* FROM books WHERE (book_title like '%zz%')`
が実行される。

`ActiveRecord`に対して`Query Interface`を呼び出しているため、
通常通り「空の***ActiceRecord::Relation***」インスタンスが返る
:::

# nilを返す可能性のあるscopeが、nilを返さない場合
## 想定通り動作する
```rb:rails c
irb(main):003:0> Book.where(id: [1,2,3]).find_by_price(2000)
  Book Load (1.0ms)  SELECT "books".* FROM "books" WHERE "books"."id" IN (?, ?, ?) AND "books"."price" = ? LIMIT ?  [["id", 1], ["id", 2], ["id", 3], ["price", 2000], ["LIMIT", 1]]
=> #<Book id: 2, book_title: "Book 2", publication_date: "2024-07-01", price: 2000, created_at: "2024-08-31 09:10:12", updated_at: "2024-08-31 09:10:12">
```
:::note
`nil`を返さない場合は、想定通りの結果を返す
:::

:::note warn
しかし、<INS>`nil`が返る可能性がある時点</INS>でscopeとして定義するのは良くない
:::

# nilが想定される場合は、クラスメソッドで定義する
```app/models/book.rb
class Book < ApplicationRecord
  def self.find_by_price(price)
    find_by(price: price)
  end
end
```

```rb:rails c
irb(main):001:0> Book.find_by_price(10000)
  Book Load (0.4ms)  SELECT "books".* FROM "books" WHERE "books"."price" = ? LIMIT ?  [["price", 10000], ["LIMIT", 1]]
=> nil
```

:::note
想定通り、`nil`が返却される
:::