背景
clean architectureでアプリケーションを作っていると db とのデータのやりとりは抽象化しておくことが多いと思います。そのときに定義する interface が findUserByXXX, findByUserZZZ だと他のデータの取り出し方をしたいと思った時に interface を新規に追加しないといけません。メソッドを分けたとしても、似たようなコードがその中に書かれ、コードベースは肥大化していきます。
そこで、クエリビルダっぽいものを作ってなるべくデータの取り出し方を抽象化したメソッド内部でやるのではなく、クエリビルダに任せたいと思いました。
試作的に書いたのでまだ考慮は必要なのですが、簡単なCRUDアプリケーションのときには十分使えそうなので自ら使って改良を加えて行きたいです。
内容
db とのやり取りを Repository を使って実装します。
Repository は Query struct を受け取り、struct に設定された条件をデータ取得時に組み立て、クエリを実行します。そのため、Query struct はビジネスロジック側で条件設定を行いRepository側にただ渡すだけにしました。本当は domain や model の層にinterface や User struct を置きたいのですが簡略化して書いています。あくまでサンプルコードです。
type User struct {
ID uint `json:"-" gorm:"primary_key"`
CreatedAt time.Time `json:"createdAt" gorm:"index"`
UpdatedAt time.Time `json:"updatedAt" gorm:"index"`
Email string `json:"email" gorm:"unique_index"`
Name string `json:"name" gorm:"name"`
}
type Repo interface {
GetUser(q *Query) (User, error)
}
type repo struct {
db *gorm.DB
}
func (a *repo) GetUser(q *Query) (User, error) {
var u User
db := q.build(a.db)
err := db.Take(&u).Error
return u, err
}
クエリはこんな風にひとまず書きました。基本的に、Repository側のメソッドで build() を呼んでもらえればあとは実行するだけです。
package gormq
import (
"time"
"github.com/jinzhu/gorm"
)
type Query struct {
SelectFields []string
Conditions []func(db *gorm.DB) *gorm.DB
Preloads []func(db *gorm.DB) *gorm.DB
Order func(db *gorm.DB) *gorm.DB
ForUpdate func(db *gorm.DB) *gorm.DB
}
func NewQuery(f []string) *Query {
if len(f) == 0 {
f = []string{"*"}
}
return &Query{
SelectFields: f,
}
}
func (a *Query) AddWhere(cond string, v interface{}) {
a.Conditions = append(a.Conditions, func(db *gorm.DB) *gorm.DB {
return db.Where(cond, v)
})
}
func (a *Query) AddPreload(target string) {
a.Preloads = append(a.Preloads, func(db *gorm.DB) *gorm.DB {
return db.Preload(target)
})
}
func (a *Query) AddOr(cond string, v interface{}) {
a.Conditions = append(a.Conditions, func(db *gorm.DB) *gorm.DB {
return db.Or(cond, v)
})
}
func (a *Query) SetOrder(cond string) {
a.Order = func(db *gorm.DB) *gorm.DB {
return db.Order(cond)
}
}
func (a *Query) EnableForUpdate() {
a.ForUpdate = func(db *gorm.DB) *gorm.DB {
return db.Set("gorm:query_option", "FOR UPDATE")
}
}
func (a *Query) build(db *gorm.DB) *gorm.DB {
db = db.Select(a.SelectFields)
for _, item := range a.Conditions {
db = item(db)
}
for _, item := range a.Preloads {
db = item(db)
}
if a.Order != nil {
db = a.Order(db)
}
if a.ForUpdate != nil {
db = a.ForUpdate(db)
}
return db
}
しかし、色々な部分で build() が呼ばれるため小さな変更にも気を使う必要があるかもしれません。そんな時のために、データの取り出し方について最低限テストを書いておくべきかと思います。もちろん、Query 側の SQL 発行のテストもしておきたいですよね。go-sql-mock を使えば簡単にできます。
package gormq
import (
"database/sql/driver"
"reflect"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/jinzhu/gorm"
)
func emptyConn() *gorm.DB {
db, _, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
gdb, _ := gorm.Open("mysql", db)
// gdb.LogMode(true)
return gdb
}
func TestRepo_GetUser(t *testing.T) {
type fields struct {
db func() *gorm.DB
}
type args struct {
q func() *Query
}
tests := []struct {
name string
fields fields
args args
want User
wantErr bool
}{
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT * FROM `users` LIMIT 1").WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{})
return q
},
},
},
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT * FROM `users` WHERE (name = ?) AND (email = ?) LIMIT 1").WithArgs(driver.Value("test"), driver.Value("test@gmail.com")).WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{})
q.AddWhere("name = ?", "test")
q.AddWhere("email = ?", "test@gmail.com")
return q
},
},
},
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT * FROM `users` WHERE (name IN (?,?)) LIMIT 1").WithArgs(driver.Value("1"), driver.Value("2")).WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{})
q.AddWhere("name IN (?)", []string{"1", "2"})
return q
},
},
},
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT id, name FROM `users` WHERE (name IN (?)) OR (email = ?) LIMIT 1").WithArgs(driver.Value("1"), driver.Value("test@com")).WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{"id, name"})
q.AddWhere("name IN (?)", []string{"1"})
q.AddOr("email = ?", "test@com")
return q
},
},
},
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT id, name FROM `users` LIMIT 1").WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{"id, name"})
q.AddPreload("Profile")
return q
},
},
},
{
fields: fields{
db: func() *gorm.DB {
db, mock, _ := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
mock.ExpectQuery("SELECT id, name FROM `users` ORDER BY id desc LIMIT 1").WillReturnRows(sqlmock.NewRows([]string{}))
gdb, _ := gorm.Open("mysql", db)
return gdb
},
},
args: args{
q: func() *Query {
q := NewQuery([]string{"id, name"})
q.SetOrder("id desc")
return q
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &Repo{
db: tt.fields.db(),
}
got, err := a.GetUser(tt.args.q())
if (err != nil) != tt.wantErr {
if err != gorm.ErrRecordNotFound {
t.Errorf("Repo.GetUser() error = %v, wantErr %v", err, tt.wantErr)
return
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Repo.GetUser() = %v, want %v", got, tt.want)
}
})
}
}