rails: 自关联/自连接(self-joins)
自连接是一种常规连接,但表与自身连接。这在单个表内存在层次关系的情况下很有用。一个常见的例子是员工管理系统,其中员工可以有经理,而经理也是员工。
01 创建 Category 模型
我们首先通过 rails g model
命令生成一个 Category
模型:
# 方式1 - 一般用这种方式
rails g model Category name:string parent_id:integer
# 方式2 - 实际上不能使用这种试
rails g model Category name:string parent:references
生成的模型会包含一个 name
字段用于保存类别名称,parent_id
字段用于保存父类别的 ID。这是自连接的关键部分:parent_id
引用的就是同一个 categories
表中的其他记录。
方式1(✅)
class CreateCategories < ActiveRecord::Migration[8.0]
def change
create_table :categories do |t|
t.string :name
t.integer :parent_id
t.timestamps
end
end
end
方式2(❌)
- 这种 null 为 false,肯定不适合现在的场景,因为 parent 为空,表示顶级分类
class CreateCategories < ActiveRecord::Migration[8.0]
def change
create_table :categories do |t|
t.string :name
t.references :parent, null: false, foreign_key: true
t.timestamps
end
end
end
02 在 Category 模型中实现自连接
在模型中,我们可以使用 belongs_to
和 has_many
来设置父子关系:
class Category < ApplicationRecord
# 每个类别可以有一个父类别
belongs_to :parent, class_name: 'Category', optional: true
# 每个类别可以有多个子类别
# 在模型中,`foreign_key` 是一个纯逻辑上的概念,用于指定哪个字段用来作为关联两个模型的桥梁。
# 它并不会在数据库中创建外键约束或列,而是告诉 ActiveRecord 在查询时应该使用哪个字段。
has_many :children, class_name: 'Category', foreign_key: 'parent_id'
end
belongs_to :parent
:定义类别的父类别。我们使用class_name: 'Category'
来明确指定这个关联指向的是同一个模型。optional: true
:这个选项允许parent_id
为nil
,即某个类别不一定有父类别。has_many :children
:定义类别的子类别。这里通过指定foreign_key: 'parent_id'
,表示children
是通过parent_id
关联的。
03 创建和查询父子关系
你可以在控制台中创建类别以及其子类别:
# 创建顶级类别
electronics = Category.create(name: 'Electronics')
# 创建子类别
phones = Category.create(name: 'Phones', parent: electronics)
laptops = Category.create(name: 'Laptops', parent: electronics)
# 创建更深层级的子类别
smartphones = Category.create(name: 'Smartphones', parent: phones)
然后你可以通过 children
和 parent
来访问类别之间的关系:
# 查询子类别
electronics.children # => [#<Category id: 2, name: "Phones">, #<Category id: 3, name: "Laptops">]
# 查询父类别
smartphones.parent # => #<Category id: 2, name: "Phones">
04 为什么 optional: true
原因解释
默认情况下,`belongs_to` 关联在 Rails 5 及其以上版本中是**必须存在的**,这意味着 Rails 会要求每个 `Category` 记录都必须有一个 `parent`,即 `parent_id` 不能为空,否则会抛出验证错误。
optional: true
是 Rails 中 belongs_to
关联的一项配置,用来表示这个关联是可选的。也就是说,允许某个 Category
记录没有父类别(即 parent_id
字段可以为空)。在你的场景中,这是必要的,因为并不是所有类别都有父类别,例如根类别(顶级类别)没有父类别。
### 为什么要使用 `optional: true`?
默认情况下,`belongs_to` 关联在 Rails 5 及其以上版本中是**必须存在的**,这意味着 Rails 会要求每个 `Category` 记录都必须有一个 `parent`,即 `parent_id` 不能为空,否则会抛出验证错误。这对于自连接的场景是不合理的,因为顶级类别(根类别)是没有父类别的,它们的 `parent_id` 应该为 `nil`。
通过设置 `optional: true`,你可以让 `belongs_to :parent` 的关联变为**可选的**,即 `parent_id` 可以为空。这样你就可以创建一个没有父类别的根类别。
### 示例
```ruby
# 不使用 optional: true 的情况
electronics = Category.create(name: 'Electronics')
# 会抛出错误:Validation failed: Parent must exist
```
这时,Rails 会要求你提供一个 `parent`,即使你不希望这个类别有父类别。
通过添加 `optional: true`,则可以允许 `parent` 为空:
```ruby
class Category < ApplicationRecord
belongs_to :parent, class_name: 'Category', optional: true
has_many :children, class_name: 'Category', foreign_key: 'parent_id'
end
# 现在可以创建没有父类别的顶级类别
electronics = Category.create(name: 'Electronics')
```
### 总结
`optional: true` 的作用是允许 `belongs_to` 关联中的外键字段为空,避免强制验证父记录的存在。在你的场景中,顶级类别没有父类别,因此 `parent_id` 可以为空,使用 `optional: true` 就使这种情况成为可能。
05 递归获取所有子类别 ✅
如果你想递归获取某个类别的所有子类别(包括子类别的子类别),可以定义一个方法:
class Category < ApplicationRecord
# 自连接关系
belongs_to :parent, class_name: 'Category', optional: true
has_many :children, class_name: 'Category', foreign_key: 'parent_id'
# 获取所有子类别
def all_children
children.flat_map { |child| [child] + child.all_children }
end
end
通过 all_children
方法,你可以递归地获取某个类别的所有子类别。
通过在 Rails 模型中使用自连接关系,我们可以轻松地实现类似目录、类别、评论树等树状结构。在这个例子中,Category
模型可以通过 parent_id
字段与自身建立关联,从而允许我们创建父子关系的类别结构。
06 示例数据
可以使用 Category
模型来创建一些常见的分类结构,比如电子产品、服饰等。以下是一个层级分类的示例:
# 顶级分类
electronics = Category.create(name: 'Electronics') # 电子产品
fashion = Category.create(name: 'Fashion') # 服饰
home_appliances = Category.create(name: 'Home Appliances') # 家用电器
# 二级分类 - Electronics
phones = Category.create(name: 'Phones', parent: electronics) # 手机
laptops = Category.create(name: 'Laptops', parent: electronics) # 笔记本电脑
cameras = Category.create(name: 'Cameras', parent: electronics) # 相机
# 二级分类 - Fashion
mens_wear = Category.create(name: 'Men\'s Wear', parent: fashion) # 男装
womens_wear = Category.create(name: 'Women\'s Wear', parent: fashion) # 女装
shoes = Category.create(name: 'Shoes', parent: fashion) # 鞋类
# 二级分类 - Home Appliances
kitchen_appliances = Category.create(name: 'Kitchen Appliances', parent: home_appliances) # 厨房电器
cleaning_appliances = Category.create(name: 'Cleaning Appliances', parent: home_appliances) # 清洁电器
# 三级分类 - Phones
smartphones = Category.create(name: 'Smartphones', parent: phones) # 智能手机
feature_phones = Category.create(name: 'Feature Phones', parent: phones) # 功能手机
# 三级分类 - Laptops
gaming_laptops = Category.create(name: 'Gaming Laptops', parent: laptops) # 游戏笔记本
business_laptops = Category.create(name: 'Business Laptops', parent: laptops) # 商务笔记本
- 一级分类:
Electronics
(电子产品)、Fashion
(服饰)、Home Appliances
(家用电器) - 二级分类:每个一级分类下的子分类,如手机、笔记本电脑、男装、厨房电器等。
- 三级分类:更细分的子分类,如智能手机、游戏笔记本等。
通过这种结构,你可以递归查询某个分类的所有子分类,也可以轻松创建更复杂的分类体系。
利用 build/create 创建
c1 = Category.first
c1.children.create(name: 'sub1-1')
c1.children.create(name: 'sub1-2')