GormのDELETEで誤爆しないために

# はじめに

**[Gormが本番テーブルの数億件のデータを消そうとした話](https://scrapbox.io/keroxp/Gorm%E3%81%8C%E6%9C%AC%E7%95%AA%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB%E3%81%AE%E6%95%B0%E5%84%84%E4%BB%B6%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E6%B6%88%E3%81%9D%E3%81%86%E3%81%A8%E3%81%97%E3%81%9F%E8%A9%B1)** が盛り上がってますね。

ゼロ値に関していうとGoの仕様上structの`0`や`空文字`が未定義(ゼロ値)なのか検索条件にゼロ値の値を設定しているのか判断出来ず
Gorm v1では取り敢えずゼロ値ならWhere句を生成しないという判断にしているため全件削除するような動きになっています。
sliceのlengthが0件の場合は流石にerrorにせいよと思う部分はありますが…

この件は**GormのWhere句を生成する処理に関する話なのでUpdateの際にも当てはまります。**
[Updateの件は以前検証した記事がある](https://qiita.com/tomtwinkle/items/ab46f7972b2b472c6ae7)のでそちらを参照してください。

Gorm v2のリリースを待つか、安全な別のlibraryを使用すれば良いのですが
とはいえGorm v1を使ってるアプリケーションのlibraryを切り替えるのは結構大きなリファクタになるので
Gorm v1使って安全なDeleteするためにどうすればいいのか検証していきます。

# 前提

本記事は以下の環境で検証しています。

* MySQL: 5.7.12
* gorm: v1.9.12

```go.mod
	github.com/go-sql-driver/mysql v1.5.0
	github.com/jinzhu/gorm v1.9.12
```

# 結論

先に結論から言ってしまうと安全にDeleteをするには
[**条件に `struct` を使用しない**](https://qiita.com/tomtwinkle/items/48e2fa94ff0624b717b7#%E8%A7%A3%E6%B1%BA%E7%AD%96-%E6%9D%A1%E4%BB%B6%E3%81%AB-struct-%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%AA%E3%81%84)
ようにしないといけません。それ以外は全部ダメです。


# ダメな例1：slice 0件、ゼロ値を指定したstructを条件に指定する

まずは全件削除されてしまうパターンを検証。
検証の為に以下のようなテーブルを作成して
`Debug()`で実際に実行されるSQLを見てみましょう。

| Num (int) | Bool (bool) | Str(varchar) |
|:---------:|:----------:|:------------:|

```Go
package main

import (
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

type Sample struct {
	Num  int64
	Bool bool
	Str  string
}

func main() {
	db, err := gorm.Open("mysql", "test:password@/sample")
	if err != nil {
		panic("failed to connect database")
	}
	defer db.Close()

	// Migrate the schema
	if err := db.AutoMigrate(&Sample{}).Error; err != nil {
		panic(err.Error())
	}

	// Delete - initialization struct
	fmt.Print("Delete - initialization struct")
	db.Debug().Delete(Sample{})

	// Delete - slice size 0
	fmt.Print("Delete - slice size 0")
	db.Debug().Delete([]*Sample{})

	// Delete - zero value
	fmt.Print("Delete - zero value")
	db.Debug().Delete(&Sample{Num: 0})      // int(0)
	db.Debug().Delete(&Sample{Bool: false}) // bool(false)
	db.Debug().Delete(&Sample{Str: ""})     // string("")
}
```

```sql
Delete - initialization struct
DELETE FROM `samples`    

Delete - slice size 0
DELETE FROM `samples`    

Delete - zero value
DELETE FROM `samples`    
DELETE FROM `samples`    
DELETE FROM `samples`    
```

> * 初期化structを渡すパターン
> * size 0 の sliceを渡すパターン
> * ゼロ値を渡すパターン

は全件削除されます。
例の記事は2番目の罠に引っかかって全件DELETEのSQLを投げてしまったパターンですね。

# ダメな例2: struct の値を pointer で定義する

Updateの際は上手くいったパターンでstructの定義をpointerで指定する場合です。

```Go
package main

import (
	"fmt"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

type Sample struct {
	Num  *int64
	Bool *bool
	Str  *string
}

func main() {
	db, err := gorm.Open("mysql", "root:root@/sample")
	if err != nil {
		panic("failed to connect database")
	}
	defer db.Close()

	// Migrate the schema
	if err := db.AutoMigrate(&Sample{}).Error; err != nil {
		panic(err.Error())
	}

	// Delete - slice size 0
	fmt.Print("Delete - slice size 0")
	db.Debug().Delete([]*Sample{})

	// Delete - zero value
	fmt.Print("Delete - zero value")
	queryInt := int64(0)
	db.Debug().Delete(&Sample{Num: &queryInt})
	queryBool := false
	db.Debug().Delete(&Sample{Bool: &queryBool})
	queryString := ""
	db.Debug().Delete(&Sample{Str: &queryString})
}
```

```
Delete - slice size 0
DELETE FROM `samples`

Delete - zero value
DELETE FROM `samples`
DELETE FROM `samples`
DELETE FROM `samples`
```

slice size 0の場合はともかく
ゼロ値のWhere句生成はUpdateの時上手く動いたので正常に動作しそうですが
結果はWhere句が生成されずに正常に動作しませんでした。


# ダメな例3: `sql.Null` typeを使用する

[Go の database packageで定義されている](https://golang.org/pkg/database/sql/#NullBool) `sql.NullBool` `sql.NullString` `sql.NullInt64` を使う方法です。


```Go
package main

import (
	"database/sql"
	"fmt"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

type Sample struct {
	Num  sql.NullInt64
	Bool sql.NullBool
	Str  sql.NullString
}

func main() {
	db, err := gorm.Open("mysql", "root:root@/sample")
	if err != nil {
		panic("failed to connect database")
	}
	defer db.Close()

	// Migrate the schema
	if err := db.AutoMigrate(&Sample{}).Error; err != nil {
		panic(err.Error())
	}

	// Delete - slice size 0
	fmt.Print("Delete - slice size 0")
	db.Debug().Delete([]*Sample{})

	// Delete - zero value
	fmt.Print("Delete - zero value")
	db.Debug().Delete(&Sample{Num: sql.NullInt64{Int64: 0, Valid: true}})
	db.Debug().Delete(&Sample{Bool: sql.NullBool{Bool: false, Valid: true}})
	db.Debug().Delete(&Sample{Str: sql.NullString{String: "", Valid: true}})
}
```

```
Delete - slice size 0
DELETE FROM `samples`

Delete - zero value
DELETE FROM `samples`
DELETE FROM `samples`
DELETE FROM `samples`
```

pointerでうまく動作しなかったので`sql.Null` typeでも駄目だろうなと思ってましたが案の定ダメでした。

結局、Deleteで誤爆しないためには解決策としては条件に `struct` を使用しない事しかなさそうです。

# 解決策: 条件に `struct` を使用しない

解決策として有効なものはDeleteのWhere句でstructを使わない事です。

```Go
package main

import (
	_ "github.com/go-sql-driver/mysql"
	"github.com/jinzhu/gorm"
)

type Sample struct {
	Num  int64
	Bool bool
	Str  string
}

func main() {
	db, err := gorm.Open("mysql", "root:root@/sample")
	if err != nil {
		panic("failed to connect database")
	}
	defer db.Close()

	// Migrate the schema
	if err := db.AutoMigrate(&Sample{}).Error; err != nil {
		panic(err.Error())
	}

	// Delete list
	fmt.Print("Delete - slice size 0")
	db.Debug().Delete(&Sample{}, "Num IN (?)", []int{})

	// Delete
	fmt.Print("Delete - zero value")
	db.Debug().Delete(&Sample{}, "Num = ?", 0)
	db.Debug().Delete(&Sample{}, "Bool = ?", false)
	db.Debug().Delete(&Sample{}, "Str = ?", "")
}
```

```sql
Delete - slice size 0
DELETE FROM `samples`  WHERE (Num IN (NULL))  

Delete - zero value
DELETE FROM `samples`  WHERE (Num = 0)  
DELETE FROM `samples`  WHERE (Bool = false)  
DELETE FROM `samples`  WHERE (Str = '')  
```

カラム名指定しないといけないのが割とめんどくさいですが全件DELETEされることはなくなります。
sliceのlength=0の場合、妙なquery投げてますがMySQLの場合errorにはなりませんでした。

# 感想
今回検証で初めて知ったこともありました。
Gormを使用する場合はWhere句の生成周りのテストケースをしっかり網羅する必要がありそうです。

例の記事では恐らくテストパターンが足りていなかったので本番のコードにGormに起因するバグを埋め込んでしまったのかなと思います。
既にGormを使っている方はテストでしっかりと動作を検証する事をお勧めします。

Gormと仲良くなりたいというタイトルで幾つか記事を書いていますが
新規に開発する場合はGormを使うという選択肢はないかなと個人的には思っていたりします。