💎

Railsでparent.children = childrenで子がDBに保存されたのに驚いて調べた

2024/11/30に公開1

前提情報

ruby '3.3.6'
gem 'rails', '~> 7.2'

筆者は React.js と TypeScript を主戦場としていたが
Rails を書く機会が増えて、ちょっと驚きましたという記事です。

【問題提起】 インスタンスの代入だけで DB に保存されるのはなぜ?

parent.children = children
で子レコードの DB 書き込み処理が実行されることに「驚き」だった。

parent.saveで autosave が実行されるまではメモリ上のみの代入だと思っていました。

🔥 これ、DB に書き込みされるって知らなかったら Transaction の範囲指定から漏れたり
代入しただけのつもりが「追加、削除」してたみたいな悲劇を起こしそう、、、。
(と言うより RSpec 書いている時点でぶち当たりました)

参考のコード

# Read Only APIの処理の中で

class ParentAPI
  def call
    parent = Parent.find(id)
    children = [
      Child.build(name: '子1'),
      Child.build(name: '子2'),
    ]
    # 驚きポイント: ここで子レコードが作成されている
    parent.children = children

    # 本来はここでautosaveで親子レコードが保存されると思っていた
    parent.save!
  end
end

モデルのイメージ

class Parent < ApplicationRecord
  has_many :children
end

class Child < ApplicationRecord
  belongs_to :parent
end

メモリ上で留めたいなら parent.children.build を使おう

どうしても保存は parent の autosave のタイミングで一緒に保存したい!!
という場合は、

parent.children.build で build すれば、メモリ上のみインスタンスを紐づけて保存の実行は先送りにできる。
ただ、Rails が DB との整合性を保つためにあえて保存の実行を行っていることを考えると、メモリ上にインスタンスを突っ込んで後で保存したいのはなぜか?
を問いかけながら使うべきかも知れない。

事前に hash の形で作っておけば、まとめて build することもできる

# Read Only APIの処理の中で

class ParentAPI
  def call
    parent = Parent.find(id)
-    children = [
-      Child.build(name: '子1'),
-      Child.build(name: '子2'),
-    ]
+    children_array = [
+      { name: '子1' },
+      { name: '子2' },
+    ]

-    parent.children = children
+    parent.children.build(children_array)

    parent.save!
  end
end

save!の前で binding.pry した状態のイメージ
まだ保存されていない事がわかる

From: /hoge/fuga/parent_api.rb:45 ParentAPI#call:

    40: def call
    41:   children_array = [
    42:     { name: '子1' },
    43:     { name: '子2' },
    44:   ]
 => 45:   parent.children.build(children_array)
    46:   binding.pry
    47:   parent.save!
    48: end

[1] pry(#<ParentAPI>)> parent.children
=> [#<Child:0x0000ffff7cacd218
  id: nil,
  name: "子1",
  parent_id: 1,
  created_at: nil,
  updated_at: nil>,
 #<Child:0x0000ffff7cacd0d8
  id: nil,
  name: "子2",
  parent_id: 1,
  created_at: nil,
  updated_at: nil>]
[2] pry(#<ParentAPI>)> parent.children.first.new_record?
=> true

ちなみに、これらは parent.save の前に DB に保存されてしまう
(Rails 初心者には驚きなので「あ?何をいまさら」って心の声やめて〜w)

parent.children = children

parent.children << [child1, child2]

詳しくは下記を見たら解決するのでスルー
[初心者向け] Rails で関連するデータ(親子関係)を保存する方法あれこれ

調査結果

WIP: 本当は Rails と ActiveRecord のコードを読んで〜になる予定だったけど、タイムリミットで一旦公開します。

Q. ActiveRecord はなぜ代入だけで保存する仕様にしているのか?

WIP: DB の整合性を保つため、、、の様な話はあったが、公式の見解を見つけることができなかった。

Q. parent.children = children で追加、更新どころか削除もされるのでは?

追加の挙動は言わずもがななので省略
※ save をしなくても DB の書き込みが行われているという話

🔥 正直、この挙動を見て気軽に association 使って代入するのは怖いと思った。

🔥追加したければ << を使おうと思う。
更新が有れば重複でエラーになるし、削除はされることは無い。

削除の挙動

class ParentAPI
  def call
    parent = Parent.find(id)
    parent.children.create(name: '事前データ')

    children = [
      Child.build(name: '子1'),
      Child.build(name: '子2'),
    ]
    parent.children = children
    # ===> この時点で 「事前データ1」 は削除される(DBレベル)
  end
end

更新の挙動

class ParentAPI
  def call
    parent = Parent.find(id)
    pre_child = parent.children.create(name: '事前データ')
    pre_child[:name] = '更新された事前データ'

    children = [
      pre_child,
      Child.build(name: '子2'),
    ]
    parent.children = children
    # ===> この時点で 「事前データ1」 は更新される(DBレベル)
    # また、「子2」 が追加される(保存済み)
  end
end

ちなみに、、、
こんな感じにすると重複エラーで保存できない

class ParentAPI
  def call
    parent = Parent.find(id)
    pre_child = parent.children.create(name: '事前データ')

    children = [
      Child.build(name: '子1', id: pre_child.id),
      Child.build(name: '子2'),
    ]
    parent.children = children
    # ===> この時点で 同じ id child がある状態になる(「事前データ」と未保存の「子1」のインスタンス)
    # また、「子2」 が追加される(保存済み)

    parent.save!
    # ===> 重複エラーで保存できない(ActiveRecord::RecordNotUnique)
  end
end

Q. build 時に Association を明示指定すると挙動が変わる?

  • 🔥parent.children = childrenをしなくても既に紐付いている
    • この時点では、未保存である
    • parent.save! で保存される
    • 代入していないため、削除が実行されない
  • 必要無いが、代入(parent.children = children)をすると
    • 🔥 この時点で削除が実行される(DB レベルの削除)
    • この時点では、追加分は未保存である
    • parent.save! で保存される

こちらも暗黙的な挙動が大きいと思う。
=<< も無いので、build 時に association を入れていたかで parent.save!した時に、何かしらんやつが保存されてる、、、とかになりそう?

WIP: 本当はこの挙動の分岐をコードを読んで突き止めに行きたかった

class ParentAPI
  def call
    parent = Parent.find(id)
    parent.children.create(name: '事前データ')

    children = [
      Child.build(name: '子1', parent:), # 💎 association を明示指定
      Child.build(name: '子2', parent:), # 💎 association を明示指定
    ]
    # ===> この時点で未保存のインスタンスが既に紐付いている
    # parent.children => [事前データ, 子1(未保存), 子2(未保存)]

    # 🔥既に紐付いているので代入もいらない
    # parent.children = children
    # ===> 代入すると差分の「事前データ」は削除される(追加は未保存)
    # parent.children => [子1(未保存), 子2(未保存)]

    parent.save!
    # ===> 追加されて、事前データは削除されていない
    # parent.children => [事前データ, 子1, 子2]
  end
end

Q. parent.save 時の挙動も has_many autosave: nil, true, false で変わるのでは?

has_many の関連で autosave オプションの有無や設定値に応じて、新規レコードの保存、既存レコードの更新、既存レコードの削除の挙動が異なるそうです。
parent.save!でまとめて保存されていたのは、この設定のおかげ

class Parent < ApplicationRecord
  has_many :children, autosave: true # または false
end
autosave 設定 新しい関連レコード 既存の関連レコードの更新 既存の関連レコードの削除
設定なし(nil) ⭕️
true ⭕️ ⭕️ ⭕️
false

⭕️: 保存される
❌: 保存されない

  • autosave: 設定なし(デフォルト設定)
    • 新しい関連レコード: 保存されます。
    • 既存の関連レコードの更新: 保存されません。
    • 既存の関連レコードの削除: 保存されません(削除の操作も適用されません)。
  • autosave: true
    • 新しい関連レコード: 保存されます。
    • 既存の関連レコードの更新: 保存されます。
    • 既存の関連レコードの削除: 保存されます(削除の操作も適用されます)。
  • autosave: false
    • 新しい関連レコード: 保存されません。
    • 既存の関連レコードの更新: 保存されません。
    • 既存の関連レコードの削除: 保存されません(削除の操作も適用されません)。

https://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html
https://mogulla3.tech/articles/2021-02-07-01/

脱線: autosave 設定なしは既存の関連コードの更新されないって本当!?

筆者が「嘘だ!そんなの信じないぞ!」と公式ドキュメントに疑いを持ったので、実際に試してみた。
大人しく敗北した。

# Read Only APIの処理の中で

class ParentAPI
  def call
    parent = Parent.find(id)

    # レコードを作成する
    child1 = Child.create(name: '子1', parent:)
    child2 = Child.create(name: '子2', parent:)

    # インスタンス上では、nameが更新されるのを確認
    # ex: child1.name = "更新された子1"
    parent.children.each do |child|
      child.name = "更新された#{child.name}"
    end

    # childのnameは更新されていない!!
    # ex: child1.name = "子1"
    parent.save!
  end
end
GitHubで編集を提案
GLOBIS Tech

Discussion

堀川登喜矢堀川登喜矢

TODO:

  • メモリ上だけならassign_attributes でやるのが楽そう。という記述を追加する