Rails の nested attributes はモデルのバリデーションを突破することがある
普段は雰囲気で nested attributes を使っているんですがぎょっとする挙動があったので覚書。
次のような User
と Book
が1対多で関連づいていて accepts_nested_attributes_for :books
しているときに
class User < ActiveRecord::Base
has_many :books
accepts_nested_attributes_for :books
end
class Book < ActiveRecord::Base
belongs_to :user
validates :title, uniqueness: true
end
nested attributes 経由で関連先の books
も一緒に保存できるようになります。
user = User.new
# nested attributes 経由で books も一緒に作成される
user.attributes = {
name: "homu",
books_attributes: [
{ id: nil, title: "title" },
{ id: nil, title: "title2" },
]
}
user.save!
pp Book.all.pluck(:title)
# => ["title", "title2"]
ここまではいいんですが次のように『同じ title
で保存する』場合に問題があります。
この場合 validates :title, uniqueness: true
でユニーク制約を設定しているのでエラーになってほしいのですがエラーにならずに保存に成功してしまいます。
user = User.new
# nested attributes 経由で books も一緒に作成される
user.attributes = {
name: "homu",
books_attributes: [
{ id: nil, title: "title" },
{ id: nil, title: "title" },
]
}
# no error
user.save!
# 同じ title が複数存在する
pp Book.all.pluck(:title)
# => ["title", "title"]
これは books
を保存するときに『最初に全体をバリデーションしてから保存する』というような挙動になっておりバリデーション時には『まだ同じ title
のレコードが存在していない』のでバリデーションをすり抜けてしまいます。
nested attributes 的にはそれはそう、って話になるんですがこれがすり抜けてしまうのは結構厳しいですねえ…。
もし、回避したい場合は次のように User
側でバリデーションを定義するとかは考えられますがややもどかしいですねえ。
class User < ActiveRecord::Base
has_many :books
accepts_nested_attributes_for :books
validate -> {
# ここで重複している title がないかどうかチェックする
if books.map(&:title).size != books.map(&:title).uniq.size
errors.add(:base, "title はユニーク制約です")
end
}
end
class Book < ActiveRecord::Base
belongs_to :user
validates :title, uniqueness: true
end
user = User.new
# nested attributes 経由で books も一緒に作成される
user.attributes = {
name: "homu",
books_attributes: [
{ id: nil, title: "title" },
{ id: nil, title: "title" },
]
}
# error: 'ActiveRecord::Validations#raise_validation_error': Validation failed: title はユニーク制約です (ActiveRecord::RecordInvalid)
user.save!
おまけ
nested attributes を利用せずに関連先も一緒に保存する場合には次のようなコードも考えられます。
class User < ActiveRecord::Base
has_many :books
end
class Book < ActiveRecord::Base
belongs_to :user
validates :title, uniqueness: true
end
user = User.new
# books も一緒に作成される
user.books.build(title: "title")
user.books.build(title: "title2")
user.save!
pp Book.all.pluck(:title)
# => ["title", "title2"]
この場合だと title
が重複していてもエラーになります。
user = User.new
user.books.build(title: "title")
user.books.build(title: "title")
# error: Validation failed: Books is invalid (ActiveRecord::RecordInvalid)
user.save!
これは先程の nested attributes とは違って books が1つ1つ保存され、保存する直前でバリデーションが実行されることで意図するバリデーションが実行されるようになります。
ただし
この場合でも accepts_nested_attributes_for :books
を定義してると『バリデーションが意図する動作にならないので』引き続き注意する必要があります。
class User < ActiveRecord::Base
has_many :books
# 今度は nested attributes を定義しておく
accepts_nested_attributes_for :books
end
class Book < ActiveRecord::Base
belongs_to :user
validates :title, uniqueness: true
end
user = User.new
user.books.build(title: "title")
user.books.build(title: "title")
# no error
user.save!
pp Book.all.pluck(:title)
# => ["title", "title"]
この場合は attributes=
で割り当てられたときと同様な保存のロジックになるからみたいですね。
うーんこれは厳しい。
Discussion