🦧

Railsのscopeは何をやっているか

2022/08/27に公開

環境

本記事は以下の環境に基づきます

  • ruby 3.0.0
  • Rails 7.0.2.3

scopeはクラスメソッドと同じ!?

モデル内で以下のようなスコープを定義します。

class Fruit < ApplicationRecord
  scope :red, -> { where(color: 'red') }
end

すると、Fruit.redで呼び出し可能になります。

> Fruit.red
# Fruit Load (0.1ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."color" = ?  [["color", "red"]]

一方で、以下のようなクラスメソッドを定義します。

class Fruit < ApplicationRecord
  def self.red
    where(color: "red")
  end
end

すると同様に、Fruit.redで呼び出し可能で同様のSQL文が発行されていることがわかります。

> Fruit.red
# Fruit Load (0.1ms)  SELECT "fruits".* FROM "fruits" WHERE "fruits"."color" = ?  [["color", "red"]]

これを見るとクラスメソッドとscopeが同じよう感じられたためソースコードを読んでみました。

結論:scopeは内部的にクラスメソッドを定義する+他にも色々やっている

Ruby用語集によると、クラスメソッドとは「そのオブジェクトの特異クラスのインスタンスメソッド」と書かれています。
ここで、railsのスコープのソースコードを確認するとsingleton_class.define_methodという記述が存在します。

https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L174-L178

これはまさに特異クラスでインスタンスメソッドを定義しています。つまり、scopeを定義したクラス(今回の例で言うとFruit)から見るとクラスメソッドが定義されたということに他なりません。

一方でscopeを使用すると単純にクラスメソッドを定義するだけでは不可能な表現を行うことができます。

class Fruit < ApplicationRecord
  scope :red, -> { where(color: 'red') } do
    def tag
      'red'
    end
  end
end
> Fruit.red.tag
=> "red"

どうやらクラスメソッドの定義はあくまでscopeの一部の機能にすぎず、他にもできることがありそうです。

scopeのソースコードを見てみる

例として、以下のようなscopeを定義して、どのような処理が行われるかを見ていくことにします。

class Fruit < ApplicationRecord
  scope :red, -> { where(color: 'red') } do
    def tag
      'red'
    end
  end
end

全体のコードはこちらとなります。

引数に入るもの

https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L154

こちらからわかる通りそれぞれの引数には以下の値が渡されます。

引数名
name :red
body Procインスタンス
block Procインスタンス

body, blockともにProcインスタンスですが異なることに注意してください。

# bodyは-> { where(color: 'red') }の部分
> body.call.to_sql
# => "SELECT \"fruits\".* FROM \"fruits\" WHERE \"fruits\".\"color\" = 'red'"

# blockはtagメソッドの部分
block.call # => :tag

エラー処理

第二引数のbodycallというメソッドを持っていなければエラーを返しています。
https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L155-L157

同名のクラスメソッドが既に定義されている場合エラーを返します。
https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L159-L163

ActiveRecord::Relationインスタンスが同名のメソッドを持っている場合エラーを返します。
今回ではFruit::ActiveRecord_Relationクラスが同名のインスタンスメソッドを持っているかどうかを見るようです。
https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L165-L169

特異メソッド(クラスメソッド)の定義

https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L173-L179

結論部分でも書いたsingleton_class.define_methodFruitクラスのクラスメソッドを定義しています。
define_methodは次のようにメソッドを適宜することができます。

class Hoge
  define_method(:hello) do |name|
    puts "こんにちは!#{name}さん!"
  end
end

Hoge.new.hello('太郎') #=> こんにちは!太郎さん!

次に、scope = all._exec_scope(*args, &body)という部分に注目します。

binding.irbを挟んでallに何が入っているかを見てみたところ、

  • Fruit.redのようにモデル自体をレシーバーとしてメソッドを呼んだ場合
all.class #=> Fruit::ActiveRecord_Relation
all.to_sql # => "SELECT \"fruits\".* FROM \"fruits\""
  • Fruit.where(name: 'おいしい').redのようにActiveRecord::Relationインスタンスをレシーバーとしてメソッドを呼んだ場合
all.to_sql #=> "SELECT \"fruits\".* FROM \"fruits\" WHERE \"fruits\".\"name\" = 'おいしい'"

それぞれActiveRecord::Relationインスタンスが保存されていました。
最終的にall._exec_scope(*args, &body)を呼ぶことでscopeで定義されたメソッドが適用されたActiveRecord::Relationインスタンスを返しています。

# Fruit.redを実行した時
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
    # scope.to_sql
    # => => "SELECT \"fruits\".* FROM \"fruits\" WHERE \"fruits\".\"color\" = 'red'"
end

extendingによる拡張

最後に、三番目の引数に渡したProcインスタンスを追ってみます。

https://github.com/rails/rails/blob/d66441f39127c284c2fc7ac9bfe3e8270b115033/activerecord/lib/active_record/scoping/named.rb#L171-L176

まず、Moduleクラスのインスタンスメソッドとして定義されています。

extension = Module.new(&block) if block

その後、extendingを使用してscopが拡張されています。

scope = scope.extending(extension) if extension

これにより、以下のような形でscopeを適用した後のActiveRecord::Relationインスタンスをレシーバーとしてメソッドを呼び出すことができるようになります。

Fruit.red.tag #=> 'red'

最後に

ソースコードを読むことでscopeはクラスメソッドの定義だけではなく色んなことを行なっていることがわかりました。
まだまだしっかり読めていない部分があるので再度挑戦したいと思います。

GitHubで編集を提案

Discussion