2021-05-26

SQLBoiler の使い方を簡単にまとめた

最近、業務で使っている SQLBoiler の使い方についてわかったことをまとめておきます。

info
  • 当記事で扱うSQLBoilerのバージョンは v3.6.1 です。
  • バージョンによっては挙動が異なる箇所があるかもしれません。

SQLBoilerとは?

SQLBoilerはデータベーススキーマから Go の ORMコード を自動生成してくれるライブラリです。

参照するスキーマは定義ファイルではなく実際に動いているDBなので、 実際に試すためには実行環境が必要です。

手元の検証用コードをリポジトリに移動したので必要があれば使ってみてください。

https://github.com/righ/sqlboiler-example

$ docker-compose up # -d $ docker exec -it sqlboiler bash

のようにコンテナを立ち上げて中に入り、自動生成するためのコマンドは以下のようにインストールしましょう。

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

これで準備完了です。この記事で示すコマンド等はこのコンテナの中で実行できるはずです。

PostgreSQLの説明も多少書きますが、基本的に MySQL を前提に進めていきます。

もしかしたらハマりどこかもしれないのでスキーマに関する注意点も書いておきます。

warning
  • SQLBoilerが対象とするテーブルは一つ以上の主キーをもつ必要があります。ただしサロゲートキーである必要はありません。(ないと Error: unable to initialize tables: primary key missing in tables (pilots) のようになります)
  • ユニークキー, 外部キー, NOT NULL のような制約も自動生成されるコードに影響するので用途に合わせてできるだけ設定しておくとよいでしょう。
    • NOT NULL制約がないとモデル(後述)のフィールドはプリミティブな型ではなく null.String のような独自の型で出力されます。
      • 詳細はこの辺を: https://github.com/volatiletech/null
      • 同僚曰く扱いづらいということなので できる限り NOT NULL 制約をつけるようにしたほうがよいでしょう。

接続情報

以下のように接続情報を設定ファイルに書きます。形式は TOML, YAML, JSON のいずれかです。 今回は TOML にしてみました。

sqlboiler.toml
[psql]
  dbname = "db"
  host   = "postgres"
  port   = 5432
  user   = "usr"
  pass   = "pw"
  sslmode = "disable"
  schema = "public"
  blacklist = ["migrations", "other"]

[mysql]
  dbname  = "db"
  host    = "mysql"
  port    = 3306
  user    = "usr"
  pass    = "pw"
  sslmode = "false"

RDBMSの違いによる実装方法はさほど違いありませんが、 設定や出力方法、接続については多少の違いがあります。

  • MySQL
  • PostgreSQL
  • # スキーマ作成 $ mysql -h mysql -uusr -p db < schema.sql Enter password: # pw $ go get github.com/volatiletech/sqlboiler/drivers/sqlboiler-mysql $ sqlboiler mysql --output models_mysql --pkgname models_mysql --wipe
  • # スキーマ作成 $ psql -h postgres -U usr db -f schema.sql Password for user usr: # pw $ go get github.com/volatiletech/sqlboiler/drivers/sqlboiler-psql $ sqlboiler psql --output models_psql --pkgname models_psql --wipe
  • mysql.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	jets, _ := models.Jets().All(context.Background(), db)
    	fmt.Println("jets:", jets)
    }
    
    
  • psql.go
    package main
      
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_psql"
    
    	_ "github.com/lib/pq"
    )
    
    func main() {
    	db, _ := sql.Open(
    		"postgres",
    		"host=postgres dbname=db user=usr password=pw sslmode=disable")
    	jets, _ := models.Jets().All(context.Background(), db)
    	fmt.Println("jets:", jets)
    }
    
    

今回は複数のDBがあるため便宜的に出力先およびパッケージ名を指定しましたが 指定しなければデフォルトで models になります。

--output
  • 出力先のディレクトリを変更する(default: models)
--pkgname
  • パッケージ名を変更する(default: models)

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

スキーマ作成時に指定するセッションと利用時に接続するセッションは異なるので混同しないようにしましょう。

info
  • 上記の例では一度しかDBに接続しなかったため、とくに不便さを感じませんでしたが DBに接続するたびに dbセッションを指定するのはめんどくさいと感じるかもしれません。
  • boil.SetDB(db) とすることで接続情報をグローバル領域に保存してそれを利用することができます。
  • この接続情報は .AllG() のように G を末尾につけた関数を呼び出すことで利用できます。
  • P を指定するとエラーのときにパニックが発生するようになります。(err の返却値がなくなるため注意)
  • G と併用する場合 G を先に書き、 AllGP() のようにします。

Model

SQLBoiler では 一つのテーブルをモデル構造体として出力します。

各モデル構造体にはレコードを扱うためのメソッドが以下のように定義されています。

Insert
  • レコードをテーブルに追加するためのメソッドです。
  • insert.go
    package main
    
    import (
    	"context"
    	"database/sql"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/boil"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	pilot := models.Pilot{
    		ID:   1,
    		Name: "Righ",
    	}
    	pilot.Insert(context.Background(), db, boil.Infer())
    }
    
    
    root@8ebb694c0f41:~# go run insert.go root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM pilots;" +----+------+ | id | name | +----+------+ | 1 | Righ | +----+------+
  • バルクインサートはサポートされていません。
  • カスタムテンプレートを書けば実現できるようです。(カスタムテンプレートについては後述)
Update
  • 変更後のレコードをテーブルに反映させるメソッドです。
  • update.go
    package main
    
    import (
    	"context"
    	"database/sql"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/boil"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	pilot := models.Pilot{
    		ID:   1,
    		Name: "Crohaco",
    	}
    	pilot.Update(context.Background(), db, boil.Infer())
    }
    
    
  • root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM pilots;" +----+---------+ | id | name | +----+---------+ | 1 | Crohaco | +----+---------+
Reload
  • レコードを最新の状態に更新するメソッドです。
  • reload.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	pilot := models.Pilot{
    		ID:   1,
    		Name: "",
    	}
    	pilot.Reload(context.Background(), db)
    	fmt.Println(pilot.ID, pilot.Name)
    }
    
    
  • root@8ebb694c0f41:~# go run reload.go 1 Crohaco
Upsert
  • レコードが既に登録されていれば更新、されていなければ追加するメソッドです。 登録の有無はプライマリキーによって行われます。(未検証)
  • 3番目引数には UPDATEフィールド, 4番目の引数には INSERTフィールド を指定します。
  • upsert.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/boil"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	pilot := models.Pilot{
    		ID:   1,
    		Name: "Righ",
    	}
    	if err := pilot.Upsert(ctx, db, boil.Infer(), boil.Infer()) {
    		fmt.Println(err)
    	}
    
    	jet1 := models.Jet{
    		ID:      1,
    		PilotID: pilot.ID,
    		Age:     10,
    		Name:    "F-15",
    		Color:   "Gray",
    	}
    	if err := jet1.Upsert(ctx, db, boil.Infer(), boil.Infer()) {
    		fmt.Println(err)
    	}
    	
    	jet1.Age = 0
    	jet1.Name = "MRJ"
    	jet1.Color = "White"
    	if err := jet1.Upsert(ctx, db, boil.Whitelist("name", "color"), boil.Infer()); err != nil {
    		fmt.Println(err)
    	}
    
    	jet2 := models.Jet{
    		ID:      2,
    		PilotID: pilot.ID,
    		Age:     20,
    		Name:    "F-14",
    		Color:   "Gray",
    	}
    	if err := jet2.Upsert(ctx, db, boil.Whitelist("name", "color"), boil.Infer()); err != nil {
    		fmt.Println(err)
    	}
    }
    
    
  • root@8ebb694c0f41:~# go run upsert.go root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM jets;" +----+----------+-----+------+-------+ | id | pilot_id | age | name | color | +----+----------+-----+------+-------+ | 1 | 1 | 10 | MRJ | White | | 2 | 1 | 20 | F-14 | Gray | +----+----------+-----+------+-------+
  • フィールドは boil.Whitelist("field", "field2") のように可変長で複数指定します。 id=1 のレコードは age が更新されていないのがわかりますね。
  • UPDATEのフィールド以外に追加のフィールドも指定できますが、 追加するフィールドを限定する場合はスキーマにデフォルト値が設定されている必要があります。
    • (MySQLの場合)例えば boil.Whitelist("id", "pilot_id") を INSERTフィールド に指定すると以下のようなエラーになります。
      • models_mysql: unable to upsert for jets: Error 1364: Field 'age' doesn't have a default value
    • フィールドを指定しない場合は boil.Infer() を指定すると推測されたフィールドが利用されます。大抵はこれで十分です。
  • 複合キーを持つテーブルでのUpsertはバグにより正常に動作しない箇所があります。
  • https://github.com/volatiletech/sqlboiler/issues/328
  • 正確には覚えていないんですが、追加用のフィールドと更新用のフィールドを両方とも明示するとどちらかが勝ってしまって期待した動作をしなかったような記憶があります。 こんな感じにUpsertはいくつかバグがあったので、正常に動作しない場合は発行されているSQLを確認してみることをおすすめします。
Delete
  • 該当レコードをテーブルから削除するためのメソッドです。
  • delete.go
    package main
    
    import (
    	"context"
    	"database/sql"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	ctx := context.Background()
    	pilot, _ := models.FindPilot(ctx, db, 1)
    	pilot.Delete(ctx, db)
    }
    
    
  • root@8ebb694c0f41:~# go run delete.go root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM pilots;" root@8ebb694c0f41:~#
info
  • テーブルが外部から参照されている場合、 外部レコードを作るためのメソッド(Add{モデル}s?, Set{モデル}s?)が作られますが 制約によって作られるメソッドは異なります。
    • 例えば Pilot モデルは AddJets メソッドを持ちますが、 jets.pilot_id にUNIQUE制約があったり、 pilot_id 自体を主キーに設定した場合、 AddJets ではなく SetJet メソッドが作られるという具合です。
  • (記憶が曖昧ですが) 主キーを間違えた場合に AddJet (単数形)が作られたこともありました。 期待通りのモデルが作られない場合はスキーマを見直してみてください。
  • 2カラムしかない純粋な中間テーブルはモデル自体が生成されないと言った挙動をするみたいです。
  • 生成ルールに関するドキュメントはなさそうな気がする

Query

Queryはテーブルからレコードを抽出するための関数群です。

Django をやったことがあるなら QuerySet と同じようなものと考えるとわかりやすいです。

Modelと同様、Queryという名前の関数があるわけではなくテーブルごとに対応するQueryがあります。 models.Jet モデルに対応するQueryなら models.Jets のような関数があり、 実行するには models.Jets(mods...) のようにします。

(modsについては次のセクションで説明します)

Mod

SQLBoilerはクエリに対する操作は mod という概念で表現され、以下のような種類があります。

Select
  • 抽出対象の列を指定
InnerJoin
  • 内部結合のテーブルと条件を指定
Where
  • 検索条件を指定
Eq
  • 完全一致検索の条件を指定
GroupBy
  • グルーピングする列を1つ以上指定する
OrderBy
  • ソート対象の列を1つ以上指定する
Having
  • HAVING(グルーピング後の絞り込み条件)を指定する
Limit
  • LIMITを指定する
Offset
  • OFFSETを指定する
Load
  • Earger load する列を指定する

これだけだとどのように使うかわかりにくいので使用例を書いてみます。たとえば

SELECT pilots.name, MIN(jets.age) AS age FROM jets INNER JOIN pilots ON pilots.id = jets.pilot_id WHERE jets.age >= 20 GROUP BY pilots.name HAVING COUNT(*) >= 2 ORDER BY age desc LIMIT 2 OFFSET 1 ;

のようなSQLを作ろうとしたら以下のように書きます。

mods.go
package main

import (
	"context"
	"database/sql"
	"fmt"

	models "sqlboiler-example/models_mysql"

	_ "github.com/go-sql-driver/mysql"
	"github.com/volatiletech/sqlboiler/queries/qm"
)

func main() {
	ctx := context.Background()
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")

	jets, err := models.Jets(
		qm.Select("pilots.name as name", "MIN(jets.age) AS age"),
		qm.InnerJoin("pilots on pilots.id = jets.pilot_id"),
		qm.Where("age >= 20"),
		qm.GroupBy("pilots.name"),
		qm.Having("count(*) >= 2"),
		qm.OrderBy("age DESC"),
		qm.Limit(2),
		qm.Offset(1),
	).All(ctx, db)
	if err != nil {
		fmt.Println(err)
	}
	for _, jet := range jets {
		fmt.Println(jet.Name, jet.Age)
	}
}

root@8ebb694c0f41:~# go run mods.go Albert 20 Warner 20

Eager loading

先程軽く触れましたが、これについてはもう少し詳細に書いておきます。

qm.Load("Field") とすることで 外部キーのIDをまとめて参照先テーブルに別クエリを発行し、取得した結果を自動的に紐付けてくれます。 SQL自体を変更するものではないと言う意味で ほかの Mod とは少々異なる機能と言えます。

Django でいうなら prefetch_related といったところでしょうか。イメージがわかない方はこちらを見たほうがわかりやすいかもしれません。

外部キーのリレーションを一つずつ解決しても同じ結果を得ることはできますが、 レコード件数の回数クエリが発生する所謂 N+1問題 となるためおすすめできません。

Load の第一引数はリレーションを表す ドット区切りの文字列です。 この文字列は XXXRels (XXXはモデル名)に定義されているのでそれを使うのが確実です。

(ドット区切りにする場合は自分で組み立てる必要がある)

load.go
package main

import (
	"context"
	"database/sql"
	"fmt"

	models "sqlboiler-example/models_mysql"

	_ "github.com/go-sql-driver/mysql"
	"github.com/volatiletech/sqlboiler/queries/qm"
)

func main() {
	ctx := context.Background()
	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")

	pilots, err := models.Pilots(
		qm.Load("Languages"),
	).All(ctx, db)
	if err != nil {
		fmt.Println("err:", err)
	}
	fmt.Println("num of pilots:", len(pilots))
	for _, pilot := range pilots {
		fmt.Printf("%s: ", pilot.Name)
		for _, lang := range pilot.R.Languages {
			fmt.Printf("%s ", lang.Language)
		}
		fmt.Println()
	}
}

ちなみにLoadしたリレーションは R 経由で参照できます。 リレーションが単数形か複数系かはリレーションの向きとユニーク制約によります。

languagespilots は中間テーブルにより N:M の関係になっているため複数形での参照となります。

info
  • 別のリレーションを参照するときに真っ先に思い浮かぶのは JOIN ですね。
  • では JOIN と比べた Eager load の優位性はなんでしょうか?
  • JOINは結合条件に該当するレコードが複数あれば行数が増え、該当がなければ行数は0になります。 この性質はうまく使えばありがたいものですが、単に一覧したい場合は扱いにくいものでしょう。
  • Eager load で取得したリレーションはその件数によらず、もとのレコード数に影響を与えません。
  • 先程のコードを見てもわかるかもしれませんが、 qm.InnerJoin によって結合したフィールドは Select() で指定することで参照できますが、それを受けるためのフィールドが定義された構造体が必要です。
  • https://github.com/volatiletech/sqlboiler/issues/536
  • おそらく、 SQLBoiler は外部リレーション取得は基本的に JOIN ではなく Eager load で行うという コンセプトなんでしょう。(LEFT JOINも使えないしね)
warning
  • qm.Select でフィールドを限定する場合、外部キーのフィールドも含めないとロードしたリレーションが nil になります。
  • Loadしないでリレーション(R)を参照しても何も取れません。

Finisher

さて、先程実行例を書いたのでもうお気づきかも知れませんが実はクエリはそのままでは利用できません。

クエリから結果を抽出したり、テーブルに変更を加えるための操作が Finisher です。

One
  • 該当レコードの先頭をモデル(構造体)のリファレンスで取得します。
  • one.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	pilot, err := models.Pilots().One(ctx, db)
    	if err != nil {
    		fmt.Println("err:", err)
    	}
    	fmt.Println(pilot)
    }
    
    
  • root@8ebb694c0f41:~# go run one.go &{1 Manfred <nil> {}}
All
  • すべての該当レコードをモデルのスライスで取得します。
  • all.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	pilots, err := models.Pilots().All(ctx, db)
    	if err != nil {
    		fmt.Println("err:", err)
    	}
    	for _, pilot := range pilots {
    		fmt.Println(pilot.Name)
    	}
    }
    
    
  • root@8ebb694c0f41:~# go run all.go Manfred William Albert Warner Andrew
Count
  • 該当レコードの数を返却します。
  • count.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	count, err := models.Pilots().Count(ctx, db)
    	if err != nil {
    		fmt.Println("err:", err)
    	}
    	fmt.Println("num of pilots:", count)
    }
    
    
  • root@8ebb694c0f41:~# go run count.go num of pilots: 5
UpdateAll
  • 該当レコードをすべて更新します。更新内容は map で指定します。
  • update_all.go
    package main
    
    import (
    	"context"
    	"database/sql"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	models.Pilots().UpdateAll(ctx, db, map[string]interface{}{
    		"name": "Saki",
    	})
    }
    
    
  • root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM pilots;" +----+------+ | id | name | +----+------+ | 1 | Saki | | 2 | Saki | | 3 | Saki | | 4 | Saki | | 5 | Saki | +----+------+
DeleteAll
  • 該当レコードをすべて削除します。
  • delete_all.go
    package main
    
    import (
    	"context"
    	"database/sql"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	models.Jets().DeleteAll(ctx, db)
    }
    
    
  • root@8ebb694c0f41:~# go run delete_all.go root@8ebb694c0f41:~# mysql -h mysql -uusr -ppw db -e "SELECT * FROM jets;" root@8ebb694c0f41:~#
Exists
  • 該当するレコードが1件以上存在することを確認します。
  • exists2.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	exists, err := models.Pilots().Exists(ctx, db)
    	if err != nil {
    		fmt.Println("err:", err)
    	}
    	fmt.Println(exists)
    }
    
    
  • root@8ebb694c0f41:~# go run exists2.go true

Shortcuts

クエリを経由して Finisher で結果を得られますが、単純な処理を長々と書きたくないものです。

以下のメソッドについては Queryを介さずに呼び出せます。

Find
  • Query(qm.Where("id=?", id)).One(ctx, db) と同じです。
  • find.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	pilot1, _ := models.FindPilot(ctx, db, 1)
    	pilot2, _ := models.FindPilot(ctx, db, 2)
    
    	fmt.Println(pilot1, pilot2)
    }
    
    
  • root@8ebb694c0f41:~# go run find.go &{1 Crohaco <nil> {}} <nil>
Exists
  • Query(qm.Where("id=?", id)).Exists(ctx, db) と同じです。
  • exists.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	exists1, _ := models.PilotExists(ctx, db, 1)
    	exists2, _ := models.PilotExists(ctx, db, 2)
    
    	fmt.Println(exists1, exists2)
    }
    
    
  • root@8ebb694c0f41:~# go run exists.go true false

Raw query

残念なことにこれまでの機能ではサブクエリ を含む複雑なクエリや LEFT JOIN などを実現できません。

一応 Raw() によって生SQLを書けばカバーできるのでその方法を抑えておきましょう。

Bind
  • Raw によって生成されたクエリを Bind() によって構造体に紐付けることでレコードを抽出できます。
  • このときフィールド名を構造体のフィールド合わせる必要があります。 複数の構造体を埋め込んでフィールド名が重複している場合 {モデル名(小文字).フィールド名} とします。 (READMEだと複数形で書いてあったけど動かなかった..)
  • raw.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/queries"
    )
    
    type PilotAndJet struct {
    	models.Pilot    `boil:",bind"`
    	models.Language `boil:",bind"`
    }
    
    func main() {
    	var pajs []PilotAndJet
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    	if err := queries.Raw(`
    		SELECT
    		  p.id AS "pilot.id", p.name, COALESCE(l.language, 'NO LANG') AS language
    		FROM pilots p
    		  LEFT JOIN pilot_languages pl ON pl.pilot_id = p.id
    		  LEFT JOIN languages l ON l.id = pl.language_id AND p.id != 1`,
    	).Bind(context.Background(), db, &pajs); err != nil {
    		fmt.Println(err)
    	}
    	for _, paj := range pajs {
    		fmt.Println(paj)
    	}
    }
    
    
  • $ go run raw.go {{1 Saki <nil> {}} {0 NO LANG <nil> {}}} {{2 Saki <nil> {}} {0 English <nil> {}}} {{3 Saki <nil> {}} {0 English <nil> {}}} {{4 Saki <nil> {}} {0 German <nil> {}}} {{4 Saki <nil> {}} {0 English <nil> {}}} {{5 Saki <nil> {}} {0 NO LANG <nil> {}}}
  • LEFT JOIN で NULL になると変換に失敗するので COALESCE 関数などで補完する必要がありそうです。
  • error
    • failed to bind pointers to obj: sql: Scan error on column index 2, name "language": converting NULL to string is unsupported
Exec
  • Exec は クエリを実行するためのメソッドです。 SELECT 以外の変更を加える文を実行するために使います。
  • exec.go
    package main
    
    import (
    	"context"
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/queries"
    )
    
    func main() {
    	ctx := context.Background()
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	queries.Raw(`
    		INSERT INTO pilots (id, name)
    		VALUES (6, 'Diddy')
    	`).Exec(db)
    
    	pilot, _ := models.FindPilot(ctx, db, 6)
    	fmt.Println(pilot)
    }
    
    
  • root@8ebb694c0f41:~# go run exec.go &{6 Diddy <nil> {}}

おまけ

以下のメソッドはクエリの実行結果を sql.Row または sql.Rows で返します。

SQLBoilerをメインで使っていればあまり使うことはないかもしれませんが、 これまでの資産としてこれらを引数にしたユーティリティーなどを持っていれば使ってみましょう。

QueryRow
  • queryrow.go
    package main
    
    import (
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/queries/qm"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	row := models.Pilots(
    		qm.Select("id", "name"),
    	).Query.QueryRow(db)
    
    	var id int
    	var name string
    	if err := row.Scan(&id, &name); err != nil {
    		fmt.Println("err:", err)
    	}
    	fmt.Println(id, name)
    }
    
    
  • root@8ebb694c0f41:~# go run queryrow.go 1 Saki
Query
  • query.go
    package main
    
    import (
    	"database/sql"
    	"fmt"
    
    	models "sqlboiler-example/models_mysql"
    
    	_ "github.com/go-sql-driver/mysql"
    	"github.com/volatiletech/sqlboiler/queries/qm"
    )
    
    func main() {
    	db, _ := sql.Open("mysql", "usr:pw@tcp(mysql)/db")
    
    	rows, err := models.Pilots(
    		qm.Select("id", "name"),
    	).Query.Query(db)
    	if err != nil {
    		fmt.Println("err:", err)
    	}
    	defer rows.Close()
    
    	for rows.Next() {
    		var id int
    		var name string
    		if err := rows.Scan(&id, &name); err != nil {
    			fmt.Println("err:", err)
    			continue
    		}
    		fmt.Println(id, name)
    	}
    }
    
    
  • root@8ebb694c0f41:~# go run query.go 1 Saki 2 Saki 3 Saki 4 Saki 5 Saki

カスタムテンプレート

詳しくはやりませんが、欲しい機能がないときは自分でテンプレートを書くことで自動生成されるコードに機能追加できます。

sqlboiler.toml (設定ファイル)に以下のように書くか

templates = [ "/path/to/sqlboiler/templates", # sqlboilerのテンプレートディレクトリ "/path/to/sqlboiler/templates_test", # sqlboilerのテスト用テンプレートディレクトリ "/path/to/your_project/more_templates" # 自分が作ったカスタムテンプレートディレクトリ ]

--templates オプションで指定します。

sqlboiler mysql --output models_mysql --pkgname models_mysql --wipe \ --templates /path/to/sqlboiler/templates --templates /path/to/sqlboiler/templates_test \ --templates /path/to/your_project/more_templates

注意が必要なのはこれらのテンプレートパスには SQLBoiler自身のテンプレートも含める必要があるということです。

オプションで指定する場合は以下のように $GOPATH のパスを指定してあげるか

sqlboiler mysql --output models_mysql --pkgname models_mysql --wipe \ --templates $GOPATH/pkg/mod/github.com/volatiletech/sqlboiler/[email protected]/templates --templates $GOPATH/pkg/mod/github.com/volatiletech/sqlboiler/[email protected]/templates_test \ --templates /path/to/your_project/more_templates

SQLBoilerのテンプレートディレクトリをプロジェクトリポジトリにコピーしてそのパスを指定する必要があります。

info
  • この方法は冗長だと感じたので追加分のテンプレートだけ指定できる引数を作って PR を投げたんですが、明示的に指定する今の方法が気に入っているということで却下されてしまったのでおそらく今後もテンプレートの指定方法が変わることはないと思います。
  • 機能不足をカスタムテンプレートで補えるようになっているのにカスタムテンプレートの指定が冗長でやりづらいというのは個人的に残念なポイントでした。
  • 仮にMakefileでやるにしても私は実行者の環境に依存するような書き方をしたくないし、バージョンが変わるたびにパスを書き換えるのも忘れそうです。

終わりに

実務で使っていたものも総合すると(ネガティブになりますが)、 構造体の自動生成自体は便利でしたが、テンプレート指定の件を抜きにしても現時点(v3.6.1)では新規プロジェクトがあっても採用はしないかなーという感想です。

どうしても複雑なクエリは生SQLでの実行になりますし、期待通りのメソッドが生成されなくてハマるみたいなことが結構ありました。 これについては前述したように制約で変わるものですが、詳細な生成ルールがドキュメントされていないため慣れないうちはしんどいです。 開発者が自分だけなら慣れればいいだけですが他のプロジェクトメンバーに強いるのは辛そう。実際、他の開発者も違うORMを使いたがっていたように思えます。

あとプロジェクトや実行環境に拠ると思いますがコードの自動生成に結構時間(数分)がかかるので最初はそれが割と苦痛でした(ちなみに当時使っていたのは普通スペックのMacbook Pro)。 慣れてくると先にコード生成しておいて別のタスクをやるようになるので大丈夫w

似たようなライブラリとして以下があるので、今後Goでの開発で技術選定する機会があったら調べてみようと思います。

https://github.com/xo/xo https://github.com/smallnest/gen

DBスキーマからコードを生成するというコンセプトはおもしろいものでした。 今後に期待ですね!