はじめに
Go言語のORMパッケージとして、GORM があります。
「データの取得」「論理削除」「物理削除」について色々検証してみたので、その結果を共有します。
前提
- Go言語をある程度触ったことがある
-
GORM パッケージをある程度触ったことがある
- 主に、
gorm.DeletedAt
とその周辺について話します - データベースの内容は、VSCode 拡張機能の MySQL で閲覧します
- 主に、
-
Gin パッケージを触ったことがある
- 最終的にAPIレスポンスとして検証するので、Gin を使います
- APIへのリクエストは、VSCode 拡張機能の RESTClient を用います
- 論理削除と物理削除についてなんとなく知っている
- 今回のメイントピック
この記事を読んでわかること
-
gorm.DeletedAt
の機能 -
gorm.DeletedAt
のFind
,Scan
メソッドへの作用 - 独自定義の構造体で、クエリ結果を受け取るときに、論理削除データがレスポンスに含まれる件
- 独自構造体で論理削除データを含ませない方法を2種類
- クエリに
WHERE deleted_at IS NULL
を指定する方法 - 独自定義の構造体で
WHERE deleted_at IS NULL
を自動適用させる方法
- クエリに
- 独自構造体で論理削除データを含ませない方法を2種類
- 論理削除と物理削除の違い
- GORMで論理削除データを参照する方法・参照させない方法
- 関連付け(Association)のあるデータの「物理削除」方法を2種類
- 対象データの関連付けのみ削除し、対象データを「物理削除」する方法
- 対象データと関連付けされたデータもろとも「物理削除」する方法
GORMやGinの使い方、今回実装しているGoの細かい実装内容は解説しません。
長いので、お急ぎの方は まとめ が少し参考になるかもしれません。
もくじ
gorm.DeletedAtについて
gorm.DeletedAt
は、GORMでdeleted_at
カラムを使いたいときに指定する型で、レコードが作成された段階では、その値はNULL
になります。
レコードが削除されたときには、自動的に削除日時で更新してくれますし、Find
やScan
をするとき、自動でWHERE deleted_at IS NULL
を指定してくれるので、論理削除済みのデータはレスポンスに含まれないように勝手に動いてくれます。
つまり、開発者が特に意識しなくとも「論理削除」の処理を実現してくれます。
なので、この型を指定するだけでそのモデルに対して、論理削除を実装できているようなものです。便利!
しかし、開発していくうちに、
「なんか期待通りにAPIレスポンスが返ってこないな?」
「論理削除できてるデータも一緒に返ってくるな?なんでだ?」
ってなったので、いろいろ試したのがこの記事のモチベーションです。
モデル定義
今回User
テーブルとCompany
テーブルの2つを用意します。
インポート関連は省略します。
// 共通の構造体(埋め込み用)
type General struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// Usreテーブル
type User struct {
General `gorm:"embedded"`
Name string `gorm:"not null" json:"name"`
Age uint `gorm:"not null" json:"age"`
CompanyId uint `json:"company_id"`
Company *Company `json:"-"`
}
// Companyテーブル
type Company struct {
General `gorm:"embedded"`
Name string `gorm:"not null" json:"name"`
Employees []User `json:"employees"`
}
今回、gorm.Model
は使わずに、自前で用意したGeneral
構造体をgorm:"embedded"
タグを用いて埋め込んでいます。
Company
テーブルを例に取り上げると、上記は以下と等価です。
type Company struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Name string `gorm:"not null" json:"name"`
Employees []User `json:"employees"`
}
また、ER図で表すと画像のようになります。
hasMany
と hasOne
の関係です。
基本コード
基本的なコードは以下のようになり、後にルーティング部分のハンドラーを追加していきます。
細かいインポートとDBへの接続部分の細かい処理は省略しています。
デモ用のCompanyとして、株式会社USO(id:1)
を起動時に作成します。
var (
GormDB *gorm.DB
DB *sql.DB
)
// DBへの接続、マイグレーション、初期データ作成
func init() {
gormDB, err := controllers.ConnectMySQL() // MySQLに接続
if err != nil {
log.Fatalf("main mysql.Open error err:%v", err)
}
db, err := gormDB.DB()
if err != nil {
log.Fatal(err)
}
if err := gormDB.AutoMigrate(&User{}, &Company{}); err != nil {
log.Fatal(err)
}
DB = db
GormDB = gormDB
// Companyのデモ用レコード
if err := GormDB.Create(&Company{Name: "株式会社USO"}).Error; err != nil {
log.Fatal(err)
}
}
func main() {
defer DB.Close()
r := gin.Default()
r_user := r.Group("/user")
{
r_user.GET("/list", Middleware(UserList))
r_user.GET("/list/name", Middleware(UserNameList))
r_user.GET("/list/name-deletedAt", Middleware(UserNameListWithDeletedAt))
r_user.GET("/list/name-where", Middleware(UserNameListWithWhere))
r_user.POST("/create", UserCreate)
r_user.DELETE("/delete/:id", UserDelete)
r_user.DELETE("/soft-delete/:id", UserSoftDelete)
}
r_company := r.Group("/company")
{
r_company.GET("/info/:id", CompanyInfo)
r_company.GET("/info-unscoped/:id", CompanyInfoUnscoped)
r_company.DELETE("/association-delete/:id", CompanyMiddleware(CompanyAssociationDelete, "Company association was deleted"))
r_company.DELETE("/association-unscoped-delete/:id", CompanyMiddleware(CompanyAssociationUnscopedDelete, "Company association(unscoped) was deleted"))
r_company.DELETE("/force-delete/:id", CompanyMiddleware(CompanyForceDelete, "Company was force-deleted"))
r_company.DELETE("/delete/:id", CompanyMiddleware(CompanyDelete, "Company was deleted"))
r_company.DELETE("/soft-delete/:id", CompanyMiddleware(CompanySoftDelete, "Company was soft-deleted"))
}
r.Run(":8080")
}
APIへのリクエスト用に、.http
拡張子のファイルを用意しました。
折り畳みに入れているので、気になる人だけ見てください。
test.httpファイル
### env
@BASEURL=http://localhost:8080
@USER={{BASEURL}}/user
@COMPANY={{BASEURL}}/company
@CTYPE=application/json
### user create
# @prompt name
POST {{USER}}/create HTTP/1.1
Content-Type: {{CTYPE}}
{
"name": "{{name}}",
"age": 22
}
### user list
GET {{USER}}/list HTTP/1.1
Content-Type: {{CTYPE}}
### user name list
GET {{USER}}/list/name HTTP/1.1
Content-Type: {{CTYPE}}
### user name list with where
GET {{USER}}/list/name-where HTTP/1.1
Content-Type: {{CTYPE}}
### user name list with deleted_at
GET {{USER}}/list/name-deletedAt HTTP/1.1
Content-Type: {{CTYPE}}
### user soft delete
DELETE {{USER}}/soft-delete/2 HTTP/1.1
Content-Type: {{CTYPE}}
### user delete
DELETE {{USER}}/delete/3 HTTP/1.1
Content-Type: {{CTYPE}}
### company info
GET {{COMPANY}}/info/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company info unscoped
GET {{COMPANY}}/info-unscoped/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company soft delete
DELETE {{COMPANY}}/soft-delete/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company delete
DELETE {{COMPANY}}/delete/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company delete only assosiation
DELETE {{COMPANY}}/association-delete/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company unscoped-delete only assosiation
DELETE {{COMPANY}}/association-unscoped-delete/1 HTTP/1.1
Content-Type: {{CTYPE}}
### company force delete
DELETE {{COMPANY}}/force-delete/1 HTTP/1.1
Content-Type: {{CTYPE}}
データの準備
デモで使うデータの準備を行います。
Userレコードの作成
まずは、ユーザーデータを作成するメソッドを定義します。
ユーザー名(name
)と年齢(age
)が、リクエストボディに含まれます。
/* リクエストボディ */
type UserCreateRequest struct {
Name string `json:"name"`
Age uint `json:"age"`
}
/* ユーザーの作成 */
func UserCreate(c *gin.Context) {
// リクエスト取り出し
var json UserCreateRequest
if err := c.ShouldBindJSON(&json); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Company(id:1)を取り出し
company := models.Company{}
if err := GormDB.First(&company).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ユーザーデータ作成
user := models.User{
Name: json.Name,
Age: json.Age,
Company: &company,
}
if err := GormDB.Create(&user).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// API レスポンス
c.JSON(http.StatusOK, user)
}
今回4人分のデータを作成しました。
全員株式会社USO(id:1)
に所属しています。
ユーザーのリスト表示と削除
ここでは、論理削除した時のデータ取得の際、クエリによってそのデータがDBレスポンスに含まれたり含まれなかったりと、gorm.DeletedAt
の恩恵に与れないことがあるため、この現象を検証します。
手順は以下の通りです。
- ユーザーを一覧表示(4つの方法を試す)
- ユーザーBさんを「論理削除」
- ユーザーCさんを「物理削除」
- ユーザーを一覧表示(4つの挙動の違いを見る)
期待としては、論理削除されているデータはAPIレスポンスに含めたくないです。
ユーザーのリストを取得するコードと削除するコードを示した後に、実際に手順通り試した結果を載せていく。
ユーザーのリストを取得するコード
今回、4つの方法でユーザーのリストを取得します。
- チュートリアルでよく見るお手軽に全部取得する方法
- 独自の構造体にSELECT句を使って必要なレスポンスを入れる方法
- 方法2. +
WHERE deleted_at IS NULL
を指定する - 方法2. + 独自の構造体に
DeletedAt
フィールドを追加
また、本記事を見るにあたって、クエリ部分に集中できるように、なるべく共通の処理はミドルウェア化しています。
ここは特に意識しなくてよいですが、クエリ部分の関数がハンドラーっぽくないように見えるので紹介しておきました。
/* 共通処理をラップするミドルウェア */
func Middleware(exec func() (interface{}, error)) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := exec() // ここでクエリを走らせる
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
}
以下に各方法での実装を示しますが、注目してほしいのはDBからデータを受け取るusers
変数とクエリ実行をした結果が入るquery
変数です。
/* 1. チュートリアルでよく見るお手軽に全部取得する方法 */
func UserList() (interface{}, error) {
users := []models.User{}
query := GormDB.Table("users").Find(&users)
return users, query.Error
}
/* 2. 独自の構造体にSELECT句を使って必要なレスポンスを入れる方法 */
// id, nameだけ抽出したい構造体
type UserNameListResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
}
func UserNameList() (interface{}, error) {
users := []UserNameListResponse{}
query := GormDB.Table("users").Select("id, name").Find(&users)
return users, query.Error
}
/* 3. 方法2. + `WHERE deleted_at IS NULL`を指定する */
func UserNameListWithWhere() (interface{}, error) {
users := []UserNameListResponse{}
query := GormDB.Table("users").Select("id, name").Where("deleted_at IS NULL").Find(&users)
return users, query.Error
}
/* 4. 方法2. + 独自の構造体に`DeletedAt`フィールドを追加 */
// DeletedAt カラムを UserNameListResponse 構造体に追加
type UserNameListResponseWithDeletedAt struct {
UserNameListResponse
DeletedAt gorm.DeletedAt `json:"-"` // 追加
}
func UserNameListWithDeletedAt() (interface{}, error) {
users := []UserNameListResponseWithDeletedAt{}
query := GormDB.Table("users").Select("id, name").Find(&users) // ここは方法2. と同じ
return users, query.Error
}
ユーザーを削除するコード
論理削除コードと物理削除コードをそれぞれ実装しました。
チュートリアルでよく見る通り、Delete()
を使うと「論理削除」になってしまいます。
「物理削除」を行いたいときは、Unscoped()
を追加してあげればよいです。
/* ユーザーの論理削除(Soft Delete) */
func UserSoftDelete(c *gin.Context) {
id := c.Param("id")
if err := GormDB.Delete(&models.User{}, id).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User was soft-deleted"})
}
/* ユーザーの物理削除 */
func UserDelete(c *gin.Context) {
id := c.Param("id")
if err := GormDB.Unscoped().Delete(&models.User{}, id).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "User was deleted"})
}
手順通り試す
- ユーザーを一覧表示(4つの方法を試す)
- 全員分のデータが取得できた方法1 方法2 方法3 方法4 - ユーザーBさんを「論理削除」(
UserSoftDelete
)
-deleted_at
がNULL
じゃなくなった
- ユーザーCさんを「物理削除」(
UserDelete
)
- 完全にCさんのデータが消えた
- ユーザーを一覧表示(4つの挙動の違いを見る)
- 期待するレスポンスは、AさんとDさんのデータのみ
- 方法1,3,4は期待通り
- 方法2ではBさんのデータも含まれている方法1 方法2 方法3 方法4
これが今回の本題の1つで、チュートリアルやネット記事でよく見かけるこの「方法2.」でデータを取り出すと、WHERE deleted_at IS NULL
を自動で行ってくれる恩恵に与ることができなくなるみたいです。
もちろん、「方法3.」のように自前でWHERE deleted_at IS NULL
で条件を与えてあげればいいわけですが...(めんどくさい)
TIPS的な(あまりネットで見かけたことがない?)使い方かもしれませんが、DBレスポンス用の構造体にDeletedAt: gorm.DeletedAt
フィールドを追加してあげるとちゃんと期待通りになります。
「方法1.」も確かに、DeletedAt
フィールドありますし、挙動が同じになるのはなんとなく納得?
私的には、「方法4.」が好みです。
というのも、もともと「方法2.」で実装されていたコードのレスポンス型の部分に、単にフィールド追加してあげればいいだけなので、「方法3.」よりなんとなく汚れない感があって好きです。
正直好みだと思います。
もしかしたら、ちゃんとした方法があるのかもしれませんが調べきれていません。
会社の情報取得と削除
さて、データを「物理削除」するとき、データに対して 関連付け(Association)
があると、データを「物理削除」できません。
もちろん、関連付け(Association)
が削除されさえすれば、そのデータを「物理削除」できます。
今回、User
から Company
へ hasOne
の関連付けがあります。
そのため、CompanyはUserから持たれていると「物理削除」できないはずです。
逆に会社の従業員が空の状態であれば、会社をたためるわけです。
では、論理削除されたデータと関連付けがあればどうなるのか?
これらを検証していきます。
試すこととその手順は以下の通り。
- 論理削除データへのアクセス
- 会社の情報を取得(2つの方法を試す)
- 会社を「論理削除」
- 会社情報を取得(2つの挙動の違いを確認)
- 関連付けのみ削除
- 会社の情報を取得(2つの方法を試す)
- 会社を「物理削除」してみる
- 会社の関連付け(Association)を削除
- 会社を「物理削除」してみる
- 関連付けのみ削除(Unscoped)
- 会社の情報を取得(2つの方法を試す)
- 会社を「物理削除」してみる
- 会社の関連付け(Association)を削除(Unscoped)
- 会社を「物理削除」
- 関連付けされたデータもろとも削除
- 会社の情報を取得(2つの方法を試す)
- 会社を「物理削除」してみる
- 会社の関連付け(Association)もろとも(社員すらも)削除
会社情報を取得するコードと会社を削除するコードを示した後に、実際に手順通り試した結果を載せていく。
会社情報を取得するコード
会社情報を取得するコードは2つ示します。
先ほどのユーザーの物理削除で見たのと同じで、論理削除された会社データにアクセスしたければ、Unscoped()
を用いなければいけません。
以下の2つのコードで、Unscoped()
の挙動を再確認します。
/* チュートリアルでよく見る形式 */
func CompanyInfo(c *gin.Context) {
id := c.Param("id")
company := models.Company{}
if err := GormDB.Preload("Employees").First(&company, id).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, company)
}
/* Unscoped で情報取得 */
func CompanyInfoUnscoped(c *gin.Context) {
id := c.Param("id")
company := models.Company{}
if err := GormDB.Preload("Employees").Unscoped().First(&company, id).Error; err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, company)
}
会社を削除するコード
会社の削除に関して5つコードを示す。
- 会社を「論理削除」するコード
- チュートリアルでよく見るコード
- 会社を「物理削除」するコード
- 関連付けがあると削除できないはず
- 会社の関連付けのみ削除するコード
- こちらの実行後なら「物理削除」できるはず?
- 会社の関連付けのみ削除するコード(Unscoped)
- こちらの実行後なら「物理削除」できるはず
- 会社の関連付けデータもろとも削除するコード
- DBから会社が消えると社員もDBから消える
こちらも、なるべくクエリに集中できるよう、共通処理はミドルウェア化しています。
/* Company 共通処理をラップするミドルウェア */
func CompanyMiddleware(exec func(id string) error, message string) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if err := exec(id); err != nil { // ここでクエリを実行
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": message})
}
}
以下に、クエリを示します。
/* 1. 会社を「論理削除」するコード */
func CompanySoftDelete(id string) error {
return GormDB.Delete(&models.Company{}, id).Error
}
/* 2. 会社を「物理削除」するコード */
func CompanyDelete(id string) error {
return GormDB.Unscoped().Delete(&models.Company{}, id).Error
}
関連付けを削除する場合は、Association()
を用いて、関連付けのあるカラムを指定します。
指定したカラムのすべての関連付けを削除したい場合、Clear()
を用います。
/* 3. 会社の関連付けのみ削除するコード */
func CompanyAssociationDelete(c *gin.Context) {
company := models.Company{}
if err := GormDB.Unscoped().First(&company, id).Error; err != nil {
return err
}
return GormDB.Model(&company).Association("Employees").Clear()
}
/* 4. 会社の関連付けのみ削除するコード(Unscoped) */
func CompanyAssociationUnscopedDelete(id string) error {
company := models.Company{}
if err := GormDB.Unscoped().First(&company, id).Error; err != nil {
return err
}
return GormDB.Unscoped().Model(&company).Association("Employees").Clear()
}
関連データをまとめて削除する場合は、Select().Delete()
を用いて、削除したい関連カラムを指定します。
/* 5. 会社の関連付けデータもろとも削除するコード */
func CompanyForceDelete(id string) error {
company := models.Company{}
if err := GormDB.Unscoped().First(&company, id).Error; err != nil {
return err
}
return GormDB.Unscoped().Select("Employees").Delete(company).Error
}
論理削除データへのアクセス
- 会社の情報を取得(2つの方法を試す)
Unscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
) - 会社を「論理削除」(
CompanySoftDelete
)
-deleted_at
がNULL
でないので論理削除されている
- 会社情報を取得(2つの挙動の違いを確認)
- Unscopedなしであれば、やはりレコードを取得できない(期待通り)
- Unscopedありであれば、レコードを取得できる(レコード復活時に必要)Unscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
)
また上の結果を見ると、論理削除されているUser(Bさん)の情報は、期待通り取得されていない。
関連付けのみ削除
- 会社の情報を取得(2つの方法を試す)
Unscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
) - 会社を「物理削除」してみる(
CompanyDelete
)
- やはり、関連付けがあるため削除できない
- 会社の関連付け(Association)を削除 (
CompanyAssociationDelete
)
- 会社情報をみると、関連付けが空になっている
いわゆる、リストラってやつかな? - 会社を「物理削除」してみる(
CompanyDelete
)
- できない!?
理由は単純で、論利削除済みのデータに対して、Clear()
が働いてくれていないからです。
なので、データベースを除くと、論理削除されていないUserはcompany_id
がNULL
になっているが、されていたBさんは、まだ会社に所属しています。
1人だけ、リストラ回避してましたね。
次の方法で、これを解消しています。
関連付けのみ削除(Unscoped)
- 会社の情報を取得(2つの方法を試す)
Unscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
) - 会社を「物理削除」してみる(
CompanyDelete
)
- ここはやはり、関連付けがあるため削除できない
- 会社の関連付け(Association)を削除(Unscoped) (
CompanyAssociationUnscopedDelete
)
- 会社情報をみると、関連付けが空になっている
- データベースの方を確認しても、company_id
が全員NULL
になっている
全員リストラです。 - 会社を削除
- これで本当に関連付けがないので「物理削除」できる(CompanyDelete
)
Unscopedなし(CompanyInfo ) |
Unscopedあり(CompanyInfoUnscoped ) |
---|---|
一応次のために見せておくが、この処理の場合Userのレコードは残っている。
関連付けされたデータもろとも削除
- 会社の情報を取得(2つの方法を試す)
Unscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
) - 会社を「物理削除」してみる(
CompanyDelete
)
- ここはやはり、関連付けがあるため削除できない
- 会社の関連付け(Association)もろとも(社員すらも)削除 (
CompanyForceDelete
)
- 関連していたUserもろとも消えるUnscopedなし( CompanyInfo
)Unscopedあり( CompanyInfoUnscoped
)
いわゆる夜逃げってやつかな?
まとめ
-
gorm.DeletedAt
を使えば論理削除が実装される- (論理)削除するときは
Delete()
を使用 - 物理削除するときは
Unscoped()
を併用
- (論理)削除するときは
- DBクエリ結果を受け取るために独自実装した構造体では、
gorm.DeletedAt
の恩恵がない- クエリに
.Where("deleted_at IS NULL")
を付与するか - 独自実装した構造体に
DeletedAt gorm.DeletedAt
フィールドを追加
- クエリに
- 論理削除されたデータを見るときは
Unscoped()
を使用 - 関連付けのあるデータは物理削除できない
- 関連付けのみを削除するときは
Association()
とClear()
を使用 - 論理削除済みデータの関連付けも削除したいときは
Unscoped()
も併用 - 関連付けデータもろとも一掃するときは、
Unscoped
とSelect()
とDelete()
を使用
- 関連付けのみを削除するときは
おわりに
今回の検証を通して、gorm.DeletedAt
とDelete
関連の挙動が何となくわかってきた気がします。
データを消す処理を実装する際の、一助になればと願っています。
それでは
参考
- モデルを宣言する #構造体の埋め込み|GORM公式
- レコードの削除 #論理削除|GORM公式
- レコードの削除 #論理削除されたレコードを取得する|GORM公式
- レコードの削除 #完全な削除(物理削除)|GORM公式
- アソシエーション #関連を全て削除する|GORM公式
- アソシエーション #関連を指定して削除する|GORM公式
- アソシエーション #関連を削除する|GORM公式