こんにちは!kossyです!
今回は、Railsのbuild_associationの挙動がよくわからなかったので調べてみました。
前提として、userがreservation(予約)を1つ持つという関連が組まれていることとします。この場合、userクラスのインスタンスメソッドとして自動的にbuild_reservationメソッドが定義されます。
ソースコードを見る
build_associationを定義しているのは singular_association.rb でした。
# 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を呼ぶ場合は挙動を頭に入れた上で実行した方がよさそうですね。(思わぬバグを誘発するかも)