mcpField
A
active-record-associations

active-record-associations

github.com/thinkoodle开发7177 次安装8 周前更新
代码仓库

关于 active-record-associations

面向 Rails 开发者的 Active Record 关联技能,指导设计模型关系、迁移和查询加载。重点涵盖外键、依赖删除、双向关联与数据库约束,以减少孤儿记录和 N+1 查询。

主要功能

  1. 01设计常见及多态关联
  2. 02配置依赖删除与反向关联
  3. 03编写外键和索引迁移
  4. 04通过预加载减少 N+1 查询
  5. 05调试关联及查询行为

适用场景

  1. 01为 Rails 模型建立一对多或多对多关系
  2. 02为关联创建带外键约束的迁移
  3. 03排查关联导致的 N+1 查询
  4. 04处理多态或自引用关系

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill active-record-associations

安装后在 Claude Code 中输入/active-record-associations确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
association-types.md
counter-caches.md
eager-loading.md
polymorphic.md
self-referential.md
testing.md

详细介绍

Active Record 关联专家

为 Rails 模型定义正确、高效且易于维护的关联。

理念

核心原则:

  1. 外键始终位于 belongs_to 一侧。如果不确定外键该放在哪里,就问自己:“哪个表有 _id 列?”该模型使用 belongs_to。
  2. 始终设置 dependent:——孤儿记录迟早会引发问题。
  3. 优先使用 has_many :through,而非 has_and_belongs_to_many——始终如此。HABTM 是条死路,最终会让你后悔。
  4. 默认采用双向关联——声明关联的双方。Rails 无法推断时,使用 inverse_of。
  5. 积极预加载——N+1 查询是 Rails 应用中最大的性能杀手。
  6. 用数据库约束为模型关联兜底——外键、索引和唯一约束应放在迁移中,而不只是模型里。

何时使用此技能

  • 定义模型之间的关系(一对一、一对多、多对多)
  • 创建带外键和连接表的迁移
  • 修复 N+1 查询问题
  • 设置多态或自引用关联
  • 在 has_many :through 和 HABTM 之间进行选择
  • 添加计数器缓存
  • 调试与关联相关的错误

说明

第 1 步:确定关联类型

编写代码前,先确定关联关系:

关联关系父模型子模型外键位置
一对多has_many :childrenbelongs_to :parent子表
一对一has_one :childbelongs_to :parent子表
多对多has_many :others, through: :joinhas_many :others, through: :join连接表
多态has_many :things, as: :thingablebelongs_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:编写迁移

每个关联都需要对应的迁移。 关联不会创建数据库列。

始终包含:

  1. 外键列——使用 t.references 或 add_reference
  2. 数据库级外键约束——foreign_key: true
  3. 索引——t.references 会自动创建,但请核实
  4. 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: truefalse允许外键为 NULL
counter_cache: truefalse在父对象上维护计数列
touch: truefalse保存时更新父对象的 updated_at
polymorphic: truefalse多态关联
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)

应避免的反模式

  1. 不要在 has_many/has_one 上设置 dependent: —— 孤立记录会导致难以察觉的数据错误
  2. HABTM —— 改用 has_many :through;HABTM 无法在连接上保存属性、验证或回调
  3. 缺少数据库约束 —— 在迁移中设置 foreign_key: true
  4. 在循环中延迟加载 —— 使用 includes / preload / eager_load
  5. 仅有 2 个模型时使用多态关联 —— 直接使用单独的外键即可
  6. 默认设置 optional: true —— 仅在外键确实允许为空时使用
  7. 缺少 inverse_of —— 使用自定义 :foreign_key 或 :class_name 时应设置
  8. has_one 外键缺少唯一索引 —— 不设置唯一索引会允许出现重复记录
  9. 忘记创建迁移 —— 关联不会创建列
  10. 在大型集合上使用 dependent: :destroy —— 改用 :delete_all 或 :destroy_async

如需了解详细模式、选项参考和高级示例,请参阅 references/ 目录:

  • references/association-types.md — 深入介绍 belongs_to、has_one、has_many、has_many :through、HABTM
  • references/polymorphic.md — 多态关联、STI 和委托类型
  • references/self-referential.md — 树形结构、经理—员工关系、社交关注关系
  • references/eager-loading.md — includes/preload/eager_load 策略、inverse_of
  • references/counter-caches.md — 计数缓存、作用域关联、回调、扩展
  • references/testing.md — 测试模式、迁移示例、性能提示、故障排查

评论

支持 Markdown