关于 active-record-associations
面向 Rails 开发者的 Active Record 关联技能,指导设计模型关系、迁移和查询加载。重点涵盖外键、依赖删除、双向关联与数据库约束,以减少孤儿记录和 N+1 查询。
主要功能
- 01设计常见及多态关联
- 02配置依赖删除与反向关联
- 03编写外键和索引迁移
- 04通过预加载减少 N+1 查询
- 05调试关联及查询行为
适用场景
- 01为 Rails 模型建立一对多或多对多关系
- 02为关联创建带外键约束的迁移
- 03排查关联导致的 N+1 查询
- 04处理多态或自引用关系
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ thinkoodle/ rails-skills --skill active-record-associations安装后在 Claude Code 中输入/active-record-associations确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/association-types.mdcounter-caches.mdeager-loading.mdpolymorphic.mdself-referential.mdtesting.md详细介绍
Active Record 关联专家
为 Rails 模型定义正确、高效且易于维护的关联。
理念
核心原则:
- 外键始终位于
belongs_to一侧。如果不确定外键该放在哪里,就问自己:“哪个表有_id列?”该模型使用belongs_to。 - 始终设置
dependent:——孤儿记录迟早会引发问题。 - 优先使用
has_many :through,而非has_and_belongs_to_many——始终如此。HABTM 是条死路,最终会让你后悔。 - 默认采用双向关联——声明关联的双方。Rails 无法推断时,使用
inverse_of。 - 积极预加载——N+1 查询是 Rails 应用中最大的性能杀手。
- 用数据库约束为模型关联兜底——外键、索引和唯一约束应放在迁移中,而不只是模型里。
何时使用此技能
- 定义模型之间的关系(一对一、一对多、多对多)
- 创建带外键和连接表的迁移
- 修复 N+1 查询问题
- 设置多态或自引用关联
- 在
has_many :through和 HABTM 之间进行选择 - 添加计数器缓存
- 调试与关联相关的错误
说明
第 1 步:确定关联类型
编写代码前,先确定关联关系:
| 关联关系 | 父模型 | 子模型 | 外键位置 |
|---|---|---|---|
| 一对多 | has_many :children | belongs_to :parent | 子表 |
| 一对一 | has_one :child | belongs_to :parent | 子表 |
| 多对多 | has_many :others, through: :join | has_many :others, through: :join | 连接表 |
| 多态 | has_many :things, as: :thingable | belongs_to :thingable, polymorphic: true | 子表(_id + _type) |
| 自引用 | has_many :children, class_name: "Self" | belongs_to :parent, class_name: "Self" | 同一张表 |
黄金法则: belongs_to 应声明在其对应表包含外键列的模型上。
第 2 步:检查现有模式
先查看现有代码库——现有模式会告诉你应遵循哪些约定:
# Find existing associations
rg "has_many\|has_one\|belongs_to\|has_and_belongs_to_many" app/models/
# Check existing migrations for FK patterns
rg "add_reference\|t.belongs_to\|t.references\|foreign_key" db/migrate/
# Look at schema
cat db/schema.rb | grep -A5 "create_table"
遵循项目现有约定,包括命名、dependent 选项和索引模式。
第 3 步:编写关联
一对多(最常见)
# app/models/author.rb
class Author < ApplicationRecord
has_many :books, dependent: :destroy
end
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :author
end
迁移:
class CreateBooks < ActiveRecord::Migration[8.1]
def change
create_table :books do |t|
t.references :author, null: false, foreign_key: true
t.string :title
t.timestamps
end
end
end
一对一
class User < ApplicationRecord
has_one :profile, dependent: :destroy
end
class Profile < ApplicationRecord
belongs_to :user
end
迁移——添加唯一索引:
create_table :profiles do |t|
t.references :user, null: false, foreign_key: true, index: { unique: true }
t.timestamps
end
多对多(始终使用 has_many :through)
class Doctor < ApplicationRecord
has_many :appointments, dependent: :destroy
has_many :patients, through: :appointments
end
class Appointment < ApplicationRecord
belongs_to :doctor
belongs_to :patient
end
class Patient < ApplicationRecord
has_many :appointments, dependent: :destroy
has_many :doctors, through: :appointments
end
迁移——连接模型使用独立表,并拥有自己的主键:
create_table :appointments do |t|
t.references :doctor, null: false, foreign_key: true
t.references :patient, null: false, foreign_key: true
t.datetime :scheduled_at
t.timestamps
end
第 4 步:为每个关联设置 dependent:
每个 has_many 和 has_one 都需要 dependent: 选项——如果不设置,删除父记录后会留下外键悬空的孤儿子记录。
| 选项 | 使用场景 |
|---|---|
dependent: :destroy | **默认选择。**对关联子记录运行回调。当子记录有自己的关联或回调时使用。 |
dependent: :delete_all | 性能优化。跳过回调。用于没有下级关联的叶节点。 |
dependent: :nullify | 保留子记录,但移除关联。外键必须允许 NULL。 |
dependent: :restrict_with_error | 存在子记录时阻止删除。用于执行业务规则。 |
dependent: :destroy_async | 用于大型数据集。将任务加入后台队列。需要 Active Job。不要与数据库级外键约束一起使用。 |
# Missing dependent — orphaned records when author is destroyed
has_many :books
# Fixed — children cleaned up properly
has_many :books, dependent: :destroy
**关于 belongs_to:**不要在 belongs_to 上设置 dependent:——这会造成混淆,还可能导致循环销毁。
步骤 5:处理预加载(消除 N+1)
问题:
# N+1 — fires 1 query for authors + N queries for books
Author.all.each { |a| puts a.books.count }
解决方案——使用 includes:
# 2 queries total — always
Author.includes(:books).each { |a| puts a.books.count }
何时使用哪种方法:
| 方法 | SQL 策略 | 适用场景 |
|---|---|---|
includes | 智能默认选项。 根据你是否在条件中引用关联,使用 preload 或 eager_load。 | 大多数情况。从这里开始。 |
preload | 单独查询(SELECT * FROM books WHERE author_id IN (...)) | 你想要单独查询时。无法用于筛选。 |
eager_load | 单个 LEFT JOIN | 需要在 WHERE 或 ORDER BY 中按关联表的列筛选或排序时。 |
strict_loading | 懒加载时抛出异常 | 在开发期间于模型层面防止 N+1。 |
# Filter by association column — must use eager_load (or includes handles it)
Author.includes(:books).where(books: { published: true })
# Nested eager loading
Author.includes(books: :reviews)
# Strict loading on a model (development safety net)
class Author < ApplicationRecord
self.strict_loading_by_default = true # Rails 7+
has_many :books, dependent: :destroy
end
# Strict loading on a query
Author.strict_loading.all
步骤 6:按需使用 inverse_of
Rails 在简单情况下会自动检测反向关联。以下情况需要使用 inverse_of:
- 使用
:foreign_key或:class_name - 存在带作用域的关联
- 使用
:through关联
# Rails CAN'T auto-detect this — specify inverse_of
class Author < ApplicationRecord
has_many :books, inverse_of: :writer
end
class Book < ApplicationRecord
belongs_to :writer, class_name: "Author", foreign_key: "author_id"
end
重要原因: 如果 inverse_of 不正确,你会遇到:
- 额外查询(对已加载的数据产生 N+1)
- 内存中的对象不一致
- 新记录的存在性验证失败
步骤 7:编写迁移
每个关联都需要对应的迁移。 关联不会创建数据库列。
始终包含:
- 外键列——使用
t.references或add_reference - 数据库级外键约束——
foreign_key: true - 索引——
t.references会自动创建,但请核实 - NOT NULL 约束——除非该关联设置为
optional: true
# New table
create_table :books do |t|
t.references :author, null: false, foreign_key: true
t.timestamps
end
# Adding to existing table
add_reference :books, :author, null: false, foreign_key: true
对于多态关联:
t.references :commentable, polymorphic: true, null: false
# Creates: commentable_id (bigint) + commentable_type (string) + composite index
常见错误(及修正方法)
1. 外键放在了错误的表中
# Wrong — puts FK on authors table (authors don't have book_id!)
class Author < ApplicationRecord
belongs_to :book # ← backwards
end
# Correct — books table has author_id
class Book < ApplicationRecord
belongs_to :author
end
规则: 带有 _id 列的表应使用 belongs_to。
2. has_many 缺少 dependent:
# Destroys author, leaves orphaned books with dangling author_id
class Author < ApplicationRecord
has_many :books # ← missing dependent
end
# Fixed
class Author < ApplicationRecord
has_many :books, dependent: :destroy
end
3. 使用 HABTM 而不是 has_many :through
# Avoid — can't add attributes, callbacks, or validations to the join
class Student < ApplicationRecord
has_and_belongs_to_many :courses
end
# Better — flexible, extensible, future-proof
class Student < ApplicationRecord
has_many :enrollments, dependent: :destroy
has_many :courses, through: :enrollments
end
class Enrollment < ApplicationRecord
belongs_to :student
belongs_to :course
# Now you can add: grade, enrolled_at, status, etc.
end
4. 不该使用多态关联时却用了
适合使用多态关联的情况:
- 多个无关模型需要相同的子对象(评论、附件、标签)
- 你正在构建框架/引擎功能
不适合使用多态关联的情况:
- 只有 2~3 个特定模型——应改用单独的外键
- 需要数据库级引用完整性(多态外键无法设置数据库约束)
- 需要高效地跨越多态边界进行联接
# Polymorphic is overkill here — just use two belongs_to
class Comment < ApplicationRecord
belongs_to :post # Better than polymorphic if only commenting on posts and articles
belongs_to :article
end
5. 视图中的 N+1
# Controller — forgot to eager load
def index
@posts = Post.all # N+1 when view calls post.author.name
end
# Fixed
def index
@posts = Post.includes(:author, :comments)
end
6. 无理由地使用 optional: true
# belongs_to validates presence by default (Rails 5+)
# Don't add optional: true unless the FK is genuinely nullable
belongs_to :author # Validates author exists ✓
belongs_to :author, optional: true # Only if author_id can be NULL
快速参考
关联速查表
# One-to-many
has_many :posts, dependent: :destroy
belongs_to :user
# One-to-one
has_one :profile, dependent: :destroy
belongs_to :user
# Many-to-many (through)
has_many :taggings, dependent: :destroy
has_many :tags, through: :taggings
# Polymorphic
has_many :comments, as: :commentable, dependent: :destroy
belongs_to :commentable, polymorphic: true
# Self-referential
has_many :subordinates, class_name: "Employee", foreign_key: "manager_id", dependent: :nullify
belongs_to :manager, class_name: "Employee", optional: true
# Counter cache (declare on belongs_to side, column on has_many side)
belongs_to :author, counter_cache: true
# Requires: add_column :authors, :books_count, :integer, default: 0, null: false
# Scoped association
has_many :published_books, -> { where(published: true) }, class_name: "Book", dependent: :destroy
# With ordering
has_many :books, -> { order(created_at: :desc) }, dependent: :destroy
迁移模式
# Standard FK
t.references :author, null: false, foreign_key: true
# Polymorphic FK
t.references :commentable, polymorphic: true, null: false
# Self-referential FK
t.references :manager, foreign_key: { to_table: :employees }
# Custom FK name
t.bigint :creator_id
add_foreign_key :posts, :users, column: :creator_id
# Join table for has_many :through
create_table :enrollments do |t|
t.references :student, null: false, foreign_key: true
t.references :course, null: false, foreign_key: true
t.timestamps
end
add_index :enrollments, [:student_id, :course_id], unique: true
预加载决策树
Need to filter/order by associated columns?
YES → includes (auto-detects) or eager_load (explicit LEFT JOIN)
NO → includes (auto-detects) or preload (explicit separate queries)
Need nested associations?
→ Author.includes(books: [:reviews, :publisher])
Want to prevent N+1 entirely?
→ strict_loading (model or query level)
belongs_to 选项快速参考
| 选项 | 默认值 | 用途 |
|---|---|---|
optional: true | false | 允许外键为 NULL |
counter_cache: true | false | 在父对象上维护计数列 |
touch: true | false | 保存时更新父对象的 updated_at |
polymorphic: true | false | 多态关联 |
class_name: | 自动推断 | 指定非标准类名 |
foreign_key: | "#{name}_id" | 指定非标准外键列 |
inverse_of: | 自动检测 | 指定反向关联名称 |
has_many / has_one 选项快速参考
| 选项 | 默认值 | 用途 |
|---|---|---|
dependent: | 无 | 始终设置此项。 请参阅步骤 4。 |
class_name: | 自动推断 | 非标准类名 |
foreign_key: | "#{model}_id" | 非标准外键列 |
inverse_of: | 自动检测 | 指定反向关联 |
through: | 无 | 通过关联 |
source: | 自动推断 | :through 的源关联名称 |
as: | 无 | 多态接口名称 |
counter_cache: | 无 | (仅限 has_many)读取计数缓存列 |
调试关联
# Check what associations a model has
Author.reflect_on_all_associations.map { |a| [a.macro, a.name] }
# Check a specific association
Author.reflect_on_association(:books)
# See the SQL an association generates
Author.first.books.to_sql
# Check if inverse is set
Author.reflect_on_association(:books).inverse_of
# Find orphaned records
Book.left_joins(:author).where(authors: { id: nil })
# Reset counter cache
Author.reset_counters(author_id, :books)
应避免的反模式
- 不要在
has_many/has_one上设置dependent:—— 孤立记录会导致难以察觉的数据错误 - HABTM —— 改用
has_many :through;HABTM 无法在连接上保存属性、验证或回调 - 缺少数据库约束 —— 在迁移中设置
foreign_key: true - 在循环中延迟加载 —— 使用
includes/preload/eager_load - 仅有 2 个模型时使用多态关联 —— 直接使用单独的外键即可
- 默认设置
optional: true—— 仅在外键确实允许为空时使用 - 缺少
inverse_of—— 使用自定义:foreign_key或:class_name时应设置 has_one外键缺少唯一索引 —— 不设置唯一索引会允许出现重复记录- 忘记创建迁移 —— 关联不会创建列
- 在大型集合上使用
dependent: :destroy—— 改用:delete_all或:destroy_async
如需了解详细模式、选项参考和高级示例,请参阅 references/ 目录:
references/association-types.md— 深入介绍 belongs_to、has_one、has_many、has_many :through、HABTMreferences/polymorphic.md— 多态关联、STI 和委托类型references/self-referential.md— 树形结构、经理—员工关系、社交关注关系references/eager-loading.md— includes/preload/eager_load 策略、inverse_ofreferences/counter-caches.md— 计数缓存、作用域关联、回调、扩展references/testing.md— 测试模式、迁移示例、性能提示、故障排查
评论
支持 Markdown