11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-17

こんにちは。株式会社iCAREでサーバーサイドエンジニアをしている越川と申します。
この記事は、iCARE Dev Advent Calendar 第2レーン の18日目です。

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

環境
Ruby 2.6.9
Rails 6.1.4

前準備

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

# Gemfile

group :development, :test do
  gem 'pry-rails'
  gem 'pry-byebug'
end
$ bundle install

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

class User < ApplicationRecord

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

  def self.roles
    1
  end
end

これで準備完了です。

ソースコードを追う

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

> 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の処理の中に入ると、

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が呼ばれるようです。

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

> 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が呼び出されると思われます。(未確認でごめんなさい)

ActiveRecord::Delegation::DelegateCache#generate_relation_method

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の返り値は以下です。

> generated_relation_methods
=> User::GeneratedRelationMethods

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

ActiveRecord::Delegation::DelegateCache#generated_relation_methods

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メソッドで動的にメソッドを生やしているところが肝だと思いますが、処理を追ってみます。

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クラスについては以下のドキュメントが詳しかったです。

一部引用します。

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

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

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

> 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オブジェクトを作って、クラスメソッドを呼び出してみましょう。

$ 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ではエンジニア採用を強化しています

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

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

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

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

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?