解决: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在父记录持久化之前验证子记录。在验证时:
- 父记录(
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:一个有效的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。不够优雅,但有效。
预防
- 始终为唯一性验证添加数据库级唯一约束,特别是带作用域的
- 用嵌套属性测试:编写尝试创建重复嵌套记录的测试
- **考虑使用
validates_with**进行复杂的跨记录验证
参考
- GitHub Issue #20676 - 自2015年开放,76+评论
- 这影响Rails 4.x到7.x,可能也影响8.x