Posted at

Goの新定番?ORMのSQLBoilerを使ってみる


はじめに

GoのORMといえばGormが定番ですが、

SQLBoilerというORMを使ってみたので、そのレポートです。

https://github.com/volatiletech/sqlboiler


  • 既に存在するDBで動作する。DBスキーマを定義するツールではない。

  • ActiveRecordのような高い生産性を実現する。

  • Go本来のStructの使い勝手を壊さない。

  • パフォーマンスが良い。

といった特徴があるようです。

パフォーマンスについてはREADME末尾にベンチマーク結果が載せてあり、他のORMと比較しても非常に優秀です。

https://github.com/volatiletech/sqlboiler#benchmarks

この記事に掲載しているコードは、以下のリポジトリにあります。

https://github.com/uhey22e/sqlboiler-tutor


DBの準備

簡単なToDoアプリを模して、以下のようなスキーマを用意します。

DBはPostgreSQL 10.5で動作を確認しています。


sql/10_todo.sql

CREATE TABLE todo (

id bigserial NOT NULL,
title varchar(255) NOT NULL,
note text,
finished boolean NOT NULL,
due_date timestamp,
CONSTRAINT todo_pkc PRIMARY KEY(id)
);


コードの生成

SQLBoilerは、DBからテーブル構造を読み取り、各テーブルに対応するStructを生成します。

また、Structの自動生成と同時に、InsertやSelectなど各DB操作を行うメソッドも自動生成されます。

このような特徴から、SQLBoilerはリフレクションを用いる他のORMと違い、型安全で高速だそうです。

DBからコードを自動生成するために、まずは設定ファイル sqlboiler.tomlをプロジェクトルートに作成します。

$ cat sqlboiler.toml

[psql]
dbname = "sample_db"
host = "localhost"
port = 5432
user = "postgres"
pass = "postgres"
sslmode= "disable"
blacklist = []

dbnamehostなどは接続先DBの情報です。

blacklistは、モデルを自動生成"しない"テーブルを指定することが出来ます。マイグレーション用のテーブルがある場合などに便利そうです。

また、whitelistというオプションを使うと、モデルを自動生成するテーブルを選択することが出来ます。blacklistとどちらか一方の指定となります。

設定ファイルが出来たら、コード生成です。

まずは必要なパッケージの入手から。

SQLBoilerと、PostgreSQL用のドライバをgo getします。

$ go get -u -t github.com/volatiletech/sqlboiler

$ go get github.com/volatiletech/sqlboiler/drivers/sqlboiler-psql

自動生成はsqlboilerコマンドで実行出来ますが、コマンドを忘れないようにgo generateを使いましょう。


main.go

package main

//go:generate sqlboiler --wipe psql

コード生成が成功すれば、以下のようなディレクトリとファイルが生成されているはずです。

todo.goに、テーブルと対応関係のあるStructやメソッドが実装されている状態です。

$ tree ./models

./models
├── boil_main_test.go
├── boil_queries.go
├── boil_queries_test.go
├── boil_suites_test.go
├── boil_table_names.go
├── boil_types.go
├── psql_main_test.go
├── psql_suites_test.go
├── psql_upsert.go
├── todo.go # このファイルがテーブル"todo"に対応している
└── todo_test.go

生成されたStructは、以下の通りです。

カラム名はsnake_caseでしたが、フィールド名ではUpperCamelCaseに変換してくれています。

また、json用タグなども一緒に生成されていて便利そうです。

これらタグのcaseは、自動生成時のオプションでsnake_caseかcamelCaseを選択可能です。


models/todo.go

// いろいろ省略

type Todo struct {
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
Title string `boil:"title" json:"title" toml:"title" yaml:"title"`
Note null.String `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
Finished bool `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
DueDate null.Time `boil:"due_date" json:"due_date,omitempty" toml:"due_date" yaml:"due_date,omitempty"`

R *todoR `boil:"-" json:"-" toml:"-" yaml:"-"`
L todoL `boil:"-" json:"-" toml:"-" yaml:"-"`
}



DB接続

SQLBoilerではDB接続に関する機能は用意されていませんので、

database/sqlを使用し、DBコネクションを作ります。

今回はgoのテストの仕組みを使ってコードを動かします。

AppというStructは、各機能をメソッドとして実装するためのものです。


todo/todo_test.go

func TestMain(m *testing.M) {

// connect to db
psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
"localhost", 5432, "postgres", "postgres", "sample_db",
)
db, err := sql.Open("postgres", psqlInfo)
if err != nil {
panic(err)
}
defer db.Close()

// validate whether or not the connection string was correct
if err := db.Ping(); err != nil {
panic(err)
}

app := &App{
db: db,
}
}



基本的なDB操作


Insert

Sliceを引数に取り、1件以上のTodoをDBに保存するメソッドを実装しました。


todo/todo.go

// Store stores a one or more todos into DB

func (a *App) Store(obj []*models.Todo) error {
// insert for each todo
for _, v := range obj {
if err := v.Insert(context.Background(), a.db, boil.Infer()); err != nil {
return err
}
}
return nil
}

forループでInsert文を回しているのは、SQLBoilerにBulk insert機能が無いためです。

https://github.com/volatiletech/sqlboiler/issues/364

大量のデータをまとめてInsertする用途では、生SQLを書いたほうがいいかもしれません…

このメソッドを、以下のテストコードで試してみます。

null.TimeFromなどの関数は、Nullableなカラムの値を取り扱うためのnull.Timeなどの型に関連するものです。

https://github.com/volatiletech/null


todo/todo_test.go

func TestStore(t *testing.T) {

// prepare sample insert data
now := time.Now().UTC()
todos := []*models.Todo{
&models.Todo{
Title: "Sample ToDo 1",
DueDate: null.TimeFrom(now.Add(3 * time.Hour)),
Note: null.StringFrom("note1..."),
Finished: false,
},
&models.Todo{
Title: "Sample ToDo 2",
DueDate: null.TimeFrom(now.Add(6 * time.Hour)),
Note: null.StringFrom("note2..."),
Finished: false,
},
}

// store
if err := testApp.Store(todos); err != nil {
t.Fatal(err)
}

for _, v := range todos {
t.Log(fmt.Sprintf("%+v", v))
}
}


無事にInsertできました。

sample_db=# select * from todo;

id | title | note | finished | due_date
----+---------------+----------+----------+----------------------------
1 | Sample ToDo 1 | note1... | f | 2018-09-15 14:34:58.605844
2 | Sample ToDo 2 | note2... | f | 2018-09-15 17:34:58.605844
(2 rows)


Select

finished == falseなレコードをSelectするメソッドを実装してみます。

Where句をオプションで指定するのですが、

github.com/volatiletech/sqlboiler/queries/qmを別途Importする必要があります。

Allメソッドはヒットするレコードが無いとエラーを返すのですが、

ヒットしない場合には空のスライスを返したいので、一度Countを実行しています。


todo/todo.go

// FetchUnfinished fetches unfinished ToDos

func (a *App) FetchUnfinished() ([]*models.Todo, error) {
queries := []qm.QueryMod{
qm.Where(models.TodoColumns.Finished+"=?", false),
}

// count up records
counts, err := models.Todos(queries...).Count(context.Background(), a.db)
if err != nil {
return nil, err
} else if counts == 0 {
return make([]*models.Todo, 0), nil
}

// fetch!
return models.Todos(queries...).All(context.Background(), a.db)
}


以下のテストコードで無事にSelectできました。

Structの自動生成時にjsonタグも付いてくるので、jsonエンコードもよしなにやってもらえます。


todo/todo_test.go

func TestFetchUnfinished(t *testing.T) {

todos, err := testApp.FetchUnfinished()
if err != nil {
t.Fatal(err)
}
for _, v := range todos {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(v); err != nil {
t.Fatal(err)
}
t.Log(buf.String())
}
}

$ go test -v ./todo --run TestFetchUnfinished

=== RUN TestFetchUnfinished
--- PASS: TestFetchUnfinished (0.00s)
todo_test.go:80: {"id":1,"title":"Sample ToDo 1","note":"note1...","finished":false,"dueDate":"2018-09-15T14:34:58.605844Z"}

todo_test.go:80: {"id":2,"title":"Sample ToDo 2","note":"note2...","finished":false,"dueDate":"2018-09-15T17:34:58.605844Z"}

PASS
ok github.com/uhey22e/sqlboiler-tutor/todo 0.010s


Update

1つ以上の指定したIDのToDoを、finished = trueにUpdateするメソッドを実装しました。

WhereInでIDを指定するのですが、このメソッドは引数が[]interface{}型のため、キャストが必要になります。

https://github.com/volatiletech/sqlboiler/issues/227

Updateするカラムと値の指定は、map[string]interface{}型の変数で行います。

Keyがカラム名、ValueがUpdate後の値です。


todo/todo.go

// Finish update one or more records selected by ID

func (a *App) Finish(ids []int64) error {
// Set updating columns
updCols := map[string]interface{}{
models.TodoColumns.Finished: true,
}

// WhereIn method needs to pass a slice of interface{}
targetIDs := make([]interface{}, len(ids))
for i, v := range ids {
targetIDs[i] = v
}
query := qm.WhereIn(models.TodoColumns.ID+" IN ?", targetIDs...)

// update!
_, err := models.Todos(query).UpdateAll(context.Background(), a.db, updCols)

return err
}


以下のテストコードで無事にUpdateできました。


todo/todo_test.go

func TestFinish(t *testing.T) {

if err := testApp.Finish([]int64{1, 2}); err != nil {
t.Fatal(err)
}
}

sample_db=# select * from todo;

id | title | note | finished | due_date
----+---------------+----------+----------+----------------------------
1 | Sample ToDo 1 | note1... | t | 2018-09-15 14:34:58.605844
2 | Sample ToDo 2 | note2... | t | 2018-09-15 17:34:58.605844
(2 rows)


コード生成のカスタマイズ

SQLBoilerのコード生成は、オプションによってカスタマイズすることが出来ます。

ここからがSQLBoilerの真髄…?

https://github.com/volatiletech/sqlboiler#controlling-generation


型の変更

PostgreSQLにてtimestamp型のカラムがあった場合、SQLBoilerはデフォルトではtime.Timeあるいはnull.Time型を使用しますが、

この型をカスタマイズすることが可能です。

https://github.com/volatiletech/sqlboiler#types

例えば、time.Time型をJSONエンコードする際は、UnixTimestampにしたいというシチュエーションを考えてみます。

JSONエンコードの挙動を変更するには、対象の型をembedした新たなStructを定義し、MarshalJSONメソッドを再実装することで、json.Marshalerインタフェースを実装する、というのがひとつのアプローチです。

このアプローチにて、null.Time型をembedしたNullTimeというStructを新たに定義し、SQLBoilerのコードでもこの型を使うようにしてみます。


types/time.go

package types

import (
"encoding/json"
"time"

"github.com/volatiletech/null"
)

type NullTime struct {
null.Time
}

func (t NullTime) MarshalJSON() ([]byte, error) {
var ts int64
if !t.Time.Valid {
return json.Marshal(nil)
}
ts = t.Time.Time.Unix()
return json.Marshal(ts)
}

func NullTimeFrom(t time.Time) NullTime {
return NullTime{
Time: null.TimeFrom(t),
}
}


型のカスタマイズは以下のように、SQLBoilerの設定ファイルに追記します。


sqlboiler.toml

[[types]]

[types.match]
type = "null.Time"
nullable = true

[types.replace]
type = "mytypes.NullTime"

[types.imports]
third_party = ['mytypes "github.com/uhey22e/sqlboiler-tutor/types"']


これでコードの再生成を行うと、モデルは以下のようになります。


models/todo.go

// Todo is an object representing the database table.

type Todo struct {
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
Title string `boil:"title" json:"title" toml:"title" yaml:"title"`
Note null.String `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
Finished bool `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
DueDate mytypes.NullTime `boil:"due_date" json:"dueDate,omitempty" toml:"dueDate" yaml:"dueDate,omitempty"`
R *todoR `boil:"-" json:"-" toml:"-" yaml:"-"`
L todoL `boil:"-" json:"-" toml:"-" yaml:"-"`
}

この状態で先程のFetchUnfinishedメソッドを実行すると、以下のような結果が得られます。メソッドの修正は必要ありません。

dueDateがきちんとUnixTimestampになっています。データが少し変わっているのは、null値のエンコードを見るためにデータを入れ直したためです。

多少であれば手書きでも良いかもしれませんが、テーブルが増えてきたときのことを考えると、この仕組みはありがたいです。

$ go test -v ./todo --run TestFetchUnfinished

=== RUN TestFetchUnfinished
--- PASS: TestFetchUnfinished (0.00s)
todo_test.go:86: {"id":1,"title":"Sample ToDo 1","note":"note1...","finished":false,"dueDate":1537086524}

todo_test.go:86: {"id":2,"title":"Sample ToDo 2","note":"note2...","finished":false,"dueDate":1537097324}

todo_test.go:86: {"id":3,"title":"Sample ToDo 3","note":"note2...","finished":false,"dueDate":null}

PASS
ok github.com/uhey22e/sqlboiler-tutor/todo 0.009s


テーブル名のエイリアスの変更

SQLBoilerは、snake_caseのテーブル名・カラム名に対して、良い感じにUpperCamelCase/LowerCamelCaseの名前を生成してくれますが、これを変更することが出来ます。

https://github.com/volatiletech/sqlboiler#aliases

今回はtodoというテーブル名に対してTodoというStructを生成してくれていますが、これをToDoに、

finishedというカラム名に対してFinishedというフィールド名を生成してくれていますが、これをIsFinishedに変更してみます。


sqlboiler.toml

[aliases.tables.todo]

up_plural = "ToDos"
up_singular = "ToDo"
down_plural = "todos"
down_singular = "todo"

[aliases.tables.todo.columns]
finished = "IsFinished"


上記を設定ファイルに追記すると、Structは以下のようになります。


models/todo.go

type ToDo struct {

ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
Title string `boil:"title" json:"title" toml:"title" yaml:"title"`
Note null.String `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
IsFinished bool `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
DueDate mytypes.NullTime `boil:"due_date" json:"dueDate,omitempty" toml:"dueDate" yaml:"dueDate,omitempty"`
R *todoR `boil:"-" json:"-" toml:"-" yaml:"-"`
L todoL `boil:"-" json:"-" toml:"-" yaml:"-"`
}

正直あまり使い所が思いつきませんが、

DBのカラム名が変更になった際、Goのコードを修正するのが大変なときに、一時しのぎとして使えるかもしれません。

あるいは、DBのテーブル名に諸々の都合でPrefix/Suffixをつけているが、Goのコードではこれを排除したい、という場合にも使えるかもしれません。


テンプレートの追加

自動生成のカスタマイズではカバーしきれない場合、自前でテンプレートを書くことで自動生成されるコードを追加することが出来ます。

https://github.com/volatiletech/sqlboiler#templates

先程のInsertのメソッドではfor文を書きましたが、

毎回似たようなコードを書くのも大変なので、これを自動生成してみます。

モデルのSliceに対してメソッドを追加する形でテンプレートを書いてみました。


boil_templates/90)multi_insert.go.tpl

{{- $alias := .Aliases.Table .Table.Name}}

// Insert inserts multiple records
func (o {{$alias.UpSingular}}Slice) Insert({{if .NoContext}}exec boil.Executor{{else}}ctx context.Context, exec boil.ContextExecutor{{end}}, columns boil.Columns) error {
for _, v := range o {
if err := o.Insert({{if not .NoContext}}ctx, {{end -}} exec, columns); err != nil {
return err
}
}
return nil
}


テンプレートを追加した場合には、それを保存したディレクトリを生成時に指定する必要があります。

標準のテンプレートも指定する必要があるのでご注意を。

sqlboiler --wipe \

--templates ${GOPATH}/src/github.com/volatiletech/sqlboiler/templates,\
${GOPATH}/src/github.com/volatiletech/sqlboiler/templates_test,\
${GOPATH}/src/github.com/uhey22e/sqlboiler-tutor/boil_templates \
--struct-tag-casing camel psql

生成されたコードは以下です。


models/todo.go

// Insert inserts multiple records

func (o ToDoSlice) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error {
for _, v := range o {
if err := o.Insert(ctx, exec, columns); err != nil {
return err
}
}
return nil
}

ただfor文を回すだけです。Insert文は減りません。

今回はテーブルをひとつしか使っていませんが、すべてのテーブルに対して同様のコードが生成されます。


おわりに

他言語のORMを触った経験が少ないため、比較はあまり出来ませんが、

リフレクションを使った複雑なコードになっていないため、

DB周りで何かおかしなことがあったときに原因が追いやすい、というのはあると思います。

今回はひとつのテーブルに対しての操作しか試していませんが、

Eager Loadingも実装されているようなので、リレーションについても追って試してみたいと思います。