Rails中has_many和belongs_to关联怎么用

文章导读
has_many 和 belongs_to 描述的是同一件事的两端:外键在哪张表,哪一侧就写 belongs_to。比如订单属于用户,orders 表里有 user_id,那么模型里写 Order belongs_to :user 和 User has_many :orders。动手之前先确认三件事:外键落在哪张表、这个关联能不能为空、父记录被删除时子记录怎么处理。先定外键和数据库约束,再写模型声
📋 目录
  1. 一 先确认当前场景和目标
  2. 二 按这个顺序处理
  3. 三 改完后怎么验证
  4. 四 容易忽略的边界
A A

has_many 和 belongs_to 描述的是同一件事的两端:外键在哪张表,哪一侧就写 belongs_to。比如订单属于用户,orders 表里有 user_id,那么模型里写 Order belongs_to :user 和 User has_many :orders。动手之前先确认三件事:外键落在哪张表、这个关联能不能为空、父记录被删除时子记录怎么处理。先定外键和数据库约束,再写模型声明,通常比反过来更省事。

判断顺序建议固定为:先确定外键列和数据库层约束,再写模型两侧的声明,最后确认删除策略和读取时的预加载。适用场景是常规一对多;如果关联允许为空、外键名不符合 Rails 推断规则,或者用了多态关联,就需要在模型里显式声明 optional、foreign_key,或者放弃数据库外键、改为应用层校验。验证方式看 schema.rb、控制台的反射结果,以及一次带回滚的删除试跑。

先确认当前场景和目标

先确认“谁是多的那一方、外键在不在、能不能空”。这三点没定清楚,模型声明写了也要返工。建议先在项目里翻一遍现有 schema 和模型,避免和已有命名、已有约束冲突。

  • 外键位置:belongs_to 一侧的表持有外键列。Order 属于 User,外键就在 orders 表,不在 users 表。
  • 是否必填:较新的 Rails 里 belongs_to 默认要求关联存在,能不能为空要按你项目的 Rails 版本和配置确认,允许为空就显式写 optional: true。
  • 删除行为:父子记录一起删、禁止删、还是把外键置空,需要在写代码前确定,这决定了 dependent 选哪个。
  • 历史数据:现有表里是否已有指向不存在记录的孤儿行。加外键约束前先查出来处理,否则迁移会失败。
  • 命名是否标准:外键是 user_id 这类默认名可以直接省略 foreign_key;如果是 buyer_id、owner_id 这类,就要显式指定。
# 先看现状,确认外键列和历史数据
# db/schema.rb 里搜 create_table :orders
grep -n "orders" db/schema.rb

按这个顺序处理

迁移先行,模型声明随后,最后补上删除策略和读取侧的预加载。顺序反过来容易出现「模型里写了一堆关联、数据库层却没有约束」的松散结构。

  1. 建表或加列时带上外键约束。t.references 会同时建索引,foreign_key: true 会生成数据库外键,前提是数据库支持。
  2. 两侧模型都声明。只在一边写 belongs_to,另一边就查不到反向集合。
  3. 非标准外键写清楚 class_name 和 foreign_key,必要时补 inverse_of,避免内存中的对象和数据库对不上。
  4. 定 dependent 策略:需要触发子记录回调选 :destroy;只要快速删除、不管回调选 :delete_all;不允许删父记录选 :restrict_with_error;保留子记录、只清外键选 :nullify。
  5. 读取时用 includes 预加载,尤其是列表页遍历父记录再取子记录的场景。
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true
      t.timestamps
    end
  end
end

迁移版本号按项目现有迁移保持一致即可,不要照抄。已有表补外键可以用 add_reference 加列、再 add_foreign_key 补约束,两步分开做更便于排查失败原因。

class User < ApplicationRecord
  has_many :orders, dependent: :restrict_with_error
end

class Order < ApplicationRecord
  belongs_to :user
  belongs_to :coupon, optional: true
  has_many :order_items, dependent: :destroy
end

外键名不是 user_id 时,两侧都要显式声明,否则 Rails 按类名推断出的列名和实际列对不上,查询会直接报列不存在。

class User < ApplicationRecord
  has_many :orders, foreign_key: :user_id, inverse_of: :buyer
end

class Order < ApplicationRecord
  belongs_to :buyer, class_name: "User", foreign_key: :user_id, inverse_of: :orders
end

改完后怎么验证

验证分三层:数据库层有没有约束、模型层关联是否指对了列、删除行为是否符合预期。三层都过一遍,再提交代码。

  • 看 schema:迁移后在 db/schema.rb 里应能看到 add_foreign_key "orders", "users" 和对应索引。
  • 看反射:在 rails console 里确认 macro 和 foreign_key 是不是你想要的。
  • 看实际查询:取一条父记录调 orders,再取一条子记录调 user,确认返回对象正确。
  • 看 N+1:对比 User.limit(5).map { |u| u.orders.to_a } 和加了 includes 的版本,日志里 orders 查询条数应明显不同。
  • 试删除:在事务里造数据、执行 destroy、再抛出回滚,确认是报错、级联还是置空,且测试数据不残留。
# rails console
Order.reflect_on_association(:user).macro      # 期望 :belongs_to
User.reflect_on_association(:orders).macro     # 期望 :has_many
User.first.orders.to_a
Order.includes(:user).limit(5).map(&:user)

# 带回滚的删除试跑
ActiveRecord::Base.transaction do
  user  = User.create!(name: "tmp")
  order = user.orders.create!
  user.destroy
  raise ActiveRecord::Rollback
end

预期信号:schema 中出现外键声明;反射返回的 macro 与声明一致;插入不存在的 user_id 时数据库抛出外键冲突异常(不是静默成功);dependent 为 restrict_with_error 时删除父记录返回失败并在 errors 里有说明。

容易忽略的边界

下面这些点在联调或上线后才暴露,建议在写关联时一并确认。

  • has_many 不创建外键。它只是查询方法,数据库约束必须靠迁移单独加;只写模型声明,脏数据照样能写进去。
  • dependent: :delete_all 跳过回调。计数器缓存、touch、after_destroy 都不会触发;需要这些副作用就改用 :destroy,并评估数据量。
  • counter_cache 需要先有计数列,默认值给 0,历史数据要用 reset_counters 校正一次。
  • 多态关联加不了普通外键。belongs_to :commentable, polymorphic: true 只能靠应用层校验,别期待数据库兜底。
  • 大表加外键可能锁表。执行窗口和锁的影响需要结合所用数据库和发布方式确认,不确定时先加列和索引,再找低峰期补约束。
  • 回退建议:删除策略没想清楚时,先用 restrict_with_error 观察一段时间,不要直接上 destroy 或 delete_all;外键约束加失败时先回滚迁移、清完孤儿数据再重试,不要在应用层绕过。