Railsでparent.children = childrenで子がDBに保存されたのに驚いて調べた
前提情報
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
- 新しい関連レコード: 保存されません。
- 既存の関連レコードの更新: 保存されません。
- 既存の関連レコードの削除: 保存されません(削除の操作も適用されません)。
脱線: 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
Discussion
TODO:
assign_attributes
でやるのが楽そう。という記述を追加する