7
5

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-01-15

なにこれ

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

注意書き

ドキュメントに答えは書いてあります。
Active Record クエリインターフェイス#14スコープ
検索しても同様の記事がヒットしなかった。ドキュメントに書いてあるからかな?

結論(解決策)

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?みたいに値があるか確認する時に使える。

# 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 スコープ
公式から引用します。

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

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

じゃあActiveRecord::Relationってなんだよ

ActiveRecord::Relationについて簡単にまとまってる良い記事があったので引用させていただきます。
ActiveRecord::Relationとは一体なんなのか

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

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

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

[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を返すと…

クラスメソッドはスコープと全く動きをします。
(スコープはインスタンスメソッドではありません。筆者はここを勘違いしてました。

下記2つが行う動作はほぼ同じです。違うのはnilUser.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を再現できます。

[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で一致しないことが異常事態な気がするから)

# 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
7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5