この記事はテックタッチアドベントカレンダー3日目の記事です。2日目は国定による「TypeScript 4.1 に更新しました」でした。
SREチームの taisa です。「愛の不時着」をきっかけに韓国ドラマにハマっています。最近は「スタートアップ」と「梨泰院クラス」をみています。Netflix な毎日です。
今回は「GORM v2 Major Features 編」ということで、今年8月にリリースされた GORM v2 の新機能をリリースノートに従って簡単に動かしてみたので紹介します。テックタッチのバックエンドは Go で実装していて ORM には GORM v1 を利用しています。v2 の Major Features と Breaking Changes をチェックして v2 へのアップデートも検討したいところです。
- 公式ドキュメント
- GORM v2 リリースノート
- v1 のファイル構成
- v2 のファイル構成
- Goland の設定
- 動作確認用のモデル定義
- Major Features
- Context Support
- Batch Insert
- Prepared Statement Mode
- DryRun Mode
- Join Preload
- Find To Map ※ 実行できず
- Create From Map
- FindInBatches
- Nested Transaction
- SavePoint, RollbackTo
- Named Argument
- Group Conditions
- SubQuery
- Upsert
- Locking
- Optimizer/Index/Comment Hints
- Field permissions
- Track creating/updating time/unix (milli/nano) seconds for multiple fields
- Naming Strategy
- Logger
- Transaction Mode
- DataTypes (JSON as example)
- Smart Select
- Associations Batch Mode
- Delete Associations when deleting
- 今回動作確認できなかった新機能
- まとめ
公式ドキュメント
GORM は Golang の ORM ライブラリです。公式ドキュメントは、メインが v2 になり v1 のドキュメントはサブドメインになりました。
GORM v2 リリースノート
GORM v2 のリリースノートはこちらです。これを見れば大体想像できる人も多いと思いますが、本記事は実際に動かして出力されるSQLを確認することを一番の目的としています。
v1 のファイル構成
Major Features を確認する前に v1 と v2 のファイル構成を比べてみました。確認した v1 のタグバージョンは v1.9.16 です。_test.go とテスト系ファイル除くファイル数は31ファイルでした。
. ├── association.go ├── callback.go ├── callback_create.go ├── callback_delete.go ├── callback_query.go ├── callback_query_preload.go ├── callback_row_query.go ├── callback_save.go ├── callback_update.go ├── dialect.go ├── dialect_common.go ├── dialect_mysql.go ├── dialect_postgres.go ├── dialect_sqlite3.go ├── dialects │ ├── mssql │ │ └── mssql.go │ ├── mysql │ │ └── mysql.go │ ├── postgres │ │ └── postgres.go │ └── sqlite │ └── sqlite.go ├── errors.go ├── field.go ├── interface.go ├── join_table_handler.go ├── logger.go ├── main.go ├── model.go ├── model_struct.go ├── naming.go ├── scope.go ├── search.go └── utils.go
v2 のファイル構成
v2 で確認したタグバージョンは v1.20.5 です。v1 と比べてパッケージが多く切られているのがわかります。_test.go とテスト系ファイル除いたファイル数も56ファイルと増えました。スクラッチで作り直したようで、ファイル一覧を見ただけでも大分整理された印象があります。
. ├── association.go ├── callbacks │ ├── associations.go │ ├── callbacks.go │ ├── callmethod.go │ ├── create.go │ ├── delete.go │ ├── helper.go │ ├── interfaces.go │ ├── preload.go │ ├── query.go │ ├── raw.go │ ├── row.go │ ├── transaction.go │ └── update.go ├── callbacks.go ├── chainable_api.go ├── clause │ ├── clause.go │ ├── delete.go │ ├── expression.go │ ├── from.go │ ├── group_by.go │ ├── insert.go │ ├── joins.go │ ├── limit.go │ ├── locking.go │ ├── on_conflict.go │ ├── order_by.go │ ├── returning.go │ ├── select.go │ ├── set.go │ ├── update.go │ ├── values.go │ ├── where.go │ └── with.go ├── errors.go ├── finisher_api.go ├── gorm.go ├── interfaces.go ├── logger │ ├── logger.go │ ├── sql.go ├── migrator │ └── migrator.go ├── migrator.go ├── model.go ├── prepare_stmt.go ├── scan.go ├── schema │ ├── check.go │ ├── field.go │ ├── index.go │ ├── interfaces.go │ ├── naming.go │ ├── relationship.go │ ├── schema.go │ ├── utils.go ├── soft_delete.go ├── statement.go └── utils └── utils.go
Goland の設定
v1 ではインポートパスが github.com/jinzhu/gorm でしたが、v2 では gorm.io/gorm に変わりました。それに伴い Goland の設定を変更する必要がありました。Preferences > Go > Go Modules を選択し「Enable Go Modules integration」にチェックが入っていればOKです。
赤くなってしまう。
Preferences > Go > Go Modules を選択し「Enable Go Modules integration」にチェック
動作確認用のモデル定義
動作確認する為に以下のモデルを定義しました。今回 SQLite と MySQL で動作確認をしましたが、本記事に記載している SQLは MySQL のものです。
type Company struct { ID int `gorm:"primaryKey"` Name string // 新機能の動作確認用カラム // Track creating/updating time/unix (milli/nano) seconds for multiple fields CreatedAt time.Time // Set to current time if it is zero on creating UpdatedAt int // Set to current unix seconds on updating or if it is zero on creating Updated int64 `gorm:"autoUpdateTime:nano"` // Use unix Nano seconds as updating time Updated2 int64 `gorm:"autoUpdateTime:milli"` // Use unix Milli seconds as updating time Created int64 `gorm:"autoCreateTime"` // Use unix seconds as creating time } type User struct { ID int `gorm:"primaryKey"` CompanyID int Company Company Name string Address string Age int CreditCard CreditCard // 新機能の動作確認用カラム Attributes datatypes.JSON } type CreditCard struct { ID int `gorm:"primaryKey"` UserID int Number string }
Major Features
前置きが長くなりましたが、Major Features を確認していきます。 数が多いのでさらっといきます。
Context Support
WithContext
でコンテキストが渡せるようになりました。
var user model.User db.WithContext(ctx).Find(&user) // SELECT * FROM `users`
Batch Insert
バッチインサートが可能になりました。v1 だと別のライブラリを使うか SQL を書くしかなかったのでうれしいアップデートです。これは v1 にも入れられないのか?と思ったりします。
var users = []model.User{ {Name: "test", Address: "address", Age: 20, CompanyID: 1}, {Name: "test2", Address: "address2", Age: 30, CompanyID: 1}, {Name: "test3", Address: "address3", Age: 40, CompanyID: 1}, } db.Create(&users) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'test','address',20),(1,'test2','address2',30),(1,'test3','address3',40)
Prepared Statement Mode
Prepared Statement モードが使えるようになりました。DB Open 時のコンフィグでも ON にできます。
var user model.User tx := db.Session(&gorm.Session{ PrepareStmt: true, }) tx.First(&user) // SELECT * FROM `users` ORDER BY `users`.`id` LIMIT 1
DryRun Mode
DryRun モードで SQL が確認可能になりました。SQL ログにも出力されますがstmt.SQL.String()
で出力することも可能です。
var user model.User stmt := db.Session(&gorm.Session{DryRun: true}).Find(&user, 1).Statement fmt.Println(stmt.SQL.String()) // SELECT * FROM `users` WHERE `users`.`id` = ?
Join Preload
db.Joins("Company").Find
のように書くことで Join Preload が可能になりました。
Preload associations using INNER JOIN, and will handle null data to avoid failing to scan
リリースノートをみるとINNER JOIN
と書いてありますが、書き方が悪いのか出力された SQL は LEFT JOIN
になっています。(詳細追えられてません)
var users []model.User db.Joins("Company").Find(&users, "users.id IN ?", []int{1, 2}) b, _ := json.Marshal(&users) fmt.Println(string(b)) // SELECT `users`.`id`,`users`.`company_id`,`users`.`name`,`users`.`address`,`users`.`age`,`Company`.`id` AS `Company__id`,`Company`.`name` AS `Company__name` FROM `users` LEFT JOIN `companies` `Company` ON `users`.`company_id` = `Company`.`id` WHERE users.id IN (1,2)
ちなみに json で出力すると下記となります。
[ { "ID": 1, "CompanyID": 1, "Company": { "ID": 1, "Name": "company name" }, "Name": "test", "Address": "address", "Age": 20 }, { "ID": 2, "CompanyID": 1, "Company": { "ID": 1, "Name": "company name" }, "Name": "test2", "Address": "address2", "Age": 30 } ]
Find To Map ※ 実行できず
結果を Map に入れられるとのことでしたが、これについては SQLite と MySQL 両方ともエラーが発生して実行できませんでした。
var result map[string]interface{} db.Model(&model.User{}).First(&result, "id = ?", 1)
エラー内容
panic: assignment to entry in nil map goroutine 1 [running]: gorm.io/gorm.scanIntoMap(0x0, 0xc000090900, 0x6, 0x6, 0xc0000908a0, 0x6, 0x6) /Users/tt-dev/go/pkg/mod/gorm.io/gorm@v1.20.5/scan.go:40 +0x285 gorm.io/gorm.Scan(0xc0000ae300, 0xc0000b0b40, 0xc00001a000) /Users/tt-dev/go/pkg/mod/gorm.io/gorm@v1.20.5/scan.go:72 +0x445 gorm.io/gorm/callbacks.Query(0xc0000b0b40) /Users/tt-dev/go/pkg/mod/gorm.io/gorm@v1.20.5/callbacks/query.go:34 +0x4ae gorm.io/gorm.(*processor).Execute(0xc0001aaa80, 0xc0000b0b40) /Users/tt-dev/go/pkg/mod/gorm.io/gorm@v1.20.5/callbacks.go:105 +0x5d4 gorm.io/gorm.(*DB).First(0xc0000b0b40, 0x45c6c60, 0xc0000b81d8, 0xc0000928c0, 0x2, 0x2, 0xc0000b0b40) /Users/tt-dev/go/pkg/mod/gorm.io/gorm@v1.20.5/finisher_api.go:75 +0x358 main.FindToMap() /Users/tt-dev/go/src/github.com/taisa831/sandbox-gorm2/main.go:138 +0x1cb main.main() /Users/tt-dev/go/src/github.com/taisa831/sandbox-gorm2/main.go:51 +0x25e Exiting.
Create From Map
Map からインサートが可能になりました。
db.Model(&model.User{}).Create(map[string]interface{}{"Name": "test", "Address": "address", "Age": 50, "CompanyID": 1}) // INSERT INTO `users` (`address`,`age`,`company_id`,`name`) VALUES ('address',50,1,'test') datas := []map[string]interface{}{ {"Name": "test", "Address": "address1", "Age": 60, "CompanyID": 1}, {"name": "test", "Address": "address2", "Age": 70, "CompanyID": 1}, } db.Model(&model.User{}).Create(datas) // INSERT INTO `users` (`address`,`age`,`company_id`,`name`) VALUES ('address1',60,1,'test'),('address2',70,1,'test')
FindInBatches
FindInBatches
で逐次処理が可能になりました。件数が膨大なデータを処理するのに便利。
var users []model.User err := db.Where("id >= ?", 1).FindInBatches(&users, 2, func(tx *gorm.DB, batch int) error { for _, user := range users { fmt.Println(user.ID) } return nil }) if err.Error != nil { fmt.Println(err.Error.Error()) } // SELECT * FROM `users` WHERE id >= 1 LIMIT 2 // SELECT * FROM `users` WHERE id >= 1 LIMIT 2 OFFSET 2 // ・・・
Nested Transaction
トランザクションがネスト可能になりました。サンプルでは、user1 作成後 user2 の作成時に失敗しますが、user2 だけロールバックされ、次の処理に進み、user3 が作成されて処理が終わります。
db.Transaction(func(tx *gorm.DB) error { user1 := model.User{ Name: "name", Address: "address", } tx.Create(&user1) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'name','address',10) // SAVEPOINT sp0x4493200 tx.Transaction(func(tx2 *gorm.DB) error { user2 := model.User{ Name: "name", Address: "address", } tx.Create(&user2) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'name','address',10) return errors.New("rollback user2") // rollback user2 }) // ROLLBACK TO SAVEPOINT sp0x4493200 // SAVEPOINT sp0x4493340 tx.Transaction(func(tx2 *gorm.DB) error { user3 := model.User{ Name: "name", Address: "address", } // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'name','address',10) tx.Create(&user3) return nil }) return nil // commit user1 and user3 })
SavePoint, RollbackTo
トランザクション中でSavePoint
を置くことで途中で処理が失敗しても特定のSavePoint
までロールバックできます。サンプルでは、user1 と user2 を作成後ロールバックしていますが、user1 作成後のSavePoint
までロールバックされているので user1 は作成されます。
tx := db.Begin() user1 := model.User{ Name: "name1", Address: "address1", } tx.Create(&user1) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'name1','address1',10) // SAVEPOINT sp1 tx.SavePoint("sp1") user2 := model.User{ Name: "name2", Address: "address2", } tx.Create(&user2) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES (1,'name2','address2',10) // ROLLBACK TO SAVEPOINT sp1 tx.RollbackTo("sp1") // rollback user2 tx.Commit()
Named Argument
Where句の値に名前をつけることが可能になりました。同じ値を沢山セットする必要があるときに使えそう。
var user model.User db.Where("name1 = @name OR name2 = @name", sql.Named("name", "test")).Find(&user) // SELECT * FROM `users` WHERE name1 = "test" OR name2 = "test" var user2 model.User db.Where("name1 = @name OR name2 = @name", map[string]interface{}{"name": "test"}).First(&user2) // SELECT * FROM `users` WHERE name1 = "test" OR name2 = "test" ORDER BY `users`.`id` LIMIT 1 db.Raw( "SELECT * FROM users WHERE name1 = @name OR name2 = @name2 OR name3 = @name", sql.Named("name", "test"), sql.Named("name2", "test"), ).Find(&model.User{}) // SELECT * FROM users WHERE name1 = "test" OR name2 = "test" OR name3 = "test" db.Exec( "UPDATE users SET name1 = @name, name2 = @name2, name3 = @name", map[string]interface{}{"name": "test", "name2": "test"}, // UPDATE users SET name1 = "test", name2 = "test", name3 = "test"
Group Conditions
db.Where
をネストしたりOr
を使って複雑な Where句の組み立てが可能になりました。
var user model.User db.Where( db.Where("name = ?", "test").Where(db.Where("address = ?", "address").Or("company = ?", 1)), ).Or( db.Where("name = ?", "test2").Where("address = ?", "address"), ).Find(&user) // SELECT * FROM `users` WHERE (name = 'test' AND (address = 'address' OR company = 1)) OR (name = 'test2' AND address = 'address')
SubQuery
様々なタイプのサブクエリが書けるようになりました。
// Where SubQuery var user model.User db.Where("age > (?)", db.Table("users").Select("AVG(age)")).Find(&user) // SELECT * FROM `users` WHERE age > (SELECT AVG(age) FROM `users`) // From SubQuery db.Table("(?) as u", db.Model(&model.User{}).Select("name", "age")).Where("age = ?", 18).Find(&user) // SELECT * FROM (SELECT `name`,`age` FROM `users`) as u WHERE age = 20 // Update SubQuery db.Model(&model.User{}).Update( "age", db.Model(&model.Company{}).Select("age").Where("companies.id = users.company_id"), ) // UPDATE `users` SET `name`=(SELECT `name` FROM `companies` WHERE companies.id = users.company_id)
Upsert
Upsert が可能になりました。便利。
var users []model.User db.Clauses(clause.OnConflict{DoNothing: true}).Create(&users) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES ON DUPLICATE KEY UPDATE `id`=`id` db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.Assignments(map[string]interface{}{"name": "test", "address": "address", "age": 20, "company_id": 1}), }).Create(&users) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES ON DUPLICATE KEY UPDATE `address`='address',`age`=20,`company_id`=1,`name`='test' db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "address", "age", "company_id"}), }).Create(&users) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`) VALUES ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`address`=VALUES(`address`),`age`=VALUES(`age`),`company_id`=VALUES(`company_id`)
Locking
FOR UPDATE
が実行可能になりました。
db.Clauses(clause.Locking{Strength: "UPDATE"}).Find(&model.User{}) // SELECT * FROM `users` FOR UPDATE db.Clauses(clause.Locking{ Strength: "SHARE", Table: clause.Table{Name: clause.CurrentTable}, }).Find(&model.User{}) // SELECT * FROM `users` FOR SHARE OF `users`
Optimizer/Index/Comment Hints
様々な Hints が書けるようになりました。(自分は使ったことがありません。)
import "gorm.io/hints" // Optimizer Hints db.Clauses(hints.New("hint")).Find(&model.User{}) // SELECT /*+ hint */ * FROM `users` // Index Hints db.Clauses(hints.UseIndex("idx_user_name")).Find(&model.User{}) // SELECT * FROM `users` USE INDEX (`idx_user_name`) // Comment Hints db.Clauses(hints.Comment("select", "master")).Find(&model.User{}) // SELECT /* master */ * FROM `users`
Field permissions
カラム別に読み取り専用、作成専用などの指定が可能になりました。すごいけど使いどころが難しそう。
type User struct { Name string `gorm:"<-:create"` // allow read and create Name string `gorm:"<-:update"` // allow read and update Name string `gorm:"<-"` // allow read and write (create and update) Name string `gorm:"->:false;<-:create"` // createonly Name string `gorm:"->"` // readonly Name string `gorm:"-"` // ignored }
Track creating/updating time/unix (milli/nano) seconds for multiple fields
日付で unix time が使えるようになりました。
type Company struct { ID int `gorm:"primaryKey"` Name string `gorm:""` CreatedAt time.Time // Set to current time if it is zero on creating UpdatedAt int // Set to current unix seconds on updating or if it is zero on creating Updated int64 `gorm:"autoUpdateTime:nano"` // Use unix Nano seconds as updating time Updated2 int64 `gorm:"autoUpdateTime:milli"` // Use unix Milli seconds as updating time Created int64 `gorm:"autoCreateTime"` // Use unix seconds as creating time }
実際にインサートしたときの値はこちらです。
id: 2 name: company name created_at: 2020-12-02 20:58:28.746 updated_at: 1606910308 updated: 1606910308746117000 updated2: 1606910308746 created: 1606910308
Naming Strategy
テーブル名やカラム名などの名前に命名規則が指定可能になりました。
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}, Logger: newLogger, })
下記は SQLite で実行してみた結果です。
sqlite> .tables t_company t_credit_card t_user
Logger
ロガーの更新内容です。サイトの内容をそのまま引用しました。
- Context support
- Customize/turn off the colors in the log
- Slow SQL log, default slow SQL time is 100ms
- Optimized the SQL log format so that it can be copied and executed in a database console
https://gorm.io/docs/v2_release_note.html#Logger
Transaction Mode
デフォルトで GORM 書き込みはトランザクション内で実行されるようです。初期化時にトランザクションモードを無効にしておくことで、トランザクションが必要なときだけトランザクションを利用することになるので高速化がはかれます。
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{ SkipDefaultTransaction: true, })
DataTypes (JSON as example)
データタイプに json が使えるようになりました(SQLite, MySQL, Postgres)。json の検索もできて便利。
import "gorm.io/datatypes" db.Create(&model.User{ Name: "user", CompanyID: 1, Address: "address", Age: 30, Attributes: datatypes.JSON([]byte(`{"name": "user", "age": 20, "tags": ["tag1", "tag2"], "orgs": {"orga": "orga"}}`)), }) // INSERT INTO `users` (`company_id`,`name`,`address`,`age`,`attributes`) VALUES (1,'user','address',30,'{"name": "user", "age": 20, "tags": ["tag1", "tag2"], "orgs": {"orga": "orga"}}') var user model.User db.First(&user, datatypes.JSONQuery("attributes").HasKey("name")) // SELECT * FROM `users` WHERE JSON_EXTRACT(`attributes`, '$.name') IS NOT NULL ORDER BY `users`.`id` LIMIT 1 db.First(&user, datatypes.JSONQuery("attributes").HasKey("orgs", "orga")) // SELECT * FROM `users` WHERE JSON_EXTRACT(`attributes`, '$.orgs.orga') IS NOT NULL AND `users`.`id` = 27 ORDER BY `users`.`id` LIMIT 1
Smart Select
struct
で select するカラムが指定可能になりました。
type APIUser struct { ID int Name string } db.Model(&model.User{}).Limit(10).Find(&APIUser{}) // SELECT `id`,`name` FROM `users` LIMIT 10
Associations Batch Mode
Association モードで一括処理が可能になりました。
userA := model.User{ID: 1} userB := model.User{ID: 3} users := []model.User{userA, userB} var creditCard model.CreditCard db.Model(&users).Association("CreditCard").Find(&creditCard) // SELECT * FROM `credit_cards` WHERE `credit_cards`.`user_id` IN (1,3) db.Model(&users).Association("CreditCard").Delete(&userA) // UPDATE `credit_cards` SET `user_id`=NULL WHERE `credit_cards`.`user_id` IN (1,3) AND `credit_cards`.`id` = 1 db.Model(&users).Association("CreditCard").Count() // SELECT count(1) FROM `credit_cards` WHERE `credit_cards`.`user_id` IN (1,3)
今回検証できませんでしたが、下記のような処理も可能になったようです。
// For `Append`, `Replace` with batch data, argument's length need to equal to data's length or will returns error var users = []User{user1, user2, user3} // e.g: we have 3 users, Append userA to user1's team, append userB to user2's team, append userA, userB and userC to user3's team db.Model(&users).Association("Team").Append(&userA, &userB, &[]User{userA, userB, userC}) // Reset user1's team to userA,reset user2's team to userB, reset user3's team to userA, userB and userC db.Model(&users).Association("Team").Replace(&userA, &userB, &[]User{userA, userB, userC})
Delete Associations when deleting
削除時に関連するレコードを削除してから対象レコードが削除可能になりました。サンプルでは user の credit card レコードを削除してから user レコードを削除しています。cascade でない場合、関連情報削除して回るのは面倒なのでかなり使えそう。
// delete user's credit card when deleting user user := model.User{ ID: 3, } db.Select("CreditCard").Delete(&user) // DELETE FROM `credit_cards` WHERE `credit_cards`.`user_id` = 3 // DELETE FROM `users` WHERE `users`.`id` = 3
今回動作確認できなかった新機能
以下の新機能は動かしてみるところまでいけなかったので、またのタイミングに個別に取り上げたいと思います。
CRUD From SQL Expr/Context Valuer
Multiple Databases, Read/Write Splitting
Prometheus
まとめ
長くなりましたが、GORM v2 の Major Features の動作確認紹介でした。本記事で作成したサンプルは GitHub にあげているので簡単に動かしてみたい方はみてみてください。ちなみに全く同じ実装を v1 で動かそうとしましたが、一つも動きませんでした。
自分は ORM の機能を使わず SQL を書いてしまいたくなることもあるのですが、ORM の使い方に慣れてしまえばメリットも大きいので、詳しくなっていきたいところです。また、折をみてコードリーディングをして内部理解もしたいなと思っています。
明日は terunuma による「プロダクト開発と事業へ成果を出せる SET になるために読んだ書籍・スライド」です!よろしく!