Go
golang
ORM
SQLBoiler

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も実装されているようなので、リレーションについても追って試してみたいと思います。