Railsのscopeは何をやっているか
環境
本記事は以下の環境に基づきます
- 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
という記述が存在します。
これはまさに特異クラスでインスタンスメソッドを定義しています。つまり、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
全体のコードはこちらとなります。
引数に入るもの
こちらからわかる通りそれぞれの引数には以下の値が渡されます。
引数名 | 値 |
---|---|
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
エラー処理
第二引数のbody
がcall
というメソッドを持っていなければエラーを返しています。
同名のクラスメソッドが既に定義されている場合エラーを返します。
ActiveRecord::Relation
インスタンスが同名のメソッドを持っている場合エラーを返します。
今回ではFruit::ActiveRecord_Relation
クラスが同名のインスタンスメソッドを持っているかどうかを見るようです。
特異メソッド(クラスメソッド)の定義
結論部分でも書いたsingleton_class.define_method
でFruit
クラスのクラスメソッドを定義しています。
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インスタンスを追ってみます。
まず、Moduleクラスのインスタンスメソッドとして定義されています。
extension = Module.new(&block) if block
その後、extending
を使用してscopが拡張されています。
scope = scope.extending(extension) if extension
これにより、以下のような形でscopeを適用した後のActiveRecord::Relation
インスタンスをレシーバーとしてメソッドを呼び出すことができるようになります。
Fruit.red.tag #=> 'red'
最後に
ソースコードを読むことでscopeはクラスメソッドの定義だけではなく色んなことを行なっていることがわかりました。
まだまだしっかり読めていない部分があるので再度挑戦したいと思います。
Discussion