Day4. Go语言精进之路：GORM 关联模式

今天学 GORM 的关联模式，其实这是打算昨天学的，但是昨天被 beyond Many To Many 耽误太久，所以挪到今天了。

Polymorphism (多态关联)

怎么还有个这玩意没学，在 GORM 中，多态关联这个特性很强大，它允许一个模型可以属于多种不同的父模型，而不需要为每种父模型都创建单独的外键字段。

比如，Toy 和 Cat, Dog 的例子，这种能力的实现，是根据 Toy 中的两个字段实现的：

父模型的主键 ID

父模型的类型

GORM 支持 Has One 和 Has Many 关系的多态关联。

默认约定

在我们使用 多态关联 时，会有一些默认的约定：

类型字段 (Type Field)：GORM 会在子模型表中自动创建一个字段来存储父模型的类型。默认情况下，字段的名字是 polymorphic 指定的值加上 Type

ID 字段 (ID Field)：GORM 会在子模型表中自动创建一个字段来存储父模型的主键 ID。默认情况下，字段的名字是 polymorphic 指定的值加上 ID

类型值 (Type Value)：存储在类型字段中的值，默认是父模型对应的数据表的复数。

Copy
// 定义 Dog 模型
type Dog struct {
ID int
Name string
// Dog 有多个 Toy，使用多态关联
// gorm:"polymorphic:Owner;" 指定了多态关联的前缀为 "Owner"
Toys []Toy `gorm:"polymorphic:Owner;"`
}

// 定义 Toy 模型
type Toy struct {
ID int
Name string
// OwnerID 存储父模型 (Dog) 的 ID
OwnerID int
// OwnerType 存储父模型 (Dog) 的类型，默认为 "dogs"
OwnerType string
}

// 创建记录
db.Create(&Dog{Name: "dog1", Toys: []Toy{{Name: "toy1"}, {Name: "toy2"}}})

在这个案例中，OwnerType 将被设置为 dogs。

自定义标签

当然，我们也可以使用一些 tag 来自定义我们的多态关联：

polymorphicType：指定存储类型的字段名

polymorphicId：指定存储 ID 的字段名

polymorphicValue：指定存储在类型字段中的具体值

Copy
type Dog struct {
ID int
Name string
// 自定义多态关联：
// polymorphicType:Kind -> 类型字段名为 Kind
// polymorphicId:OwnerID -> ID 字段名为 OwnerID
// polymorphicValue:master -> 类型值为 "master"
Toys []Toy `gorm:"polymorphicType:Kind;polymorphicId:OwnerID;polymorphicValue:master"`
}

// 定义 Toy 模型
type Toy struct {
ID int
Name string
// OwnerID 存储父模型 (Dog) 的 ID (由 polymorphicId 指定)
OwnerID int
// Kind 存储父模型 (Dog) 的类型 (由 polymorphicType 指定)，值为 "master" (由 polymorphicValue 指定)
Kind string
}

// 创建记录
db.Create(&Dog{Name: "dog1", Toys: []Toy{{Name: "toy1"}, {Name: "toy2"}}})

关联模式

GORM 提供了强大的功能来处理数据库模型之间的关联，这让开发变得高效。

自动创建、更新

GORM 会在创建时，自动引用或更新其关联数据。例如：

Copy
type User struct {
gorm.Model
Languages []Language
}

type Language struct {
gorm.Model
Name string
UserID uint
}

user := User{
Languages: []Language{
{Name: "ZH"},
{Name: "EN"},
},
}

// 创建用户以及其关联的语言
db.Create(&user)

db.Save(&user)

这样的操作，GORM 会自动创建 Language 并且自动与 User 绑定 Has Many 的外键关系。

FullSaveAssociations

FullSaveAssociations 是 GORM 为 Session 提供的一个参数，可以强制 GORM 完整的保存或更新所有关联的记录，而不只是处理关系。

在我们不使用 FullSaveAssociations 参数时，或者说 FullSaveAssociations:false 时，默认的 GORM 是不会去处理所关联模型的变化的，而只是会检查其关联关系（也就是检查外键 ID 是否链接正确）。例如：

我们现在有以下模型：

Copy
type User struct {
gorm.Model
Name string
Age int
BillingAddress Address // Has One 关系
BillingAddressID uint
}

type Address struct {
gorm.Model
Address1 string
Address2 string
PostCode string
}

我们执行以下操作：

Copy
// 查询用户及其关联地址
var user User
db.Preload("BillingAddress").First(&user, 1)
// user.ID = 1
// user.Name = "jinzhu"
// user.BillingAddressID = 10
// user.BillingAddress.ID = 10
// user.BillingAddress.Address1 = "Old Billing St"
// user.BillingAddress.Address2 = "Unit 1"
// user.BillingAddress.PostCode = "10000"

// 修改用户和地址的信息
user.Name = "jinzhu_updated"
user.BillingAddress.Address1 = "New Billing Avenue" // 修改了关联对象的字段
user.BillingAddress.PostCode = "20000" // 修改了关联对象的字段

db.Save(&user)
// 或者 db.Updates(&user)

在不使用 FullSaveAssociations:true 时，BillingAddress 的信息不会被更新，也就是，在 user 模型上对 BillingAddress 模型的修改是无效的。在使用 FullSaveAssociations:true 后，再执行 db.Save(&user)，BillingAddress 的更新会被关联更新。

也就是说，FullSaveAssociations 这个 tag，提供了一种更全面的数据同步方式，会在我们使用 Save 或 Updates 时，其关联模型 (不管是一对一、一对多、还是多对多) 在 Go 代码中的修改也能被持久化到数据库中。

跳过自动创建、更新

GORM 会自动在创建时，自动引用并创建或更新所关联对象的数据（如果有关联关系的话），但 GORM 也提供了灵活性，使用 Select 或 Omit 方法可以指定哪些字段可以被删除或被包含。

使用 Select 指定字段范围

Select 方法可以选择模型中的哪些字段应该被保存，也就是说只有被选中的字段才会包含在 SQL 中。

Copy
var user User

user = User{
Name: "gopher",
Age: 21
}

db.Select("Name").Create(&user)

这里在数据库层面，就只会为 user 填充 name 字段，而我们所定义的 age 字段则会被抛弃忽略。

使用 Omit 来排除字段或关联

Omit 允许我们在执行创建或更新操作时，指定跳过哪些字段或关联关系，这提供了更加精细的控制，决定哪些数据会被持久化。

一共有以下几种用法：

排除指定字段

Copy
// 创建用户时跳过字段“BillingAddress”
db.Omit("BillingAddress").Create(&user)

排除全部关联关系

Copy
// 创建用户时跳过全部关联关系
db.Omit(clause.Associations).Create(&user)

排除多对多关系

Copy
// 跳过更新"Languages"关联
db.Omit("Languages.*").Create(&user)

这个用法，仍然会保持 user 和 language 的多对多关联，但是会跳过对 language 的更新。也就是。如果 language 不存在，那么还是会创建，并关联；但是如果 language 已经存在，那么则不会进行更新。

排除创建关联及引用

Copy
// 跳过创建 'Languages' 关联及其引用
db.Omit("Languages").Create(&user)

这样会完全忽略 Language，在创建 user 时，既不会关联创建，也不会在连接表中处理关联关系。

Select/Omit 关联字段

在 GORM 创建或更新记录时，可以使用 Select 和 Omit 来指定是否包含某个关联的字段。例如：

Select

Copy
// 创建用户和他的账单地址,邮寄地址,只包括账单地址指定的字段
db.Select("BillingAddress.Address1", "BillingAddress.Address2").Create(&user)
// SQL: 只使用地址1和地址2来创建用户和账单地址

这行代码会正常处理 user 的创建，但是在创建 BillingAddress 时，只包含 Address1 和 Address2 这两个字段，其他字段不会被包含在对 address 表的 SQL 中。

Omit

Copy
// 创建用户和账单地址,邮寄地址,但不包括账单地址的指定字段
db.Omit("BillingAddress.Address2", "BillingAddress.CreatedAt").Create(&user)
// SQL: 创建用户和账单地址,省略'地址2'和创建时间字段

同样正常处理 user 的创建，但是在处理 BillingAddress 时，除了 Address2 和 CreatedAt 这两个字段，其他全部包含。

也就是，Select 是指定只处理哪些，而 Omit 是指定排除哪些。

删除关联

GORM 提供了一个机制，可以让我们在删除主模型记录时，通过 Select 去删除其关联的记录（一对一、一对多、多对多），这对于维护数据完整性很有用，可以将无用数据一并清理。

使用 Select 指定要删除的关联：

删除单个关联

Copy
// 删除用户时，同时删除该用户的 Account 记录
db.Select("Account").Delete(&user)

删除多个关联

Copy
// 删除用户时，同时删除该用户的 Orders 和 CreditCards 记录
db.Select("Orders", "CreditCards").Delete(&user)

删除所有关联

Copy
// 删除用户时，删除该用户所有 has one, has many, many2many 类型的关联记录
db.Select(clause.Associations).Delete(&user)

批量删除关联

Copy
// 删除 users 切片中每个用户时，也删除他们各自的 Account 记录
db.Select("Account").Delete(&users) // users 是一个 []*User 或 []User 切片

这样删除十分快捷，但是有一个重要前提：主键必须非零，GORM 需要利用主键 ID 作为条件来查找并删除相关记录。

关联模式

GORM 提供了一系列便捷的方法来处理模型之间的关系，能让开发者高效的管理数据。

如果要开始使用关联模式，我们需要启用关联模式，也就是指定源模型和关联关系的字段名：

Copy
var user User // 假设 User 是你的模型，并且有一个名为 Languages 的关联字段
// 启动关联模式，操作 user 对象的 Languages 关联
db.Model(&user).Association("Languages")

// 你也可以检查在启动关联模式时是否发生错误
err := db.Model(&user).Association("Languages").Error
if err != nil {
// 处理错误
}

Association 里填写的不是所关联的模型名称，而是主模型中定义的包含关联关系的字段名

查询关联

使用 Find 可以检索关联的记录，也可以选择性的添加查询条件。

Copy
var languages []Language // 假设 Language 是关联的模型

// 简单查询：获取 user关联的所有 Language 记录
db.Model(&user).Association("Languages").Find(&languages)

// 带条件查询：获取 user 关联的 Language 记录，但只包括指定的 code
codes := []string{"zh-CN", "en-US", "ja-JP"}
db.Model(&user).Where("code IN ?", codes).Association("Languages").Find(&languages)

// 注意：使用 Where 条件时，它会应用在关联模型的查询上

这里的 where 条件，是落实在关联模型上的，如果我们需要 user 模型的条件呢？比如，我想要查找 user.id = 1 的 Language，该如何处理？代码如下：

Copy
// 这里我认为也可以直接创建一个 user 类型的 model，将 id 改为 1
// 也就是：
// user := User{ID: 1}
var user User
result := db.First(&user, 1) // 查找主键为 1 的 User
if result.Error != nil {
// 处理错误，比如用户未找到
panic("failed to find user with id 1")
}

var languages []Language
// 现在 user 变量就代表 id 为 1 的用户
// GORM 会自动使用 user.ID 来过滤 languages
db.Model(&user).Association("Languages").Find(&languages)

// 如果你还想加上 code 的过滤条件，可以像之前那样链式调用：
codes := []string{"zh-CN", "en-US", "ja-JP"}
db.Model(&user).Where("code IN ?", codes).Association("Languages").Find(&languages)

追加关联

Append 方法用于添加新的关联。在不同的关联关系中，Append 的效果不一：

对于 多对多关系 和 一对多关系，Append 会添加新的关联记录

对于 一对一关系 (Has One) 和 属于关系 (Belongs to)，Append 会替换掉当前关联

Copy
// 追加单个或多个已存在的 Language 对象
db.Model(&user).Association("Languages").Append([]Language{languageZH, languageEN})

// 追加一个新的 Language 对象（如果 Language 不存在，GORM 可能会尝试创建它，取决于你的设置）
db.Model(&user).Association("Languages").Append(&Language{Name: "DE"})

// 追加/替换 CreditCard (假设是 has one 或 belongs to 关系)
db.Model(&user).Association("CreditCard").Append(&CreditCard{Number: "411111111111"})

上面 Languages 不存在时，取决于设置来决定是否创建，其实就是 FullSaveAssociations 这个 tag，默认为 false，也就是不会创建。

替换关联

替换关联会将当前主模型所有的关联关系替换为新的关联记录，也就是抛弃原关联关系，替换为新的。

Copy
// 将 user 的 Languages 关联替换为 languageZH 和 languageEN
db.Model(&user).Association("Languages").Replace([]Language{languageZH, languageEN})

// 也可以传入多个独立的对象
db.Model(&user).Association("Languages").Replace(Language{Name: "DE"}, languageEN)

删除关联

这里的 Delete 方法，用于移除主模型和指定参数模型之间的关联关系，这只会删除他们的引用，不会去删除关联对象本身。

Copy
// 删除 user 与 languageZH 和 languageEN 的关联
db.Model(&user).Association("Languages").Delete([]Language{languageZH, languageEN})

// 也可以传入多个独立的对象
db.Model(&user).Association("Languages").Delete(languageZH, languageEN)

清空关联

清空关联会移除主模型与该模型之间的所有关联关系。

Copy
// 清空 user 的所有 Languages 关联
db.Model(&user).Association("Languages").Clear()

关联计数

关联计数可以用来获取当前关联记录的数量，也可以带条件计数：

Copy
// 获取 user 关联的所有 Languages 的数量
count := db.Model(&user).Association("Languages").Count()

// 带条件计数：获取 user 关联的 Language 中，code 在指定列表内的数量
codes := []string{"zh-CN", "en-US", "ja-JP"}
countWithCondition := db.Model(&user).Where("code IN ?", codes).Association("Languages").Count()

批量数据处理

关联模式也支持对多个记录进行批量操作，包括查询、追加、替换、删除和计数关联数据。

批量查询关联

Copy
var users []User
var roles []Role
// 假设 users 是一个 User 切片，查询这些 users 关联的所有 Role
db.Model(&users).Association("Role").Find(&roles)

批量删除关联

Copy
var users []User
// 假设 users 是一个 User 切片，删除这些 users 与 userA 的 Team 关联
db.Model(&users).Association("Team").Delete(&userA)

批量计数关联

Copy
var users []User
// 假设 users 是一个 User 切片，计算这些 users 关联的 Team 总数（或根据具体关系计数）
count := db.Model(&users).Association("Team").Count()

批量追加 / 替换关联

Copy
var users = []User{user1, user2, user3} // 假设有三个用户

// 批量追加：
// - 给 user1 的 Team 追加 userA
// - 给 user2 的 Team 追加 userB
// - 给 user3 的 Team 追加 userA, userB, userC
db.Model(&users).Association("Team").Append(&userA, &userB, &[]User{userA, userB, userC})

// 批量替换：
// - 将 user1 的 Team 替换为 userA
// - 将 user2 的 Team 替换为 userB
// - 将 user3 的 Team 替换为 userA, userB, userC
db.Model(&users).Association("Team").Replace(&userA, &userB, &[]User{userA, userB, userC})

删除关联内容

我们在使用 Replace, Delete, Clear 方法时，理解它们默认如何处理关联记录非常重要，这关联到数据完整性。

默认行为：仅更新引用

默认情况下，这些方法主要影响的是外键引用或连接表记录，而不是记录本身。

更新引用：这些方法通常会将关联的外键设置为 NULL（对于 belongs to, has one, has many) 或者删除连接表中的对应行，通过这样来断开主模型和关联模型之间的链接。

不物理删除记录：关联数据表中的实际记录不会被删除，这些记录仍然存在于数据库中，只是不在和主模型有关联关系。

使用 Unscoped 改变删除行为

如果希望断开关联关系的同时，实际删除关联的记录，就需要使用 Unscoped 方法。

软删除关联记录（Soft Delete）

如果你的关联模型配置了软删除功能，那么可以使用 Unscoped() 来触发对关联记录的软删除。

行为：这会将关联记录中的 deleted_at 字段设置为当前时间，将它们标记为已删除，但记录仍然在数据库中。

Copy
// 假设 Language 模型支持软删除 (有 gorm.DeletedAt 字段)
// 清除关联，并软删除 user 关联的所有 Language 记录
db.Model(&user).Association("Languages").Unscoped().Clear()

// 同样适用于 Delete 和 Replace
// db.Model(&user).Association("Languages").Unscoped().Delete(&languageZH)

物理删除关联记录

如果我们想彻底从数据库中物理删除关联记录，你需要结合使用两个 Unscoped()：

第一个 Unscoped() 应用在 db 上下文，指示 GORM 在后续操作中忽略软删除逻辑，执行删除逻辑。

第二个 Unscoped() 应用在 Association 链中，确保对关联记录也使用物理删除。

Copy
// 物理删除：
// 1. db.Unscoped() 指示 GORM 忽略全局的软删除设定，进行物理删除操作。
// 2. Association("Languages").Unscoped() 确保对关联记录执行物理删除。

// 清除关联，并从数据库中物理删除 user 关联的所有 Language 记录
db.Unscoped().Model(&user).Association("Languages").Unscoped().Clear()

// 同样适用于 Delete
// db.Unscoped().Model(&user).Association("Languages").Unscoped().Delete(&languageZH)