Railsのbuild_associationの挙動がよくわからなかったので調べてみた

こんにちは!kossyです!




今回は、Railsのbuild_associationの挙動がよくわからなかったので調べてみました。



Ruby 2.7.6
Rails 6.0.5.1




前提として、userがreservation(予約)を1つ持つという関連が組まれていることとします。この場合、userクラスのインスタンスメソッドとして自動的にbuild_reservationメソッドが定義されます。


ソースコードを見る

build_associationを定義しているのは singular_association.rb でした。

github.com

    # Defines the (build|create)_association methods for belongs_to or has_one association
    def self.define_constructors(mixin, name)
      mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
        def build_#{name}(*args, &block)
          association(:#{name}).build(*args, &block)
        end
        def create_#{name}(*args, &block)
          association(:#{name}).create(*args, &block)
        end
        def create_#{name}!(*args, &block)
          association(:#{name}).create!(*args, &block)
        end
      CODE
    end

class_evalで動的にメソッド定義を行ってますね。

では実際に呼び出される時はどのような挙動になるのでしょうか。試しにuserクラスに適当なインスタンスメソッドを定義して、binding.pryでdebugしてみましょう。

class User
  has_one :reservation

  def test_method
    binding.pry

    build_reservation
  end
end

この状態でコンソールを開いて、userインスタンスに対してtest_methodを実行します。するとREPLが起動するので、stepメソッドでメソッド内部に移動すると、

lib/active_record/associations/builder/singular_association.rb:29

    28: def build_#{name}(*args, &block)
 => 29:   association(:#{name}).build(*args, &block)
    30: end

動的にメソッド定義を行っている場所に辿り着きました。buildメソッドでインスタンス定義を行なっていると思われるので、処理を見てみます。

lib/active_record/associations/singular_association.rb:21

    20: def build(attributes = {}, &block)
 => 21:   record = build_record(attributes, &block)
    22:   set_new_record(record)
    23:   record
    24: end

build_recordは実際に関連先のインスタンスを生成する処理かと思います。set_new_recordの処理も見てみます。

lib/active_record/associations/has_one_association.rb:75

    74: def set_new_record(record)
 => 75:   replace(record, false)
    76: end

replaceメソッドの中身はこちら。

    42: def replace(record, save = true)
 => 43:   raise_on_type_mismatch!(record) if record
    44:
    45:   return target unless load_target || record
    46:
    47:   assigning_another_record = target != record
    48:   if assigning_another_record || record.has_changes_to_save?
    49:     save &&= owner.persisted?
    50:
    51:     transaction_if(save) do
    52:       remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record
    53:
    54:       if record
    55:         set_owner_attributes(record)
    56:         set_inverse_instance(record)
    57:
    58:         if save && !record.save
    59:           nullify_owner_attributes(record)
    60:           set_owner_attributes(target) if target
    61:           raise RecordNotSaved, "Failed to save the new associated #{reflection.name}."
    62:         end
    63:       end
    64:     end
    65:   end
    66:
    67:   self.target = record
    68: end

いくつかのバリデーションを行った後、target(関連先のレコード)が既に存在していて、関連先のレコードが削除されておらず、新しい関連先のレコードがある場合、

remove_target!メソッドを呼んでいます。メソッド名から察するに、既に存在している関連先のレコードを削除するメソッドだと思いますが、処理を追ってみます。

    78: def remove_target!(method)
 => 79:   case method
    80:   when :delete
    81:     target.delete
    82:   when :destroy
    83:     target.destroyed_by_association = reflection
    84:     target.destroy
    85:   else
    86:     nullify_owner_attributes(target)
    87:     remove_inverse_instance(target)
    88:
    89:     if target.persisted? && owner.persisted? && !target.save
    90:       set_owner_attributes(target)
    91:       raise RecordNotSaved, "Failed to remove the existing associated #{reflection.name}. " \
    92:                             "The record failed to save after its foreign key was set to nil."
    93:     end
    94:   end
    95: end

私の実行環境だと引数のmethodは:destroyでした。

82行目で関連先のレコードに組まれたAssociationのレコードを削除し、83行目で関連先のレコードを削除しています。


まとめ

build_associationメソッドは、既に関連先のレコード(今回だとreservation)が存在する場合は、そのレコードを削除して新たなインスタンスを生成していました。更新時にbuild_associationを呼ぶ場合は挙動を頭に入れた上で実行した方がよさそうですね。(思わぬバグを誘発するかも)