Feedforce Developer Blog

フィードフォース開発者ブログ

Rails の update メソッドのオーバーライドを調べた

はじめまして! 昨年の 4 月に入社いたしました、Shinsuke Kido です。

弊社が提供するエンジニア教育プログラム「e-Navigator」を受けて、未経験からエンジニア転職いたしました!

現在、Rails に触れて、一年が経とうとしています。 はじめたての頃は、Rails は裏でいろいろと動いてくれていて、楽! というイメージで code を書いていましたが、 レベルアップするにつれ、その「裏」では一体何が動いているのかを、しっかりと理解する必要性を感じるようになってきました。

今回の記事は、Rails で当たり前のように使っているであろう、update method について調べたことを書きました!

疑問に思ったこと

下記、Todo model が存在すると仮定します ( Rails 5.2.0 )

# app/models/todo.rb
class Todo < ApplicationRecord

(中略)

end
pry(main)> todo = Todo.first
  Todo Load (0.4ms)  SELECT  "todos".* FROM "todos" ORDER BY "todos"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<Todo:0x00007feff4c9f0b8
 id: "ca648b82-a36e-4f76-a98d-13380cdf913e",
 title: "abc",
 text: "text",
 created_at: Wed, 18 Jul 2018 05:10:53 UTC +00:00,
 updated_at: Tue, 19 Feb 2019 07:45:12 UTC +00:00>

pry(main)> todo.title
=> "abc"

この、Todo model に下記実装を施すと

# app/models/todo.rb
class Todo < ApplicationRecord

(中略)

  def title
    'ABC'
  end

(中略)

end
pry(main)> todo.title
=> "ABC"

このように、#title が override され、todo.title の返り値が変更される
これは感覚的にも、よく分かるのですが、、、

下記実装を Todo class に施すと、、、

#app/models/todo.rb
class Todo < ApplicationRecord

(中略)

  def title=(arg)
    self[:title] = 'CBA'
  end

(中略)

end
pry(main)> todo.update(title: 'ABC')
   (0.3ms)  BEGIN
  Todo Update (0.5ms)  UPDATE "todos" SET "title" = $1, "updated_at" = $2 WHERE "todos"."id" = $3  [["title", "CBA"], ["updated_at", "2019-02-19 08:08:01.666492"], ["id", "ca648b82-a36e-4f76-a98d-13380cdf913e"]]
   (0.6ms)  COMMIT
=> true

pry(main)> todo.title
=> "CBA"

#update が override されてしまい、 title の値が 'ABC' ではなく、 'CBA' になる、、、
これが感覚的に分からなかったので、一体 Active Record のどの method が override されているのかが、気になり、調べてみるとにしました!

調べた結果

まずは、下記、ActiveRecord の #update が呼ばれ

# vendor/bundle/ruby/2.5.0/gems/activerecord-5.2.0/lib/active_record/persistence.rb
module ActiveRecord
  # = Active Record \Persistence
  module Persistence
    extend ActiveSupport::Concern

    module ClassMethods

(中略)

    def update(attributes)
      # The following transaction covers any possible database side-effects of the
      # attributes assignment. For example, setting the IDs of a child collection.
      with_transaction_returning_status do
        assign_attributes(attributes)
        save
      end
    end

(中略)

end

その後、いくつかの method を経由し、下記、public_send までは、Todo model での #update の上書きの有無に関わらず、呼ばれているようでした。

# vendor/bundle/ruby/2.5.0/gems/activemodel-5.2.0/lib/active_model/attribute_assignment.rb
module ActiveModel
  module AttributeAssignment
    include ActiveModel::ForbiddenAttributesProtection

(中略)

      def _assign_attribute(k, v)
        setter = :"#{k}="
        if respond_to?(setter)
          public_send(setter, v)
        else
          raise UnknownAttributeError.new(self, k)
        end
      end

(中略)

end

次に、#update の上書きの有無による public_send から呼ばれる method の分岐を調べるために、 binding.pry を仕込んで試してみると、、、

Todo model で #update を上書きした時、

pry(main)> todo.update(title: 'ABC')
   (0.3ms)  BEGIN

From: /Users/shinsukekido/Simple-TODO-API/vendor/bundle/ruby/2.5.0/gems/activemodel-5.2.0/lib/active_model/attribute_assignment.rb @ line 52 ActiveModel::AttributeAssignment#_assign_attribute:

    48: def _assign_attribute(k, v)
    49:   setter = :"#{k}="
    50:   if respond_to?(setter)
    51:     binding.pry
 => 52:     public_send(setter, v)
    53:   else
    54:     raise UnknownAttributeError.new(self, k)
    55:   end
    56: end

pry(#<Todo>)> step

From: /Users/shinsukekido/Simple-TODO-API/app/models/todo.rb @ line 10 Todo#title=:

     9: def title=(arg)
 => 10:   self[:title] = 'CBA'
    11: end

Todo model で #update を上書きしなかった時、

pry(main)> todo.update(title: 'ABC')
   (0.2ms)  BEGIN

From: /Users/shinsukekido/Simple-TODO-API/vendor/bundle/ruby/2.5.0/gems/activemodel-5.2.0/lib/active_model/attribute_assignment.rb @ line 52 ActiveModel::AttributeAssignment#_assign_attribute:

    48: def _assign_attribute(k, v)
    49:   setter = :"#{k}="
    50:   if respond_to?(setter)
    51:     binding.pry
 => 52:     public_send(setter, v)
    53:   else
    54:     raise UnknownAttributeError.new(self, k)
    55:   end
    56: end

pry(#<Todo>)> step

From: /Users/shinsukekido/Simple-TODO-API/vendor/bundle/ruby/2.5.0/gems/activerecord-5.2.0/lib/active_record/attribute_methods/write.rb @ line 22 self.__temp__479647c656=:

    17:             ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name
    18:             sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key
    19: 
    20:             generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1
    21:               def __temp__#{safe_name}=(value)
 => 22:                 name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
    23:                 #{sync_with_transaction_state}
    24:                 _write_attribute(name, value)
    25:               end
    26:               alias_method #{(name + '=').inspect}, :__temp__#{safe_name}=
    27:               undef_method :__temp__#{safe_name}=

この結果から、

# vendor/bundle/ruby/2.5.0/gems/activerecord-5.2.0/lib/active_record/attribute_methods/write.rb
module ActiveRecord
  module AttributeMethods
    module Write
      extend ActiveSupport::Concern

(中略)

          def define_method_attribute=(name)
            safe_name = name.unpack("h*".freeze).first
            ActiveRecord::AttributeMethods::AttrNames.set_name_cache safe_name, name
            sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key

            generated_attribute_methods.module_eval <<-STR, __FILE__, __LINE__ + 1
              def __temp__#{safe_name}=(value)
                name = ::ActiveRecord::AttributeMethods::AttrNames::ATTR_#{safe_name}
                #{sync_with_transaction_state}
                _write_attribute(name, value)
              end
              alias_method #{(name + '=').inspect}, :__temp__#{safe_name}=
              undef_method :__temp__#{safe_name}=
            STR
          end

(中略)

end

こちらの、def __temp__#{safe_name}=(value) を、 Todo class の def title=(arg) が override することで、#update を上書きしている、ということが分かりました!

ちなみに、Rails 5.2.2 では、こちらを override するようです。

最後に

今回、gem pry-byebug が用意している step コマンドを使うことで、最終的に method を特定することができました。
しかし、最初の段階から step だけで先に進んでしまうと、あまり重要でない箇所で深く深くに沈んでしまい、目当ての method まで到達しない、というケースが起こりうるかと思います。
ですので、この method は飛ばしても大丈夫、と思ったら、next コマンドを使い、この method が怪しい、と思ったら step コマンドを使う、といった使い分けが大切なのかな、と感じました!

今後の debug に活かしていきたいと思います!!