この記事は「はてなエンジニア Advent Calendar 2024」の34日目向けに書きました。
昨日は、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 } }
これを見ると、以下のどちらかを満たしている場合、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 {
おっと、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.Schema
がnilになるので、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)
〆
したがって、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に聞いてみるのも便利ですね。
ヒントをもらえることがあります。
では、自分は先月手に入れた逆転検事の続きをやって、事件を解決してきます。
はてなのカレンダーは、明日もまだ続く予定です。
はてなエンジニア Advent Calendar 2024 - Hatena Developer Blog