NaaN日記

やったこと、覚えたことを発信する場

GORMのUpdatesに構造体を渡したらupdated_atが自動で埋まらなかったので実装を見に行ってみた

この記事は「はてなエンジニア Advent Calendar 2024」の34日目向けに書きました。

developer.hatenastaff.com

昨日は、id:koudenpa さんのCloudWatchメトリクスの歩き方 - koudenpaのブログでした。
メトリクスは時々見てますが、よく迷子になりながら探しているので、参考にできそうです。
エルデンリングがどこまで進んだのかも、楽しみですね。


こんにちは。id:CNaan です。普段はPerlを書いていて、ORMapperはTeng - very simple DBI wrapper/ORMapper - metacpan.orgを使っています。

Perlの開発では、レコードの更新のためupdateメソッドを呼び出すとき、「updated_atが指定されていなかったら、updated_atに現在時刻を自動で入力する」という処理を入れています。
そのため、普段の開発では、updated_atを意識していません。
MySQLでの ON UPDATE CURRENT_TIMESTAMP 句は使っていません。

// userテーブルは、id, name, created_at, updated_atを持ちます。
$teng->update('user',
    {
        name => 'new_name',
        // updated_atは指定していない
    },
    {
        id => 1
    }
);

そこで、同じ処理をGORMでやろうと思って、次のようなUPDATEを書きました。

type UpdatingName struct {
	Name string
}

func main() {
        // 〜〜 DBとの接続などは省略 〜〜

        // 更新するカラムを構造体に持たせることで制限しようとした
	updatingName := UpdatingName{
		Name: "new_name",
	}

	db.Table("users").Where("id = ?", 1).Updates(updatingName)
}

これで、ユーザー名が変更されたことを確認して開発を完了した後で、
ふと、「あれ、updated_at、古くね?」と気づきました。

テストでも、名前が変わっている、などは確認していましたが、updated_atが新しくなっている、は確認していませんでした。

GORMでupdated_atを更新するには

こうして、社内で、「updated_at、自動的に埋まるものだと思ってました〜」と話をしていたところ、
「GORMだとCreatedAt, UpdatedAtを自動で更新してくれるはずだけど……」という話になりました。
gorm.io


さて、ではどうすればUpdatedAtが自動で埋まるのでしょうか?

早速ですが、上記の記事には、

GORMの規約では、作成/更新時間をトラッキングするのに CreatedAt, UpdatedAt を使用します。それらのフィールドがモデルに定義されている場合、作成/更新時間に現在時刻を値としてセットします。

とあります。

type UpdatingName struct {
	Name string
	UpdatedAt time.Time
}

したがって、モデルにUpdatedAtを持たせると更新できそうです。
ただ、これだけだと exhaustruct のlinterの設定があった場合に引っかかってしまうので、

	updatingName := UpdatingName{
		Name: "new_name",
		UpdatedAt: time.Time{},
	}

UpdatedAtにゼロ値となるような構造体を渡す必要があります。
しかし、毎回UpdatedAtをモデルに持たせないといけないとなると、どこかで忘れそうですね。

実装を見に行ってみる

他の方法を探すため、実際のGORMのソースコードから、UpdatedAtに関係する処理を探してみます。

	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
		}
	}

https://github.com/go-gorm/gorm/blob/f482f25c714b67562af1d487a669d80a527963b3/schema/field.go#L304-L314

これを見ると、以下のどちらかを満たしている場合、AutoUpdateTimeが埋まりそうです。

  • フィールドにAUTOUPDATETIMEのタグが設定されている
  • タグはないが、フィールドの名前がUpdatedAtであり、DataTypeがTimeまたはIntまたはUintである

フィールドの名前はUpdatedAtなので、この条件は満たしていそうです。

さらに実装を見ていきます。
構造体の場合、update.goで処理されるようですが……どうやら、渡された構造体の中にupdatedAtがあるかを見ているので、構造体の要素として持っていないと自動で更新してくれないようです。

    // stmt.Schema.DBNames, updatingSchemaどちらも渡された構造体のカラム名の要素しか持っていない。
    for _, dbName := range stmt.Schema.DBNames {
        if field := updatingSchema.LookUpField(dbName); field != nil {

https://github.com/go-gorm/gorm/blob/f482f25c714b67562af1d487a669d80a527963b3/callbacks/update.go#L260-L297

おっと、DBNamesが構造体のカラムしか持っていなかったのは、Tableを使っていたからのようでした。
Tableの代わりに、Modelを用意してみます。

    type User struct {
	ID          uint
	Name        string
	CreatedAt   time.Time
	UpdatedAt   time.Time
    }

    func main() {
        // 省略
        user := User{
		ID: 1,
		Name: "cnaan",
		CreatedAt: time.Now().Add(time.Hour * -2),
		UpdatedAt: time.Now().Add(time.Hour * -2),
	}
        db.Model(&user).Where("id = ?", 1).Updates(updatingName)
    }

Modelを使うようにすると、stmt.Schemaおよびstmt.Schema.DBNamesは、そのテーブルのモデルのカラムを全て持ちます。
ただ、updatingSchemaは渡されたnameのみの構造体なので、updated_atはif field := updatingSchema.LookUpField(dbName)の条件が false となり、やはりupdated_atの自動更新はできなさそうです。

map[string]interface{}を使う

構造体の場合に、updated_atを自動で埋めることは難しいとわかったので、他の方法も確認します。

レコードの更新 | GORM - The fantastic ORM library for Golang, aims to be developer friendly. によると、Updatesには、構造体だけでなく、 map[string]interface{}も渡すことができるようなので、その場合も見てみます。


map[string]interface{}の場合は、ここで処理されそうです。
https://github.com/go-gorm/gorm/blob/f482f25c714b67562af1d487a669d80a527963b3/callbacks/update.go#L192-L246


構造体の場合同様に、Table名を指定するパターンだと、updated_atは更新されませんでした。
stmt.Schemanilになるので、AutoUpdateTimeの処理の前に打ち切られてしまいます。

db.Table("users").Where("id = ?", 1).Updates(map[string]interface{}{"name": "new_name"})

ゼロ値を渡すとどうでしょうか?

db.Table("users").Where("id = ?", 1).Updates(map[string]interface{}{"name": "new_name", "updated_at":time.Time{}})
// Error 1292 (22007): Incorrect datetime value: '0000-00-00' for column 'updated_at' at row 1

エラーになりますね。

Modelを使うことにします。

db.Model(&user).Where("id = ?", 1).Updates(map[string]interface{}{"name": "new_name"})

stmt.Schema に値が埋まりました。カラムも全て揃っています。

&schema.Schema{
  Name:      "User",
  // 省略
  DBNames: []string{
    "id",
    "name",
    "created_at",
    "updated_at",
  },
}

AutoUpdateTimeも、1になっていますね。

&schema.Field{
    Name:      "UpdatedAt",
    DBName:    "updated_at",
    BindNames: []string{
      "UpdatedAt",
    },
    EmbeddedBindNames: []string{
      "UpdatedAt",
    },
    DataType:               "time",
    GORMDataType:           "time",
    PrimaryKey:             false,
    AutoIncrement:          false,
    AutoIncrementIncrement: 1,
    Creatable:              true,
    Updatable:              true,
    Readable:               true,
    AutoCreateTime:         0,
    AutoUpdateTime:         1,
    // 省略
}

どうやら、Modelを使うことで、Modelが持っているupdatedAtがhttps://github.com/go-gorm/gorm/blob/f482f25c714b67562af1d487a669d80a527963b3/schema/field.go#L304-L314のモデルのParse処理を通過するようですね。

あとは、map[string]interface{}の場合は、両方Modelが持っているSchema(stmt.Schema)を見ているようです。
structの場合は`updatingSchema`のLookUpFieldを見ていたので、そこが大きく処理の違いとなっていそうですね。

    for _, dbName := range stmt.Schema.DBNames {
        field := stmt.Schema.LookUpField(dbName)

https://github.com/go-gorm/gorm/blob/f482f25c714b67562af1d487a669d80a527963b3/callbacks/update.go#L227-L228

したがって、updated_atを意識せずにUpdatesを書きたい場合には、Modelを持たせてmap[string]interface{}を使うのが一番良さそうです。

db.Model(&user).Where("id = ?", 1).Updates(map[string]interface{}{"name": "new_name"})


結果の確認には、k0kubun/pp の結果を貼り付けました。
PerlのDDP(Data::Printer)と似た感じで出せるので、便利ですね。
github.com

また、初めて読むコードは、Copilotに聞いてみるのも便利ですね。
ヒントをもらえることがあります。

CopilotにUpdatedAtを自動で埋める実装をどこでやっているか尋ねる様子


では、自分は先月手に入れた逆転検事の続きをやって、事件を解決してきます。

はてなのカレンダーは、明日もまだ続く予定です。
はてなエンジニア Advent Calendar 2024 - Hatena Developer Blog