この記事は 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
-
User
belongs toCompany
-
- Has One
-
User
has aCreditCard
-
- Has Many
-
User
has manyPosts
-
- Many To Many
-
Post
has manyTags
-
Tag
has 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