LoginSignup
1
0

チーム開発参加の記録【2023-10~2024-03】(1) Go言語用ORM「Bun」をDBファーストで使う試み(SQLite使用)

Last updated at Posted at 2023-12-13

数か月前まで、あるオンラインサロンでチーム開発に参加していました。
その優勝チームと準優勝チームが合体し、そこにサロン主も加わり、継続部隊を結成して新規開発を行うことになりました。

私は再びバックエンド側メンバーに入りました。
継続部隊のバックエンドは、Go+Echoを使用することになりました。
チーム開発に参加しながら、私の学習の軌跡を記事にしていきたいと思います。

本シリーズのリンク

本記事で行うこと

前回(6月~8月)のチーム開発ではPostgreSQLを使用しましたが、今回はSQLiteが最有力候補となりました。
それに合わせて、DBクライアントライブラリも見直すことにしました。
候補に挙がったのは、SQLファーストを謳うORM「Bun」です。
前回はsqlcを使用して、私は大変気に入りましたが、Bunは気に入るでしょうか?

DBファーストで開発したい

前回使ったsqlcはDBファーストでした。
DBファーストって何?
大雑把にコードファーストと比較すると:

  • コードファースト:アプリ側でORMのModelを記述し、アプリを動かしてテーブルを作成する
  • DBファースト:ERモデリングツールにDDLを生成してもらうなり、人がDDLを書くなりして、それをDB側で実行する

今回もsqlc同様、DBファーストにしたいのです。
本記事は、ORM「Bun」をDBファーストで使う試みです。

何故DBファーストか

理由が2つあります。

Bunを使って、試しにSQLiteのテーブルをコードファーストで作成したところ、STRICTでないテーブルが作成されました。
SQLiteの世界観を否定する気はありませんが、私はSTRICTテーブルを使いたいです。
厳格な型付けのテーブルを作成するために、自分でDDLを書いてSTRICTテーブルを作りたい、というのが理由の一つです。

  • DBとアプリを疎結合にしたいから

コードファーストでDBを作ると、DBとアプリが相互に依存して密結合になります。
一方でDBファーストで作れば、DBはアプリのコードに依存しません。

DBはアプリよりずっと長く使われるので、相対的に短命なアプリに依存させたくありません。
選択したORMがオワコン化する可能性もありますので、DBを特定のORMに依存させたくもありません。
また、DBはシステムの基盤的存在で、様々なアプリから共有されることもありますので、一つのアプリに依存させず疎結合にしたい、というのがもう一つの理由となります。

テーブルを作る

それでは作業に移ります。
DBファーストですので、まずDDLを書きます。

CREATE TABLE foo (
    col_text           TEXT    NOT NULL,
    col_text_null      TEXT,
    col_int            INTEGER NOT NULL,
    col_int_null       INTEGER,
    col_real           REAL    NOT NULL,
    col_real_null      REAL,
    col_timestamp      REAL    NOT NULL,
    col_timestamp_null REAL,
    col_json           TEXT    NOT NULL,
    col_json_null      TEXT,
    col_array          TEXT    NOT NULL,
    col_array_null     TEXT
) STRICT;

上記DDLの意図

STRICTテーブルで使える型は以下に限られています。

  • INT
  • INTEGER
  • REAL
  • TEXT
  • BLOB
  • ANY

このうち、プロジェクトで使う可能性が高い以下の型を試します。

  • TEXT
  • INTEGER
  • REAL

加えて、日付・時刻も試します。
日付・時刻は、以下の型で保存できます。

  • TEXT:ISO-8601形式
  • REAL:ユリウス通日
  • INTEGER:1970年からの秒数

本記事では日付・時刻をREAL型で保存することにしました。理由は、

  • INTEGER型で持つと精度が秒になるが、REAL型やTEXT型は秒の小数の精度まで持てる
  • TEXT型は保存フォーマットに気を遣う必要があって面倒だし、バイト数もREAL型より大きい

日付・時刻に加えて、jsonと配列の保存も試します。
これらはTEXT型で保存します。

以上が上記DDLの意図です。

テスト用データをINSERTする

作成したfooテーブルに、以下のSQLでテスト用データを2件INSERTしました。

INSERT INTO foo
(
    col_text,
    col_text_null,
    col_int,
    col_int_null,
    col_real,
    col_real_null,
    col_timestamp,
    col_timestamp_null,
    col_json,
    col_json_null,
    col_array,
    col_array_null
)
VALUES
(
    'text_data',
    NULL,
    1234567890123456789,
    NULL,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    NULL,
    JULIANDAY('now'),
    NULL,
    '{"a":100,"b":"bbb"}',
    NULL,
    '[10,null]',
    NULL
),
(
    'text_data',
    'text_data',
    1234567890123456789,
    1234567890123456789,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    JULIANDAY('now'),
    JULIANDAY('now'),
    '{"a":200,"b":null}',
    '{"a":300,"z":"zzz"}',
    '[30,null,50]',
    '[]'
);

INSERTしたら、fooテーブルにSELECT文を発行してみます。

SELECT
    col_text,
    col_text_null,
    col_int,
    col_int_null,
    col_real,
    col_real_null,
    STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp,
    STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null,
    col_json,
    col_json_null,
    col_array,
    col_array_null
FROM
    foo;

画像が小さいですが、SELECT文の結果です。

image.png

EchoとBunを使って、fooテーブルにCRUD操作するWeb APIサーバーを作ってみる

それでは、Go言語からBunを使っていきます。
最初に、今回書いたソースコード全体を載せてしまいます。

ディレクトリ構造とファイル一覧


Project Root
  ├── app.go
  └── go.mod

app.go

app.go
package main

import (
	"context"
	"database/sql"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/sqlitedialect"
	"github.com/uptrace/bun/driver/sqliteshim"
	"github.com/uptrace/bun/extra/bundebug"
)

type (
	httpError struct {
		Error string `json:"error"`
	}

	httpMessage struct {
		Message string `json:"message"`
	}
)

var (
	db *bun.DB
)

func selectFoo(c echo.Context) error {
	type (
		jsonType struct {
			A *int    `json:"a"`
			B *string `json:"b"`
		}

		resultSetType struct {
			ColText          string       `json:"colText"`
			ColTextNull      *string      `json:"colTextNull"`
			ColInt           int64        `json:"colInt"`
			ColIntNull       *int64       `json:"colIntNull"`
			ColReal          float64      `json:"colReal"`
			ColRealNull      *float64     `json:"colRealNull"`
			ColTimestamp     time.Time    `json:"colTimestamp"`
			ColTimestampNull bun.NullTime `json:"colTimestampNull"`
			ColJson          jsonType     `json:"colJson"`
			ColJsonNull      *jsonType    `json:"colJsonNull"`
			ColArray         []*int64     `json:"colArray"`
			ColArrayNull     *[]*int64    `json:"colArrayNull"`
		}
	)

	ctx := context.TODO()

	resultSetFoo := make([]resultSetType, 0)
	if err := db.NewSelect().
		Column("col_text").
		Column("col_text_null").
		Column("col_int").
		Column("col_int_null").
		Column("col_real").
		Column("col_real_null").
		ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp").
		ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null").
		Column("col_json").
		Column("col_json_null").
		Column("col_array").
		Column("col_array_null").
		Table("foo").
		Scan(ctx, &resultSetFoo); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, resultSetFoo)
}

func updateFoo(c echo.Context) error {
	ctx := context.TODO()

	if _, err := db.NewUpdate().
		Table("foo").
		SetColumn("col_text_null", "?", "ABC").
		Where("col_text_null IS NULL").
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}

func insertFoo(c echo.Context) error {
	type dummyModel struct{}

	ctx := context.TODO()

	if _, err := db.NewInsert().
		Model((*dummyModel)(nil)).
		ModelTableExpr("foo").
		Value("col_text", "?", "XYZ").
		Value("col_text_null", "?", nil).
		Value("col_int", "?", 123).
		Value("col_int_null", "?", nil).
		Value("col_real", "?", 3.14).
		Value("col_real_null", "?", nil).
		Value("col_timestamp", "JULIANDAY('now')").
		Value("col_timestamp_null", "?", nil).
		Value("col_json", "?", "{}").
		Value("col_json_null", "?", nil).
		Value("col_array", "?", "[]").
		Value("col_array_null", "?", nil).
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}

func deleteFoo(c echo.Context) error {
	ctx := context.TODO()

	if _, err := db.NewDelete().
		Table("foo").
		Where("col_text = ?", "XYZ").
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, httpMessage{Message: "ok"})
}

func main() {
	sqlite, err := sql.Open(sqliteshim.ShimName, "/home/user/DataGripProjects/sqlite/exercise.sqlite3")
	if err != nil {
		panic(err)
	}
	sqlite.SetMaxOpenConns(1)

	db = bun.NewDB(sqlite, sqlitedialect.New())
	db.AddQueryHook(bundebug.NewQueryHook(
		bundebug.WithVerbose(true),
		bundebug.FromEnv("BUNDEBUG"),
	))

	// EchoでAPIサーバー
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	// Routing
	api := e.Group("/api")
	api.GET("/selectFoo", selectFoo)
	api.PUT("/updateFoo", updateFoo)
	api.POST("/insertFoo", insertFoo)
	api.DELETE("/deleteFoo", deleteFoo)

	e.Logger.Fatal(e.Start(":1323"))
}

go.mod

go.mod
module exercise_bun

go 1.21

require (
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/fatih/color v1.15.0 // indirect
	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
	github.com/google/uuid v1.3.1 // indirect
	github.com/jinzhu/inflection v1.0.0 // indirect
	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
	github.com/labstack/echo/v4 v4.11.3 // indirect
	github.com/labstack/gommon v0.4.0 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.19 // indirect
	github.com/mattn/go-sqlite3 v1.14.17 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
	github.com/uptrace/bun v1.1.16 // indirect
	github.com/uptrace/bun/dialect/sqlitedialect v1.1.16 // indirect
	github.com/uptrace/bun/driver/sqliteshim v1.1.16 // indirect
	github.com/uptrace/bun/extra/bundebug v1.1.16 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasttemplate v1.2.2 // indirect
	github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
	golang.org/x/crypto v0.14.0 // indirect
	golang.org/x/mod v0.12.0 // indirect
	golang.org/x/net v0.17.0 // indirect
	golang.org/x/sys v0.13.0 // indirect
	golang.org/x/text v0.13.0 // indirect
	golang.org/x/time v0.3.0 // indirect
	golang.org/x/tools v0.13.0 // indirect
	lukechampine.com/uint128 v1.3.0 // indirect
	modernc.org/cc/v3 v3.41.0 // indirect
	modernc.org/ccgo/v3 v3.16.15 // indirect
	modernc.org/libc v1.24.1 // indirect
	modernc.org/mathutil v1.6.0 // indirect
	modernc.org/memory v1.7.1 // indirect
	modernc.org/opt v0.1.3 // indirect
	modernc.org/sqlite v1.25.0 // indirect
	modernc.org/strutil v1.2.0 // indirect
	modernc.org/token v1.1.0 // indirect
)

CRUD操作

それでは、CRUD操作について一つ一つ見ていきます。

SELECT文

まずはSELECT文です。

selectFooハンドラの中身

ハンドラの中身を見ていきます。
ます、クエリーの結果表(リザルトセット)を格納する受け皿の型を定義しています。
NULLが入る可能性のある変数は、日付・時刻はbun.NullTime型で定義し、それ以外はポインタ型で定義します。

	type (
		jsonType struct {
			A *int    `json:"a"`
			B *string `json:"b"`
		}

		resultSetType struct {
			ColText          string       `json:"colText"`
			ColTextNull      *string      `json:"colTextNull"`
			ColInt           int64        `json:"colInt"`
			ColIntNull       *int64       `json:"colIntNull"`
			ColReal          float64      `json:"colReal"`
			ColRealNull      *float64     `json:"colRealNull"`
			ColTimestamp     time.Time    `json:"colTimestamp"`
			ColTimestampNull bun.NullTime `json:"colTimestampNull"`
			ColJson          jsonType     `json:"colJson"`
			ColJsonNull      *jsonType    `json:"colJsonNull"`
			ColArray         []*int64     `json:"colArray"`
			ColArrayNull     *[]*int64    `json:"colArrayNull"`
		}
	)

この型をモデルとみなすかどうかですが、広義ではモデルだと思いますが、私はORMのModelとは別物と認識しています。

本記事のResultSetと、ORMのModelの違い

ORMのModelは、コードファーストで必要です。
RDBの実テーブルとアプリのModelが密結合する形になります。

一方、本記事のようなDBファーストでは、「SELECT文では」実テーブルと結合したModelは不要と私は考えています。
代わりに必要なのが、クエリーの結果表に相当するResultSetです。

ResultSetとModelの違いですが、ここでデータベーススペシャリスト試験でも出題される「ANSI/SPARC 3層スキーマ」を思い出しましょう。
3層スキーマは、データベースのスキーマを「外部スキーマ/概念スキーマ/内部スキーマ」の3階層で定義する方式ですね。
ResultSetは外部スキーマに相当し、Modelは概念スキーマに相当します。

3層スキーマでは、ユーザー(アプリも含む)は「外部スキーマを介してデータにアクセスせよ」と言っています。
3層スキーマに従うなら、SELECT文において、概念スキーマと結合したModelをアプリで使うのは、行儀が悪いというのが私の意見です。
従って、本記事のselectFooハンドラではModelは登場せず、代わりにResultSetが登場しています。
アプリケーションは本来は、SQLの結果表をモデルとして扱うべきだと思います。

クエリー~レスポンスを返す部分

続きのソースコードを見てみます。
BunはSQLファーストを謳っているだけあって、SQL文が透けて見えますね。
本記事では単純なクエリーしか生成してもらっていませんが、Bunでは複雑なクエリーも生成してくれるそうです。
私みたいにSQLを書きたい人、N+1問題を気にしながらORMを使うなんて馬鹿らしいと思っている人、必要に応じてSQLチューニングをしたい人にとって、Bunは嬉しいORMだと思います。

	resultSetFoo := make([]resultSetType, 0)
	if err := db.NewSelect().
		Column("col_text").
		Column("col_text_null").
		Column("col_int").
		Column("col_int_null").
		Column("col_real").
		Column("col_real_null").
		ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp").
		ColumnExpr("STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null").
		Column("col_json").
		Column("col_json_null").
		Column("col_array").
		Column("col_array_null").
		Table("foo").
		Scan(ctx, &resultSetFoo); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

	return c.JSON(http.StatusOK, resultSetFoo)

Bunが生成したSQL

上記コードからBunが生成したSQLを、整形して見やすくしました。

SELECT
	"col_text",
	"col_text_null",
	"col_int",
	"col_int_null",
	"col_real",
	"col_real_null",
	STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp) AS col_timestamp,
	STRFTIME('%Y-%m-%dT%H:%M:%fZ', col_timestamp_null) AS col_timestamp_null,
	"col_json",
	"col_json_null",
	"col_array",
	"col_array_null"
FROM
	"foo"

selectFoo APIの実行結果

selectFoo APIを呼び出した結果です。
レスポンスは、ハンドラの最後の一行で、結果表をそのままjsonにして返しているだけですが、きれいなレスポンスが生成できていますね。
シンプルなコードできれいなレスポンスを返せているのはポイントが高いです。

[
    {
        "colText": "text_data",
        "colTextNull": null,
        "colInt": 1234567890123456789,
        "colIntNull": null,
        "colReal": 3.141592653589793,
        "colRealNull": null,
        "colTimestamp": "2023-12-13T10:41:18.524Z",
        "colTimestampNull": null,
        "colJson": {
            "a": 100,
            "b": "bbb"
        },
        "colJsonNull": null,
        "colArray": [
            10,
            null
        ],
        "colArrayNull": null
    },
    {
        "colText": "text_data",
        "colTextNull": "text_data",
        "colInt": 1234567890123456789,
        "colIntNull": 1234567890123456789,
        "colReal": 3.141592653589793,
        "colRealNull": 3.141592653589793,
        "colTimestamp": "2023-12-13T10:41:18.524Z",
        "colTimestampNull": "2023-12-13T10:41:18.524Z",
        "colJson": {
            "a": 200,
            "b": null
        },
        "colJsonNull": {
            "a": 300,
            "b": null
        },
        "colArray": [
            30,
            null,
            50
        ],
        "colArrayNull": []
    }
]

image.png

SELECT文振り返り

ORMはコードファースト前提で開発されていることが多く、Bunも例外ではないと思います。
でもBunをDBファーストで試したら、問題なく動いてくれました。
jsonや配列を格納した列もGoの型にマッピングできて、とても良いと思います。

UPDATE文

次はUPDATE文を見ていきます。

updateFooハンドラの中身

ハンドラのクエリー部分を見ます。
col_text_null列がNULLの行について、同じ列を"ABC"に更新する内容です。

	if _, err := db.NewUpdate().
		Table("foo").
		SetColumn("col_text_null", "?", "ABC").
		Where("col_text_null IS NULL").
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

Bunが生成したSQL

上記コードからBunが生成したSQLを、整形して見やすくしました。

UPDATE "foo" SET
	col_text_null = 'ABC'
WHERE
	(col_text_null IS NULL)

updateFoo APIの実行結果

updateFoo APIを呼び出した結果です。
col_text_nullがNULLだった行の意図した列が、意図した値に更新されました。

image.png

image.png

Bunとsqlcを比較:Bunは動的SQLを組み立てやすそう

ここでBunとsqlcを比較してみます。
UPDATE文では、テーブルの一部の列を更新したいケース、それもどの列を更新するか実行時に決まるケースがしばしば出てきます。
その場合、事前にSQL文を用意しようとすると大量のSQLが必要になるため、そうせずに実行時に動的にSQL文を組み立てることが多いです。
sqlcは事前にSQL文を用意する方式であり、動的にSQL文を組み立てる機能はなさそうです。
その点Bunは、updateFooハンドラのコードで言えば、必要なSetColumn()を実行時に付け加えていけば良いので、簡単に動的SQL文を組み立てられそうです。
そんな訳で、私はsqlcよりもBunを使っていきたいと思いました。

INSERT文

次はINSERT文を見ていきます。

insertFooハンドラの中身

ハンドラのクエリー部分を見ます。
NULLを入れたい列にnilを設定しています。

	type dummyModel struct{}

	ctx := context.TODO()

	if _, err := db.NewInsert().
		Model((*dummyModel)(nil)).
		ModelTableExpr("foo").
		Value("col_text", "?", "XYZ").
		Value("col_text_null", "?", nil).
		Value("col_int", "?", 123).
		Value("col_int_null", "?", nil).
		Value("col_real", "?", 3.14).
		Value("col_real_null", "?", nil).
		Value("col_timestamp", "JULIANDAY('now')").
		Value("col_timestamp_null", "?", nil).
		Value("col_json", "?", "{}").
		Value("col_json_null", "?", nil).
		Value("col_array", "?", "[]").
		Value("col_array_null", "?", nil).
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

このコード、Model周りの記述が変ですみません。
本当はUPDATE文の時みたいに「Table("foo")」と書きたかったのですが、Bunが「"bun: Model(nil)"」とエラーメッセージを吐いて受け付けてくれませんでした。
やり方を変えれば、変じゃないコードを書くことが可能でしょう。

Bunが生成したSQL

上記コードからBunが生成したSQLを、整形して見やすくしました。

INSERT INTO foo (
	"col_text",
	"col_text_null",
	"col_int",
	"col_int_null",
	"col_real",
	"col_real_null",
	"col_timestamp",
	"col_timestamp_null",
	"col_json",
	"col_json_null",
	"col_array",
	"col_array_null"
)
VALUES (
	'XYZ',
	NULL,
	123,
	NULL,
	3.14,
	NULL,
	JULIANDAY('now'),
	NULL,
	'{}',
	NULL,
	'[]',
	NULL
)

insertFoo APIの実行結果

insertFoo APIを呼び出した結果です。
3行目に、意図した行がINSERTされたことを確認できました。

image.png

image.png

DELETE文

次はDELETE文を見ていきます。

deleteFooハンドラの中身

ハンドラのクエリー部分を見ます。
col_text列が"XYZ"の行を削除する内容です。

	if _, err := db.NewDelete().
		Table("foo").
		Where("col_text = ?", "XYZ").
		Exec(ctx); err != nil {

		return c.JSON(http.StatusInternalServerError, httpError{Error: err.Error()})
	}

Bunが生成したSQL

上記コードからBunが生成したSQLを、整形して見やすくしました。

DELETE FROM
	"foo"
WHERE
	(col_text = 'XYZ')

deleteFoo APIの実行結果

deleteFoo APIを呼び出した結果です。
意図した行(3行目)が正常に削除されました。

image.png

image.png

まとめ

BunをDBファーストで使ってみたところ、問題なく動いてくれました。
BunはSQLファーストを謳っているだけあって、SQLを書きたい私でも使いたいと思わせてくれるORMでした。
記事中でBunとsqlcを比較しましたが、私はsqlcからBunに乗り換えたいと思いました。

おまけ

BunでRelationという名のメソッドがありますが、これはRelationshipと命名してほしかったです。
リレーショナルモデルのリレーション(関係、集合)と、ERモデルのリレーションシップ(関連、関係性)は全く別物ですので。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0