Rails .build 创建 has_many 关联时为何未拦截重复记录?
结论
根据给出的代码,问题通常出在 uniqueness 的检查范围上:它查询数据库中已有的记录,不会自动比较同一批尚未保存的关联对象。
如果数据库中没有对应值,也没有相应的唯一索引,两条通过 .build 创建的记录可能都通过校验。要阻止这类重复数据,需要检查本次关联集合中的重复值,并用数据库唯一索引保证最终的数据约束。
为什么 validates_associated 没有拦住?
.build 会先在内存中创建关联对象,不会立即插入数据库。validates_associated :definitions 会调用关联对象的校验,但不会对集合额外去重。
题目中的两条记录都未写入数据库时,各自的唯一性查询可能都得到“没有相同记录”的结果。即使两个对象都经过校验,也无法通过查询数据库发现彼此。Rails 对 uniqueness 和 validates_associated 的说明见 Active Record Validations。
accepts_nested_attributes_for :definitions 会自动启用该关联的 autosave,子记录默认参与关联校验。因此,补上 autosave: true 也不能解决内存中重复的问题。参见 Nested Attributes 文档和 AutosaveAssociation 文档。
在父模型中检查关联集合
保留现有的关联配置和子模型校验,在 Headword 中增加一个自定义校验,分别检查 content 和 order:
class Headword < ApplicationRecord
# 保留现有的关联配置和其他校验
validate :definitions_have_unique_values
private
def definitions_have_unique_values
records = definitions.reject(&:marked_for_destruction?)
[:content, :order].each do |attribute|
records.group_by { |definition| definition.public_send(attribute) }
.each_value do |duplicates|
next if duplicates.size < 2
errors.add(:definitions, "中的 #{attribute} 不能重复")
end
end
end
end
该校验直接比较关联集合里的对象,包括通过 .build 创建、尚未保存的对象。排除 marked_for_destruction? 的记录,可以避免将本次准备删除的记录算入重复检查。
需要分别按两个属性分组。原代码要求 content 和 order 各自唯一;按 [content, order] 分组只能检查二者的组合是否重复,约束含义不同。
加入校验后,可以用原来的重复数据检查结果:
red = Headword.new(title: 'wouj')
red.definitions.build([
{ content: 'bugs', order: 1 },
{ content: 'bugs', order: 1 }
])
red.valid?
red.errors.full_messages
red.definitions.map { |definition| definition.errors.full_messages }
父模型的集合校验会添加错误,使 red.valid? 返回 false;随后调用 red.save! 会因校验失败抛出 ActiveRecord::RecordInvalid。这是所示代码的预期行为,不是实际项目的运行结果。
添加与业务规则一致的唯一索引
模型校验便于返回错误信息,数据库唯一索引则防止重复数据落库,也能处理并发请求之间的冲突。Rails 官方指南指出,uniqueness 校验不会自动创建数据库唯一约束。
原模型没有指定 scope,表达的是整个 headword_definitions 表中,content 和 order 分别唯一。如果这就是业务要求,可以在项目生成的 migration 的 change 方法中添加:
add_index :headword_definitions, :content, unique: true
add_index :headword_definitions, :order, unique: true
如果实际要求是“同一个 Headword 内不能重复,不同 Headword 之间允许重复”,则应同时修改模型校验和数据库索引:
# HeadwordDefinition
validates :content, uniqueness: { scope: :headword_id }
validates :order, uniqueness: { scope: :headword_id }
对应的索引为:
add_index :headword_definitions,
[:headword_id, :content],
unique: true
add_index :headword_definitions,
[:headword_id, :order],
unique: true
两种索引方案应按实际业务规则选择。只添加 scope 仍无法发现同批未保存对象之间的重复,父模型中的集合校验仍然有用。
排查和使用时的注意事项
- 原问题没有提供 Rails 版本、数据库类型和现有索引,不能据此断言项目中的所有校验都被跳过。可以先执行
red.valid?,查看父子对象的errors,再结合 SQL 日志确认查询过程。 - 创建唯一索引前,应先处理已有重复数据,否则索引可能创建失败。
- 上面的集合校验按 Ruby 属性值分组。如果业务要求忽略大小写或统一去除首尾空格,需要让值的规范化、模型校验和数据库约束保持一致。
- 唯一索引冲突通常会抛出
ActiveRecord::RecordNotUnique。它与模型校验失败不同,需要在业务保存流程中按需处理;save!不会自动增加额外的去重能力。
备注:内容仅供参考。
