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_tohas_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_idnil,即某个类别不一定有父类别。
  • 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)

然后你可以通过 childrenparent 来访问类别之间的关系:

# 查询子类别
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')
rubyonrails rails category children parent