解決策: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は親レコードが永続化される前に子を検証します。検証時:

  1. 親(Country)はまだIDを持っていない
  2. Citycountry_idnil
  3. ユニーク検証は次をチェック:「language: 'en-US' かつ country_id: nilの別の都市はあるか?」
  4. データベースクエリは何も見つからない(まだコミットされたレコードがない)
  5. 両方の都市で検証がパス

検証はメモリ内で実行されますが、ユニークチェックはレコードがまだ存在しないデータベースにクエリします。

解決策

オプション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の割り当てを強制します。きれいではありませんが、動作します。

予防

  1. 常にユニーク検証にはデータベースレベルのユニーク制約を追加、特にスコープ付きのもの
  2. ネストした属性でテスト:重複したネストレコードを作成しようとするテストを書く
  3. 複雑なクロスレコード検証にはvalidates_withの使用を検討

参考

  • GitHub Issue #20676 - 2015年からオープン、76以上のコメント
  • これはRails 4.xから7.x、おそらく8.xにも影響します