4
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.

interface をなるべく変更しないように gorm を使う

Posted at

背景

clean architectureでアプリケーションを作っていると db とのデータのやりとりは抽象化しておくことが多いと思います。そのときに定義する interface が findUserByXXX, findByUserZZZ だと他のデータの取り出し方をしたいと思った時に interface を新規に追加しないといけません。メソッドを分けたとしても、似たようなコードがその中に書かれ、コードベースは肥大化していきます。
そこで、クエリビルダっぽいものを作ってなるべくデータの取り出し方を抽象化したメソッド内部でやるのではなく、クエリビルダに任せたいと思いました。
試作的に書いたのでまだ考慮は必要なのですが、簡単なCRUDアプリケーションのときには十分使えそうなので自ら使って改良を加えて行きたいです。

内容

db とのやり取りを Repository を使って実装します。
Repository は Query struct を受け取り、struct に設定された条件をデータ取得時に組み立て、クエリを実行します。そのため、Query struct はビジネスロジック側で条件設定を行いRepository側にただ渡すだけにしました。本当は domain や model の層にinterface や User struct を置きたいのですが簡略化して書いています。あくまでサンプルコードです。

repo.go

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() を呼んでもらえればあとは実行するだけです。

query.go
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 を使えば簡単にできます。

query_test.go
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)
			}
		})
	}
}

参考

4
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
4
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?