解决:accepts_nested_attributes_for破坏了带作用域的唯一性验证

如果你使用过accepts_nested_attributes_for配合作用域到父记录的唯一性验证,你可能遇到过一个令人沮丧的bug:重复记录绕过了验证。这是一个困扰了许多开发者的长期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. 每个City上的country_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:一个有效的hack

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