この記事は DeNA 23 新卒 Advent Calendar 2022 の18日目の記事です!
さまざまなジャンルの技術に関する記事が投稿されていくので, ぜひご覧ください!🎄🌟
https://qiita.com/advent-calendar/2022/dena-23-shinsotsu
背景
インターン業務のなかで, Go言語のORMであるGORMを使う機会がありました. その際に, GORMが高機能であるがゆえに, 個人的に次の2つの課題を感じていました.
- 「レコードを取得したい」, 「レコードを更新したい」などの目的に対し, 方法がいくつかあり, どれを使えばいいのかわからない
- DBに関する知識が乏しいため, ドキュメントにある説明だけでは使用方法がわからないことがある
本記事ではこれらの課題を解消すべく, GORMを使った基本的なCRUD操作の方法についてまとめました.
なお, 基本的に公式ドキュメントに沿った内容となっており, 細かい部分は公式ドキュメントの該当箇所を記載していますので, そちらをご参照ください.
個人的に実務を通して学んだ部分や, ドキュメントの説明を補充したような部分だけしっかり目に書いております.
CRUD操作するテーブルを作成しておく
今回は以下の構造で, users, companies, creditCards, posts, tagsテーブルを作成します.
なお, これらのテーブルは本記事でCRUD操作をおこなうためのサンプルであり, 実際の業務で使用しているものではございません.
type User struct {
ID uuid.UUID `gorm:"type:char(36);primary_key"`
Name string
Age uint
is_active bool
CompanyID uuid.UUID
Company Company
CreditCard CreditCard
Posts []Post
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Company struct {
ID uuid.UUID `gorm:"type:char(36);primary_key"`
Name string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type CreditCard struct {
ID uuid.UUID `gorm:"type:char(36);primary_key"`
Number string
UserID uuid.UUID `gorm:"type:char(36)"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Post struct {
ID uuid.UUID `gorm:"type:char(36);primary_key"`
Title string
Content string
UserID uuid.UUID `gorm:"type:char(36)"`
Tags []Tag `gorm:"many2many:post_tags;"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Tag struct {
ID uuid.UUID `gorm:"type:char(36);primary_key"`
Name string
Posts []Post `gorm:"many2many:post_tags;"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
GORMには構造体を定義する際に便利なgorm.Modelという構造体があります。gorm.Modelを埋め込むことで, ID, CreatedAt, UpdatedAt, DeletedAtフィールドを含めることができます.
今回は主キーをUUIDにしたかったのでgorm.Modelは使っていませんが, 例えばUserの構造体をgorm.Modelを使って以下のように定義することができます.
type User struct {
gorm.Model
Name string
Age uint
is_active bool
CompanyID uuid.UUID
Company Company
CreditCard CreditCard
Posts []Post
}
上記の構造体は下記のものと同義です.
type User struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Age uint
is_active bool
CompanyID uuid.UUID
Company Company
CreditCard CreditCard
Posts []Post
}
また, 各テーブルは以下のような関係になっています.
- Belong To
-
Userbelongs toCompany
-
- Has One
-
Userhas aCreditCard
-
- Has Many
-
Userhas manyPosts
-
- Many To Many
-
Posthas manyTags -
Taghas manyPosts
-
DBと接続
ここは公式ドキュメント通りに接続します.
func InitDB() *gorm.DB {
dsn := "root:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=true&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
db.AutoMigrate(
&User{},
&Company{},
&CreditCard{},
&Post{},
&Tag{},
)
return db
}
AutoMigrateを使うと, その名の通りスキーマ定義のマイグレーションを自動でおこなってくれます.
ただし, カラムの削除はおこなわないので, カラムを削除する場合はMigratorインタフェースに用意されているDropColumnで削除することができます.
また, Many To Manyの関係が存在する場合, 自動的に中間テーブル(今回の例ではPostsとTagsを結合するpost_tagsテーブル)を作成してくれます.
CRUD操作をしてみる
お試ししたいときに便利な機能
実際にDBの操作をおこなう前に, どんな実行がされるかをお試しする際に便利な機能をご紹介します. (私もこの記事を書く際にお世話になりました)
- DryRun
- 公式ドキュメント該当箇所
- 実行はせずにSQLの生成のみおこないます.
db.Session(&gorm.Session{DryRun: isDryRun}) - Debug
- 公式ドキュメント該当箇所
- 操作をデバッグし, 操作のログレベルをlogger.Infoに出力します.
db.Debug()
Select系
公式ドキュメント該当箇所
レコードの取得はFirst, Take, Last, Findメソッドを使っておこないます.
また, リレーションを跨ったレコードの取得にはPreloadメソッドを使います.
単一レコードの取得
単一レコードの取得はFirst, Take, Last, Findメソッドのどれを使っても可能ですが, Findメソッドとそれ以外のメソッドで若干振る舞いが異なります.
First, Take, Lastメソッドを使う場合
使い方の詳細は公式ドキュメントにある通りなので重要な部分しか説明しませんが, この3つのメソッドはLIMIT 1の条件をクエリに追加します. また, レコードが見つからなった場合にErrRecordNotFoundを返します.
ErrRecordNotFoundをハンドリングする場合は, errors packageのerrors.Isメソッドを使ってErrRecordNotFoundであるかをチェックできます.
レコードがないときにErrRecordNotFoundが発生することを避けたい場合は, 後述するFindメソッドとLIMIT 1を組み合わせることで, 単一レコードを取得しつつ, エラーを避けることができます.
以下はFirstメソッドを用いて単一のユーザーを取得するシンプルな例です.
user := User{}
// 最初のユーザーを取得
err := db.First(&user).Error
if err != nil {
// ErrRecordNotFoundであるかをチェック
if errors.Is(err, gorm.ErrRecordNotFound) {
fmt.Print("Select first user error: user not found")
return
}
fmt.Printf("Select first user error: %s", err.Error())
return
}
fmt.Printf("user name: %s", user.Name)
実行すると, 以下のようなSQLが生成されます.
主キーが定義されていれば, それを使ってソートされ, 定義されていない場合は一番最初に定義されたフィールドを使ってソートします.
また, 取得されたレコードはFirstメソッドに渡したモデルに代入されます. この例では「admin」という名前のユーザーが取得できていることがわかります.
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL ORDER BY `users`.`id` LIMIT 1
-- user name: admin
上記の例では主キー等の条件を指定していませんが, 次の3つの例のように主キーを指定することができます.
例1
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{ID: userID}
db.First(&user)
実行されるSQL
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' ORDER BY `users`.`id` LIMIT 1
例2
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{}
db.First(&user, "id = ?", userID)
実行されるSQL
SELECT * FROM `users` WHERE id = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' AND `users`.`deleted_at` IS NULL ORDER BY `users`.`id` LIMIT 1
例3(主キーが数値の場合)
主キーが数値の場合のみ, 下記のように主キーを指定できます.
(と, ドキュメントにはありますが, UUID等の文字列で試してもうまくいきそうでした...)
user := User{}
db.First(&user, 10)
Findメソッド
Findメソッドを用いた単一レコードの取得では, 主キーを指定するか, limit=1の条件を付与します.
主キーを指定する方法はFirst, Take, Lastメソッドで説明した方法と全く同じなので割愛します.
limit=1を指定する場合は以下のようにLimitメソッドを用います.
user := User{}
db.Limit(1).Find(&user)
文字列を扱う場合の注意
公式ドキュメント該当箇所
文字列を扱う場合は, SQLインジェクションを避けるために注意が必要です.
GORMはdatabase/sqlのプレースホルダ引数を使用してSQL文を構築し, 引数を自動的にエスケープしてくれています.
なので, ユーザーからの入力は引数としてのみ使用する必要があります.
以下は公式ドキュメントにある例です.
userInput := "jinzhu;drop table users;"
// エスケープされる
db.First(&user, "name = ?", userInput)
// SQLインジェクションが起こりうる
db.First(&user, fmt.Sprintf("name = %v", userInput))
単一レコードの取得まとめ
個人的には単一レコードを取得する場合はFisrtメソッドを使い, ErrRecordNotFoundを避けたい場合だけFindを使うと, ひと目で単一レコードを取得しようとしていることがわかるので, いいかと思いました.
複数レコードの取得
公式ドキュメント該当箇所
複数レコードの取得にはFindメソッドを用います. (単一レコードの例では必ず実行結果が単一になるような条件をFindメソッドに付与していました)
以下はusersテーブルを全件取得する例です.
users := []User{}
db.Find(&users)
条件を指定する
公式ドキュメント該当箇所
Whereメソッドを使うか, これまで紹介したメソッドにインラインで条件を付与することで, 取得条件を指定することができます.
いろいろな条件の指定方法があるので, 詳しくは公式ドキュメントを参照してください.
基本的な条件の指定方法
以下はname = user1かつis_active = trueに一致するUserのレコードを取得する例です.
Whereメソッドを使う場合
- 構造体で条件指定
users := []User{}
db.Where(&User{Name: "user1", IsActive: true}).Find(&users)
- マップで条件指定
users := []User{}
db.Where(map[string]interface{}{"name": "user1", "is_active": true}).Find(&users)
Findメソッドを使う場合
- 構造体で条件指定
users := []User{}
db.Find(&users, User{Name: "user1", IsActive: true})
- マップで条件指定
users := []User{}
db.Find(&users, map[string]interface{}{"name": "user1", "is_active": true})
Scopesを使って共通のロジックを切り出す
公式ドキュメント
Scopesを利用することで, 共通のロジックを切り出すことができます. 共有ロジックは func(*gorm.DB) *gorm.DBという型を満たすように定義します.
例えば非アクティブなユーザーを取得するロジックがいたるところで使用されている場合, 以下のようにロジックを切り出すことができます.
func InActiveUser(db *gorm.DB) *gorm.DB {
return db.Where("is_active = ?", false)
}
このように定義したScopeは次のように使用できます.
users := []User{}
db.Scopes(InActiveUser).Find(&users)
上記を実行すると, 以下のSQLが生成され, InActiveUserが利用できていることがわかります.
SELECT * FROM `users` WHERE is_active = false AND `users`.`deleted_at` IS NULL
条件を指定する際の注意
注意1
FindやFirstメソッドに渡すオブジェクトに主キーがセットされている時に, Whereメソッドにも主キーに関する条件を持たせてしまうと, AND条件でクエリが発行されてしまいます.
公式ドキュメントの例では, 下記が実行されたとき,
var user = User{ID: 10}
db.Where("id = ?", 20}.First(&user)
下記のようなSQLが生成されてしまいます.
SELECT * FROM users WHERE id = 10 and id = 20 ORDER BY id ASC LIMIT 1
Whereメソッドは主キー以外の条件を指定する際に使い, 主キーに関する条件を指定する場合はFindメソッドに付与したり, 構造体に持たせるようにするとよさそうです.
注意2
構造体使って条件を指定する場合, ゼロ値が指定されたフィールドは条件に含まれせん. (goにおけるゼロ値についてはこちら)
具体的には, is_active = falseのUserを取得したい場合, 以下のように構造体を用いて条件を指定しても, 生成されるSQLにis_active = falseが反映されません.
users := []User{}
db.Where(&User{IsActive: false}).Find(&users)
上記を実行すると, 以下のSQLが生成され, is_active = falseが反映が反映されていないことがわかります...
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL
これを回避するためには, ゼロ値を指定する際には構造体を使わないようにします.
以下はマップを用いた例です.
users := []User{}
db.Where(map[string]interface{}{"is_active": false}).Find(&users)
上記を実行すると以下のSQLが実行され, is_active = falseが無事反映されていることがわかります.
SELECT * FROM `users` WHERE `is_active` = false AND `users`.`deleted_at` IS NULL
以上の例ではWhereメソッドを例に説明していますが, Findメソッドを使った条件指定でも同様のことが起きます.
条件を指定する方法まとめ
条件を指定する際には上述した2点に注意が必要です.
また, 複雑な条件が発生するアプリケーションでは, 主キー以外の条件はWhereメソッドで指定し, 必要に応じてScopesを使ってロジックの再利用をすると可読性が上がるのではないかと思いました.
関連レコードの取得
公式ドキュメント該当箇所
「Userが複数のPostを持っている」というようなリレーションがあるレコードを取得する場合, Preloadメソッドを使うことでeager loadingができます.
以下は特定のUserと, そのユーザーが持つ複数のPostを取得する例です.
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{ID: userID}
db.Preload("Posts").First(&user)
上記を実行すると, 以下のSQLが実行されます.
先にPostが取得され, その後にUserが取得されていることがわかります.
SELECT * FROM `posts` WHERE `posts`.`user_id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' AND `posts`.`deleted_at` IS NULL
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' ORDER BY `users`.`id` LIMIT 1
Create系
単一レコードの作成
公式ドキュメント該当箇所
レコードの作成には基本的にCreateメソッドを用います.
名前がcompany1のCompanyを作成する場合は, 以下のように作成することができます.
company := Company{Name: "company1"}
err := db.Create(&company).Error
if err != nil {
fmt.Printf("Create company error: %s", err.Error())
return
}
fmt.Print("company id: %s", company.ID)
ただ, 今回のテーブル定義では主キーがuuid.UUID型でデフォルト値を持たないため, このままでは以下のエラーが出力されてしまいます.
Create Company Error: Error 1364: Field 'id' doesn't have a default value
そこでHookを使用して, 作成時にUUIDを割り当てるようにします.
Hookは作成/取得/更新/削除の前後に呼び出される関数です.
オブジェクトの作成前にはBeforeCreateが実行されます. 他のHookについては公式ドキュメントを参照してください.
Hookはfunc(*gorm.DB) errorという型を満たす必要があります.
以下のようなBeforeCreateを作成することで, データベースに保存する前にUUIDを割り当てることができます.
func (company *Company) BeforeCreate(db *gorm.DB) error {
if company.ID == uuid.Nil {
company.ID = uuid.New()
}
return nil
}
BeforeCreateメソッドを作成したあとに先ほどのサンプルコードを実行すると, 以下のようなSQLが生成されます.
また, レコードの取得のときと同様に, 取得されたレコードはCreateメソッドに渡したモデルに代入されます. レコードを追加した際に生成されたIDも代入されます.
INSERT INTO `companies` (`name`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES ('company1','2022-12-06 17:27:28.123','2022-12-06 17:27:28.123',NULL,'2893bac3-e2f0-4f66-b61e-45553aec6398')
-- company id: fbbe1f9f-2957-468e-bd60-ee914634bf46
同様の方法で関連データを関連づけて作成することができます. 値がfalseや0等のゼロ値でない場合は関連データもupsertされ, 関連データのHookメソッドも実行されます.
user := User{
Name: "user1",
Company: Company{Name: "company2"}, // 関連するCompanyを作成
CreditCard: CreditCard{Number: "1111"}, // 関連するCreditCardを作成
}
err := db.Create(&user).Error
if err != nil {
fmt.Printf("Create user with related data error: %s", err.Error())
return
}
関連付けの作成及び更新を避けたい場合は以下のように関連をOmitで指定することでスキップできます.
db.Omit("CreditCard").Create(&user)
// skip all associations
db.Omit(clause.Associations).Create(&user)
レコード一括作成
公式ドキュメント該当箇所
レコードを一括作成する場合, createメソッドまたは CreateInBatchesメソッドを使います.
createメソッドを使う場合
以下は3件のPostを追加する例です
// 既にDBにあるPostのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
posts := []Post{
{
Title: "post1",
Content: "post1 content",
UserID: userID,
},
{
Title: "post2",
Content: "post2 content",
UserID: userID,
},
{
Title: "post3",
Content: "post3 content",
UserID: userID,
},
}
db.Create(&posts)
上記を実行すると, 以下のようなSQLが実行されます.
INSERT INTO `posts` (`title`,`content`,`view`,`user_id`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES ('post1','post1 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:46:21.805','2022-12-10 21:46:21.805',NULL,'374f16cf-0996-45e0-9c71-e903a6ef72c8'),('post2','post2 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:46:21.805','2022-12-10 21:46:21.805',NULL,'e6759971-37a6-4b6f-8331-61f3cf7da957'),('post3','post3 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:46:21.805','2022-12-10 21:46:21.805',NULL,'c36dc846-43c2-4e30-a4b6-0a904c2665a8')
CreateInBatchesメソッドを使う場合
CreateInBatchesメソッドを使うと, バッチサイズを指定してレコードを作成することができます.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
posts := []Post{}
// Postを100件生成
for i := 0; i < 100; i++ {
posts = append(posts, Post{
Title: fmt.Sprintf("post %d", i),
Content: fmt.Sprintf("post %d content", i),
UserID: userID,
})
}
// バッチサイズを50件に指定
db.CreateInBatches(&posts, 50)
上記を実行すると以下のように, 50件ずつデータがインサートされます. (一部を省略して表示しています)
INSERT INTO `posts` (`title`,`content`,`view`,`user_id`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES ('post 0','post 0 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:54:11.439','2022-12-10 21:54:11.439',NULL,'36621d66-e85c-400a-86ea-3e7bf9ddc03e'),...,('post 49','post 49 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:54:11.439','2022-12-10 21:54:11.439',NULL,'451e331a-664e-4777-9275-ede688d8585c')
INSERT INTO `posts` (`title`,`content`,`view`,`user_id`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES ('post 50','post 50 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:54:11.459','2022-12-10 21:54:11.459',NULL,'9f56c919-905e-4e87-b690-404371b698d0'),...,('post 99','post 99 content',0,'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64','2022-12-10 21:54:11.459','2022-12-10 21:54:11.459',NULL,'34e805c9-ab6c-46ad-87c1-46ab8f0fdfeb')
関連があるデータを追加する
公式ドキュメント該当箇所
関連があるデータを追加する場合, Association Modeが便利です.
Association Modeは, ソースモデルに対してAssociationメソッドで関連を指定することで開始されます. Association Modeでは関連に対してさまざまな操作をすることができます.
例えばTagをPostに追加する場合,
- ソースモデルとして
Postを指定 -
AssociationメソッドでPostに関連しているTagsを指定 -
Appendメソッドで関連データを追加する
ということができます.
// 既にDBにあるPostのID
postID := uuid.MustParse("0155dffb-c98b-4e58-a169-04cb3f8634d2")
post := Post{ID: postID}
tags := []Tag{
{Name: "tag1"},
{Name: "tag2"},
}
// Tagを追加
db.Model(&post).Association("Tags").Append(&tags)
上記を実行すると, 以下のSQLが実行され, 中間テーブルの更新までおこなってくれていることがわかります.
INSERT INTO `tags` (`name`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES ('tag1','2022-12-10 22:07:40.596','2022-12-10 22:07:40.596',NULL,'9f63212f-1f35-4f66-a31a-7aba352ea949'),('tag2','2022-12-10 22:07:40.596','2022-12-10 22:07:40.596',NULL,'8e377a09-1d89-4e61-8515-b29769a7d7e6') ON DUPLICATE KEY UPDATE `id`=`id`
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES ('0155dffb-c98b-4e58-a169-04cb3f8634d2','9f63212f-1f35-4f66-a31a-7aba352ea949'),('0155dffb-c98b-4e58-a169-04cb3f8634d2','8e377a09-1d89-4e61-8515-b29769a7d7e6') ON DUPLICATE KEY UPDATE `post_id`=`post_id`
UPDATE `posts` SET `updated_at`='2022-12-10 22:07:40.594' WHERE `posts`.`deleted_at` IS NULL AND `id` = '0155dffb-c98b-4e58-a169-04cb3f8634d2'
{9f63212f-1f35-4f66-a31a-7aba352ea949 tag1 [] 2022-12-10 22:07:40.596 +0900 JST 2022-12-10 22:07:40.596 +0900 JST {0001-01-01 00:00:00 +0000 UTC false}}
Upsert
Upsertについては公式ドキュメントの通りの説明となってしまいますが, 以下のようにすることで, 主キー以外のカラムはUpsertすることができます.
例えばUserモデルでnameとageのUpsertを許可したい場合は, 以下のようにします.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{
ID: userID,
Name: "new name",
Age: 30,
}
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
// upsertを許可するカラムを指定する
DoUpdates: clause.AssignmentColumns([]string{"name", "age"}),
}).Create(&user)
上記の例ではCreateメソッドを使っていますが, CreateInBatchesメソッドでも同様にUpsertすることができます.
Update系
公式ドキュメント該当箇所
UpdateはUpdateおよびUpdatesメソッドを使うか, レコード取得関連のメソッドとSaveメソッドを合わせて使うことで実現できます.
単一カラムの更新
公式ドキュメントの該当箇所
Updateメソッドに更新したいカラムの名前と, 更新後の値を渡します.
条件を指定せずにUpdateメソッドを使用するとErrMissingWhereClauseというエラーが起きてしまうので, 基本的にはWhereメソッドを使ったり, 構造体に主キーをセットするなどして何かしらの条件を付与する必要があります.
あまりなさそうですが, もしErrMissingWhereClauseを無視して指定したカラムのデータを更新したい場合は
-
db.Session(&gorm.Session{AllowGlobalUpdate: true})を指定してAllowGlobalUpdateモードを有効にする -
1=1などの無意味な条件をWhereメソッドで指定する -
Execメソッドで生のSQLを実行する
などをすることで実現できます.
以下は, 構造体にIDをセットしておくことで, IDを指定してnameを更新している例です.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{ID: userID}
db.Model(&user).Update("name", "new name")
上記を実行すると, 次のSQLが実行されます.
UPDATE `users` SET `name`='new name',`updated_at`='2022-12-10 23:37:12.008' WHERE `users`.`deleted_at` IS NULL AND `id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
注意したいのが, Updateメソッドは条件に一致するレコードがない場合でもエラーになりません. 例えば上記の例で, DBに存在しないユーザーのIDを指定したとしても, エラーになりません.
アップデートする対象が存在していないことがそもそも問題になる場合は, Updateメソッドを実行する前にFindやFirstメソッドでレコードの存在を確認する必要があります。
レコード取得関連のメソッドとSaveメソッドを組み合わせる
公式ドキュメント該当箇所
前述したように, レコードの存在確認にはFindやFirstメソッドを使う必要があります. その際にSaveメソッドを合わせて使うことで, データの更新ができます.
以下は年齢をインクリメントする例です.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{ID: userID}
err := db.First(&user).Error
if err != nil {
// ErrRecordNotFoundであるかをチェック
if errors.Is(err, gorm.ErrRecordNotFound) {
fmt.Print("Select first user error: user not found")
return
}
fmt.Printf("Select first user error: %s", err.Error())
return
}
fmt.Printf("user age before update: %d", user.Age)
user.Age += 1
db.Save(user)
上記を実行すると, 次のようなSQLが実行されます.
Saveメソッドは更新するカラムを指定しないので, 全てのカラムがアップデートされます.
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' ORDER BY `users`.`id` LIMIT 1
-- user age before update: 20
UPDATE `users` SET `id`='b89d13f3-bd28-46d1-b1ad-4f48d7b11d64',`name`='new name',`age`=21,`is_active`=false,`company_id`='84aa8c00-b179-4842-8d94-c360a1354cfe',`created_at`='2022-12-03 02:22:46.488',`updated_at`='2022-12-10 23:54:49.341',`deleted_at`=NULL WHERE `users`.`deleted_at` IS NULL AND `id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
これだけでも一見問題なさそうですが, Firstメソッド実行後に, 他のユーザーによって取得したレコードのageが更新されてしまったり, レコード自体が削除されてしまうと, データの整合性が取れなくなってしまいます.
この問題を回避するためには, Selectする際にUpdateが完了するまでレコードをロックするように指定します.
GORMは複数種類のロックをサポートしており, 詳細は公式ドキュメントに譲りますが, 今回の例では以下のようにしてSelectする際にFOR UPDATEのオプションを追加します.
db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user)
またFOR UPDATEでは, トランザクションがコミットまたはロールバックされるとロックが解除されるため, Select~Updateをひとつのトランザクションで実行する必要があります. (ロック読み取りについての詳細はMySQLのドキュメントに譲ります)
トランザクション内で一連の操作を実行する場合はTransactionメソッドを使います.
トランザクションはnilを返すことでコミットされます.
トランザクションについての詳細は, 公式ドキュメントを参照してください.
FOR UPDATEとTransactionメソッドを取り入れると, 先ほどの年齢をインクリメントする例は, 以下のように書き換えることができます.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{ID: userID}
err := db.Transaction(func(tx *gorm.DB) error {
err := db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user).Error
if err != nil {
return err
}
fmt.Printf("user age before update: %d", user.Age)
user.Age += 1
db.Save(user)
// nilが返却されるとトランザクション内の全処理がコミットされる
return nil
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fmt.Print("Update user error: user not found")
return
}
fmt.Printf("Update user error: %s", err.Error())
return
}
上記が実行されると, 以下のSQLが実行されます.
SELECT * FROM `users` WHERE `users`.`deleted_at` IS NULL AND `users`.`id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64' ORDER BY `users`.`id` LIMIT 1 FOR UPDATE
-- user age before update: 20
UPDATE `users` SET `id`='b89d13f3-bd28-46d1-b1ad-4f48d7b11d64',`name`='new name',`age`=21,`is_active`=false,`company_id`='84aa8c00-b179-4842-8d94-c360a1354cfe',`created_at`='2022-12-03 02:22:46.488',`updated_at`='2022-12-11 00:21:04.217',`deleted_at`=NULL WHERE `users`.`deleted_at` IS NULL AND `id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
複数カラムのアップデート
公式ドキュメント該当箇所
複数のカラムを更新する場合, Updatesメソッドに構造体もしくはマップを渡します.
以下は構造体を用いて複数カラムを更新する例です.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{
ID: userID,
Name: "updated name",
IsActive: true,
}
db.Model(&user).Updates(user)
上記を実行すると, 以下のSQLが生成されます.
UPDATE `users` SET `id`='b89d13f3-bd28-46d1-b1ad-4f48d7b11d64',`name`='updated name',`is_active`=true,`updated_at`='2022-12-11 00:33:39.769' WHERE `users`.`deleted_at` IS NULL AND `id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
UpdateおよびUpdatesの注意点
Whereメソッドと同様に, 構造体にゼロ値をセットしても, そのフィールドは更新されません. これを回避するためには, Whereメソッドと同様にマップ使うか, 次のようにSelectメソッドを使って更新するフィールドを指定します.
// 既にDBにあるUserのID
userID := uuid.MustParse("b89d13f3-bd28-46d1-b1ad-4f48d7b11d64")
user := User{
ID: userID,
IsActive: false,
}
db.Model(&user).Select("is_active").Updates(user)
上記を実行すると以下のSQLが実行され, ゼロ値でアップデートができていることがわかります.
UPDATE `users` SET `is_active`=false,`updated_at`='2022-12-11 00:48:21.101' WHERE `users`.`deleted_at` IS NULL AND `id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
Update系その他
UpdateにもCreateと同様にHookがあったり, SQL式やサブクエリを使うことで柔軟にデータをアップデートできます. 詳しくは公式ドキュメントをご覧ください.
Delete系
公式ドキュメント
レコードの削除にはDeleteメソッドを使用します.
単一レコードの削除
公式ドキュメント該当箇所
単一レコードを削除する場合, 単一レコードを取得する場合のように主キーをインライン条件で指定します.
以下はIDを指定してPostを削除する例です.
postID := uuid.MustParse("0204e10c-20f2-40cf-886d-cd38f1789c0f")
post := Post{ID: postID}
db.Delete(&post)
実行されるSQLは後述する論理削除か物理削除かで異なります.
Updateと同様に, 対象レコードが存在しない場合でもエラーにならないので, 対象レコードが存在しないことが問題になるような場合にはFindやFirstメソッドでレコードの存在を確認する必要があります.
一括削除
公式ドキュメント該当箇所
主キーが指定されない場合は条件に一致したレコードを一括削除します.
以下の例では 非アクティブなユーザーを一括削除しています.
users := []User{}
db.Where("is_active = ?", false).Delete(&users)
こちらも実行されるSQLは後述する論理削除か物理削除かで異なります.
論理削除
公式ドキュメント該当箇所
gorm.DeletedAtというフィールをモデルに含めると, そのモデルは論理削除されるようになります.
gorm.DeletedAtはgorm.Modelに含まれています.
今回のようにgorm.Modelを使わない場合は, 以下の例のようにgorm.DeletedAt型のDeletedをモデルに含めることで論理削除されるようになります.
type User struct {
ID int
Deleted gorm.DeletedAt
Name string
}
論理削除機能をオンにすると, レコードを取得する際のSQLにdeleted_at IS NULLという条件が自動で付与され, DeletedがNULLでないレコードはデフォルトで取得されなくなります.
論理削除されたレコードを含めてレコードを取得する場合は, Unscopedメソッドを使います.
users := []User{}
db.Unscoped().Find(&users)
上記を実行すると, 以下のSQLが実行されます
SELECT * FROM users;
物理削除する場合も, 以下のようにUnscopedメソッドを使います.
// Postを持っていないUserのID
userID := uuid.MustParse("117963d3-16f0-494f-8da2-7b6fa7fbcbd3")
user := User{ID: userID}
db.Unscoped().Delete(&user)
上記を実行すると以下のSQLが実行され, レコードが物理削除されたことがわかります.
DELETE FROM `users` WHERE `users`.`id` = '117963d3-16f0-494f-8da2-7b6fa7fbcbd3'
関連の削除
公式ドキュメント該当箇所
PostとCreditCardを持つUserのような関連を持つレコードを削除する場合は, Selectメソッドで関連を指定することで, 関連も同時に削除することができます.
// 削除するユーザーのID
userID := uuid.MustParse("103a58cf-44e6-4e31-8a33-802fdcce58ae")
user := User{ID: userID}
db.Select("Posts", "CreditCard").Delete(&user)
生成されるSQL
UPDATE `posts` SET `deleted_at`='2022-12-11 21:53:13.293' WHERE `posts`.`user_id` = '103a58cf-44e6-4e31-8a33-802fdcce58ae' AND `posts`.`deleted_at` IS NULL
UPDATE `credit_cards` SET `deleted_at`='2022-12-11 21:53:13.309' WHERE `credit_cards`.`user_id` = '103a58cf-44e6-4e31-8a33-802fdcce58ae' AND `credit_cards`.`deleted_at` IS NULL
DELETE FROM `posts` WHERE `posts`.`user_id` = 'b89d13f3-bd28-46d1-b1ad-4f48d7b11d64'
UPDATE `users` SET `deleted_at`='2022-12-11 21:53:13.311' WHERE `users`.`id` = '103a58cf-44e6-4e31-8a33-802fdcce58ae' AND `users`.`deleted_at` IS NULL
また, 関連の参照のみを削除する場合には, 関連データの追加のときのようにAssociation Modeを使います.
例えば, あるPostから特定のTagを外したい(参照を削除したい)場合は, 以下のようになります.
// あるPostのID
postID := uuid.MustParse("003ad45c-538e-4568-b2c2-a93d8be6a791")
post := Post{ID: postID}
// 外したいTagのID
tagID := uuid.MustParse("8ddf4cf9-9ac2-428a-a5c2-8dcd15cfd44c")
tag := Tag{ID: tagID}
db.Model(&post).Association("Tags").Delete(&tag)
上記を実行すると以下のようなSQLが生成され, 参照が削除されたことがわかります.
DELETE FROM `post_tags` WHERE `post_tags`.`post_id` = '003ad45c-538e-4568-b2c2-a93d8be6a791' AND `post_tags`.`tag_id` = '8ddf4cf9-9ac2-428a-a5c2-8dcd15cfd44c'
Delete系その他
DeleteメソッドにもUpdate, Createメソッドと同様にHookがあり, 削除前と削除後におこなう処理を指定することができます. 定義方法等はCreateメソッドと同様であるため, 詳細は公式ドキュメントに譲ります.
最後に
かなり長くなってしまいましたが, GORMを用いたCRUD操作をまとめてみました.
最後まで読んでくださり, ありがとうございました!!
GORMを使う方のお役に立てるととてもうれしいです.
明日以降の記事も, ご期待ください🌟
https://qiita.com/advent-calendar/2022/dena-23-shinsotsu