10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

BeeXAdvent Calendar 2019

Day 18

gormと仲良くなりたい(1) - gorm の Where/Update で struct を使いたい

Last updated at Posted at 2019-12-17

概要

GoでORMを使用する時の定番である gorm のお話です。

SQL BuilderAuto Migrationsstructへの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が良さそう
10
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?