なにこれ
タイトルの通り、筆者が業務でRailsを書いている時に、scope内でfind_byを使用しました。
そこで、条件に一致しなかった場合にnil
ではなく該当テーブルのレコード全件が返される事象に遭遇したので、解決策と原因を備忘録として残しておきます。
注意書き
ドキュメントに答えは書いてあります。
Active Record クエリインターフェイス#14スコープ
検索しても同様の記事がヒットしなかった。ドキュメントに書いてあるからかな?
結論(解決策)
1.scopeではなくクラスメソッドを使用する。
そうすることで、条件に一致するものがなかった場合、レコード全件ではなくnil
が返ります。
# 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つが行う動作はほぼ同じです。違うのはnil
やUser.none
の場合返り値だけ。
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