0
1

GORM Saveメソッドの挙動を追ってみた

Last updated at Posted at 2024-07-26

GORMのsaveメソッドはupsertをやってくれて便利だが、update時にCreatedAtは更新されるのか?あれupdateなのになんかinsert動いてない?みたいなことがあって調べた

知ってないとハマるポイント1

SaveメソッドはUpdateして0件だとINSERTするよ

ちょっと意外だったのが、Updateを実行した際に結果が0件だと、INSERTが実行されます。
これは便利ともいえるけど、何らかのバグでプライマリフィールドに意図しないIDが渡るとエラーにならずに、レコードがINSERTされてしまいます。

finisher_api.go
func (db *DB) Save(value interface{}) (tx *DB) {
	tx = db.getInstance()
	tx.Statement.Dest = value

	reflectValue := reflect.Indirect(reflect.ValueOf(value))
	for reflectValue.Kind() == reflect.Ptr || reflectValue.Kind() == reflect.Interface {
		reflectValue = reflect.Indirect(reflectValue)
	}

	switch reflectValue.Kind() {
 // 配列の場合
	case reflect.Slice, reflect.Array:
		if _, ok := tx.Statement.Clauses["ON CONFLICT"]; !ok {
			tx = tx.Clauses(clause.OnConflict{UpdateAll: true})
		}
		tx = tx.callbacks.Create().Execute(tx.Set("gorm:update_track_time", true))
  // 構造体の場合
  // プライマリーキーにあたるものが0ならCreateを実行
	case reflect.Struct:
		if err := tx.Statement.Parse(value); err == nil && tx.Statement.Schema != nil {
			for _, pf := range tx.Statement.Schema.PrimaryFields {
				if _, isZero := pf.ValueOf(tx.Statement.Context, reflectValue); isZero {
                    // Create実行
					return tx.callbacks.Create().Execute(tx)
				}
			}
		}

		fallthrough
	default:
   // プライマリーキーにあたるものが0でないならUpdateを実行
		selectedUpdate := len(tx.Statement.Selects) != 0
		// when updating, use all fields including those zero-value fields
		if !selectedUpdate {
			tx.Statement.Selects = append(tx.Statement.Selects, "*")
		}
        // Update実行
		updateTx := tx.callbacks.Update().Execute(tx.Session(&Session{Initialized: true}))

        // Updateの結果がエラーもしくは0件だったらCreateを実行する
		if updateTx.Error == nil && updateTx.RowsAffected == 0 && !updateTx.DryRun && !selectedUpdate {
			return tx.Session(&Session{SkipHooks: true}).Clauses(clause.OnConflict{UpdateAll: true}).Create(value)
		}

		return updateTx
	}

	return
}

知ってないとハマるポイント2

Updateの際、CreatedAtは値指定がなければゼロ値で更新、UpdatedAtは自動生成されるよ

Saveメソッドを利用してUpdateを実施する際、気を利かせてCreatedAtは更新対象外になり、UpdatedAtだけ更新されるのかな?みたいな期待を持っていましたが違いました。

CreatedAtは他のカラム同様更新対象のカラムとして扱われ、値がなければゼロ値がセットされます。UpdatedAtだけ特別扱いされる形です。

sample.go
type Sample struct {
	ID        int       `gorm:"primarykey" name:"ID"`
	CreatedAt time.Time
	UpdatedAt time.Time
}

func (e Sample) SaveSample(tx *gorm.DB) {

    // デバッグモードでSQLを出力するようにする
    tx = tx.Debug()

	tx.Create(&sample{ID: 1})
    // 生成されるSQL
    // INSERT INTO `samples` (`created_at`,`updated_at`,`id`) VALUES ('2024-07-26 05:30:45.075','2024-07-26 05:30:45.075',1)

	tx.Save(&sample{ID: 1})
    // 生成されるSQL
    // UPDATE `samples` SET `created_at`='0000-00-00 00:00:00',`updated_at`='2024-07-26 05:30:47.888' WHERE `id` = 1

    // ポイント:UPDATEの際はcreated_atがゼロ値でセット。updated_atは自動生成。

そもそもcreatedAtとupdatedAtをgorm側でどう判定しているのか

構造体でgormのタグを指定してないとうまく動作しないのでは?と思っていたが、以下の通りタグを指定しているもしくは、フィールド名が CreatedAt になっていて、それがTime型Int型Uint型だったら自動でCreatedAtの時刻を生成するよというのがわかった。

field.go

// 作成日時フィールドに対する処理
	if v, ok := field.TagSettings["AUTOCREATETIME"]; (ok && utils.CheckTruth(v)) || (!ok && field.Name == "CreatedAt" && (field.DataType == Time || field.DataType == Int || field.DataType == Uint)) {
		if field.DataType == Time {
			field.AutoCreateTime = UnixTime
		} else if strings.ToUpper(v) == "NANO" {
			field.AutoCreateTime = UnixNanosecond
		} else if strings.ToUpper(v) == "MILLI" {
			field.AutoCreateTime = UnixMillisecond
		} else {
			field.AutoCreateTime = UnixSecond
		}
	}

// 更新日時フィールドに対する処理
	if v, ok := field.TagSettings["AUTOUPDATETIME"]; (ok && utils.CheckTruth(v)) || (!ok && field.Name == "UpdatedAt" && (field.DataType == Time || field.DataType == Int || field.DataType == Uint)) {
		if field.DataType == Time {
			field.AutoUpdateTime = UnixTime
		} else if strings.ToUpper(v) == "NANO" {
			field.AutoUpdateTime = UnixNanosecond
		} else if strings.ToUpper(v) == "MILLI" {
			field.AutoUpdateTime = UnixMillisecond
		} else {
			field.AutoUpdateTime = UnixSecond
		}
	}

 // ~
 // GORM time types
const (
	UnixTime        TimeType = 1
	UnixSecond      TimeType = 2
	UnixMillisecond TimeType = 3
	UnixNanosecond  TimeType = 4
)

フィールド名が CreatedAt なら自動で日時を生成。
フィールド名が CreatedAt でない場合でも、フィールドにタグをつけることで自動で日時生成対象にすることも可能。
https://gorm.io/ja_JP/docs/models.html#%E4%BD%9C%E6%88%90%E3%83%BB%E6%9B%B4%E6%96%B0%E6%97%A5%E6%99%82%E3%81%AE%E3%83%88%E3%83%A9%E3%83%83%E3%82%AD%E3%83%B3%E3%82%B0%EF%BC%8FUnix-%E3%83%9F%E3%83%AA%E3%83%BB%E3%83%8A%E3%83%8E-%E7%A7%92%E3%81%A7%E3%81%AE%E3%83%88%E3%83%A9%E3%83%83%E3%82%AD%E3%83%B3%E3%82%B0

sample.go

// gormは自動でcreatedAtの日付を生成
type sampleA struct {
  ID int
  CreatedAt time.Time
}

// gormは自動でcreatedAtの日付を生成
type sampleB struct {
  ID int
  CreatedTestTime time.Time `gorm:"autoCreateTime"`
}

SaveメソッドからUpdateを実施する際の処理はどうなってる?

update.go
func Update(config *Config) func(db *gorm.DB) {
	supportReturning := utils.Contains(config.UpdateClauses, "RETURNING")

	return func(db *gorm.DB) {
		if db.Error != nil {
			return
		}

		if db.Statement.Schema != nil {
			for _, c := range db.Statement.Schema.UpdateClauses {
				db.Statement.AddClause(c)
			}
		}

		if db.Statement.SQL.Len() == 0 {
			db.Statement.SQL.Grow(180)
			db.Statement.AddClauseIfNotExists(clause.Update{})

   
			if _, ok := db.Statement.Clauses["SET"]; !ok {
                // この関数でクエリを組み立てています!!!
				if set := ConvertToAssignments(db.Statement); len(set) != 0 {
					defer delete(db.Statement.Clauses, "SET")
					db.Statement.AddClause(set)
				} else {
					return
				}
			}

			db.Statement.Build(db.Statement.BuildClauses...)
		}

// ~

  • ConvertToAssignments関数
    ここでは、UpdatedAtのフィールドには自動で値をセットすること。
    CreatedAtを対象から除外するなどのロジックは入っていないことを確認できます。
    (※SelectやOmitで指定・除外していれば除外されます)
    https://github.com/go-gorm/gorm/blob/master/callbacks/update.go#L131
ConvertToAssignments
// ~

		switch updatingValue.Kind() {
		case reflect.Struct:
			set = make([]clause.Assignment, 0, len(stmt.Schema.FieldsByDBName))
			for _, dbName := range stmt.Schema.DBNames {
				if field := updatingSchema.LookUpField(dbName); field != nil {
					if !field.PrimaryKey || !updatingValue.CanAddr() || stmt.Dest != stmt.Model {
     
                        // フィールド名がupdatedAtのとき field.AutoUpdateTImeが1になり、自動生成の値がセットされます!!
						if v, ok := selectColumns[field.DBName]; (ok && v) || (!ok && (!restricted || (!stmt.SkipHooks && field.AutoUpdateTime > 0))) {
							value, isZero := field.ValueOf(stmt.Context, updatingValue)
							if !stmt.SkipHooks && field.AutoUpdateTime > 0 {
								if field.AutoUpdateTime == schema.UnixNanosecond {
									value = stmt.DB.NowFunc().UnixNano()
								} else if field.AutoUpdateTime == schema.UnixMillisecond {
									value = stmt.DB.NowFunc().UnixNano() / 1e6
								} else if field.AutoUpdateTime == schema.UnixSecond {
									value = stmt.DB.NowFunc().Unix()
								} else {
                                    // ここで自動生成された値がupdatedAtにセットされる
									value = stmt.DB.NowFunc()
								}
								isZero = false
							}

							if (ok || !isZero) && field.Updatable {
                                // ここでupdateのset対象を追加していく
								set = append(set, clause.Assignment{Column: clause.Column{Name: field.DBName}, Value: value})
								assignField := field
								if isDiffSchema {
									if originField := stmt.Schema.LookUpField(dbName); originField != nil {
										assignField = originField
									}
								}
								assignValue(assignField, value)
							}
						}
					} else {
						if value, isZero := field.ValueOf(stmt.Context, updatingValue); !isZero {
							stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.Eq{Column: field.DBName, Value: value}}})
						}
					}
				}
			}
		default:

SaveメソッドでCreatedAtの更新を除外したいときは?

Omitを使おう

sample.go
type Sample struct {
	ID        int       `gorm:"primarykey" name:"ID"`
	CreatedAt time.Time
	UpdatedAt time.Time
}

func (e Sample) SaveSample(tx *gorm.DB) {

    // デバッグモードでSQLを出力するようにする
    tx = tx.Debug()

	tx.Create(&sample{ID: 1})
    // 生成されるSQL
    // INSERT INTO `samples` (`created_at`,`updated_at`,`id`) VALUES ('2024-07-26 05:30:45.075','2024-07-26 05:30:45.075',1)

	tx.Omit("CreatedAt").Save(&sample{ID: 1})
    // 生成されるSQL
    // UPDATE `samples` SET `updated_at`='2024-07-26 06:20:45.558' WHERE `id` = 1

    // ポイント:UPDATEの際はOmitを使うことでcreated_atの更新を除外できた

感想

便利だけど、思わぬ挙動とかでハマることもあるかも。
UpsertはSaveメソッドではなく、Create or Updatesなど自前で実装したほうが安全かもしれない。
→SaveでUpdateする際は事前にレコードチェックしてやれば安全かも。

GORMを使って以下を実現したいときは、結局Updatesでマップを渡すのがいいのかな・・・
Saveメソッドはシンプルに実装できるのが利点だけど、今回のようなハマるくらいならUpdatesを使って構造体をマップに変換して実行するほうがよいのかも?
→ただし構造体をmapに変換するのはリフレクションを使ったりする必要がありそうなのでちょと抵抗あり。。

  • ゼロ値も更新したい
  • UpdatedAtを更新したい

関連 GORM Updateメソッド一覧
https://qiita.com/yukiyoshimura/items/a6fa5ac9ff1bd8d03be5

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1