自己紹介
皆さんこんにちは。ohashi_tと申します。
弊社ではバックエンドにRuby on Railsを採用しているプロジェクトが多数あり、 日々押し寄せる業務要件に応え、アプリケーションがスケールしていく中でも絶えず改善が行われております。
私も日々の業務を通じて様々な要件に対応する機会があり、 その都度Ruby on Railsに対する理解が段々と深まって来ていると 実感を得られています。 今回、その中で特に印象に残っている 「Model関連のクラスで定義するscopeとクラスメソッドの違いについて」 事例を1つご紹介したいと思います。
はじめに
scopeとクラスメソッドの違いについてググるとこれに関する記事が沢山出てきます。 しかし、個人的には誤解のある表現が多いと感じ なかなか想定する動作を実現出来ず、振り回された経験がありました。 この度記事としてアウトプットさせていただき、 少しでも皆様のお役に立てるよう願っております。
今回扱うデータはこちらになります。
irb(main):001:0> Weather.all Weather Load (0.4ms) SELECT `weathers`.* FROM `weathers` => [#<Weather:0x000000010fc8a4a0 id: 2, temperature: 21.4, place: "Bankok", status: "cloudy", observed_at: Sat, 01 Apr 2017 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:50:52.170025000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:50:52.170025000 UTC +00:00>, #<Weather:0x000000010fde3978 id: 3, temperature: 20.6, place: "Tokyo", status: "rainy", observed_at: Fri, 01 Jun 2018 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:37.625832000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:37.625832000 UTC +00:00>, #<Weather:0x000000010fde38b0 id: 4, temperature: 34.7, place: "Sydney", status: "sunny", observed_at: Thu, 01 Aug 2019 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:46.206855000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:46.206855000 UTC +00:00>, #<Weather:0x000000010fde36f8 id: 5, temperature: 27.8, place: "Newyork", status: "windy", observed_at: Thu, 01 Oct 2020 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:54.038531000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:54.038531000 UTC +00:00>, #<Weather:0x000000010fde3630 id: 6, temperature: 0.0, place: "London", status: "snowy", observed_at: Wed, 01 Dec 2021 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:54:01.782352000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:54:01.782352000 UTC +00:00>]
多分一番分かりやすいと思われる違い
何もしないクラスメソッド・scopeの比較
早速定義して動作を確認してみましょう。
class Weather < ApplicationRecord ..... scope :do_nothing_by_scope, -> {} def self.do_nothing_by_class_method; end end
scopeを使用した場合
irb(main):001:0> Weather.do_nothing_by_scope Weather Load (0.6ms) SELECT `weathers`.* FROM `weathers` => [#<Weather:0x0000000113a6ff90 id: 2, temperature: 21.4, place: "Bankok", status: "cloudy", observed_at: Sat, 01 Apr 2017 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:50:52.170025000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:50:52.170025000 UTC +00:00>, #<Weather:0x0000000113be6f40 id: 3, temperature: 20.6, place: "Tokyo", status: "rainy", observed_at: Fri, 01 Jun 2018 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:37.625832000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:37.625832000 UTC +00:00>, #<Weather:0x0000000113be6e78 id: 4, temperature: 34.7, place: "Sydney", status: "sunny", observed_at: Thu, 01 Aug 2019 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:46.206855000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:46.206855000 UTC +00:00>, #<Weather:0x0000000113be6db0 id: 5, temperature: 27.8, place: "Newyork", status: "windy", observed_at: Thu, 01 Oct 2020 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:53:54.038531000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:53:54.038531000 UTC +00:00>, #<Weather:0x0000000113be6ce8 id: 6, temperature: 0.0, place: "London", status: "snowy", observed_at: Wed, 01 Dec 2021 00:00:00.000000000 UTC +00:00, created_at: Fri, 24 Jun 2022 05:54:01.782352000 UTC +00:00, updated_at: Fri, 24 Jun 2022 05:54:01.782352000 UTC +00:00>]
どうやら戻り値が偽(false, nil)であればActiveRecord_Relationsのインスタンス群を返すようです。
クラスメソッドを使用した場合
irb(main):002:0> Weather.do_nothing_by_class_method => nil
一方クラスメソッドで定義した場合はそのまま戻り値が返ります。
調べてみたところ、どうやらここのinstance_exec(*args, &block) || self
の箇所で||演算子が使用されているため、lambdaで定義された評価結果がnilもしくはfalseの場合にselfを返すようです。
def _exec_scope(name, *args, &block) # :nodoc: @delegate_to_klass = true _scoping(_deprecated_spawn(name)) { instance_exec(*args, &block) || self } ensure @delegate_to_klass = false end
気をつけなければいけないパターン
条件分岐をする場合
弊社のプロジェクトでもよく見るパターンですが、 例えばデフォルト引数でnilを宣言しておき、空でない値が指定された時のみ絞り込みを行うパターンが考えられます。
def self.filter_by_class_method(status = nil) where(status: status) if status.present? end scope :filter_by_scope, ->(status = nil) { where(status: status) if status.present? }
引数が空でない値が指定されれば同等の出力結果が期待出来ますが、 引数を指定せず呼び出した場合は先程の例のように差異が発生してしまいます。
もしscopeと同等の結果を得たいならば下記のように引数が指定されなかった場合の条件も明示する必要があります。
def filter_by_class_method(status = nil) return where(status: status) if status.present? all end
scopeを使用する際のイメージとしては、幾つかメソッドチェーンして抽出条件を絞り込んでいくのがscopeと言う名称と役割がマッチしていて用途が適切なのではないかと思います。
そのためには全ての条件で戻り値がActiveRecord::Relationsを返すように工夫する必要があります。
注意しなければいけないのは全てのActiveRecordに関するクエリがActiveRecord::Relationsを返すわけではなく、
例えばfirst
やtake
は戻り値にインスタンスや配列を返す(ActiveRecord::Relationsを返さない)ため、
メソッドチェーンさせることが出来ません。
このようなパターンでは役割の違いを明確にするため、 クラスメソッドを使用するのが良いのではないかと思います。
ちなみにscopeでfind_by
を使用する場合は更に注意が必要で、
普段であれば該当するレコードが見つかればそのレコードに関するインスタンスを返す、無ければnil.....
のはずがnilではなくレシーバーで評価され得るインスタンス群全てが返ってくるので
挙動の違いをしっかりと把握していないと想定外のエラーに見舞われます。
scopeで1件も返したくない場合
ActiveRecordではnone
というメソッドがあり、空のActiveRecord::Relationsを返します。こちらはrubyで例えると空配列に相当するので、
もしこのようなケースではこちらを使用するのが良いと思います。
scopeでのブロックの役割はメソッドと異なる
scopeでのブロックの役割はscopeが適用されたActiveRecord_Relationsのみに追加で名前空間を提供 (そのscopeを介したActiveRecord::Relationの集合体だけに更にメソッドやscopeを追加) することが出来ます。
このようにscopeではブロックの役割が既に決まっておりクラスメソッドで用いていた要領でブロックを用いることが出来ません。
普段使用しているような要領でメソッド内のタイミングでブロックの評価をさせたい場合なども クラスメソッドを使用する方が良いのではないかと思います。
さいごに
「scopeはクラスメソッドに比べて短く宣言出来て素敵~」 みたいにscopeの意図を十分に掴めていないままアウトプットに踏み切った記事が見られましたので 上記で説明したように
「クラスメソッドとscopeでは挙動や用途が異なる」
ということをしっかりと理解された上で使い分けることをおすすめします。
最後にこちらが今回取り上げたscopeに関するソースコードです。ざっと目を通してみましょう。
def scope(name, body, &block) unless body.respond_to?(:call) raise ArgumentError, "The scope body needs to be callable." end if dangerous_class_method?(name) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but Active Record already defined " \ "a class method with the same name." end if method_defined_within?(name, Relation) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \ "an instance method with the same name." end extension = Module.new(&block) if block if body.respond_to?(:to_proc) singleton_class.define_method(name) do |*args| scope = all._exec_scope(*args, &body) scope = scope.extending(extension) if extension scope end else singleton_class.define_method(name) do |*args| scope = body.call(*args) || all scope = scope.extending(extension) if extension scope end end singleton_class.send(:ruby2_keywords, name) generate_relation_method(name) end
この辺はガード説ですね。同名のメソッドが既に定義されていたり、Procクラスでは呼べるであろうcallを持って無ければここで弾かれます。
unless body.respond_to?(:call) raise ArgumentError, "The scope body needs to be callable." end if dangerous_class_method?(name) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but Active Record already defined " \ "a class method with the same name." end if method_defined_within?(name, Relation) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \ "an instance method with the same name." end
先程も説明したとおり、scope独自の名前空間の作成に使用されます。
extension = Module.new(&block) if block
そしてこのタイミングでメソッドが定義されて、 呼び出された時にdo...end内が評価されるんですね。
if body.respond_to?(:to_proc) singleton_class.define_method(name) do |*args| scope = all._exec_scope(name, *args, &body) scope = scope.extending(extension) if extension scope end else singleton_class.define_method(name) do |*args| scope = body.call(*args) || all scope = scope.extending(extension) if extension scope end end
最後にリレーション間でもメソッドが呼び出せるになるようです。
generate_relation_method(name)
以上です。ありがとうございました!