SQLBoilerの後継stephenafamo/bobに入門してみた(ハンズオン)
はじめに
GoのORM SQLBoilerから移行先として推奨されているstephenafamo/bobを試した時の備忘録です。Code Generateのみの例ですが、ハンズオン形式のチュートリアルとしてコードの参考にどうぞ
動作環境
- OS: Ubuntu 22.04
- Goバージョン: 1.24.1
- MySQL: 8.0 (Dockerコンテナ)
- bob:
github.com/stephenafamo/bob/gen/bobgen-mysql@v0.31.0
- マイグレーションツール: Atlas CLI 0.32.0
- Docker Compose: v2.21
作成したコード
stephenafamo/bobって何?
GoのORMライブラリの中の一つ。ORMとは何ぞや?という方は以下を参照。
簡単に言えば、RDBのテーブル定義とコードのクラスオブジェクトに紐づけてくれるライブラリ
Goの代表的なORMとしては以下の通り
コードベースのORM
-
GORM
Goで最も使われているORM -
ent
meta(旧Facebook)が開発しているORM
Atlasマイグレーションとセットで使われることがある -
uptrace/bun
最近イケてると噂のORM
DBファストなORM
-
SQLBoiler
スキーマ定義からモデル生成とコード生成を行ってくれるORM -
sqlc
スキーマ定義とクエリからコードを生成してくれる -
stephenafamo/bob
SQLBoilerのメンテナーによって作成されたORM
SQLBoilerからの移行先として
stephenafamo/bobが公式からアナウンスされています。
今回はstephenafamo/bobの基本的な機能を確認してみましたので、ご参考までにどうぞ
試してみる
ディレクトリ構成
.
├── bobgen.yaml
├── cmd
│ └── server
│ └── main.go
├── database
│ ├── atlas.sum
│ └── user.sql
├── docker-compose.yml
├── environment.env
├── go.mod
├── go.sum
├── internal
│ └── factory.go
├── pkg
│ └── gen
└── volumes
コード生成を行ってCreate,Read(CRUD)してみる
テーブル構成
今回は以下のテーブルを利用しました。
-- MySQL syntax
create table users (
id binary(16) primary key unique COMMENT 'ユーザーID ULID',
name varchar(255) not null COMMENT 'ユーザー名',
email varchar(255) not null unique COMMENT 'メールアドレス',
password varchar(255) not null COMMENT 'パスワード',
created_at datetime not null default current_timestamp COMMENT '作成日時',
updated_at datetime not null default current_timestamp on update current_timestamp COMMENT '更新日時'
);
stephenafamo/bobを利用する前に、
テーブル定義をデータベースに読み込ませる(マイグレーションする)必要があります。
stephenafamo/bobはActive RecordやEloqumentの様な
オートマイグレーション機能を有していないデータベースファストのORMです。
マイグレーションを行うには、sql-migrateやatlasの様なmigrationツールを利用するのがおすすめです。
マイグレーション後に、以下の様にyamlを定義してコマンドを実行すると
DBからテーブル定義が読み込まれてコードが生成されます。
mysql:
# データベースのドライバー
driver: mysql
# データベースのDSN
dsn: root:root_password@tcp(localhost:3306)/main
#出力先のディレクトリ
output: ./pkg/gen/models
# マッピングするテーブル名の指定
only:
# テーブル名
users:
$ go run github.com/stephenafamo/bob/gen/bobgen-mysql@latest -c ./bobgen.yaml
2388 bytes ./pkg/gen/models/bob_main.bob.go
94 bytes ./pkg/gen/models/bob_main_test.bob.go
== SKIPPED == ./pkg/gen/models/bob_mysql_blocks.bob.go
== SKIPPED == ./pkg/gen/models/bob_enums.bob.go
13340 bytes ./pkg/gen/models/users.bob.go
1884 bytes ./pkg/gen/models/users.bob_test.go
802 bytes pkg/gen/models/factory/bobfactory_random_test.bob.go
565 bytes pkg/gen/models/factory/bobfactory_context.bob.go
== SKIPPED == pkg/gen/models/factory/bobfactory_enums.bob.go
462 bytes pkg/gen/models/factory/bobfactory_main.bob.go
652 bytes pkg/gen/models/factory/bobfactory_random.bob.go
12287 bytes pkg/gen/models/factory/users.bob.go
生成されたコードを見るとテーブル定義がマッピングされています。
// User is an object representing the database table.
type User struct {
// ユーザーID ULID
ID []byte `db:"id,pk" `
// ユーザー名
Name string `db:"name" `
// メールアドレス
Email string `db:"email" `
// パスワード
Password string `db:"password" `
// 作成日時
CreatedAt time.Time `db:"created_at" `
// 更新日時
UpdatedAt time.Time `db:"updated_at" `
}
// UserSlice is an alias for a slice of pointers to User.
// This should almost always be used instead of []*User.
type UserSlice []*User
// Users contains methods to work with the users table
var Users = mysql.NewTablex[*User, UserSlice, *UserSetter]("users", []string{"email"}, []string{"id"})
// UsersQuery is a query on the users table
type UsersQuery = *mysql.ViewQuery[*User, UserSlice]
コードを生成できたら、DBへのCRUD操作を行うことができます。
以下の様にDBとの接続を行い、CRUDを試してみます。
type User struct {
ID ulid.ULID
Name string
Email string
Password string
}
func main() {
// テーブルモデルを取得
userTable := models.Users
dsn := os.Getenv("MYSQL_DSN")
ctx := context.Background()
db, err := bob.Open("mysql", dsn)
if err != nil {
slog.Error("failed to open database", "error", err)
return
}
defer func() {
if err := db.Close(); err != nil {
slog.Error("failed to close database", "error", err)
}
}()
// user一覧を取得(select * from users)
users, err := userTable.View.Query().All(ctx, db)
if err != nil {
slog.Error("failed to get users", "error", err)
return
}
// 空の場合にデータを作成
if len(users) == 0 {
slog.Info("No users found")
CreateUser(ctx, &User{
ID: ulid.Make(),
Name: "John Doe",
Email: "john.doe@example.com",
Password: "password",
}, &db)
}
for _, user := range users {
//...
}
}
func CreateUser(ctx context.Context, user *User, db *bob.DB) {
johnID := ulid.Make()
slog.Info("johnID", slog.String("johnID before binary", johnID.String()))
// Insertでuserを追加
setter := &models.UserSetter{
ID: omit.From(johnID.Bytes()),
Name: omit.From(user.Name),
Email: omit.From(user.Email),
Password: omit.From(user.Password),
CreatedAt: omit.From(time.Now()),
}
m, err := models.Users.Insert(setter).Exec(ctx, db)
if err != nil {
//...
}
//データが挿入されたか確認
john, err := models.FindUser(ctx, db, johnID.Bytes())
if err != nil {
//...
}
// UnmarshalBinaryでIDを取得
var id ulid.ULID
err = id.UnmarshalBinary(john.ID)
if err != nil {
//...
}
slog.Info(//...)
return
}
DBへのコネクションを貼る
先のmain.goの処理を細かく見ていきましょう。
DBへの接続は以下の様にbob.Oepn()
メソッドを利用して行います。
db, err := bob.Open("mysql", dsn)
if err != nil {
slog.Error("failed to open database", "error", err)
return
}
defer func() {
if err := db.Close(); err != nil {
slog.Error("failed to close database", "error", err)
}
}()
なぜ、bob.Open()
メソッドを使うのかというのは後述しますが、
基本的な使い方はdatabase/sql
のsql.Open()
と変わりありません。
OpenしたDBは関数の終了時にコネクションを閉じるようにdefer db.Clode()
します。
生成したmodelを利用する
コネクションを上記の様に貼った後はテーブルへのデータのCRUD処理を行うことができます。
生成したモデルを利用するために
モジュール名/pkg/gen/models
を読み込み、テーブルモデルを取得します。
import (
"github.com/junsazanami430u/test-bob/pkg/gen/models"
)
インポートしたパッケージは後述するテーブルモデルを利用してCRUDを行うことが可能です。
- selectで取得
// テーブルモデルを取得
userTable := models.Users
// user一覧を取得(select * from users)
users, err := userTable.View.Query().All(ctx, db)
- Insertでテーブルにデータを追加
// Insertでuserを追加
setter := &models.UserSetter{
ID: omit.From(johnID.Bytes()),
Name: omit.From(user.Name),
Email: omit.From(user.Email),
Password: omit.From(user.Password),
CreatedAt: omit.From(time.Now()),
}
m, err := models.Users.Insert(setter).Exec(ctx, db)
Executor
ここから先は各機能を細かく見ていきましょう
Executorはデータベースへの接続、トランザクション処理等を担っています。
type userObj struct {
ID int
Name string
}
//...
ctx := context.Background()
//bob.DBを取得
db, err := bob.Open("postgresql","")
if err != nil {
// ...
}
// トランザクションを開始
// txにはbob.Txを取得
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return
}
defer func() {
if err := tx.Rollback(); err != nil {
//...
}
}()
// アップデート操作を行うQueryModを作成
q:=psql.Update(...)
//クエリを実行する
result, err := bob.Exec(ctx, db, q)
if err != nil {
// ...
}
// セレクト操作を行うQueryModを作成
q := psql.Select(...)
// userObj{}の構造体にマッピングを行い、データを一行取得
user, err := bob.One(ctx, db, q, scan.StructMapper[userObj]())
if err != nil {
// ...
}
// userObj{}の構造体にマッピングを行い、データを複数行取得
users, err := bob.All(ctx, db, q, scan.StructMapper[userObj]())
if err != nil {
// ...
}
// トランザクションをコミット
if err := tx.Commit(); err != nil {
slog.Error("failed to commit transaction", "error", err)
return
}
上記を見てdatabase/sql
のsql.DB
,sql.Conn
,sql.Tx
をwrapしていることに気が付く人もいるでしょう。
By default, *sql.DB does not implement the bob.Executor interface.
This is because the QueryContext() method of *sql.DB return an *sql.Rows object which is very difficult to mock or implement.
To be able to interoperate with other libraries and for ease of testing/mocking, Bob's Executor instead return a scan.Rows interface.
wrapした構造体やメソッド、関数を使う理由は上記の通りです。
BobではQueryContext()
を利用するときに*sql.Rows
ではなく、scan.Rowsを返すようにしています。
このようにすることで他のライブラリと相互運用可能になり、テストやモッキングを容易になるとのこと。
Models
構造体定義。後述するCode Generatorを利用してSQLのテーブル定義から精製することも可能です。
// View Model定義
type User struct {
ID int `db:",pk"` // needed to know the primary key when updating
VehicleID int
Name string
Email string
}
//...
//View Model
var userView = psql.NewView[User]("public", "users")
//View ModelはSelect操作(Read)のみで利用する
// SELECT * FROM "users" LIMIT 1
userView.Query().One(ctx, db)
// SELECT * FROM "users"
userView.Query().All(ctx, db)
// SELECT count(1) FROM "users"
userView.Query().Count(ctx, db)
// Like One(), but only returns a boolean indicating if the model was found
userView.Query().Exists(ctx, db)
// Table Model定義
func (u User) PrimaryKeyVals() bob.Expression {
return psql.Arg(u.ID)
}
// UserSetter must implement orm.Setter
type UserSetter struct {
ID omit.Val[int]
VehicleID omit.Val[int]
Name omit.Val[string]
Email omit.Val[string]
}\
//...
//Table Model
var userTable = psql.NewTable[User, UserSetter]("public", "users")
//Table ModelではInsert処理やUpdate処理を行う
userTable.Insert(&UserSetter{
Name: omit.From("Stephen"),
}).One(ctx, db)
// UPDATE "users" SET "kind" = $1 RETURNING *;
// QueryModを設定して上記の様なSQLを実行することが可能
updateQ := userTable.Update(
um.SetCol("kind").ToArg("Dramatic"),
um.Returning("*"),
)
Table Modelでは他にもbulk insert
やupsert
を行うことが可能です。
また、TableModelを利用するのにSetter構造体を定義する理由は以下の通り述べられています。
A setter is necessary because if we run userTable.Insert(User{}), due to Go's zero values it will be difficult to know which fields we purposefully set.
Typically, we can leave out fields that we never intend to manually set, such as auto increment or generated columns.
空の構造体(上記の場合はUser構造体)でInsertした際に、
Goのゼロ値の定義によってどのフィールドを意図的に設定したかを知ることが困難になるとのことで、回避するためにTableModelの定義にてSettter構造体を定義するようです。
Query Builder
クエリの組み立てはSQLBoiler同様にQueryModを通して行うことができます。
mysqlを利用している場合には下記の様になります。
import (
"github.com/stephenafamo/bob"
"github.com/stephenafamo/bob/dialect/mysql"
"github.com/stephenafamo/bob/dialect/mysql/sm"
"github.com/stephenafamo/bob/dialect/mysql/im"
"github.com/stephenafamo/bob/dialect/mysql/um"
"github.com/stephenafamo/bob/dialect/mysql/dm"
)
type userObj struct {
ID int
Name string
}
func main() {
//...
//SELECT id, name, (CASE WHEN (`id` = '1') THEN 'A' ELSE 'B' END) AS `C` FROM users
sq := mysql.Select(
sm.Columns(
"id",
"name",
mysql.Case().
When(mysql.Quote("id").EQ(mysql.S("1")), mysql.S("A")).
Else(mysql.S("B")).
As("C"),
),
sm.From("users"),
)
//一件取得するときにはExecutorのOneを使う
//ViewModel,TableModelを利用しない場合は構造体をStructMapperに入れてデータを取得する。
user, err := bob.One(ctx, db, sq, scan.StructMapper[userObj]())
if err != nil {
// ...
}
//複数件取得するときにはExecutorのAllを使う
users, err := bob.All(ctx, db, sq, scan.StructMapper[userObj]())
if err != nil {
// ...
}
//INSERT INTO films SELECT * FROM tmp_films WHERE (`date_prod` < ?)
iq := mysql.Insert(
im.Into("films"),
im.Query(mysql.Select(
sm.From("tmp_films"),
sm.Where(mysql.Quote("date_prod").LT(mysql.Arg("1971-07-13"))),
)),
)
//Insert,Update,Delete実行時にはExecutorのExecを利用する
result, err := bob.Exec(ctx, db, iq)
if err != nil {
// ...
}
//UPDATE employees, accounts SET `sales_count` = sales_count + 1 WHERE (`accounts`.`name` = ?) AND (`employees`.`id` = `accounts`.`sales_person`)
uq := mysql.Update(
um.Table("employees, accounts"),
um.SetCol("sales_count").To("sales_count + 1"),
um.Where(mysql.Quote("accounts", "name").EQ(mysql.Arg("Acme Corporation"))),
um.Where(mysql.Quote("employees", "id").EQ(mysql.Quote("accounts",
"sales_person"))),
)
//...
//DELETE FROM films, actors USING films
//INNER JOIN film_actors ON (films.id = film_actors.film_id)
//INNER JOIN actors ON (film_actors.actor_id = actors.id) WHERE (`kind` = ?)
dq := mysql.Delete(
dm.From("films"),
dm.From("actors"),
dm.Using("films"),
dm.InnerJoin("film_actors").OnEQ(mysql.Raw("films.id"),
mysql.Raw("film_actors.film_id")),
dm.InnerJoin("actors").OnEQ(mysql.Raw("film_actors.actor_id"),
mysql.Raw("actors.id")),
dm.Where(mysql.Quote("kind").EQ(mysql.Arg("Drama"))),
)
//...
}
QueryModは、SQLBoilerと比べるとSelect
,Insert
,Update
,Delete
ごとにsm
,im
,um
,dm
と使い分けるタイプとなっています。
これは、SQLBoilerではQuerModがCRUDで共通であるために不正なクエリを組むことができてしまうので、BobではCRUD操作ごとにQueryModパッケージを分けています。
Operator(演算子)はSQLBoilerでお馴染みのGTE
やEQ
の区間演算子だけではなく
Between(y, z any)
やNotBetween(y, z any)
等のBetWeen句などをサポートするようになりました。
また、先述したModelsのView Model
のQueryメソッドやTable Model
でも使うことが可能です。
Code Generation
SQLBoilerの後継ORMとして、本機能がstephenafamo/bobの最大の特徴になります。
コード生成はDBのテーブル情報を解析(dump)して生成されます。
コード生成の基本
コード生成を行うにはRDBの種類に合わせてCLIを実行します。
今回はmysql
の例で見ていきましょう。
コード生成を行う手順としては以下の様な2パターンがあります。
- DBのDSNを環境変数に入れてCLIを実行する
$ MYSQL_DSN=user:pass@tcp(host:port)/dbname go run github.com/stephenafamo/bob/gen/bobgen-mysql@latest
こちらはお手軽ですが、生成されたファイルの出力先等の設定はできない様で、
デフォルトはmodelsディレクトリに作成されます。
- yamlに入出力設定を書きこんでCLIを実行する
$ go run github.com/stephenafamo/bob/gen/bobgen-mysql@latest -c ./bobgen.yaml
こちらは後述するyaml設定によって細かなパラメータ設定が可能です。
yamlに入出力設定を行う場合、
最初の例の様にbobgen.yaml
にDBからの入力設定、コード出力の設定を書きこみます。
# テストコードを生成しない
no_tests: true
# ファクトリーを生成しない
no_factory: true
# 生成時にwipeする
wipe: true
# mysqlを使用することを明示
mysql:
# データベースのドライバー
driver: mysql
# データベースのDSN
dsn: root:root_password@tcp(localhost:3306)/main
#出力先のディレクトリ
output: ./pkg/gen/models
# マッピングするテーブル名の指定
only:
# テーブル名
users:
factoryとリレーション設定は後述します。
現状ではSQLiteに限られていますが、sqlcの様にクエリからコード生成を行うように設定ができるようです。
リレーションを貼る
ここからはcodeGenerateの機能について見ていきます。
例えば、以下の様なテーブルを持つRDB(データを投入済み)をbobでコード生成します。
CREATE TABLE pilots (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
license_number VARCHAR(50) NOT NULL UNIQUE
);
CREATE TABLE jets (
id INT PRIMARY KEY AUTO_INCREMENT,
model VARCHAR(100) NOT NULL,
pilot_id INT NOT NULL,
FOREIGN KEY (pilot_id) REFERENCES pilots(id)
);
パイロットが運転できる飛行機の機種を取得したい場合、
以下の様に書いて1対N
のレコードを取得することが可能です。
jet, err := models.FindJet(ctx, db, 1)
// SELECT * FROM "pilots" WHERE "id" = $1
// $1 => jet.PilotID
jetPilotQuery, err := jet.Pilots(ctx, db)
取得したjetPilotQuery
はtabel modelのTableQueryです。
そのため、UpdateAll()
やOne()
,All()
等の操作が可能です。
factoryを使う
現状では、modelsのSetter構造体でテンプレート化を行いたいときに使える機能です。
package internal
import (
"github.com/junsazanami430u/test-bob/pkg/gen/models/factory"
)
func BobFactryExample() *factory.UserTemplate {
f := factory.New()
f.AddBaseUserMod(
factory.UserMods.Email("test@example.com"),
factory.UserMods.Password("password"),
)
return f.NewUser()
}
上記の様にテンプレート化を行うことで値が入ったUserSetter
を生成することが可能です。
user := internal.BobFactryExample("George Doe", "george.doe@example.com", "password")
m := user.BuildSetter()
_, err := models.Users.Insert(m).Exec(ctx, db)
posetgres等では、templateから直接データ書き込みすることが可能なようです。
// Create a new jet from the template
jet, err := jetTemplate.Create(ctx, db)
// Create a slice of 5 jets using the template
jets, err := jetTemplate.CreateMany(ctx, db, 5)
// Must variants panic on error
jet := jetTemplate.MustCreate(ctx, db)
jets := jetTemplate.MustCreateMany(ctx, db, 5)
// OrFail variants will fail the test or benchmark if an error occurs
jet := jetTemplate.CreateOrFail(t, db)
jets := jetTemplate.CreateManyOrFail(t, db, 5)
mysqlでは下記のissueの様にInsert時のエラーがあるため使えませんが、
プルリク自体は出ているので今後の実装が待ち遠しい機能です。
SQLBoilerからの移行メリット
ここまでBobの基本機能を紹介しましたが、SQLBoilerからの移行メリットとして以下の通りです。
SQLBoiler | stephenafamo/bob | |
---|---|---|
Query Object | 全ての方言と共通のため、クエリオブジェクトの変更があったときにSQL方言間の互換性問題が発生する。 | クエリオブジェクトは方言ごとに分割しているため、SQL方言間の互換性問題が改善している。 |
Query Mods | QueryModパッケージはCRUDで共通。 そのため、方言によって不正なクエリを組む恐れがある。 | QueryModパッケージはCRUDごとに別のため、不正なクエリを組んだ時にエラーハンドリングが容易になる。 |
カスタムSQLの組み立て | 生成されたコードを基本的に利用する。 複雑なクエリを組む場合にはハードコーディングが必要。 | 生成されたコードの他に、sqlcの様にクエリからコードを生成することも可能。 様々な方法でクエリの構築することができる。 |
SQLBoilerでは方言ごとにモジュールが分割されていないため、
新たな機能追加が難しいことや不正なクエリを組み立てられてしまう問題がありました。
Bobでは方言別モジュール化による疎結合になったことでメンテナンスが容易になり、
機能のアップデートや方言別のサポートが容易になった形です。
感想
全体的な印象としてはSQLBoilerの正統後継のORMといった感じです。
開発途上のORMですが、正式リリースが楽しみなライブラリです。
また、同系統のsqlcやxoと比べてもクエリを書くのにSQLを書かなくて済むので、
DBファストな開発はしたいけど、ORMのメリットを享受したい人には有力な選択肢となりえるでしょう。
Discussion