なぜModel::ActiveRecord_RelationオブジェクトはModelに定義されたクラスメソッドを呼び出せるのか調べてみた

こんにちは。株式会社iCAREでサーバーサイドエンジニアをしている越川と申します。
この記事は、[iCARE Dev Advent Calendar 第2レーン](https://qiita.com/advent-calendar/2021/icaredev) の18日目です。



今回は、なぜModel::ActiveRecord_RelationオブジェクトはModelに定義されたクラスメソッドを呼び出せるのか調べてみました。


環境
Ruby 2.6.9
Rails 6.1.4





# 前準備

pry-railsとpry-byebugをinstallしておきます。

```ruby
# Gemfile

group :development, :test do
  gem 'pry-rails'
  gem 'pry-byebug'
end
```

```terminal
$ bundle install
```

次に、ApplicationRecordを継承した適当なクラスにscopeとクラスメソッドを定義しておきます。

```ruby
class User < ApplicationRecord

  scope :test_scope, -> {
    binding.pry
    roles
  }

  def self.roles
    1
  end
end
```

これで準備完了です。

# ソースコードを追う

rails c でコンソールを起動して、Userクラスに対してtest_scopeを実行するところからスタートです。

```ruby
> User.test_scope

From: /app/app/models/user.rb:9 :

     7:   scope :test_scope, -> {
     8:     binding.pry
 =>  9:     roles
    10:   }
    11:
    12:   def self.roles
    13:       1
    14:   end
    15: end
```

ここからstepメソッドでコードリーディングの旅の始まりです。



## ActiveRecord::Delegation::ClassSpecificRelation#method_missing

stepメソッドでrolesの処理の中に入ると、

```ruby
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:106 ActiveRecord::Delegation::ClassSpecificRelation#method_missing:

    105: def method_missing(method, *args, &block)
 => 106:   if @klass.respond_to?(method)
    107:     @klass.generate_relation_method(method)
    108:     scoping { @klass.public_send(method, *args, &block) }
    109:   else
    110:     super
    111:   end
    112: end
```

ActiveRecord::Delegation::ClassSpecificRelationクラスのmethod_missingが呼ばれるようです。

引数やインスタンス変数の中身を確認していきます。

```ruby
> method
=> :roles

> args
=> []

> block
=> nil

> @klass
=> User (call 'User.connection' to establish a connection)

> @klass.respond_to?(method)
=> true
```

Userクラスに対して引数のmethodが定義されているかどうか？をチェックし、定義されていればgenerate_relation_method(method)を呼び出すようになっていました。

もし定義されていなければ、BasicObjectクラスのmethod_missingが呼び出されると思われます。(未確認でごめんなさい)

https://docs.ruby-lang.org/ja/latest/method/BasicObject/i/method_missing.html


## ActiveRecord::Delegation::DelegateCache#generate_relation_method

```ruby
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:38 ActiveRecord::Delegation::DelegateCache#generate_relation_method:

    37: def generate_relation_method(method)
 => 38:   generated_relation_methods.generate_method(method)
    39: end
```

generated_relation_methodsの返り値は以下です。

```ruby
> generated_relation_methods
=> User::GeneratedRelationMethods
```

今回はどうやってUser::GeneratedRelationMethodsが定義されるのか、そもそもGeneratedRelationMethodsってなんぞや、については追いませんが、調査してみると面白そうです。

## ActiveRecord::Delegation::DelegateCache#generated_relation_methods

```ruby
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:49 ActiveRecord::Delegation::DelegateCache#generated_relation_methods:

    48: def generated_relation_methods
 => 49:   @generated_relation_methods ||= GeneratedRelationMethods.new.tap do |mod|
    50:     const_set(:GeneratedRelationMethods, mod)
    51:     private_constant :GeneratedRelationMethods
    52:   end
    53: end
```

||=の構文が使われているので、@generated_relation_methodsが未定義ならGeneratedRelationMethods.new.tap以降の結果が代入される感じですね。

stepメソッドでさらにコードに潜ってみます。

## ActiveRecord::Delegation::GeneratedRelationMethods#generate_method

このメソッドが本丸ですね、、、

module_evalメソッドで動的にメソッドを生やしているところが肝だと思いますが、処理を追ってみます。

```ruby
From: /usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb:60 ActiveRecord::Delegation::GeneratedRelationMethods#generate_method:

    59: def generate_method(method)
 => 60:   synchronize do
    61:     return if method_defined?(method)
    62:
    63:     if /\A[a-zA-Z_]\w*[!?]?\z/.match?(method) && !DELEGATION_RESERVED_METHOD_NAMES.include?(method.to_s)
    64:       definition = RUBY_VERSION >= "2.7" ? "..." : "*args, &block"
    65:       module_eval <<-RUBY, __FILE__, __LINE__ + 1
    66:         def #{method}(#{definition})
    67:           scoping { klass.#{method}(#{definition}) }
    68:         end
    69:       RUBY
    70:     else
    71:       define_method(method) do |*args, &block|
    72:         scoping { klass.public_send(method, *args, &block) }
    73:       end
    74:       ruby2_keywords(method) if respond_to?(:ruby2_keywords, true)
    75:     end
    76:   end
    77: end
```

synchronizeメソッドの定義元は以下でした。

```
$ method(:synchronize).source_location
=> ["/usr/local/lib/ruby/2.6.0/mutex_m.rb", 77]
```

Mutexクラスについては以下のドキュメントが詳しかったです。

https://docs.ruby-lang.org/ja/3.0.0/class/Thread=3a=3aMutex.html

一部引用します。

>
Mutex(Mutal Exclusion = 相互排他ロック)は共有データを並行アクセスから保護する ためにあります。Mutex の典型的な使い方は(m を Mutex オブジェクトとします):
出典: https://docs.ruby-lang.org/ja/3.0.0/class/Thread=3a=3aMutex.html

generate_methodの実行は並行アクセスの可能性があるから、synchronizeメソッドを使って排他制御を加えているようですね。

引き続きコードを読み進めてみます。

```ruby
> method_defined?(method)
=> false

> /\A[a-zA-Z_]\w*[!?]?\z/.match?(method)
=> true

> !DELEGATION_RESERVED_METHOD_NAMES.include?(method.to_s)
=> true

# 返り値を見たところ予約語の集合でした。
> DELEGATION_RESERVED_METHOD_NAMES.class
=> Set

> definition = RUBY_VERSION >= "2.7" ? "..." : "*args, &block"
=>  "*args, &block"

> RUBY_VERSION
=> "2.6.9"

> RUBY_VERSION >= "2.7"
=> false
```

諸々の処理を行った後で、module_evalメソッドを使って、引数で与えられたメソッド名を元にメソッドをActiveRecord_Relationオブジェクトに対して動的に定義していることがわかりました。


# 実際にActiveRecord_Relationに対してクラスメソッドを呼び出してみる

一通りコードを追えました。これでブラックボックスだった内部の仕組みの一端を理解することができましたね。(Rubyすごい)

この感動を味わうために、ActiveRecord_Relationオブジェクトを作って、クラスメソッドを呼び出してみましょう。

```ruby
$ rails c

$ @users = User.all

$ @users.method(:roles).source_location
=> nil

$ @users.roles
=> 1

$  @users.method(:roles).source_location
=> ["/usr/local/bundle/gems/activerecord-6.1.4.1/lib/active_record/relation/delegation.rb", 66]
```

定義元がUserクラスではなくActiveRecord::Relation::Delegationクラスの66行目になっていることがわかります。Rubyすごい(2回目)



# まとめ

method_missingメソッドを使って、メソッドの呼び出し時にそのメソッドがレシーバーに定義されてなかったら、

レシーバーのクラスに対してメソッドが定義されているかを確認し、定義されていればレシーバーにそのメソッドを生やすという黒魔術的なコードになっていました、、、

Rubyの表現力というか自由さには本当に驚かされます、、、

ただ、「実務で同様の手法を使うのか？」と問われると、コードの難読化を招き、実装によっては後々の負債の原因にもなりかねないので、

「あーこういうやり方もあるんだなぁ」で留めておきたいと思います、、、

# 株式会社iCAREではエンジニア採用を強化しています

各ポジションにて求人をしております。働くひとの健康を世界中に創る事業にご興味のある方は、まずはカジュアル面談からでもお気軽ご連絡ください！

https://herp.careers/v1/icare

# 紹介商談も募集しております

弊社、株式会社iCAREでは、企業が従業員の健康管理をするSaaSの商談をしていただける企業さまをご紹介いただける企業さまを募っております。

健康管理にお困りの人事の方のお知り合いがいらっしゃいましたら是非株式会社iCAREまでご連絡くださいませ！