UUUMエンジニアブログ

UUUMのエンジニアによる技術ブログです

Ruby on Rails Model関連のクラスで定義するscopeとクラスメソッドの違いについて

自己紹介

皆さんこんにちは。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を返すようです。

github.com

    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を返すわけではなく、 例えばfirsttakeは戻り値にインスタンスや配列を返す(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に関するソースコードです。ざっと目を通してみましょう。

github.com

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)

以上です。ありがとうございました!