Rails 8.0.2 で autosave の挙動が静かに変わった話
Ruby on Rails のモデル同士の関連付けにおいて、親モデルの保存のタイミングで子モデルも一緒に保存するための設定として autosave オプションがあります。
今回は、この autosave オプションの挙動が v8.0.1 → v8.0.2 で少し変わったため、その内容について書きたいと思います。
autosave を内部で使用している accepts_nested_attributes_for を使っている場合も影響があります。
何が変わったか
リリースノートでは以下のように説明されています。
Fix autosave associations to no longer validated unmodified associated records.
Active Record was incorrectly performing validation on associated record that weren't created nor modified as part of the transaction:
Post.create!(author: User.find(1)) # Fail if user is invalid
作成も変更もされていない関連レコードについてバリデーションを誤って実行していたので修正したとあります。
たしかに作成も変更もされていないのであれば、そのモデルについてバリデーションを行うことは余計な処理に思えるため、この対応はちょっとしたパフォーマンス改善のように見えます。
だだし、例えば孫モデルのみに変更が入り、その親にあたる子モデルで孫モデルに関するバリデーションを行っている場合、子モデル自体は何も変更されていないため、孫モデルの変更内容にかかわらずバリデーションが行われなくなってしまいます。
具体的なコードで動きを見てみる
文章だけだと分かりづらいので実際のコードで確認してみます。
以下の3モデルを定義します。
class Library < ApplicationRecord
has_many :books
accepts_nested_attributes_for :books
end
class Book < ApplicationRecord
belongs_to :library
has_many :pages
accepts_nested_attributes_for :pages
validate :page_numbers_are_unique
# ぺージ番号が Book 内で一意であることをチェックする
def page_numbers_are_unique
page_numbers = pages.map(&:number)
if page_numbers.uniq.length != page_numbers.length
errors.add(:pages, 'Page numbers are not unique')
end
end
end
class Page < ApplicationRecord
belongs_to :book
validates :number, presence: true # 必須チェック
end
Library (1:N) Book (1:N) Page の関係になっており、Page にはベージ番号を表す number フィールドがあります。
number は必須かつ Book 内でユニークである必要があり、必須チェックを Page モデル、ユニークチェックを Book モデルで行っています。
なお autosave ではなく accepts_nested_attributes_for を自分は使うことが多いため、accepts_nested_attributes_for で定義しています。
Library, Book, Page をまとめて作成します。Page のみ3レコード作成します。
> library = Library.create(books_attributes: [{ pages_attributes: [{ number: 1 }, { number: 2 }, { number: 3 }] }])
Page レコードの number を修正します。
> books = library.books.to_a
> book = books.first
> pages = book.pages.to_a
> pages[1].number = 1 # pages[0] と pages[1] の number が重複
> pages[2].number = nil # 必須チェック違反
pages[0] と pages[1] がユニークチェック pages[2] が必須チェックにかかるようになりました。
この状態で Library のバリデーション実行 & 各モデルのエラー確認を行うと v8.0.1 と v8.0.2 で一部違いが生じます。
v8.0.1
> library.validate
=> false
> library.errors
=> #<ActiveModel::Errors [
# <ActiveRecord::Associations::NestedError attribute=books.pages.number, type=blank, options={}>,
# <ActiveRecord::Associations::NestedError attribute=books.pages, type=Page numbers are not unique, options={}>
#]>
> book.errors
=> #<ActiveModel::Errors [
# <ActiveRecord::Associations::NestedError attribute=pages.number, type=blank, options={}>,
# <ActiveModel::Error attribute=pages, type=Page numbers are not unique, options={}>
#]>
> pages[0].errors
=> #<ActiveModel::Errors []>
> pages[1].errors
=> #<ActiveModel::Errors []>
> pages[2].errors
=> #<ActiveModel::Errors [
# <ActiveModel::Error attribute=number, type=blank, options={}>
#]>
v8.0.2
> library.validate
=> false
> library.errors
=> #<ActiveModel::Errors [
# <ActiveRecord::Associations::NestedError attribute=books.pages.number, type=blank, options={}>
#]>
# ↑重複チェックの NestedError が消えている
> book.errors
=> #<ActiveModel::Errors [
# <ActiveRecord::Associations::NestedError attribute=pages.number, type=blank, options={}>,
# <ActiveModel::Error attribute=pages, type=Page numbers are not unique, options={}>
#]>
> pages[0].errors
=> #<ActiveModel::Errors []>
> pages[1].errors
=> #<ActiveModel::Errors []>
> pages[2].errors
=> #<ActiveModel::Errors [
# <ActiveModel::Error attribute=number, type=blank, options={}>
#]>
library.errors の結果が異なっており、それ以外は同じです。
v8.0.1 で library.errors に NestedError として含まれていたページ番号の重複チェックのエラーが v8.0.2 では含まれなくなっています。
必須チェックのエラーは変わらず含まれています。
今回は Page のみ修正したうえで親にあたる Library のバリデーションを実行したため
Page のバリデーションは対象
Book のバリデーションは対象外
と判定されているようです。
ただ、book.errors には Book のエラー情報が存在しています。
そのため厳密にはバリデーションが行われなくなったわけではなく、バリデーションは行ったうえで実行元の Library にはエラーが入らなくなったというのが正しそうです。
おわりに
v8.0.2 から変更された autosave の挙動について説明しました。
autosave や accepts_nested_attributes_for を使用すると、親モデルのバリデーション実行時に子孫のモデルのバリデーションもひとくくりに実行することができますが、子孫モデルを直接修正している場合は少し注意が必要そうです。
Discussion