Open5

Railsの`parent.children = children`で書き込みが走る件について調べる

堀川登喜矢堀川登喜矢

問題提起: childrenはいつ保存されている?

今回のイメージコード
何故か、parent.children = childrenの時点でchildrenが保存されてしまっていたのが不思議だった

# Read Only APIの処理の中で

class ParentAPI
    def call
        parent = Parent.find(id)
        children = [
            Child.build(name: '子1', parent_id: parent.id),
            Child.build(name: '子2', parent_id: parent.id),
        ]
        # この時点でchildrenが保存されている!?
        parent.children = children

        # ここで、parentとchildrenがまとめて保存されると思っていた
        parent.save!
    end
end

こっちのコードは想定通り parent.save!でまとめて保存された
(この違いを見つけた時点でなんとなく察しは付いた)

# Read Only APIの処理の中で

class ParentAPI
    def call
        parent = Parent.find(id)
        children = [
            Child.build(name: '子1'),
            Child.build(name: '子2'),
        ]
        # この時点では、childrenは保存されていない
        parent.children = children

        # ここで、parentとchildrenがまとめて保存される
        parent.save!
    end
end

ActiveRecord::AutosaveAssociation

この問題を理解するには、どうやらAutosaveAssocitationを理解する必要がありそう

https://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html
https://mogulla3.tech/articles/2021-02-07-01/
https://railsguides.jp/v6.1/association_basics.html

堀川登喜矢堀川登喜矢

parent.save!の前に保存されるのは、id直接指定かassociation名指定か

  • ActiveRecord::AutosaveAssociationは、親オブジェクトの保存時に、子オブジェクトも自動で保存してくれる
  • しかし、子オブジェクトの関連を手動(parent_id: parent.id)で設定すると、Railsが子オブジェクトを独立したエンティティとして扱い、即座に保存を試みる動作となる

なぜ挙動が変わる?

parent.children = children の処理が呼ばれた時点で、Railsが「childrenはすでに永続化可能」と判断する

has_manyの関連付けの仕組み

parent.children = children が行われた時の手順

  1. parent.children (has_many :children) の関連をクリア(既存の関連がある場合)
  2. 渡された children の各要素に対して parent_id を設定
  3. この時点で Rails は「新たに関連付けられたオブジェクトは永続化が必要」と判断し、暗黙的に保存処理を実行

保存のトリガーはなに?

前提:

  • Rails の関連付けには、自動保存を行う コールバック(内部的なトリガー処理) がある
  • データベース上でも一貫性を持たせようとする

条件:

  • 子オブジェクトの主キー(id)が nil(未保存状態)
  • 子オブジェクトに parent_id が手動で割り当てられている
堀川登喜矢堀川登喜矢

混合だと挙動がおかしい

parent指定なしと、parentを関連で指定するケースを混合にすると、
何故かparentを指定された方だけレコードが保存される。
(ちなみに、混合ではなく両方 parent: を指定する or しないにするとレコードは作られなかった)

# Read Only APIの処理の中で

class ParentAPI
    def call
        parent = Parent.find(id)
        children = [
            Child.build(name: '子1'),
            Child.build(name: '子2', parent:),
        ]
        parent.children = children
        binding.pry # ここで止めて検証

        parent.save!
    end
end

binding.pryで止めて確認すると、何故か片方だけレコードが作られている

> parent.children
=> [#<Child
  id: nil,
  body: "子1",
  parent_id: 1,
  created_at: nil,
  updated_at: nil>,
 #<Child
  id: 1, # parentを指定した方だけレコードが作成される
  body: "子2",
  parent_id: 1,
  created_at: "2024-11-24 14:04:39.204987000 +0900",
  updated_at: "2024-11-24 14:04:39.204987000 +0900">]
> parent.children[0].new_record?
=> false
> parent.children[1].new_record?
=> true
堀川登喜矢堀川登喜矢

原因がわからないけど、おそらく内部の条件分岐の問題なんだろう、、、
基本的には親の関連は指定しなくていいし
混合で指定するケースなんて無いから問題なさそうなので、頭の片隅にこの挙動を入れておくで済ませる。誰か強い人とかソースコード読みに行った偉い人いたら教えて下さい。

堀川登喜矢堀川登喜矢

この動作、なぜか再現できなくなっていた
あれ?間違っていたのか