はじめに
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
環境
- Go v1.11
- SQLBoiler v3.0.1
- PostgreSQL v10.5
DBの準備
簡単なToDoアプリを模して、以下のようなスキーマを用意します。
DBはPostgreSQL 10.5で動作を確認しています。
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 = []
dbname
、host
などは接続先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
を使いましょう。
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を選択可能です。
// いろいろ省略
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は、各機能をメソッドとして実装するためのものです。
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に保存するメソッドを実装しました。
// 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
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を実行しています。
// 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エンコードもよしなにやってもらえます。
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後の値です。
// 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できました。
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のコードでもこの型を使うようにしてみます。
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の設定ファイルに追記します。
[[types]]
[types.match]
type = "null.Time"
nullable = true
[types.replace]
type = "mytypes.NullTime"
[types.imports]
third_party = ['mytypes "github.com/uhey22e/sqlboiler-tutor/types"']
これでコードの再生成を行うと、モデルは以下のようになります。
// 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
に変更してみます。
[aliases.tables.todo]
up_plural = "ToDos"
up_singular = "ToDo"
down_plural = "todos"
down_singular = "todo"
[aliases.tables.todo.columns]
finished = "IsFinished"
上記を設定ファイルに追記すると、Structは以下のようになります。
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に対してメソッドを追加する形でテンプレートを書いてみました。
{{- $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
生成されたコードは以下です。
// 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も実装されているようなので、リレーションについても追って試してみたいと思います。