🐁

メンテナンスモードとなった Go ORM の SQLBoiler と代替候補 Bob を比較

に公開

はじめに

データベースファーストで Go の ORM を自動生成することができるライブラリとして SQLBoiler があります。

https://github.com/volatiletech/sqlboiler

単純な Web アプリケーションであれば SQLBoiler が生成したコードだけでシステムを構築できるのでとても重宝します。
しかし、そんな SQLBoiler が 2024年11月 にメンテナンスモードとなってしまいました。

Maintenance Mode

This package is currently in maintenance mode, which means:

  1. It generally does not accept new features.
  2. It does accept bug fixes and version compatability changes provided by the community.
  3. Maintainers usually do not resolve reported issues.
  4. Community members are encouraged to help each other with reported issues.

SQLBoiler は移行先として Bobsqlc を提示しています。

今回の記事で SQLBoiler と似ている Bob を取り上げ、実装の比較を行い移行性について確認してみたのでご紹介します。
※ sqlc は SQLBoiler とは思想が異なると思うので今回の記事では扱いません。

TL;DR

SQLBoiler

https://github.com/volatiletech/sqlboiler

「データベースファースト」と呼ばれるデータベーススキーマに合わせて Go の ORM を自動で生成するツールです。
データベーススキーマのライフサイクル管理は別途ツールが必要となります。
コード生成時に実際のデータベースへとアクセスしスキーマの型や各スキーマを操作する一般的な CRUD 操作クエリが用意されます。

Bob

https://github.com/stephenafamo/bob

基本的には SQLBoiler と同様のことができます。
公式ドキュメントに SQLBoiler との違いが概要として列挙されていますのでこちらもご参照ください。

https://bob.stephenafamo.com/vs/sqlboiler/

ツールのインストール & バージョン

検証は PostgreSQL を用いたものになります。
他の RDBMS を扱う場合はドキュメントにてご確認ください。

SQLBoiler を以下のコマンドにて導入します。

go get -tool github.com/volatiletech/sqlboiler/v4@latest
go get -tool github.com/volatiletech/sqlboiler/v4/drivers/sqlboiler-psql@latest
go tool github.com/volatiletech/sqlboiler/v4 --version
SQLBoiler v4.18.0

以下のコマンドにてコードを自動生成します。

go tool github.com/volatiletech/sqlboiler/v4 psql

Bob を以下のコマンドにて導入します。

go get -tool github.com/stephenafamo/bob/gen/bobgen-psql@latest
go tool github.com/stephenafamo/bob/gen/bobgen-psql --version
bobgen-psql version v0.31.0

以下のコマンドにてコードを自動生成します。

go tool github.com/stephenafamo/bob/gen/bobgen-psql

設定(コンフィグ)ファイル

コード自動生成のためにデータベースドライバーの構成やオプションをファイルに記載することができます。

SQLBoiler は下記のような toml ファイルを用いて管理します。

pkgname = "models"
output = "models"
wipe = true
no-tests = true
add-enum-types = true

[psql]
dbname = "postgres"
host = "localhost"
port = 5432
user = "postgres"
pass = "postgres"
schema = "public"
sslmode = "disable"

[[types]]
[types.match]
db_type = "uuid"

[types.replace]
type = "uuid.UUID"

[types.imports]
third_party = [ "\"github.com/google/uuid\"" ]

ref: https://github.com/volatiletech/sqlboiler?tab=readme-ov-file#configuration

一方で Bob は下記のような yaml ファイルを用いて管理します。

no_factory: true
no_tests: true
psql:
  dsn: "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"
  driver_name: "github.com/lib/pq"
  output: "models"
  pkgname: "models"
  uuid_pkg: "github.com/google/uuid"

ref: https://bob.stephenafamo.com/docs/code-generation/configuration

※ 今回自動生成したコードはどちらも models パッケージとしています。

オプションが微妙に異なるので SQLBoiler の設定をそのまま Bob へと適応するというのは難しそうです。

基本的な使い方

SQLBoiler の使い方は https://github.com/volatiletech/sqlboiler?tab=readme-ov-file#features--examples を参考にしています。
Bob の使い方は https://bob.stephenafamo.com/docs/models/table および https://bob.stephenafamo.com/docs/code-generation/usage を参考にしています。

Executor

クエリを実行するにあたり DB との接続に必要なインスタンスを用意する必要があります。
SQLBoiler も Bob も生成されたクエリを実行する際に用意したインスタンスを渡します。

SQLBoiler は database/sql.DB を利用します。
ref: https://pkg.go.dev/database/sql#DB

実装は以下の通りとなります。

package main

import (
	"database/sql"
	"os"

	// postgres driver.
	_ "github.com/lib/pq"
)

func main() {
	db, err := sql.Open("postgres", os.Getenv("DSN"))
	if err != nil {
		panic(err)
	}

	defer db.Close()
}

一方で Bob は独自の DB インスタンスを利用できます。
ref: https://pkg.go.dev/github.com/stephenafamo/bob#DB

実装は以下の通りです。

package main

import (
	"os"

	"github.com/stephenafamo/bob"
)

func main() {
	db, err := bob.Open("postgres", os.Getenv("DSN"))
	if err != nil {
		panic(err)
	}

	defer db.Close()
}

スキーマ(モデル)

サンプルコードを提示するにあたり以下のようなIDと名前をフィールドに持つ users テーブルを扱います。

CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

SQLBoiler

// Code generated by SQLBoiler 4.18.0 (https://github.com/volatiletech/sqlboiler). DO NOT EDIT.
// This file is meant to be re-generated in place and/or deleted at any time.

package models

// ...

// User is an object representing the database table.
type User struct {
	ID        uuid.UUID `boil:"id" json:"id" toml:"id" yaml:"id"`
	Name      string    `boil:"name" json:"name" toml:"name" yaml:"name"`
	CreatedAt time.Time `boil:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
	UpdatedAt time.Time `boil:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`

	R *userR `boil:"-" json:"-" toml:"-" yaml:"-"`
	L userL  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

userR ... users テーブルとのリレーション情報
userL ... 各リレーションのロード情報

Bob

// Code generated by BobGen psql v0.31.0. DO NOT EDIT.
// This file is meant to be re-generated in place and/or deleted at any time.

package models

// ...

// User is an object representing the database table.
type User struct {
	ID        uuid.UUID `db:"id,pk" `
	Name      string    `db:"name" `
	CreatedAt time.Time `db:"created_at" `
	UpdatedAt time.Time `db:"updated_at" `
}

SELECT

SELECT * FROM "users";

SQLBoiler

users, err := models.Users().All(ctx, db)
if err != nil {
	panic(err)
}

Bob

users, err := models.Users.View.Query().All(ctx, db)
if err != nil {
	panic(err)
}

SELECT WHERE

SELECT * FROM "users" WHERE "id" = $1;

SQLBoiler

user, err := models.FindUser(ctx, db, uuid.MustParse("$1"))
if err != nil {
	panic(err)
}

Bob

user, err := models.FindUser(ctx, db, uuid.MustParse("$1"))
if err != nil {
	panic(err)
}
SELECT * FROM "users" WHERE "name" = $1;

SQLBoiler

users, err := models.Users(
	models.UserWhere.Name.EQ("$1"),
).All(ctx, db)
if err != nil {
	panic(err)
}

Bob

users, err := models.Users.View.Query(
	models.SelectWhere.Users.Name.EQ("$1"),
).All(ctx, db)
if err != nil {
	panic(err)
}

INSERT

INSERT INTO "users" ("name","created_at","updated_at") VALUES ($1,$2,$3) RETURNING "id"

SQLBoiler

user := models.User{
	Name: "$1",
}
if err := user.Insert(ctx, db, boil.Infer()); err != nil {
	panic(err)
}

Bob

user, err := models.Users.Insert(&models.UserSetter{
	Name: omit.From("$1"),
}).One(ctx, db)
if err != nil {
	panic(err)
}

UPDATE

UPDATE "users" SET "name"=$1,"updated_at"=$2 WHERE "id"=$3

SQLBoiler

user := models.User{
	ID: uuid.MustParse("$3"),
	Name: "$1",
}

if _, err := user.Update(ctx, db, boil.Infer()); err != nil {
	panic(err)
}

Bob

user := models.User{
	ID: uuid.MustParse("$3"),
}

if err := user.Update(ctx, db, &models.UserSetter{
	Name: omit.From("$1"),
}); err != nil {
	panic(err)
}

UPSERT

INSERT INTO "users" ("id", "name", "created_at", "updated_at") VALUES ($1,$2,$3,$4) ON CONFLICT (id) DO NOTHING

SQLBoiler

user := models.User{
	ID: uuid.MustParse("$1"),
	Name: "$2",
}

if err := user.Upsert(
	ctx, 
	db, 
	false, 
	nil, 
	boil.Columns{}, 
	boil.Infer(),
); err != nil {
	panic(err)
}

Bob

if _, err := models.Users.Insert(&models.UserSetter{
	ID:   omit.From("$1"),
	Name: omit.From("$2"),
}, im.OnConflict("id").DoNothing()).One(ctx, db); err != nil && err != sql.ErrNoRows {
	panic(err)
}
INSERT INTO "users" ("id", "name", "created_at", "updated_at") VALUES ($1,$2,$3,$4) ON CONFLICT DO UPDATE SET "name" = EXCLUDED."name"

SQLBoiler

user := models.User{
	ID: uuid.MustParse("$1"),
	Name: "$2",
}

if err := user.Upsert(
	ctx, 
	db, 
	true, 
	[]string{"id"}, 
	boil.Infer(), 
	boil.Infer(),
); err != nil {
	panic(err)
}

Bob

if _, err := models.Users.Insert(&models.UserSetter{
	ID:   omit.From("$1"),
	Name: omit.From("$2"),
}, im.OnConflict("id").DoUpdate(
	im.SetExcluded("name"),
)).One(ctx, db); err != nil {
	panic(err)
}

DELETE

DELETE FROM "users" WHERE "id"=$1

SQLBoiler

user := models.User{
	ID: uuid.MustParse("$1"),
}

if _, err := user.Delete(ctx, db); err != nil {
	panic(err)
}

Bob

user := models.User{
	ID: uuid.MustParse("$1"),
}

if err := user.Delete(ctx, db); err != nil {
	panic(err)
}

TRANSACTION

BEGIN;

COMMIT;

SQLBoiler

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	panic(err)
}

// ...

if err := tx.Commit(); err != nil {
	panic(err)
}

Bob

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	panic(err)
}

if err := tx.Commit(); err != nil {
	panic(err)
}

おわりに

SQLBoiler と Bob の実装を比較してみて移行について調査してみました。
SQLBoiler からの移行の一助になれば幸いです。

今回試したコードは以下に置いておきます。

https://github.com/otakakot/sample-go-generate-orm

Discussion