解決策:accepts_nested_attributes_forがスコープ付きユニーク検証を壊す問題
accepts_nested_attributes_forを親へのスコープ付きユニーク検証と一緒に使ったことがある方は、フラストレーションの溜まるバグに遭遇したかもしれません:重複レコードが検証をすり抜けてしまうのです。これは多くの開発者を悩ませてきたRailsの長年の問題です。
問題
この一般的な設定を考えてみてください:
class Country < ActiveRecord::Base
has_many :cities, dependent: :destroy
accepts_nested_attributes_for :cities
end
class City < ActiveRecord::Base
belongs_to :country
validates :language, uniqueness: { scope: :country_id }
end
重複した都市を持つ国を作成してみましょう:
country = Country.new(
name: "USA",
cities: [
City.new(language: "en-US", name: "New York"),
City.new(language: "en-US", name: "Los Angeles") # 同じ言語!
]
)
country.save! # これは成功してしまう(本来は失敗すべき)!
同じcountry_id内で同じlanguageを持っているにもかかわらず、両方の都市が保存されます。ユニーク検証は重複を検出できませんでした。
なぜこれが起こるのか
accepts_nested_attributes_forを通じてネストされたレコードを作成する際、Railsは親レコードが永続化される前に子を検証します。検証時:
- 親(
Country)はまだIDを持っていない - 各
Cityのcountry_idはnil - ユニーク検証は次をチェック:「
language: 'en-US'かつcountry_id: nilの別の都市はあるか?」 - データベースクエリは何も見つからない(まだコミットされたレコードがない)
- 両方の都市で検証がパス
検証はメモリ内で実行されますが、ユニークチェックはレコードがまだ存在しないデータベースにクエリします。
解決策
オプション1:データベース制約(推奨)
データベースレベルでユニークインデックスを追加:
class AddUniquenessConstraintToCities < ActiveRecord::Migration[7.0]
def change
add_index :cities, [:country_id, :language], unique: true
end
end
これは挿入時に重複をキャッチし、ActiveRecord::RecordNotUniqueを発生させます。この例外をハンドリングする必要があります:
class CountriesController < ApplicationController
def create
@country = Country.new(country_params)
@country.save!
rescue ActiveRecord::RecordNotUnique
@country.errors.add(:cities, "に重複した言語が含まれています")
render :new, status: :unprocessable_entity
end
end
オプション2:カスタム検証
メモリ内のコレクション内でユニーク性を検証:
class Country < ActiveRecord::Base
has_many :cities, dependent: :destroy
accepts_nested_attributes_for :cities
validate :cities_have_unique_languages
private
def cities_have_unique_languages
languages = cities.reject(&:marked_for_destruction?).map(&:language)
if languages.length != languages.uniq.length
errors.add(:cities, "はユニークな言語を持つ必要があります")
end
end
end
オプション3:関連付けで検証
validates_associatedをカスタムバリデータと共に使用:
class City < ActiveRecord::Base
belongs_to :country
validates :language, uniqueness: { scope: :country_id }, on: :update
validate :unique_language_in_siblings, on: :create
private
def unique_language_in_siblings
return unless country
siblings = country.cities.reject { |c| c.equal?(self) }
if siblings.any? { |c| c.language == language }
errors.add(:language, "はすでに使用されています")
end
end
end
オプション4:動くがハックな方法
GitHubのissueディスカッションからの回避策で、if procを使って外部キーを早期に割り当てます:
class City < ActiveRecord::Base
belongs_to :country
validates :language,
uniqueness: { scope: :country_id },
if: -> {
self.country_id = country&.id if country.present? && country_id.nil?
true
}
end
これにより検証が実行される前にcountry_idの割り当てを強制します。きれいではありませんが、動作します。
予防
- 常にユニーク検証にはデータベースレベルのユニーク制約を追加、特にスコープ付きのもの
- ネストした属性でテスト:重複したネストレコードを作成しようとするテストを書く
- 複雑なクロスレコード検証には
validates_withの使用を検討
参考
- GitHub Issue #20676 - 2015年からオープン、76以上のコメント
- これはRails 4.xから7.x、おそらく8.xにも影響します