はじめに
Gormが本番テーブルの数億件のデータを消そうとした話 が盛り上がってますね。
ゼロ値に関していうとGoの仕様上structの0
や空文字
が未定義(ゼロ値)なのか検索条件にゼロ値の値を設定しているのか判断出来ず
Gorm v1では取り敢えずゼロ値ならWhere句を生成しないという判断にしているため全件削除するような動きになっています。
sliceのlengthが0件の場合は流石にerrorにせいよと思う部分はありますが…
この件はGormのWhere句を生成する処理に関する話なのでUpdateの際にも当てはまります。
Updateの件は以前検証した記事があるのでそちらを参照してください。
Gorm v2のリリースを待つか、安全な別のlibraryを使用すれば良いのですが
とはいえGorm v1を使ってるアプリケーションのlibraryを切り替えるのは結構大きなリファクタになるので
Gorm v1使って安全なDeleteするためにどうすればいいのか検証していきます。
前提
本記事は以下の環境で検証しています。
- MySQL: 5.7.12
- gorm: v1.9.12
github.com/go-sql-driver/mysql v1.5.0
github.com/jinzhu/gorm v1.9.12
結論
先に結論から言ってしまうと安全にDeleteをするには
条件に struct
を使用しない
ようにしないといけません。それ以外は全部ダメです。
ダメな例1:slice 0件、ゼロ値を指定したstructを条件に指定する
まずは全件削除されてしまうパターンを検証。
検証の為に以下のようなテーブルを作成して
Debug()
で実際に実行されるSQLを見てみましょう。
Num (int) | Bool (bool) | Str(varchar) |
---|
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("")
}
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で指定する場合です。
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で定義されている sql.NullBool
sql.NullString
sql.NullInt64
を使う方法です。
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を使わない事です。
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 = ?", "")
}
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を使うという選択肢はないかなと個人的には思っていたりします。