go-gorm / gorm

The fantastic ORM library for Golang, aims to be developer friendly
https://gorm.io
MIT License
36.03k stars 3.86k forks source link

Updates 使用 struct 作为参数无法触发 BeforeUpdate 钩子 #7046

Closed mck753 closed 1 month ago

mck753 commented 1 month ago

Your Question

现有结构体:

type BaseModel struct {
    ID         uint64    `gorm:"column:id;primaryKey;autoIncrement:true;comment:自增id" json:"id"`                          // 自增id
    CreateUser string    `gorm:"column:create_user;not null;comment:创建记录调用方标识" json:"create_user"`                        // 创建记录调用方标识
    CreateTime time.Time `gorm:"column:create_time;not null;default:CURRENT_TIMESTAMP;comment:创建记录时间" json:"create_time"` // 创建记录时间
    UpdateUser string    `gorm:"column:update_user;not null;comment:更新记录调用方标识" json:"update_user"`                        // 更新记录调用方标识
    UpdateTime time.Time `gorm:"column:update_time;not null;default:CURRENT_TIMESTAMP;comment:更新记录时间" json:"update_time"` // 更新记录时间
    Version    uint32    `gorm:"column:version;not null;comment:乐观锁标记(操作版本号)" json:"version"`                             // 乐观锁标记(操作版本号)
    IsDel      uint8     `gorm:"column:is_del;not null;comment:逻辑删除标记,0未删除,1已删除" json:"is_del"`                           // 逻辑删除标记,0未删除,1已删除
}

func (m *BaseModel) BeforeUpdate(tx *gorm.DB) error {
    tx.Statement.SetColumn(version, clause.Expr{SQL: tx.Statement.Quote(version) + "+1"})
    return nil
}
    fmt.Println("Updates map :")
    err = db.Debug().
        Model(&Table{}).
        Where("id = ?", id).
        Updates(map[string]any{"entrance_year": 2023}). // 会更新version
        Error
    if err != nil {
        panic(err)
    }

    fmt.Println("Updates struct :")
    updatingData := Table{EntranceYear: 2023}
    t := &Table{BaseModel: BaseModel{ID: id}}
    err = db.Debug().
        Model(&t).
        Updates(updatingData). // 不会更新version
        Error
    if err != nil {
        panic(err)
    }

Updates 更新时是无法使用 BeforeUpdate 钩子函数的吗

The document you expected this should be explained

Expected answer

如何在参数为 struct 的情况下 使用 Updates,会触发version字段的更新

ivila commented 1 month ago

@mck753 你这个需求应该提给Golang官方而不是gorm。 当你传参为一个struct时,golang编译时是会传入struct的拷贝而不是该struct。 你的Expected answer大概是这个样子

type StructA struct {
        FieldA int
        FieldB string
}

func PassByStruct(aCopy StructA) {
        aCopy.FieldA = 1
        aCopy.FieldB = "From a copy"
}

func PassByReference(aRef *StructA) {
        aRef.FieldA = 2
        aRef.FieldB = "From a reference"
}

func main() {
        a := StructA{
                FieldA: -1,
                FieldB: "raw",
        }
        PassByStruct(a)
        // 输出:FieldA: -1, FieldB: raw
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
        PassByReference(&a)
        // FieldA: 2, FieldB: From a reference
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
}

你想做的就是想让PassByStruct支持修改传入struct的原址值,那应该是提给golang官方而不是gorm。

mck753 commented 1 month ago

@mck753 你这个需求应该提给Golang官方而不是gorm。 当你传参为一个struct时,golang编译时是会传入struct的拷贝而不是该struct。 你的Expected answer大概是这个样子

type StructA struct {
        FieldA int
        FieldB string
}

func PassByStruct(aCopy StructA) {
        aCopy.FieldA = 1
        aCopy.FieldB = "From a copy"
}

func PassByReference(aRef *StructA) {
        aRef.FieldA = 2
        aRef.FieldB = "From a reference"
}

func main() {
        a := StructA{
                FieldA: -1,
                FieldB: "raw",
        }
        PassByStruct(a)
        // 输出:FieldA: -1, FieldB: raw
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
        PassByReference(&a)
        // FieldA: 2, FieldB: From a reference
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
}

你想做的就是想让PassByStruct支持修改传入struct的原址值,那应该是提给golang官方而不是gorm。

我说的不是这个意思。 使用Updates map时候的SQL为:

UPDATE `t_xxxxxx` SET `entrance_year`=2023,`version`=version + 1 WHERE id = 18386775880800387594

使用Updates struct时候的SQL为:

UPDATE `t_xxxxxx` SET `entrance_year`=2023 WHERE id = 18386775880800387594

我debug看了 BeforeUpdate 钩子会被执行,但是后续的SQL并没有 version 字段,是因为 updates 使用结构体的时候 不支持吗

mck753 commented 1 month ago

似乎已经解决,see : https://github.com/go-gorm/gorm/issues/6079

ivila commented 1 month ago

@mck753 你这个需求应该提给Golang官方而不是gorm。 当你传参为一个struct时,golang编译时是会传入struct的拷贝而不是该struct。 你的Expected answer大概是这个样子

type StructA struct {
        FieldA int
        FieldB string
}

func PassByStruct(aCopy StructA) {
        aCopy.FieldA = 1
        aCopy.FieldB = "From a copy"
}

func PassByReference(aRef *StructA) {
        aRef.FieldA = 2
        aRef.FieldB = "From a reference"
}

func main() {
        a := StructA{
                FieldA: -1,
                FieldB: "raw",
        }
        PassByStruct(a)
        // 输出:FieldA: -1, FieldB: raw
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
        PassByReference(&a)
        // FieldA: 2, FieldB: From a reference
        fmt.Printf("FieldA: %d, FieldB: %s\n", a.FieldA, a.FieldB)
}

你想做的就是想让PassByStruct支持修改传入struct的原址值,那应该是提给golang官方而不是gorm。

我说的不是这个意思。 使用Updates map时候的SQL为:

UPDATE `t_xxxxxx` SET `entrance_year`=2023,`version`=version + 1 WHERE id = 18386775880800387594

使用Updates struct时候的SQL为:

UPDATE `t_xxxxxx` SET `entrance_year`=2023 WHERE id = 18386775880800387594

我debug看了 BeforeUpdate 钩子会被执行,但是后续的SQL并没有 version 字段,是因为 updates 使用结构体的时候 不支持吗

不,支持,而且你的version已经改了。后续使用结构体不支持的原因,是因为你传入是结构体的拷贝,每一份函数拿到的那个结构体值都是一份新的拷贝,你修改的是拷贝A的值,然后用拷贝B去执行后续操作,所以你的后续的SQL并没有 version 字段

ivila commented 1 month ago

似乎已经解决,see : #6079

你可以很明显地看到这个issue里面,对方的操作是PassByReference,而不是跟你一样的PassByStruct。 如果你要跟这个issue一样,你的代码应该改成这样:

    fmt.Println("Updates struct :")
    updatingData := Table{EntranceYear: 2023}
    t := &Table{BaseModel: BaseModel{ID: id}}
    err = db.Debug().
        Model(&t).
                // 改成传递指针(aka引用)的方式,而不是传递struct(aka拷贝)的方式
                Updates(&updatingData).
        Error
    if err != nil {
        panic(err)
    }
mck753 commented 1 month ago

似乎已经解决,see : #6079

你可以很明显地看到这个issue里面,对方的操作是PassByReference,而不是跟你一样的PassByStruct。 如果你要跟这个issue一样,你的代码应该改成这样:

  fmt.Println("Updates struct :")
  updatingData := Table{EntranceYear: 2023}
  t := &Table{BaseModel: BaseModel{ID: id}}
  err = db.Debug().
      Model(&t).
                // 改成传递指针(aka引用)的方式,而不是传递struct(aka拷贝)的方式
                Updates(&updatingData).
      Error
  if err != nil {
      panic(err)
  }

我现在的代码是这样,都使用了指针传递,似乎也更新不了

    fmt.Println("Updates struct :")
    updatingData := Table{EntranceYear: 2023}
    t := Table{BaseModel: BaseModel{ID: id}}
    err = db.Debug().
        Model(&t).
        // 改成传递指针(aka引用)的方式,而不是传递struct(aka拷贝)的方式
        Updates(&updatingData).
        Error
    // ouput:  UPDATE `t_xxxx` SET `entrance_year`=2023 WHERE `id` = 18386775880800387594
    if err != nil {
        panic(err)
    }
ivila commented 1 month ago

哦,这个啊,你这里是第二个问题,就是如果是个map[string]interface{}的话,是可以使用clause.Expr{}当作其值的。 但如果是个struct的话,clause.Expr{}是不能赋值给你的Version字段的(version是个uint32类型),所以对于Struct来说,你的BeforeUpdate是执行失败的。

mck753 commented 1 month ago

哦,这个啊,你这里是第二个问题,就是如果是个map[string]interface{}的话,是可以使用clause.Expr{}当作其值的。 但如果是个struct的话,clause.Expr{}是不能赋值给你的Version字段的(version是个uint32类型),所以对于Struct来说,你的BeforeUpdate是执行失败的。

嗯啊,那就是由于updates过程中,由于Struct不支持clause.Expr{}导致的无法更新指定字段,那有好的解决办法吗?我看我贴的那个issue,给的解决方法,似乎可以

ivila commented 1 month ago

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。

那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses&UpdateClauses method,然后match了UpdateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。

当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)UpdateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

mck753 commented 1 month ago

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。

那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。

当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。

那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。

当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

收到,感谢大佬

ivila commented 1 month ago

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。 那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。 当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。 那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。 当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

收到,感谢大佬

一种自定义实现可以是这样

// 自定义的Version,依旧使用uint32
type VersionField uint32
// 保证实现了driver.Valuer以及sql.Scanner和gorm的UpdateClausesInterface 
var _ driver.Valuer = (VersionField)(0)
var _ sql.Scanner = (*VersionField)(nil)
var _ schema.UpdateClausesInterface = (*VersionField)(nil)

func (f VersionField) Value() (driver.Value, error) {
        return sql.NullInt64{Valid: true, Int64: int64(f)}.Value()
}

func (f *VersionField) Scan(v interface{}) error {
        var t sql.NullInt64
        if err := t.Scan(v); err != nil {
                return err
        }
        *f = VersionField(t.Int64)
        return nil
}

// 以下照抄https://github.com/go-gorm/optimisticlock
func (v *VersionField) UpdateClauses(field *schema.Field) []clause.Interface {
        return []clause.Interface{VersionUpdateClause{Field: field}}
}

type VersionUpdateClause struct {
        Field *schema.Field
}

func (v VersionUpdateClause) Name() string {
        return ""
}

func (v VersionUpdateClause) Build(clause.Builder) {
}

func (v VersionUpdateClause) MergeClause(*clause.Clause) {
}

func (v VersionUpdateClause) ModifyStatement(stmt *gorm.Statement) {
        if _, ok := stmt.Clauses["version_enabled"]; ok {
                return
        }

        if c, ok := stmt.Clauses["WHERE"]; ok {
                if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) > 1 {
                        for _, expr := range where.Exprs {
                                if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 {
                                        where.Exprs = []clause.Expression{clause.And(where.Exprs...)}
                                        c.Expression = where
                                        stmt.Clauses["WHERE"] = c
                                        break
                                }
                        }
                }
        }

        if !stmt.Unscoped {
                if val, zero := v.Field.ValueOf(stmt.Context, stmt.ReflectValue); !zero {
                        if version, ok := val.(VersionField); ok {
                                stmt.AddClause(clause.Where{Exprs: []clause.Expression{
                                        clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: v.Field.DBName}, Value: version},
                                }})
                        }
                }
        }

        // convert struct to map[string]interface{}, we need to handle the version field with string, but which is an int64.
        dv := reflect.ValueOf(stmt.Dest)
        if reflect.Indirect(dv).Kind() == reflect.Struct {
                selectColumns, restricted := stmt.SelectAndOmitColumns(false, true)

                sd, _ := schema.Parse(stmt.Dest, &sync.Map{}, stmt.DB.NamingStrategy)
                d := make(map[string]interface{})
                for _, field := range sd.Fields {
                        if field.DBName == v.Field.DBName {
                                continue
                        }
                        if field.DBName == "" {
                                continue
                        }

                        if v, ok := selectColumns[field.DBName]; (ok && v) || (!ok && (!restricted || !stmt.SkipHooks)) {
                                if field.AutoUpdateTime > 0 {
                                        continue
                                }

                                val, isZero := field.ValueOf(stmt.Context, dv)
                                if (ok || !isZero) && field.Updatable {
                                        d[field.DBName] = val
                                }
                        }
                }

                stmt.Dest = d
        }

        stmt.SetColumn(v.Field.DBName, clause.Expr{SQL: stmt.Quote(v.Field.DBName) + "+1"}, true)
        stmt.Clauses["version_enabled"] = clause.Clause{}
}
mck753 commented 1 month ago

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。 那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。 当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

那种方法是可以,直接使用go-gorm/optimisticlock去替换你原来的Version字段。 那个方案的本质其实就是实现了自定义Field字段实现了CreateClauses method,然后match了CreateClausesInterface ,以此来动态修改生成的SQL(model的BeforeUpdate是在构建SQL之前)。 当然如果你不喜欢它的sql.NullInt64定义,你可以仿照着它的代码自己写一个,重点实现以下三个interface就好。 1)CreateClausesInterface : 支持动态修改生成的SQL 2)sql.Scanner: 把sql传过来的内容Unmarshal到你的字段上 3)driver.Valuer: 把你的字段marshal成sql的内容 对应optimisticlock.Version的CreateClauses, Scan, Value这三个method

收到,感谢大佬

一种自定义实现可以是这样

// 自定义的Version,依旧使用uint32
type VersionField uint32
// 保证实现了driver.Valuer以及sql.Scanner和gorm的UpdateClausesInterface 
var _ driver.Valuer = (VersionField)(0)
var _ sql.Scanner = (*VersionField)(nil)
var _ schema.UpdateClausesInterface = (*VersionField)(nil)

func (f VersionField) Value() (driver.Value, error) {
        return sql.NullInt64{Valid: true, Int64: int64(f)}.Value()
}

func (f *VersionField) Scan(v interface{}) error {
        var t sql.NullInt64
        if err := t.Scan(v); err != nil {
                return err
        }
        *f = VersionField(t.Int64)
        return nil
}

// 以下照抄https://github.com/go-gorm/optimisticlock
func (v *VersionField) UpdateClauses(field *schema.Field) []clause.Interface {
        return []clause.Interface{VersionUpdateClause{Field: field}}
}

type VersionUpdateClause struct {
        Field *schema.Field
}

func (v VersionUpdateClause) Name() string {
        return ""
}

func (v VersionUpdateClause) Build(clause.Builder) {
}

func (v VersionUpdateClause) MergeClause(*clause.Clause) {
}

func (v VersionUpdateClause) ModifyStatement(stmt *gorm.Statement) {
        if _, ok := stmt.Clauses["version_enabled"]; ok {
                return
        }

        if c, ok := stmt.Clauses["WHERE"]; ok {
                if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) > 1 {
                        for _, expr := range where.Exprs {
                                if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 {
                                        where.Exprs = []clause.Expression{clause.And(where.Exprs...)}
                                        c.Expression = where
                                        stmt.Clauses["WHERE"] = c
                                        break
                                }
                        }
                }
        }

        if !stmt.Unscoped {
                if val, zero := v.Field.ValueOf(stmt.Context, stmt.ReflectValue); !zero {
                        if version, ok := val.(VersionField); ok {
                                stmt.AddClause(clause.Where{Exprs: []clause.Expression{
                                        clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: v.Field.DBName}, Value: version},
                                }})
                        }
                }
        }

        // convert struct to map[string]interface{}, we need to handle the version field with string, but which is an int64.
        dv := reflect.ValueOf(stmt.Dest)
        if reflect.Indirect(dv).Kind() == reflect.Struct {
                selectColumns, restricted := stmt.SelectAndOmitColumns(false, true)

                sd, _ := schema.Parse(stmt.Dest, &sync.Map{}, stmt.DB.NamingStrategy)
                d := make(map[string]interface{})
                for _, field := range sd.Fields {
                        if field.DBName == v.Field.DBName {
                                continue
                        }
                        if field.DBName == "" {
                                continue
                        }

                        if v, ok := selectColumns[field.DBName]; (ok && v) || (!ok && (!restricted || !stmt.SkipHooks)) {
                                if field.AutoUpdateTime > 0 {
                                        continue
                                }

                                val, isZero := field.ValueOf(stmt.Context, dv)
                                if (ok || !isZero) && field.Updatable {
                                        d[field.DBName] = val
                                }
                        }
                }

                stmt.Dest = d
        }

        stmt.SetColumn(v.Field.DBName, clause.Expr{SQL: stmt.Quote(v.Field.DBName) + "+1"}, true)
        stmt.Clauses["version_enabled"] = clause.Clause{}
}

收到,感谢大佬~ 🥳🥳🥳