gorm.DeletedAt と Delete をあれこれ検証してみた [GORM]

# はじめに
Go言語のORMパッケージとして、[GORM][GORM] があります。
「データの取得」「論理削除」「物理削除」について色々検証してみたので、その結果を共有します。

# 前提
- Go言語をある程度触ったことがある
- [GORM][GORM] パッケージをある程度触ったことがある
  - 主に、`gorm.DeletedAt`とその周辺について話します
  - データベースの内容は、VSCode 拡張機能の [MySQL](https://marketplace.visualstudio.com/items?itemName=cweijan.vscode-mysql-client2) で閲覧します
- [Gin][Gin] パッケージを触ったことがある
  - 最終的にAPIレスポンスとして検証するので、Gin を使います
  - APIへのリクエストは、VSCode 拡張機能の [RESTClient](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) を用います
- 論理削除と物理削除についてなんとなく知っている
  - 今回のメイントピック

# この記事を読んでわかること
- `gorm.DeletedAt`の機能
- `gorm.DeletedAt`の`Find`,`Scan`メソッドへの作用
- 独自定義の構造体で、クエリ結果を受け取るときに、論理削除データがレスポンスに含まれる件
  - 独自構造体で論理削除データを含ませない方法を2種類
    - クエリに`WHERE deleted_at IS NULL`を指定する方法
    - 独自定義の構造体で`WHERE deleted_at IS NULL`を自動適用させる方法
- 論理削除と物理削除の違い
- GORMで論理削除データを参照する方法・参照させない方法
- 関連付け(Association)のあるデータの「物理削除」方法を2種類
  - 対象データの関連付けのみ削除し、対象データを「物理削除」する方法
  - 対象データと関連付けされたデータもろとも「物理削除」する方法

GORMやGinの使い方、今回実装しているGoの細かい実装内容は解説しません。
長いので、お急ぎの方は [まとめ](#まとめ) が少し参考になるかもしれません。

# もくじ
- [gorm.DeletedAtについて](#gormdeletedatについて)
- [モデル定義](#モデル定義)
- [基本コード](#基本コード)
- [データの準備](#データの準備)
    - [Userレコードの作成](#userレコードの作成)
- [ユーザーのリスト表示と削除](#ユーザーのリスト表示と削除)
    - [ユーザーのリストを取得するコード](#ユーザーのリストを取得するコード)
    - [ユーザーを削除するコード](#ユーザーを削除するコード)
    - [手順通り試す](#手順通り試す)
- [会社の情報取得と削除](#会社の情報取得と削除)
    - [会社情報を取得するコード](#会社情報を取得するコード)
    - [会社を削除するコード](#会社を削除するコード)
    - [論理削除データへのアクセス](#論理削除データへのアクセス)
    - [関連付けのみ削除](#関連付けのみ削除)
    - [関連付けのみ削除(Unscoped)](#関連付けのみ削除unscoped)
    - [関連付けされたデータもろとも削除](#関連付けされたデータもろとも削除)
- [まとめ](#まとめ)
- [おわりに](#おわりに)
- [参考](#参考)

# gorm.DeletedAtについて
`gorm.DeletedAt`は、GORMで`deleted_at`カラムを使いたいときに指定する型で、レコードが作成された段階では、その値は`NULL`になります。

レコードが削除されたときには、自動的に削除日時で更新してくれますし、`Find`や`Scan`をするとき、自動で`WHERE deleted_at IS NULL`を指定してくれるので、論理削除済みのデータはレスポンスに含まれないように勝手に動いてくれます。

つまり、開発者が特に意識しなくとも「論理削除」の処理を実現してくれます。
なので、この型を指定するだけでそのモデルに対して、論理削除を実装できているようなものです。便利！

しかし、開発していくうちに、
「なんか期待通りにAPIレスポンスが返ってこないな？」
「論理削除できてるデータも一緒に返ってくるな？なんでだ？」
ってなったので、いろいろ試したのがこの記事のモチベーションです。

# モデル定義
今回`User`テーブルと`Company`テーブルの2つを用意します。
インポート関連は省略します。

```go
// 共通の構造体(埋め込み用)
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`テーブルを例に取り上げると、上記は以下と等価です。

```go
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` の関係です。

```mermaid
%%{init:{'theme':'base'}}%%
erDiagram 
    User}o--o|Company : "0..n : 0..1"
    User {
        uint id
        string name 
        uint age 
        uint company_id FK 
        date created_at 
        date updated_at 
        date deleted_at 
    }
    Company {
        uint id
        string name 
        date created_at 
        date updated_at 
        date deleted_at 
    } 
```

# 基本コード
基本的なコードは以下のようになり、後にルーティング部分のハンドラーを追加していきます。

細かいインポートとDBへの接続部分の細かい処理は省略しています。

デモ用のCompanyとして、`株式会社USO(id:1)`を起動時に作成します。

```go
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`拡張子のファイルを用意しました。
折り畳みに入れているので、気になる人だけ見てください。

<details>
<summary>test.httpファイル</summary>
  
```python
### 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}}
```
  
</details>

# データの準備
デモで使うデータの準備を行います。
## Userレコードの作成
まずは、ユーザーデータを作成するメソッドを定義します。
ユーザー名(`name`)と年齢(`age`)が、リクエストボディに含まれます。

```go
/* リクエストボディ */
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)`に所属しています。
![](https://i.imgur.com/Gb0Y2kp.png)

# ユーザーのリスト表示と削除
ここでは、論理削除した時のデータ取得の際、クエリによってそのデータがDBレスポンスに含まれたり含まれなかったりと、`gorm.DeletedAt`の恩恵に与れないことがあるため、この現象を検証します。

手順は以下の通りです。

1. ユーザーを一覧表示(4つの方法を試す)
2. ユーザーBさんを「論理削除」
3. ユーザーCさんを「物理削除」
4. ユーザーを一覧表示(4つの挙動の違いを見る)

期待としては、論理削除されているデータはAPIレスポンスに含めたくないです。

ユーザーのリストを取得するコードと削除するコードを示した後に、実際に手順通り試した結果を載せていく。

## ユーザーのリストを取得するコード
今回、4つの方法でユーザーのリストを取得します。

1. チュートリアルでよく見るお手軽に全部取得する方法
1. 独自の構造体にSELECT句を使って必要なレスポンスを入れる方法
3. 方法2. + `WHERE deleted_at IS NULL`を指定する
4. 方法2. + 独自の構造体に`DeletedAt`フィールドを追加

また、本記事を見るにあたって、クエリ部分に集中できるように、なるべく共通の処理はミドルウェア化しています。
ここは特に意識しなくてよいですが、クエリ部分の関数がハンドラーっぽくないように見えるので紹介しておきました。

```go
/* 共通処理をラップするミドルウェア */
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`変数です。

```go
/* 1. チュートリアルでよく見るお手軽に全部取得する方法 */
func UserList() (interface{}, error) {
  users := []models.User{}
  query := GormDB.Table("users").Find(&users)
  
  return users, query.Error
}
```
```go
/* 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
}
```
```go
/* 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
}
```
```go
/* 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()`を追加してあげればよいです。

```go
/* ユーザーの論理削除(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"})
}
```

## 手順通り試す
1. ユーザーを一覧表示(4つの方法を試す)
        - 全員分のデータが取得できた
    |方法1|方法2|方法3|方法4|
    |:--:|:--:|:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/78cad422-6fea-d1ec-652c-4776d7e65197.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/e822b82e-48aa-ecb3-a849-58156cce864e.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/e822b82e-48aa-ecb3-a849-58156cce864e.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/e822b82e-48aa-ecb3-a849-58156cce864e.png)|
2. ユーザーBさんを「論理削除」(`UserSoftDelete`)
        - `deleted_at`が`NULL`じゃなくなった
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ef714494-3e1b-f27c-4bc6-fe6d18ad4ffe.png)
3. ユーザーCさんを「物理削除」(`UserDelete`)
        - 完全にCさんのデータが消えた
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/e94db16b-a06b-ad6d-3f0f-a9b00ff83716.png)
4. ユーザーを一覧表示(4つの挙動の違いを見る)
        - 期待するレスポンスは、AさんとDさんのデータのみ
        - 方法1,3,4は期待通り
        - 方法2ではBさんのデータも含まれている
    |方法1|方法2|方法3|方法4|
    |:--:|:--:|:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/f1762b84-fa57-1269-15a8-821b3172d9a7.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/5cbc959e-be0c-6a61-cdbf-319783342451.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/14c7baa5-c8ab-2618-74cb-42c8ca754fce.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/14c7baa5-c8ab-2618-74cb-42c8ca754fce.png)|

これが今回の本題の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から持たれていると「物理削除」できないはずです。
逆に会社の従業員が空の状態であれば、会社をたためるわけです。

では、論理削除されたデータと関連付けがあればどうなるのか？
これらを検証していきます。

試すこととその手順は以下の通り。

- 論理削除データへのアクセス
    1. 会社の情報を取得(2つの方法を試す)
    2. 会社を「論理削除」
    3. 会社情報を取得(2つの挙動の違いを確認)
- 関連付けのみ削除
    1. 会社の情報を取得(2つの方法を試す)
    4. 会社を「物理削除」してみる
    5. 会社の関連付け(Association)を削除
    6. 会社を「物理削除」してみる
- 関連付けのみ削除(Unscoped)
    1. 会社の情報を取得(2つの方法を試す)
    4. 会社を「物理削除」してみる
    5. 会社の関連付け(Association)を削除(Unscoped)
    6. 会社を「物理削除」
- 関連付けされたデータもろとも削除
    1. 会社の情報を取得(2つの方法を試す)
    2. 会社を「物理削除」してみる
    3. 会社の関連付け(Association)もろとも(社員すらも)削除

会社情報を取得するコードと会社を削除するコードを示した後に、実際に手順通り試した結果を載せていく。

## 会社情報を取得するコード
会社情報を取得するコードは2つ示します。

先ほどのユーザーの物理削除で見たのと同じで、論理削除された会社データにアクセスしたければ、`Unscoped()`を用いなければいけません。
以下の2つのコードで、`Unscoped()`の挙動を再確認します。

```go
/* チュートリアルでよく見る形式 */
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つコードを示す。

1. 会社を「論理削除」するコード
    - チュートリアルでよく見るコード
2. 会社を「物理削除」するコード
    - 関連付けがあると削除できないはず
3. 会社の関連付けのみ削除するコード
    - こちらの実行後なら「物理削除」できるはず？
4. 会社の関連付けのみ削除するコード(Unscoped)
    - こちらの実行後なら「物理削除」できるはず
5. 会社の関連付けデータもろとも削除するコード
    - DBから会社が消えると社員もDBから消える

こちらも、なるべくクエリに集中できるよう、共通処理はミドルウェア化しています。

```go
/* 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})
  }
}
```

以下に、クエリを示します。

```go
/* 1. 会社を「論理削除」するコード */
func CompanySoftDelete(id string) error {
  return GormDB.Delete(&models.Company{}, id).Error
}
```
```go
/* 2. 会社を「物理削除」するコード */
func CompanyDelete(id string) error {
  return GormDB.Unscoped().Delete(&models.Company{}, id).Error
}
```

関連付けを削除する場合は、`Association()`を用いて、関連付けのあるカラムを指定します。
指定したカラムのすべての関連付けを削除したい場合、`Clear()`を用います。

```go
/* 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()
}
```
```go
/* 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()`を用いて、削除したい関連カラムを指定します。

```go
/* 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
}
```

## 論理削除データへのアクセス
1. 会社の情報を取得(2つの方法を試す)
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|
2. 会社を「論理削除」(`CompanySoftDelete`)
        - `deleted_at`が`NULL`でないので論理削除されている
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/c83e4949-3a7d-5049-751d-f9ad4355eb41.png)
3. 会社情報を取得(2つの挙動の違いを確認)
        - Unscopedなしであれば、やはりレコードを取得できない(期待通り)
        - Unscopedありであれば、レコードを取得できる(レコード復活時に必要)
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/60b92b3b-65f4-c435-4072-26164a81aed0.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/269576ce-8f8f-999e-bab0-8d56caee5237.png)|

また上の結果を見ると、論理削除されているUser(Bさん)の情報は、期待通り取得されていない。

## 関連付けのみ削除
1. 会社の情報を取得(2つの方法を試す)
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|
2. 会社を「物理削除」してみる(`CompanyDelete`)
        - やはり、関連付けがあるため削除できない
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/20480d57-5ea9-4501-fa01-55de9bf0571c.png)
3. 会社の関連付け(Association)を削除 (`CompanyAssociationDelete`)
        - 会社情報をみると、関連付けが空になっている
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/0ffe9890-7406-9fe5-9fb4-414e65fcbba3.png)
    いわゆる、リストラってやつかな？
4. 会社を「物理削除」してみる(`CompanyDelete`)
        - できない！？
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/035a6cd8-89f0-ecb4-d92e-8ed410ef9a0c.png)

理由は単純で、論利削除済みのデータに対して、`Clear()`が働いてくれていないからです。
なので、データベースを除くと、論理削除されていないUserは`company_id`が`NULL`になっているが、されていたBさんは、まだ会社に所属しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/cb127b2c-03fa-3939-cc3a-84433ee42c6f.png)
1人だけ、リストラ回避してましたね。

次の方法で、これを解消しています。

## 関連付けのみ削除(Unscoped)
1. 会社の情報を取得(2つの方法を試す)
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|
2. 会社を「物理削除」してみる(`CompanyDelete`)
        - ここはやはり、関連付けがあるため削除できない
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/20480d57-5ea9-4501-fa01-55de9bf0571c.png)
3. 会社の関連付け(Association)を削除(Unscoped) (`CompanyAssociationUnscopedDelete`)
        - 会社情報をみると、関連付けが空になっている
        - データベースの方を確認しても、`company_id`が全員`NULL`になっている
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ef99a219-ef72-3ba7-d326-72072e4e1fb4.png)
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/87ba7a78-3942-dadd-bdbd-30048a7b62f4.png)
    全員リストラです。
4. 会社を削除
        - これで本当に関連付けがないので「物理削除」できる(`CompanyDelete`)
    
|Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
|:--:|:--:|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/fc41cf88-a18d-185e-c157-bf9402ccdb17.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/fc41cf88-a18d-185e-c157-bf9402ccdb17.png)|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/89c0989c-27c1-d6cf-c320-e725c4865d77.png)

一応次のために見せておくが、この処理の場合Userのレコードは残っている。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/99fbab12-81c4-4ba8-01ed-4673e809d66e.png)

## 関連付けされたデータもろとも削除
1. 会社の情報を取得(2つの方法を試す)
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/ca75a40f-93a4-01d8-20d1-17efc2bcb205.png)|
2. 会社を「物理削除」してみる(`CompanyDelete`)
        - ここはやはり、関連付けがあるため削除できない
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/20480d57-5ea9-4501-fa01-55de9bf0571c.png)
3. 会社の関連付け(Association)もろとも(社員すらも)削除 (`CompanyForceDelete`)
        - 関連していたUserもろとも消える
    |Unscopedなし(`CompanyInfo`)|Unscopedあり(`CompanyInfoUnscoped`)|
    |:--:|:--:|
    |![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/fc41cf88-a18d-185e-c157-bf9402ccdb17.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/fc41cf88-a18d-185e-c157-bf9402ccdb17.png)|

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/a2615b85-d096-ad75-3b28-8791c93bb59d.png)
ユーザーもろとも消える。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/506931/97d0dcc6-7263-e4cf-07e6-bd572808d4bf.png)

いわゆる夜逃げってやつかな？

# まとめ
- `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`関連の挙動が何となくわかってきた気がします。

データを消す処理を実装する際の、一助になればと願っています。

それでは:wave:

## 参考
- [モデルを宣言する #構造体の埋め込み](https://gorm.io/ja_JP/docs/models.html#%E6%A7%8B%E9%80%A0%E4%BD%93%E3%81%AE%E5%9F%8B%E3%82%81%E8%BE%BC%E3%81%BF)｜GORM公式
- [レコードの削除 #論理削除](https://gorm.io/ja_JP/docs/delete.html#%E8%AB%96%E7%90%86%E5%89%8A%E9%99%A4)｜GORM公式
- [レコードの削除 #論理削除されたレコードを取得する](https://gorm.io/ja_JP/docs/delete.html#%E8%AB%96%E7%90%86%E5%89%8A%E9%99%A4)｜GORM公式
- [レコードの削除 #完全な削除（物理削除）](https://gorm.io/ja_JP/docs/delete.html#%E5%AE%8C%E5%85%A8%E3%81%AA%E5%89%8A%E9%99%A4%EF%BC%88%E7%89%A9%E7%90%86%E5%89%8A%E9%99%A4%EF%BC%89)｜GORM公式
- [アソシエーション #関連を全て削除する](https://gorm.io/ja_JP/docs/associations.html#%E9%96%A2%E9%80%A3%E3%82%92%E5%85%A8%E3%81%A6%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B)｜GORM公式
- [アソシエーション #関連を指定して削除する](https://gorm.io/ja_JP/docs/associations.html#%E9%96%A2%E9%80%A3%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%A6%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B)｜GORM公式
- [アソシエーション #関連を削除する](https://gorm.io/ja_JP/docs/associations.html#%E9%96%A2%E9%80%A3%E3%82%92%E5%89%8A%E9%99%A4%E3%81%99%E3%82%8B)｜GORM公式

[GORM]:https://gorm.io/ja_JP/docs/
[Gin]:https://gin-gonic.com/ja/docs/