标签 gorm 下的文章

下面给出两种常见的做法,关键在于如何统计某个标签被多少篇文章引用:

方法一:使用 Join Table 直接查询统计

假设你的多对多关系存储在 article_tags 表中,并且标签模型(Tag)类似如下:

type Tag struct {
    ID   uint   `gorm:"primaryKey"`
    Name string
    // 其他字段...
}

那么在删除文章时,你可以通过查询 article_tags 表来统计当前标签是否被其他文章引用:

func DeletePost(uid string) error {
    var article Article
    db := sql.GetDB()

    // 预加载 Tags, 这样后续能遍历对应的标签
    if err := db.Where("uid = ?", uid).Preload("Tags").First(&article).Error; err != nil {
        return err
    }
    
    // 遍历每个标签,检查是否还存在其他文章关联
    for _, tag := range article.Tags {
        var count int64
        // 查询 join table 中该 tag 被其他文章引用的次数
        // 请确保 "article_tags" 为你实际使用的表名
        db.Table("article_tags").Where("tag_id = ?", tag.ID).Count(&count)
        if count == 1 {
            // 如果仅有一个关联,则删除该标签
            if err := db.Delete(&tag).Error; err != nil {
                return err
            }
        }
    }

    // 移除关联(删除 join table 中对应记录)
    if err := db.Model(&article).Association("Tags").Clear(); err != nil {
        return err
    }

    // 删除文章记录
    if err := db.Delete(&article).Error; err != nil {
        return err
    }
    return nil
}

方法二:借助 GORM Association Mode 的 Count 方法

如果你在标签的模型中也定义了与文章的关联(比如在 Tag 模型中加入 Articles []Article gorm:"many2many:article_tags;"`),你就可以利用 GORM 内置的 Association("Articles").Count()` 来查询关联数量。示例如下:

标签模型:

type Tag struct {
    ID       uint      `gorm:"primaryKey"`
    Name     string
    Articles []Article `gorm:"many2many:article_tags;"`
}

在删除文章的函数中:

func DeletePost(uid string) error {
    var article Article
    db := sql.GetDB()

    // 预加载 Tags,确保能遍历所有标签
    if err := db.Where("uid = ?", uid).Preload("Tags").First(&article).Error; err != nil {
        return err
    }

    // 先清除文章与标签的关联关系
    if err := db.Model(&article).Association("Tags").Clear(); err != nil {
        return err
    }

        // 检查每个标签是否已经没有文章关联,如果是则删除标签
    for _, tag := range article.Tags {
        // 使用 Association Mode 的 Count 方法
        count := db.Model(&tag).Association("Articles").Count()
        if count == 1 {
            if err := db.Delete(&tag).Error; err != nil {
                return err
            }
        }
    }
    
    // 删除文章记录
    if err := db.Delete(&article).Error; err != nil {
        return err
    }
    return nil
}

总结

  • 查询统计方式:可以直接根据 join table(如 article_tags)进行查询统计,代码简单直观,不需要在 Tag 模型中额外定义关联。
  • Association Count 方式:需要在 Tag 模型中定义反向的多对多关联,但借助 GORM 的 Association().Count() 方法,代码更加面向对象,调用简单。

两种方法各有优劣,可以根据你的项目情况(模型定义是否包含双向关联、代码风格偏好等)来选择。

假设想实现的是文章(Article)和标签(Tag)之间的多对多关系。在 GORM 中,多对多关联可以通过在关联字段上使用 gorm:"many2many:<join_table_name>" 标记来实现。

下面是一个正确的示例:

// Tag 表示标签,每个标签可以关联多篇文章。
type Tag struct {
    gorm.Model
    Name     string     // 可选的标签名称
    Articles []Article  `gorm:"many2many:article_tags;"` // 指定关联的多对多关系
}

// Article 表示文章,每篇文章可以有多个标签。
type Article struct {
    gorm.Model
    Title string     // 文章标题
    Content string   // 文章内容
    Tags   []Tag    `gorm:"many2many:article_tags;"` // 指定关联的多对多关系
}

说明

  1. 多对多关系标记
    TagArticle 结构体中,都使用了 gorm:"many2many:article_tags;" 标记。这告诉 GORM:

    • 这两个表之间存在多对多关系。
    • 关联关系使用的中间表命名为 article_tags。如果不指定,GORM 会根据一定规则生成。
  2. 自动管理中间表
    使用上述标记后,GORM 在执行自动迁移(AutoMigrate)时会自动创建中间表 article_tags,其中包含两个外键字段(例如 article_idtag_id)。

这样就完成了文章和标签之间的多对多关联定义。

示例用法

下面是一个简单的示例,展示如何保存一篇文章及其标签:

// 假设 db 是已经初始化的 *gorm.DB 实例

// 创建标签
tag1 := Tag{Name: "Golang"}
tag2 := Tag{Name: "数据库"}

// 创建文章并关联标签
article := Article{
    Title:   "使用 GORM 实现多对多关联",
    Content: "这里详细介绍如何使用 GORM 定义多对多关联关系。",
    Tags:    []Tag{tag1, tag2},
}

// 保存文章(此时 GORM 会自动插入关联的标签以及中间表数据)
if err := db.Create(&article).Error; err != nil {
    log.Fatal(err)
}

目前的问题是:在插入文章的同时直接传入包含多个 Tag 的切片,由于 GORM 在保存多对多关联时默认认为每个 Tag 都是新的记录,导致每次插入文章时相同的 Tag 都会重新创建。为了解决这个问题,我们需要确保在保存文章之前,先对每个 Tag 去重,也就是先查询数据库中是否已经存在该 Tag,如果存在则使用已有记录,否则再创建新记录。下面给出一种常见的做法,使用 GORM 的事务(Transaction)和 FirstOrCreate 方法来处理这些逻辑。

假设 Tag 模型类似于:

type Tag struct {
    ID   uint   `gorm:"primaryKey"`
    Name string `gorm:"uniqueIndex"` // 保证 tag 名称的唯一性
    // 其它字段...
}

下面是修改后的示例代码:

func CreatePost(article Article) error {
    db := sql.GetDB()

    // 使用事务确保操作的原子性
    return db.Transaction(func(tx *gorm.DB) error {
        // 处理每个 Tag
        for i, tag := range article.Tags {
            var existingTag Tag
            // 根据 Tag 的唯一字段(例如 Name)进行查找
            if err := tx.Where("name = ?", tag.Name).First(&existingTag).Error; err != nil {
                if errors.Is(err, gorm.ErrRecordNotFound) {
                    // 如果没有找到,则创建新标签
                    if err := tx.Create(&tag).Error; err != nil {
                        return err
                    }
                    // 更新 article.Tags 中的数据为新创建的 tag
                    article.Tags[i] = tag
                } else {
                    // 出现其它错误时,返回错误,事务会回滚
                    return err
                }
            } else {
                // 找到了,使用已有的 tag 记录
                article.Tags[i] = existingTag
            }
        }

        // 创建文章,GORM 会自动将 article.Tags 中的 Tag 与 Article 关联(写入中间表)
        if err := tx.Create(&article).Error; err != nil {
            return err
        }
        return nil
    })
}

说明

  1. 事务使用
    使用 db.Transaction 来确保整个操作的原子性,如果中间任一步出现错误,整个事务会回滚。
  2. Tag 去重逻辑
    对于每个传入的 Tag,我们先根据唯一字段(比如 Name)进行查找:

    • 如果找不到则使用 tx.Create(&tag) 新建标签;
    • 如果找到了,则直接使用已存在的标签,避免重复插入。
  3. 自动建立关联
    当调用 tx.Create(&article) 时,GORM 会自动处理多对多关系,将 article.Tags 中的 Tag 信息写入关联表 article_tags。注意这要求你的 Article 模型中所定义的多对多标签 gorm:"many2many:article_tags;" 是正确设置的。

通过这种方式,每次插入文章时,相同的 Tag 都只会在数据库中保留一条记录,而只是在关联表中添加一条关联记录。这样既避免了数据冗余,也能保持多对多关系的正确性。

直接调用 db.Delete(&article) 仅会删除 articles 表中的记录,而不会自动清除多对多关联关系(即 article_tags 连接表中的记录)。

在 GORM 中,对于多对多关联,如果需要同时删除关联关系,需要使用 Association Mode 来额外处理,例如:

// 删除文章所有 Tag 关联(删除连接表中的记录)
db.Model(&article).Association("Tags").Clear()

或者,可以配置数据库中的外键约束(例如添加 ON DELETE CASCADE),不过这需要在数据库层面配置,是另一种实现方式。

gorm golang对象关系映射框架

安装

go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite

连接数据库

sqlite

db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
  if err != nil {
    panic("failed to connect database")
  }

mysql

// 参考 https://github.com/go-sql-driver/mysql#dsn-data-source-name 获取详情
dsn := "user:pass@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

其他数据库或高级用法:https://gorm.io/zh_CN/docs/connecting_to_the_database.html

声明模型

例:

type Product struct {
  gorm.Model
  Code  string
  Price uint
}

GORM 通过将 Go 结构体(Go structs) 映射到数据库表来简化数据库交互。

模型是使用普通结构体定义的。 这些结构体可以包含具有基本Go类型、指针或这些类型的别名,甚至是自定义类型(只需要实现 database/sql 包中的ScannerValuer接口)。

约定

  1. 主键:GORM 使用一个名为ID 的字段作为每个模型的默认主键。
  2. 表名:默认情况下,GORM 将结构体名称转换为 snake_case 并为表名加上复数形式。 例如,一个 User 结构体在数据库中的表名变为 users 。
  3. 列名:GORM 自动将结构体字段名称转换为 snake_case 作为数据库中的列名。
  4. 时间戳字段:GORM使用字段 CreatedAt 和 UpdatedAt 来自动跟踪记录的创建和更新时间。

gorm.Model

GORM提供了一个预定义的结构体,名为gorm.Model,其中包含常用字段:

// gorm.Model 的定义
type Modelstruct {
  ID uint `gorm:"primaryKey"`
  CreatedAt time.Time
  UpdatedAt time.Time
  DeletedAt gorm.DeletedAt `gorm:"index"`
}

  • 将其嵌入在您的结构体中: 您可以直接在您的结构体中嵌入 gorm.Model ,以便自动包含这些字段。 这对于在不同模型之间保持一致性并利用GORM内置的约定非常有用,请参考嵌入结构
  • 包含的字段

    • ID :每个记录的唯一标识符(主键)。
    • CreatedAt :在创建记录时自动设置为当前时间。
    • UpdatedAt:每当记录更新时,自动更新为当前时间。
    • DeletedAt:用于软删除(将记录标记为已删除,而实际上并未从数据库中删除)。

文档:https://gorm.io/zh_CN/docs/models.html

迁移(Migration)

迁移(Migration)是什么?

迁移是指对数据库结构进行版本控制的机制,它允许您:

  1. 以编程方式定义数据库表结构
  2. 跟踪数据库模式的变化历史
  3. 在不同环境(开发/测试/生产)之间保持数据库结构一致
  4. 回滚到之前的数据库版本

迁移与创建数据库的区别

特性迁移(Migration)创建数据库
范围表结构、字段、索引等整个数据库实例
功能修改现有结构、版本控制创建新的数据库容器
粒度细粒度(表/字段级)粗粒度(数据库级)
目的模式演进数据库初始化

GORM中的迁移实现

在GORM中,迁移通常通过AutoMigrate方法实现:

// 自动迁移模型结构到数据库表
db.AutoMigrate(&User{}, &Product{}, &Order{})

// 这会在数据库中创建对应的表(如果不存在)或修改表结构以匹配模型

迁移的典型用途

  1. 初始化表结构:创建新表
  2. 添加字段:向现有表添加新列
  3. 修改字段:更改字段类型或约束
  4. 删除字段:从表中移除列
  5. 创建索引:添加或删除索引

高级迁移功能

GORM还支持更复杂的迁移操作:

// 使用Migrator接口进行精细控制
m := db.Migrator()

// 检查表是否存在
m.HasTable(&User{})

// 创建表
m.CreateTable(&User{})

// 添加列
m.AddColumn(&User{}, "CreditCardNumber")

// 修改列
m.AlterColumn(&User{}, "Name")

// 删除列
m.DropColumn(&User{}, "Age")

基本操作(CRUD)

 // Create
  db.Create(&Product{Code: "D42", Price: 100})

  // Read
  var product Product
  db.First(&product, 1) // 根据整型主键查找
  db.First(&product, "code = ?", "D42") // 查找 code 字段值为 D42 的记录

  // Update - 将 product 的 price 更新为 200
  db.Model(&product).Update("Price", 200)
  // Update - 更新多个字段
  db.Model(&product).Updates(Product{Price: 200, Code: "F42"}) // 仅更新非零值字段
  db.Model(&product).Updates(map[string]interface{}{"Price": 200, "Code": "F42"})

  // Delete - 删除 product
  db.Delete(&product, 1)

注:

Product是一个结构体

type Product struct {
  gorm.Model
  Code  string
  Price uint
}

对于CRUD的每一个操作都是传入一个地址,直接修改作用的对象

create

例如对于create来说,就是传入一个Product类型的结构体。传入的结构体实例不需要为每个字段都赋值。

基本赋值规则:

  1. 部分赋值:可以只为结构体的部分字段赋值,其他字段将保持零值或默认值
  2. 零值处理:未赋值的字段会根据类型自动设置为相应的零值:

    • 数字类型:0
    • 字符串:""
    • 布尔:false
    • 指针:nil
    • 时间:0001-01-01 00:00:00 +0000 UTC

示例代码

type User struct {
    ID        uint
    Name      string
    Age       int
    Email     *string  // 指针类型
    IsActive  bool
    CreatedAt time.Time
}

// 只设置部分字段
user := User{
    Name: "张三",
    Age:  25,
    // 其他字段未设置
}

result := db.Create(&user)  // 成功插入,未设置字段使用零值

特殊情况处理

  1. 指针字段

    • 如果字段是指针类型且未赋值,GORM会将其作为NULL插入
    • 如果想明确设置NULL,可以使用指针的nil值
    // 明确设置Email为NULL
    user := User{
        Name:  "李四",
        Email: nil,  // 将存储为NULL
    }
  2. 默认值

    • 可以在模型定义中使用gorm标签设置默认值
    • 未赋值时会使用默认值而非零值
    type Product struct {
        Code  string
        Price float64 `gorm:"default:100"`  // 默认值100
    }
  3. 忽略字段

    • 使用gorm:"-"标签可以完全忽略字段,不会被插入
    type User struct {
        Name string
        Temp string `gorm:"-"`  // 不会存入数据库
    }

数据库约束的影响

  1. NOT NULL约束

    • 如果字段在数据库中有NOT NULL约束且没有默认值
    • 尝试插入时会返回错误
  2. 主键

    • 如果使用自动递增主键,可以省略ID字段
    • 如果想手动设置ID,可以显式赋值

最佳实践

  1. 只设置业务需要的字段,让其他字段保持零值
  2. 对于可选字段,考虑使用指针类型以便明确表示NULL
  3. 重要的必填字段应该在模型层或数据库层添加验证
  4. 对于有默认值的字段,可以省略赋值

read

对于read,可以现在外面定义一个新的对应类型的变量,然后将这个变量传入,因为传的是地址,所以执行完,read之后对于product就是我们需要的一个变量,对应着数据库表中特定的某一行。

update

不可以直接使用未经过Read赋值的product变量进行Update操作。这样会导致更新失败或产生意外行为。

Update操作的工作机制

GORM的Update操作需要两个关键信息:

  1. 要更新的模型实例:用于确定更新哪条记录
  2. 更新内容:指定要修改的字段和值

为什么需要先Read

  1. 主键标识

    • Update操作需要知道要更新哪条记录,这通常通过模型的主键(ID)来确定
    • 未经Read的product变量没有有效的ID值
  2. 模型状态

    • GORM会跟踪模型的状态(是否从数据库加载)
    • 未加载的模型无法正确执行更新

具体问题分析

var product Product  // 未赋值的product变量
db.Model(&product).Update("Price", 200)

这种情况下:

  1. product的ID字段为0(零值)
  2. GORM会尝试更新ID=0的记录,这通常不是您想要的
  3. 如果没有ID=0的记录,则不会更新任何数据

正确做法

  1. 先查询再更新 (推荐):

    var product Product
    db.First(&product, 1) // 或通过其他条件查询
    db.Model(&product).Update("Price", 200)
  2. 直接指定条件更新 (不依赖模型实例):

    // 更新所有code="D42"的记录
    db.Model(&Product{}).Where("code = ?", "D42").Update("Price", 200)
    
    // 更新主键为1的记录
    db.Model(&Product{}).Where("id = ?", 1).Update("Price", 200)

特殊情况

如果明确知道要更新的记录ID,也可以这样:

product := Product{ID: 1} // 只设置ID
db.Model(&product).Update("Price", 200)

但这种方法:

  1. 不如先查询再更新直观
  2. 无法利用GORM的模型状态跟踪功能
  3. 可能引发并发问题

最佳实践

  1. 对于单个记录更新,总是先查询获取完整模型
  2. 对于批量更新,使用Where条件明确指定范围
  3. 避免使用零值模型进行更新操作
  4. 重要更新操作前检查受影响的行数

错误示例

// 错误用法1: 完全未初始化的模型
var p Product
db.Model(&p).Update("Price", 200) // 不会按预期工作

// 错误用法2: 只有部分字段的模型
p := Product{Code: "D42"}
db.Model(&p).Update("Price", 200) // 仍然不知道更新哪条记录

delete

在GORM中执行Delete操作时,如果已经有一个完整赋值的product实例,通常不需要再额外传入primary key。以下是详细说明:

基本删除方式

  1. 使用已赋值的模型实例删除(推荐)
var product Product
db.First(&product, 1) // 先查询获取完整记录

// 方式1:直接使用实例删除
db.Delete(&product) // 不需要再传ID

这种方式:

  • GORM会自动使用模型中的主键值作为删除条件
  • 是最简洁、最安全的删除方式
  • 推荐在日常开发中使用
  1. 同时传入实例和主键(冗余方式)
// 方式2:虽然可以运行,但冗余
db.Delete(&product, 1) // 传入实例同时再传ID

这种方式:

  • 可以运行但没必要
  • 如果传入的ID与实例中的ID不一致,可能导致意外行为
  • 不推荐使用

特殊情况处理

批量删除

如果需要批量删除,可以不使用实例:

// 删除所有price>100的记录
db.Where("price > ?", 100).Delete(&Product{})

// 删除主键为1的记录(不依赖实例)
db.Delete(&Product{}, 1)

软删除考虑

如果模型启用了软删除(有DeletedAt字段):

// 软删除会自动设置DeletedAt时间
db.Delete(&product)

// 要真正硬删除可以加Unscoped
db.Unscoped().Delete(&product)

为什么不需要重复传主键?

  1. GORM的智能处理

    • 当传入模型实例时,GORM会自动提取其主键值
    • 内部会生成类似WHERE id = ?的条件
  2. 数据一致性

    • 避免主键值不一致的风险
    • 减少人为错误的可能性
  3. 代码简洁性

    • 减少冗余参数
    • 提高代码可读性

最佳实践建议

  1. 查询后删除

    var product Product
    if err := db.First(&product, 1).Error; err != nil {
        // 处理错误
    }
    db.Delete(&product)
  2. 批量删除

    // 明确条件
    db.Where("code LIKE ?", "D%").Delete(&Product{})
  3. 避免的做法

    // 不推荐 - 可能误删
    db.Delete(&Product{}, someVariable)
    
    // 危险 - 可能清空整个表
    db.Delete(&Product{})

关于Unscoped()

基本概念

Unscoped()是GORM提供的一个链式调用方法,用于绕过GORM的默认作用域限制,特别是针对软删除(Soft Delete)功能。

核心作用

  1. 绕过软删除机制

    • 默认情况下,GORM会对有DeletedAt字段的模型启用软删除
    • 使用Unscoped()可以操作已被软删除的记录
  2. 执行"硬删除"

    • 真正从数据库中物理删除记录
    • 而不是仅仅设置deleted_at时间戳
  3. 查询所有记录

    • 包括已被软删除的记录

使用场景

  1. 真正删除记录(硬删除)
// 普通删除(如果是软删除模型,只是设置deleted_at)
db.Delete(&user)

// 硬删除(真正从数据库删除)
db.Unscoped().Delete(&user)
  1. 查询包含已删除的记录
// 默认查询会排除已软删除的记录
db.Find(&users)

// 查询所有记录(包括已软删除的)
db.Unscoped().Find(&users)
  1. 恢复软删除的记录
// 将deleted_at设置为NULL来恢复记录
db.Unscoped().Model(&user).Update("DeletedAt", nil)

实现原理

GORM的软删除是通过在SQL查询中自动添加条件实现的:

  • 默认查询会自动添加:WHERE deleted_at IS NULL
  • 使用Unscoped()后会移除这个条件

注意事项

  1. 数据安全

    • 硬删除是不可逆的操作
    • 生产环境慎用,确保有备份机制
  2. 关联删除

    • 如果模型有关联关系,硬删除可能导致外键约束问题
    • 需要考虑级联删除或手动处理关联记录
  3. 审计要求

    • 某些业务场景要求保留所有历史记录
    • 硬删除可能违反合规要求

最佳实践

// 1. 硬删除前先确认
if isAdmin {
    db.Unscoped().Delete(&sensitiveData)
}

// 2. 批量硬删除时添加条件限制
db.Unscoped().Where("created_at < ?", time.Now().AddDate(-5,0,0)).Delete(&OldRecords{})

// 3. 开发环境可以设置全局禁用软删除
db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&TestData{})

与其他方法的对比

方法作用影响
Delete()普通删除对软删除模型是逻辑删除
Unscoped().Delete()硬删除物理删除记录
Where().Delete()条件删除遵循模型作用域
Unscoped().Where().Delete()条件硬删除无视软删除限制

总结:Unscoped()是GORM中处理软删除边界的强大工具,但需要谨慎使用,特别是在生产环境中。理解它的工作原理有助于您更好地控制数据删除行为。

参考文档:https://gorm.io/zh_CN/docs/