概要
GoでORMを使用する時の定番である gorm のお話です。
SQL Builder
や Auto Migrations
、structへのauto mapping
等
ORM に必要な機能が一通り揃っている便利なやつです。
が、こいつを使う際には幾つか注意が必要です。
今回はそのうちの一つ
gorm で Where/Update の際にstructを使用する
事を目的として検証していきます。
Gormを使用したDelete処理は別途特別に注意が必要です
GormのDELETEで誤爆しないために
を参照してください。
検証 - 失敗パターン
gorm で struct を使用して検索条件を指定したり、レコードの更新を行おうとすると 正常に動作しない場合があります。
どういうことなのか動きを見ていきましょう。
以下はgormを使って
- Table 作成 (AutoMigrate)
- データ投入 (Delete/Create)
- レコード取得 (Find)
をするだけの単純なコードです。
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", "test:test@/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
db.Delete(&Sample{})
// Create
db.Create(&Sample{Num: 0, Bool: false, Str: ""})
db.Create(&Sample{Num: 1, Bool: true, Str: "sample"})
var result []Sample
// 期待する発行SQL: Select * From samples Where Num = 0
db.Where(&Sample{Num: 0}).Find(&result)
fmt.Printf("Where Num=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Bool = 0
db.Where(&Sample{Bool: false}).Find(&result)
fmt.Printf("Where Bool=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Str = ""
db.Where(&Sample{Str: ""}).Find(&result)
fmt.Printf("Where Str=\"\" :\t %+v\n", result)
}
データ投入後テーブルには以下のレコードが入っています。
Num (int) | Bool (int) | Str(varchar) |
---|---|---|
0 | 0 | "" |
1 | 1 | "sample" |
発行される(想定の)SQLは以下の通り
Select * From samples Where Num = 0
Select * From samples Where Bool = 0
Select * From samples Where Str = ""
実行の結果 {Num:0 Bool:false Str:}
1レコードだけ取得されることが期待されますが
実行してみると
Where Num=0 : [{Num:0 Bool:false Str:} {Num:1 Bool:true Str:sample}]
Where Bool=0 : [{Num:0 Bool:false Str:} {Num:1 Bool:true Str:sample}]
Where Str="" : [{Num:0 Bool:false Str:} {Num:1 Bool:true Str:sample}]
全レコード取れてしまいます。
これは int
, bool
, string
の初期値が struct に設定されている場合
未定義(ゼロ値)なのか、検索条件にゼロ値の値を設定しているのか判断出来ないためです。
実際のロジックはgormのコードのここらへんを参照。
Updateについても同様です。
例えば、UPDATE samples SET Bool = 0
を発行する想定で
Gorm Update の記載に従って
updateSample := Sample{Bool:false}
db.Model(&Sample{}).Updates(&updateSample)
のように書いても実際には更新されません。
これは gorm が悪いというよりは Null値 が存在しない Go の仕様上仕方のない話です。
公式のDocにもstruct使うときの注意点として以下のように記述があります。
NOTE When query with struct, GORM will only query with those fields has non-zero value, that means if your field’s value is 0, '', false or other zero values, it won’t be used to build query conditions
解決策1: 条件に struct
を使用しない
これの解決策として一番単純なものはWhere/Updateで struct を使わない事です。
// 期待する発行SQL: Select * From samples Where Num = 0
//db.Where(&Sample{Num: 0}).Find(&result)
db.Where("Num = ?", 0).Find(&result)
fmt.Printf("Where Num=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Bool = 0
//db.Where(&Sample{Bool: false}).Find(&result)
db.Where("Bool = ?", false).Find(&result)
fmt.Printf("Where Bool=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Str = ""
//db.Where(&Sample{Str: ""}).Find(&result)
db.Where("Str = ?", "").Find(&result)
fmt.Printf("Where Str=\"\" :\t %+v\n", result)
Where Num=0 : [{Num:0 Bool:false Str:}]
Where Bool=0 : [{Num:0 Bool:false Str:}]
Where Str="" : [{Num:0 Bool:false Str:}]
Updateの場合はこんな感じ
db.Model(Sample{}).Update("Bool", false)
db.Model(Sample{}).Updates(map[string]interface{}{"Num": 0, "Bool": false, "Str": ""})
解決っちゃ解決なのですが
見ての通り、ORMなのにプリペアードクエリ書かないといけないのが とても つらい
解決策2: struct
の値を pointer
で定義する
今回の問題は要するに struct 内の値がゼロ値か未定義か判断出来ない事が根本原因です。
無理やり感がありますが struct の値を pointer にしてしまえば nil か否かで判断出来ます。
package main
import (
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/jinzhu/gorm"
"reflect"
)
type Sample struct {
Num *int64
Bool *bool
Str *string
}
func (s *Sample) Set(num int64, bool bool, str string) {
s.Num = &num
s.Bool = &bool
s.Str = &str
}
// struct の値を文字列で取得する
func (s Sample) ToString() string {
var str string
v := reflect.Indirect(reflect.ValueOf(s))
t := v.Type()
str = "{"
for i := 0; i < t.NumField(); i++ {
str = str + fmt.Sprintf("%v=%v ", t.Field(i).Name, v.Field(i).Elem().Interface())
}
str = str + "}"
return str
}
func main() {
db, err := gorm.Open("mysql", "test:test@/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
db.Delete(&Sample{})
// Create
var sample Sample
sample.Set(int64(0), false, "")
db.Create(&sample)
sample.Set(int64(1), true, "sample")
db.Create(&sample)
var result []Sample
// 期待する発行SQL: Select * From samples Where Num = 0
queryNum := int64(0)
db.Where(&Sample{Num: &queryNum}).Find(&result)
for _, r := range result {
fmt.Printf("Where Num = 0:\t %s\n", r.ToString())
}
// 期待する発行SQL: Select * From samples Where Bool = 0
queryBool := false
db.Where(&Sample{Bool: &queryBool}).Find(&result)
for _, r := range result {
fmt.Printf("Where Bool = 0:\t %s\n", r.ToString())
}
// 期待する発行SQL: Select * From samples Where Str = ""
queryStr := ""
db.Where(&Sample{Str: &queryStr}).Find(&result)
for _, r := range result {
fmt.Printf("Where Str = \"\":\t %s\n", r.ToString())
}
}
Where Num = 0: {Num=0 Bool=false Str= }
Where Bool = 0: {Num=0 Bool=false Str= }
Where Str = "": {Num=0 Bool=false Str= }
書いてみましたがぶっちゃけpointerだと値の操作がめんどくさいです。
でも、きちんと想定通りの動作はします。
この実装で行くなら ToString()
や Getter/Setter
をきちんと実装したり
Helper書かないとやってられないですね。
解決策2で上手い実装思いついたらまた更新します。
解決策3: sql.Null
typeを使用する
Go の database packageで定義されている sql.NullBool
sql.NullString
sql.NullInt64
を使う方法です。
sql.Null
type はNullかゼロ値か判断する為にValidを定義したstructです。
type NullInt64 struct {
Int64 int64
Valid bool // Valid is true if Int64 is not NULL
}
実際の実装は以下の通り。
struct の値を sql.Null
type に置き換え
その値を使用する際に Valid=true
で使用するようにします。
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 (s *Sample) Set(num int64, bool bool, str string) {
s.Num = sql.NullInt64{Int64: num, Valid: true}
s.Bool = sql.NullBool{Bool: bool, Valid: true}
s.Str = sql.NullString{String: str, Valid: true}
}
func main() {
db, err := gorm.Open("mysql", "test:test@/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
db.Delete(&Sample{})
// Create
var sample Sample
sample.Set(0, false, "")
db.Create(sample)
sample.Set(1, true, "sample")
db.Create(sample)
var result []Sample
// 期待する発行SQL: Select * From samples Where Num = 0
db.Where(&Sample{Num: sql.NullInt64{Int64: 0, Valid: true}}).Find(&result)
fmt.Printf("Where Num=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Bool = 0
db.Where(&Sample{Bool: sql.NullBool{Bool: false, Valid: true}}).Find(&result)
fmt.Printf("Where Bool=0 :\t %+v\n", result)
// 期待する発行SQL: Select * From samples Where Str = ""
db.Where(&Sample{Str: sql.NullString{String: "", Valid: true}}).Find(&result)
fmt.Printf("Where Str=\"\" :\t %+v\n", result)
}
Where Num=0 : [{Num:{Int64:0 Valid:true} Bool:{Bool:false Valid:true} Str:{String: Valid:true}}]
Where Bool=0 : [{Num:{Int64:0 Valid:true} Bool:{Bool:false Valid:true} Str:{String: Valid:true}}]
Where Str="" : [{Num:{Int64:0 Valid:true} Bool:{Bool:false Valid:true} Str:{String: Valid:true}}]
結果は想定通り、きちんとWhereで検索条件を絞り込めています。
pointerを使う案よりは値を使いやすく書けると思います。
毎回 Valid: true
を設定するのは面倒なので、Helperを用意するのが良さそうです。
結論
- gorm を使う前には事前に公式DocのNOTEの部分をよく読むこと
- gorm で 値にGoのゼロ値を使う可能性がある場合は 解決策1~3 のような工夫が必要
- 個人的には解決策3が良さそう