【Rails】スコープでfind_byを使用して、条件に一致するレコードがないとnilではなくレコード全件が返される理由

# なにこれ
タイトルの通り、筆者が業務でRailsを書いている時に、scope内でfind_byを使用しました。
そこで、条件に一致しなかった場合に`nil`ではなく**該当テーブルのレコード全件が返される**事象に遭遇したので、解決策と原因を備忘録として残しておきます。

### 注意書き
ドキュメントに答えは書いてあります。
[Active Record クエリインターフェイス#14スコープ](https://railsguides.jp/active_record_querying.html#%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97)
~~検索しても同様の記事がヒットしなかった。ドキュメントに書いてあるからかな？~~

# 結論（解決策）
## 1．**scopeではなくクラスメソッドを使用する。**
そうすることで、条件に一致するものがなかった場合、レコード全件ではなく`nil`が返ります。

```product.rb
  # bad
  scope :find_by, ->(product_id) {
    find_by(product_id: product_id)
  } #=> ActiveRecord::Relation

  # good
  def self.find_by(product_id)
    find_by(product_id: product_id)
  end #=> nil
```

## 2．**find_by!メソッドに変更して例外を投げる**（一番下の余談を参照）

## 3．whereを使う
whereだと条件に一致しない場合は空の配列`[ ]`を返すので、`product.where(hoge: true).blank?`みたいに値があるか確認する時に使える。

```rb
# product.rb
  has_one :owner, -> { owner }, class_name: :User

# user.rb
  scope :owner, -> { where(is_owner: true) }
```

こんな感じにすれば、whereだけどhas_oneのおかげで配列の一番目を取得（find_byと同じ挙動）が再現できる。
上記の例だと、`product.owner`でowner（仮）を取得できます。
状況と場面によって使い分ける。

# 原因
## なぜクラスメソッドだとnilが返るのか？
それを説明するために、スコープとクラスメソッドの違いを確認します。

## そもそもスコープとは具体的になんぞや
[Railsガイド 14 スコープ](https://railsguides.jp/active_record_querying.html#%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%97)
公式から引用します。

> スコープを設定することで、関連オブジェクトやモデルへのメソッド呼び出しとして参照される、よく使用されるクエリを指定することができます。スコープでは、where、joins、includesなど、これまでに登場したすべてのメソッドを使用できます。どのスコープメソッドも、常にActiveRecord::Relationオブジェクトを返します。このオブジェクトに対して、別のスコープを含む他のメソッド呼び出しを行なうこともできます。

伝えたいことを要約すると、スコープは**常に`ActiveRecord::Relation`オブジェクトを返します。**
検索結果が`nil`だった場合でも、`ActiveRecord::Relation`オブジェクトを返します。絶対にです。絶対に！！~~めっちゃ大事なことなので2回言いました。~~

## じゃあActiveRecord::Relationってなんだよ
`ActiveRecord::Relation`について簡単にまとまってる良い記事があったので引用させていただきます。
[ActiveRecord::Relationとは一体なんなのか](https://doruby.jp/users/whale/entries/ActiveRecord--Relation%E3%81%A8%E3%81%AF%E4%B8%80%E4%BD%93%E3%81%AA%E3%82%93%E3%81%AA%E3%81%AE%E3%81%8B)

上記の記事から要約します。
`Product`なるモデルがあったとすると `Product.all` や `Product.where(name: "hoge")` などで返ってくるものが `ActiveRecord::Relation` のインスタンスです。

ざっくり言うと、`ActiveRecord::Relation`は複数のレコード（インスタンス？）を持ったやつです。

`where.class`をすると、`Product::ActiveRecord_Relation`が返されてるのが確認できます。
`find_by.class`をすると、`Product`クラス自身を返します。

```rb
[31] pry(main)> Product.where(id: 1).class
=> Product::ActiveRecord_Relation
[32] pry(main)> Product.find_by(id: 1).class
  Product Load (2.1ms)  SELECT `products`.* FROM `products` WHERE `products`.`id` = 1 LIMIT 1
=> Product(id: integer, name: string)
```

## クラスメソッドとスコープの違い
下記の記事が非常に分かりやすかったので引用させてください。
[ActiveRecordのscopeでnilを返すと…
](https://qiita.com/uenosy/items/9079df58d1c524d9bb34)
クラスメソッドはスコープと全く動きをします。
（スコープはインスタンスメソッドではありません。~~筆者はここを勘違いしてました。~~）

下記2つが行う動作はほぼ同じです。違うのは`nil`や`User.none`の場合返り値だけ。

```user.rb
class User < ActiveRecord::Base
  scope :hoge, -> (fuga) { find_by(fuga: fuga) }
end

class User < ActiveRecord::Base
  def self.hoge(fuga)
    find_by(fuga: fuga)
  end
end
```

# 結局なんでスコープだとダメなの？
スコープだとレコード全件返す理由は、**常に`ActiveRecord::Relation`オブジェクトを返すからです。**
常に`ActiveRecord::Relation`を返すので、nilだった場合は自動的に全てのレコードを返します。

クラスメソッドを使用すると、メソッド本来の動きをするので`nil`が返されます。
これが答えです。

## 茶番劇
スコープくん「よっしゃ、DBにアクセスしてレコードを絞り込むで〜」

⬇️SQL実行

スコープくん「は！？一致するもんなくて`nil`なんやが。。。」

スコープくん「でも、ワイは絶対`ActiveRecord::Relation`オブジェクトを返すって決められてるからなあ。。。」

スコープくん「うーん、`nil`を返したら規約違反だし、しょうがないからレコード全件返すべ！」

みたいなことが繰り広げられてると思ってます。笑
（スコープで実行したのと同じSQLを実行したら、nilの時は0件で表示されます。）


# 感想
`ActiveRecord::Relation`について全く知らなかったので、初めて今回の事象に遭遇した時に「Railsのバグじゃね！？」と思ってました。~~Railsを疑ってすみません。バリバリ仕様でした。~~

`find_by`でレコードが1件も一致しないことがレアケースだと思いますが、こういった事象が起こることを頭の片隅のギリギリにでも置いてもらえたら幸いです。

# 別解：where + take
`find_by`は、そもそも内部の動き的には`where(wheres).limit(1)`をしています。
`where`で絞り込んだ後に`take`メソッドを呼び出すことで、`find_by`を再現できます。

```rb
[32] pry(main)> Product.where(id: 1).take.class
  Product Load (2.1ms)  SELECT `products`.* FROM `products` WHERE `products`.`id` = 1 LIMIT 1
=> Product(id: integer, name: string)
```

状況によっては、`where + take`の方が適切な場面がありそう（`nil`を返して欲しい時とか）

ここでややこしいのが、`where + take`してもスコープで1件もヒットしない場合は、同じく該当テーブルのレコード全件が返されます。
`nil`が返される ＋ スコープは`ActiveRecord::Relation`を返すっていう仕様のため。

余談にもありますが、`take!`メソッドは、`ActiveRecord::RecordNotFound`例外を投げるため、`find_by!`を代用できそうです。

# 余談（find_by!のススメ）
ここまで長々と書いてきましたが、
`find_by`で一致するものが無い可能性がある時は、`find_by!`メソッドにして`ActiveRecord::RecordNotFound`例外を投げた方がエラーハンドリングできるし処理的に良さそう？
（そもそも、`find_by`で一致しないことが異常事態な気がするから）

```rb
# product.rb
  scope :hoge, ->(product_id) {
    find_by!(product_id: product_id)
  }

# product_controller.rb
def fuga
    product = Product.hoge(params[:product_id])
    render json: product, status: :ok
rescue ActiveRecord::RecordNotFound => e
    render json: e, status: :not_found
end
```