📘

Rails の nested attributes はモデルのバリデーションを突破することがある

に公開

普段は雰囲気で nested attributes を使っているんですがぎょっとする挙動があったので覚書。
次のような UserBook が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= で割り当てられたときと同様な保存のロジックになるからみたいですね。
うーんこれは厳しい。

GitHubで編集を提案

Discussion