PAYATHON 2026

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! 不会自动增加额外的去重能力。

备注:内容仅供参考。