LoginSignup
0
0

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

Last updated at Posted at 2023-12-22

本シリーズのリンク

本記事で行うこと

本シリーズの1番目の記事で、ORMのBunからSQLiteを使用しました。
そして2番目の記事で、BunからTursoを使ってみて、動くことを確認しました。
しかし、2番目の記事で使用したlibSQLのGo言語用クライアントライブラリは、記事執筆時点でまだwip(開発途中)だそうです。
チームメンバーから、wipのライブラリを使うのは不安という声が上がりましたので(当然ですね)、使用するデータベースをチームで再検討しました。
その結果、SQLiteとPostgreSQLのどちらにも対応できるように開発を行い、どっちを使うかは後日動かしてみて判断することにしました。

本記事では、1番目の記事でBunからSQLiteに試したのと同様の作業を、PostgreSQLにも試すことにします。

テーブルを作る

それでは作業に移ります。
1番目の記事と同様にDBファーストで行きますので、まずDDLを書きます。
DBファーストで行く理由は1番目の記事に書きましたので、そちらをお読みいただければと思います。

CREATE TYPE type_composite AS (
    i INTEGER,
    j TEXT
);

CREATE TABLE foo
(
    col_serial                 SERIAL                   NOT NULL,
    col_bigserial              BIGSERIAL                NOT NULL,
    col_boolean                BOOLEAN                  NOT NULL,
    col_boolean_null           BOOLEAN,
    col_smallint               SMALLINT                 NOT NULL,
    col_smallint_null          SMALLINT,
    col_int                    INTEGER                  NOT NULL,
    col_int_null               INTEGER,
    col_bigint                 BIGINT                   NOT NULL,
    col_bigint_null            BIGINT,
    col_numeric                NUMERIC                  NOT NULL,
    col_numeric_null           NUMERIC,
    col_real                   REAL                     NOT NULL,
    col_real_null              REAL,
    col_double                 DOUBLE PRECISION         NOT NULL,
    col_double_null            DOUBLE PRECISION,
    col_text                   TEXT                     NOT NULL,
    col_text_null              TEXT,
    col_char                   CHAR(1)                  NOT NULL,
    col_char_null              CHAR(1),
    col_date                   DATE                     NOT NULL,
    col_date_null              DATE,
    col_time_with              TIME WITH TIME ZONE      NOT NULL,
    col_time_with_null         TIME WITH TIME ZONE,
    col_time                   TIME                     NOT NULL,
    col_time_null              TIME,
    col_timestamp_with         TIMESTAMP WITH TIME ZONE NOT NULL,
    col_timestamp_with_null    TIMESTAMP WITH TIME ZONE,
    col_timestamp              TIMESTAMP                NOT NULL,
    col_timestamp_null         TIMESTAMP,
    col_uuid                   UUID                     NOT NULL,
    col_uuid_null              UUID,
    col_json                   JSONB                    NOT NULL,
    col_json_null              JSONB,
    col_array                  INTEGER[]                NOT NULL,
    col_array_null             INTEGER[],
    col_composite              type_composite           NOT NULL,
    col_composite_null         type_composite,
    col_composite_array        type_composite[]         NOT NULL,
    col_composite_array_null   type_composite[]
);

上記DDLの意図

列について、プロジェクトで使うかもしれない型を広めに試します。
JSON型、配列型、複合型(composite type)、複合型の配列も試します。
SQLiteよりも、だいぶ型の種類が多いですね。

テスト用データをINSERTする

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

INSERT INTO foo
(
    col_boolean,
    col_boolean_null,
    col_smallint,
    col_smallint_null,
    col_int,
    col_int_null,
    col_bigint,
    col_bigint_null,
    col_numeric,
    col_numeric_null,
    col_real,
    col_real_null,
    col_double,
    col_double_null,
    col_text,
    col_text_null,
    col_char,
    col_char_null,
    col_date,
    col_date_null,
    col_time_with,
    col_time_with_null,
    col_time,
    col_time_null,
    col_timestamp_with,
    col_timestamp_with_null,
    col_timestamp,
    col_timestamp_null,
    col_uuid,
    col_uuid_null,
    col_json,
    col_json_null,
    col_array,
    col_array_null,
    col_composite,
    col_composite_null,
    col_composite_array,
    col_composite_array_null
)
VALUES
(
    TRUE,
    NULL,
    12345,
    NULL,
    123456789,
    NULL,
    1234567890123456789,
    NULL,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    NULL,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    NULL,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    NULL,
    'text_data',
    NULL,
    'c',
    NULL,
    '2023-01-01',
    NULL,
    '12:00:00',
    NULL,
    '12:00:00',
    NULL,
    '2023-12-31 23:59:59',
    NULL,
    '2023-12-31 23:59:59',
    NULL,
    GEN_RANDOM_UUID(),
    NULL,
    '{"a": 10, "b": "Json"}'::JSONB,
    NULL,
    ARRAY[10, NULL, 30]::INTEGER[],
    NULL,
    ROW(10, 'Composite')::type_composite,
    NULL,
    ARRAY[ROW(10, NULL), NULL, ROW(NULL, 'CompositeArray')]::type_composite[],
    NULL
),
(
    FALSE,
    FALSE,
    32767,
    32767,
    987654321,
    987654321,
    876543210987654321,
    876543210987654321,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117,
    'text_data',
    'text_data',
    'c',
    'c',
    '2023-01-01',
    '2023-01-01',
    '12:00:00',
    '12:00:00',
    '12:00:00',
    '12:00:00',
    '2023-12-31 23:59:59',
    '2023-12-31 23:59:59',
    '2023-12-31 23:59:59',
    '2023-12-31 23:59:59',
    GEN_RANDOM_UUID(),
    GEN_RANDOM_UUID(),
    '{"a": 20, "b": null}'::JSONB,
    '{"a": null, "b": "Json"}'::JSONB,
    ARRAY[10, NULL, 30]::INTEGER[],
    ARRAY[10, NULL, 30]::INTEGER[],
    ROW(20, NULL)::type_composite,
    ROW(NULL, 'Composite')::type_composite,
    ARRAY[ROW(20, 'CompositeArray'), NULL]::type_composite[],
    ARRAY[NULL, ROW(30, 'CompositeArray')]::type_composite[]
);

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

SELECT
    *
FROM
    foo;

SELECT文の結果です。

image.png

image.png

image.png

image.png

image.png

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"
	"github.com/google/uuid"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect/pgdialect"
	"github.com/uptrace/bun/driver/pgdriver"
	"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 *int64  `json:"a"`
			B *string `json:"b"`
		}

		compositeType struct {
			I *int64  `json:"i"`
			J *string `json:"j"`
		}

		resultSetType struct {
			ColSerial             int32             `json:"colSerial"`
			ColBigserial          int64             `json:"colBigserial"`
			ColBoolean            bool              `json:"colBoolean"`
			ColBooleanNull        *bool             `json:"colBooleanNull"`
			ColSmallint           int16             `json:"colSmallint"`
			ColSmallintNull       *int16            `json:"colSmallintNull"`
			ColInt                int32             `json:"colInt"`
			ColIntNull            *int32            `json:"colIntNull"`
			ColBigint             int64             `json:"colBigint"`
			ColBigintNull         *int64            `json:"colBigintNull"`
			ColNumeric            string            `json:"colNumeric"`
			ColNumericNull        *string           `json:"colNumericNull"`
			ColReal               float32           `json:"colReal"`
			ColRealNull           *float32          `json:"colRealNull"`
			ColDouble             float64           `json:"colDouble"`
			ColDoubleNull         *float64          `json:"colDoubleNull"`
			ColText               string            `json:"colText"`
			ColTextNull           *string           `json:"colTextNull"`
			ColChar               string            `json:"colChar"`
			ColCharNull           *string           `json:"colCharNull"`
			ColDate               time.Time         `json:"colDate"`
			ColDateNull           bun.NullTime      `json:"colDateNull"`
			ColTimeWith           time.Time         `json:"colTimeWith"`
			ColTimeWithNull       bun.NullTime      `json:"colTimeWithNull"`
			ColTime               time.Time         `json:"colTime"`
			ColTimeNull           bun.NullTime      `json:"colTimeNull"`
			ColTimestampWith      time.Time         `json:"colTimestampWith"`
			ColTimestampWithNull  bun.NullTime      `json:"colTimestampWithNull"`
			ColTimestamp          time.Time         `json:"colTimestamp"`
			ColTimestampNull      bun.NullTime      `json:"colTimestampNull"`
			ColUuid               uuid.UUID         `json:"colUuid"`
			ColUuidNull           *uuid.UUID        `json:"colUuidNull"`
			ColJson               jsonType          `json:"colJson"`
			ColJsonNull           *jsonType         `json:"colJsonNull"`
			ColArray              []int64           `bun:",array" json:"colArray"`
			ColArrayNull          *[]int64          `bun:",array" json:"colArrayNull"`
			ColArray2             []*int64          `json:"colArray2"`
			ColArrayNull2         *[]*int64         `json:"colArrayNull2"`
			ColComposite          compositeType     `json:"colComposite"`
			ColCompositeNull      *compositeType    `json:"colCompositeNull"`
			ColCompositeArray     []*compositeType  `json:"colCompositeArray"`
			ColCompositeArrayNull *[]*compositeType `json:"colCompositeArrayNull"`
		}
	)

	ctx := context.TODO()

	resultSetFoo := make([]resultSetType, 0)
	if err := db.NewSelect().
		Column("col_serial").
		Column("col_bigserial").
		Column("col_boolean").
		Column("col_boolean_null").
		Column("col_smallint").
		Column("col_smallint_null").
		Column("col_int").
		Column("col_int_null").
		Column("col_bigint").
		Column("col_bigint_null").
		Column("col_numeric").
		Column("col_numeric_null").
		Column("col_real").
		Column("col_real_null").
		Column("col_double").
		Column("col_double_null").
		Column("col_text").
		Column("col_text_null").
		Column("col_char").
		Column("col_char_null").
		Column("col_date").
		Column("col_date_null").
		Column("col_time_with").
		Column("col_time_with_null").
		Column("col_time").
		Column("col_time_null").
		Column("col_timestamp_with").
		Column("col_timestamp_with_null").
		Column("col_timestamp").
		Column("col_timestamp_null").
		Column("col_uuid").
		Column("col_uuid_null").
		Column("col_json").
		Column("col_json_null").
		Column("col_array").
		Column("col_array_null").
		ColumnExpr("TO_JSONB(col_array) AS col_array2").
		ColumnExpr("TO_JSONB(col_array_null) AS col_array_null2").
		ColumnExpr("TO_JSONB(col_composite) AS col_composite").
		ColumnExpr("TO_JSONB(col_composite_null) AS col_composite_null").
		ColumnExpr("TO_JSONB(col_composite_array) AS col_composite_array").
		ColumnExpr("TO_JSONB(col_composite_array_null) AS col_composite_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_array", "ARRAY[?, ?, ?]::INTEGER[]", 111, nil, 333).
		SetColumn("col_composite", "ROW(?, ?)::type_composite", 222, nil).
		SetColumn("col_composite_array", "ARRAY[?, ROW(?, ?)]::type_composite[]", nil, 111, "Hello").
		Where("col_serial = ?", 1).
		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_boolean", "?", false).
		Value("col_boolean_null", "?", nil).
		Value("col_smallint", "?", 9999).
		Value("col_smallint_null", "?", nil).
		Value("col_int", "?", 999999999).
		Value("col_int_null", "?", nil).
		Value("col_bigint", "?", 999999999999999999).
		Value("col_bigint_null", "?", nil).
		Value("col_numeric", "?",
			"2.718281828459045235360287471352662497757247093699959574966967627724076630353547594571382178").
		Value("col_numeric_null", "?", nil).
		Value("col_real", "?", 2.71828182845904523536028747135266).
		Value("col_real_null", "?", nil).
		Value("col_double", "?", 2.71828182845904523536028747135266).
		Value("col_double_null", "?", nil).
		Value("col_text", "?", "Hello").
		Value("col_text_null", "?", nil).
		Value("col_char", "?", "z").
		Value("col_char_null", "?", nil).
		Value("col_date", "?", "2023-12-22").
		Value("col_date_null", "?", nil).
		Value("col_time_with", "?", "13:00").
		Value("col_time_with_null", "?", nil).
		Value("col_time", "?", "13:00").
		Value("col_time_null", "?", nil).
		Value("col_timestamp_with", "?", "2023-12-22 13:00:00.000000").
		Value("col_timestamp_with_null", "?", nil).
		Value("col_timestamp", "?", "2023-12-22 13:00:00.000000").
		Value("col_timestamp_null", "?", nil).
		Value("col_uuid", "GEN_RANDOM_UUID()").
		Value("col_uuid_null", "?", nil).
		Value("col_json", "?", httpMessage{Message: "Hello"}).
		Value("col_json_null", "?", map[string]int{"key": 777}).
		Value("col_array", "ARRAY[?, ?, ?]::INTEGER[]", 111, nil, 333).
		Value("col_array_null", "?", nil).
		Value("col_composite", "ROW(?, ?)::type_composite", 222, nil).
		Value("col_composite_null", "?", nil).
		Value("col_composite_array", "ARRAY[?, ROW(?, ?)]::type_composite[]", nil, 111, "Hello").
		Value("col_composite_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_serial = (SELECT MAX(col_serial) FROM foo)").
		Exec(ctx); err != nil {

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

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

func main() {
	dsn := "postgres://postgres:secret@localhost:5432/postgres?sslmode=disable"
	sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

	db = bun.NewDB(sqldb, pgdialect.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/google/uuid v1.5.0
	github.com/labstack/echo/v4 v4.11.4
	github.com/uptrace/bun v1.1.16
	github.com/uptrace/bun/dialect/pgdialect v1.1.16
	github.com/uptrace/bun/driver/pgdriver v1.1.16
	github.com/uptrace/bun/extra/bundebug v1.1.16
)

require (
	github.com/fatih/color v1.15.0 // indirect
	github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
	github.com/jinzhu/inflection v1.0.0 // indirect
	github.com/labstack/gommon v0.4.2 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // 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.17.0 // indirect
	golang.org/x/net v0.19.0 // indirect
	golang.org/x/sys v0.15.0 // indirect
	golang.org/x/text v0.14.0 // indirect
	golang.org/x/time v0.5.0 // indirect
	mellium.im/sasl v0.3.1 // indirect
)

CRUD操作

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

SELECT文

まずはSELECT文です。

selectFooハンドラの中身

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

ソースコード中のColArrayとColArray2の違いは、後ほど説明します。

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

		compositeType struct {
			I *int64  `json:"i"`
			J *string `json:"j"`
		}

		resultSetType struct {
			ColSerial             int32             `json:"colSerial"`
			ColBigserial          int64             `json:"colBigserial"`
			ColBoolean            bool              `json:"colBoolean"`
			ColBooleanNull        *bool             `json:"colBooleanNull"`
			ColSmallint           int16             `json:"colSmallint"`
			ColSmallintNull       *int16            `json:"colSmallintNull"`
			ColInt                int32             `json:"colInt"`
			ColIntNull            *int32            `json:"colIntNull"`
			ColBigint             int64             `json:"colBigint"`
			ColBigintNull         *int64            `json:"colBigintNull"`
			ColNumeric            string            `json:"colNumeric"`
			ColNumericNull        *string           `json:"colNumericNull"`
			ColReal               float32           `json:"colReal"`
			ColRealNull           *float32          `json:"colRealNull"`
			ColDouble             float64           `json:"colDouble"`
			ColDoubleNull         *float64          `json:"colDoubleNull"`
			ColText               string            `json:"colText"`
			ColTextNull           *string           `json:"colTextNull"`
			ColChar               string            `json:"colChar"`
			ColCharNull           *string           `json:"colCharNull"`
			ColDate               time.Time         `json:"colDate"`
			ColDateNull           bun.NullTime      `json:"colDateNull"`
			ColTimeWith           time.Time         `json:"colTimeWith"`
			ColTimeWithNull       bun.NullTime      `json:"colTimeWithNull"`
			ColTime               time.Time         `json:"colTime"`
			ColTimeNull           bun.NullTime      `json:"colTimeNull"`
			ColTimestampWith      time.Time         `json:"colTimestampWith"`
			ColTimestampWithNull  bun.NullTime      `json:"colTimestampWithNull"`
			ColTimestamp          time.Time         `json:"colTimestamp"`
			ColTimestampNull      bun.NullTime      `json:"colTimestampNull"`
			ColUuid               uuid.UUID         `json:"colUuid"`
			ColUuidNull           *uuid.UUID        `json:"colUuidNull"`
			ColJson               jsonType          `json:"colJson"`
			ColJsonNull           *jsonType         `json:"colJsonNull"`
			ColArray              []int64           `bun:",array" json:"colArray"`
			ColArrayNull          *[]int64          `bun:",array" json:"colArrayNull"`
			ColArray2             []*int64          `json:"colArray2"`
			ColArrayNull2         *[]*int64         `json:"colArrayNull2"`
			ColComposite          compositeType     `json:"colComposite"`
			ColCompositeNull      *compositeType    `json:"colCompositeNull"`
			ColCompositeArray     []*compositeType  `json:"colCompositeArray"`
			ColCompositeArrayNull *[]*compositeType `json:"colCompositeArrayNull"`
		}
	)

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

続きのソースコードを見てみます。
複合型や複合型の配列については、試行錯誤の結果、sqlcを試したときと同様に、TO_JSONB()を使ってJSON型を経由すれば、Go言語の構造体にマッピングできることがわかりました。
複合型についてsqlcを試したとき、DBのスネークケースの列名から、JSONレスポンスのキャメルケースに変換するのが面倒でしたので、本記事の複合型type_compositeはその変換が必要ないように列名(=i, j)を定義してあります。
こういう面倒は、避けられるなら最初から避けたいですね。

	resultSetFoo := make([]resultSetType, 0)
	if err := db.NewSelect().
		Column("col_serial").
		Column("col_bigserial").
		Column("col_boolean").
		Column("col_boolean_null").
		Column("col_smallint").
		Column("col_smallint_null").
		Column("col_int").
		Column("col_int_null").
		Column("col_bigint").
		Column("col_bigint_null").
		Column("col_numeric").
		Column("col_numeric_null").
		Column("col_real").
		Column("col_real_null").
		Column("col_double").
		Column("col_double_null").
		Column("col_text").
		Column("col_text_null").
		Column("col_char").
		Column("col_char_null").
		Column("col_date").
		Column("col_date_null").
		Column("col_time_with").
		Column("col_time_with_null").
		Column("col_time").
		Column("col_time_null").
		Column("col_timestamp_with").
		Column("col_timestamp_with_null").
		Column("col_timestamp").
		Column("col_timestamp_null").
		Column("col_uuid").
		Column("col_uuid_null").
		Column("col_json").
		Column("col_json_null").
		Column("col_array").
		Column("col_array_null").
		ColumnExpr("TO_JSONB(col_array) AS col_array2").
		ColumnExpr("TO_JSONB(col_array_null) AS col_array_null2").
		ColumnExpr("TO_JSONB(col_composite) AS col_composite").
		ColumnExpr("TO_JSONB(col_composite_null) AS col_composite_null").
		ColumnExpr("TO_JSONB(col_composite_array) AS col_composite_array").
		ColumnExpr("TO_JSONB(col_composite_array_null) AS col_composite_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_serial",
    "col_bigserial",
    "col_boolean",
    "col_boolean_null",
    "col_smallint",
    "col_smallint_null",
    "col_int",
    "col_int_null",
    "col_bigint",
    "col_bigint_null",
    "col_numeric",
    "col_numeric_null",
    "col_real",
    "col_real_null",
    "col_double",
    "col_double_null",
    "col_text",
    "col_text_null",
    "col_char",
    "col_char_null",
    "col_date",
    "col_date_null",
    "col_time_with",
    "col_time_with_null",
    "col_time",
    "col_time_null",
    "col_timestamp_with",
    "col_timestamp_with_null",
    "col_timestamp",
    "col_timestamp_null",
    "col_uuid",
    "col_uuid_null",
    "col_json",
    "col_json_null",
    "col_array",
    "col_array_null",
    TO_JSONB(col_array) AS col_array2,
    TO_JSONB(col_array_null) AS col_array_null2,
    TO_JSONB(col_composite) AS col_composite,
    TO_JSONB(col_composite_null) AS col_composite_null,
    TO_JSONB(col_composite_array) AS col_composite_array,
    TO_JSONB(col_composite_array_null) AS col_composite_array_null
FROM
    "foo"

selectFoo APIの実行結果

selectFoo APIを呼び出した結果です。
レスポンスは、ハンドラの最後の一行で結果表をそのままjsonにして返しているだけですが、おおむねきれいなレスポンスが生成できています。
ただし、このレスポンスにはおかしな部分もありますので、個別にみていきます。

[
    {
        "colSerial": 1,
        "colBigserial": 1,
        "colBoolean": true,
        "colBooleanNull": null,
        "colSmallint": 12345,
        "colSmallintNull": null,
        "colInt": 123456789,
        "colIntNull": null,
        "colBigint": 1234567890123456789,
        "colBigintNull": null,
        "colNumeric": "3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117",
        "colNumericNull": null,
        "colReal": 3.1415927,
        "colRealNull": null,
        "colDouble": 3.141592653589793,
        "colDoubleNull": null,
        "colText": "text_data",
        "colTextNull": null,
        "colChar": "c",
        "colCharNull": null,
        "colDate": "2023-01-01T00:00:00Z",
        "colDateNull": null,
        "colTimeWith": "0000-01-01T12:00:00Z",
        "colTimeWithNull": null,
        "colTime": "0000-01-01T12:00:00Z",
        "colTimeNull": null,
        "colTimestampWith": "2023-12-31T23:59:59Z",
        "colTimestampWithNull": null,
        "colTimestamp": "2023-12-31T23:59:59Z",
        "colTimestampNull": null,
        "colUuid": "40b4e894-e812-4fd6-b8c7-8beec353e078",
        "colUuidNull": null,
        "colJson": {
            "a": 10,
            "b": "Json"
        },
        "colJsonNull": null,
        "colArray": [
            10,
            0,
            30
        ],
        "colArrayNull": null,
        "colArray2": [
            10,
            null,
            30
        ],
        "colArrayNull2": null,
        "colComposite": {
            "i": 10,
            "j": "Composite"
        },
        "colCompositeNull": null,
        "colCompositeArray": [
            {
                "i": 10,
                "j": null
            },
            null,
            {
                "i": null,
                "j": "CompositeArray"
            }
        ],
        "colCompositeArrayNull": null
    },
    {
        "colSerial": 2,
        "colBigserial": 2,
        "colBoolean": false,
        "colBooleanNull": false,
        "colSmallint": 32767,
        "colSmallintNull": 32767,
        "colInt": 987654321,
        "colIntNull": 987654321,
        "colBigint": 876543210987654321,
        "colBigintNull": 876543210987654321,
        "colNumeric": "3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117",
        "colNumericNull": "3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117",
        "colReal": 3.1415927,
        "colRealNull": 3.1415927,
        "colDouble": 3.141592653589793,
        "colDoubleNull": 3.141592653589793,
        "colText": "text_data",
        "colTextNull": "text_data",
        "colChar": "c",
        "colCharNull": "c",
        "colDate": "2023-01-01T00:00:00Z",
        "colDateNull": "2023-01-01T00:00:00Z",
        "colTimeWith": "0000-01-01T12:00:00Z",
        "colTimeWithNull": "0000-01-01T12:00:00Z",
        "colTime": "0000-01-01T12:00:00Z",
        "colTimeNull": "0000-01-01T12:00:00Z",
        "colTimestampWith": "2023-12-31T23:59:59Z",
        "colTimestampWithNull": "2023-12-31T23:59:59Z",
        "colTimestamp": "2023-12-31T23:59:59Z",
        "colTimestampNull": "2023-12-31T23:59:59Z",
        "colUuid": "e35eae09-2654-44b3-be64-4a2a011f2dda",
        "colUuidNull": "636ce143-6b3c-4b71-8012-2adcc50d267a",
        "colJson": {
            "a": 20,
            "b": null
        },
        "colJsonNull": {
            "a": null,
            "b": "Json"
        },
        "colArray": [
            10,
            0,
            30
        ],
        "colArrayNull": [
            10,
            0,
            30
        ],
        "colArray2": [
            10,
            null,
            30
        ],
        "colArrayNull2": [
            10,
            null,
            30
        ],
        "colComposite": {
            "i": 20,
            "j": null
        },
        "colCompositeNull": {
            "i": null,
            "j": "Composite"
        },
        "colCompositeArray": [
            {
                "i": 20,
                "j": "CompositeArray"
            },
            null
        ],
        "colCompositeArrayNull": [
            null,
            {
                "i": 30,
                "j": "CompositeArray"
            }
        ]
    }
]

image.png

レスポンスの変な部分:タイムゾーンなしの日付・時間型の値にタイムゾーンが付いた

PostgreSQLのタイムゾーンなしの以下の型について、Go言語側でtime.Time型やbun.NullTime型にマッピングしたら、タイムゾーン付きになってしまった様子です。

  • DATE
  • TIME WITHOUT TIME ZONE
  • TIMESTAMP WITHOUT TIME ZONE

上記の型は、Go言語側でstring型にマッピングした方が良さそうです。

レスポンスの変な部分:colArrayとcolArrayNullを見ると、NULLの配列要素を受け取れていない

最初にresultSetTypeのColArrayとColArrayNullを以下のように定義して、NULLの配列要素を受け取ろうとしましたが、

			ColArray              []*int64          `bun:",array" json:"colArray"`
			ColArrayNull          *[]*int64         `bun:",array" json:"colArrayNull"`

以下のレスポンスが返ってきました。

{
    "error": "sql: Scan error on column index 34, name \"col_array\": strconv.ParseInt: parsing \"\": invalid syntax"
}

そこで、resultSetTypeを以下のように修正したのが現行のソースコードになります。

			ColArray              []int64           `bun:",array" json:"colArray"`
			ColArrayNull          *[]int64          `bun:",array" json:"colArrayNull"`
			ColArray2             []*int64          `json:"colArray2"`
			ColArrayNull2         *[]*int64         `json:"colArrayNull2"`

問い合わせ部分にも以下のようにColumnExprを追加しました。
動かしてレスポンスを見たら、colArray2とcolArrayNull2がNULLの配列要素を受け取れていることが確認できました。
TO_JSONB()を使って一度JSONを経由すれば受け取れるようです。

		ColumnExpr("TO_JSONB(col_array) AS col_array2").
		ColumnExpr("TO_JSONB(col_array_null) AS col_array_null2").

配列にNULL要素が入らないケースではColArrayやColArrayNullのようにコーディングし、NULL要素が入るケースではColArray2やColArrayNull2のようにコーディングすれば良さそうです。

UPDATE文

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

updateFooハンドラの中身

ハンドラのクエリー部分を見ます。
col_serial列が1の行について、配列型、複合型、複合型の配列の列をそれぞれ更新する内容です。

	if _, err := db.NewUpdate().
		Table("foo").
		SetColumn("col_array", "ARRAY[?, ?, ?]::INTEGER[]", 111, nil, 333).
		SetColumn("col_composite", "ROW(?, ?)::type_composite", 222, nil).
		SetColumn("col_composite_array", "ARRAY[?, ROW(?, ?)]::type_composite[]", nil, 111, "Hello").
		Where("col_serial = ?", 1).
		Exec(ctx); err != nil {

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

Bunが生成したSQL

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

UPDATE "foo" SET
    col_array = ARRAY[111, NULL, 333]::INTEGER[],
    col_composite = ROW(222, NULL)::type_composite,
    col_composite_array = ARRAY[NULL, ROW(111, 'Hello')]::type_composite[]
WHERE
    (col_serial = 1)

updateFoo APIの実行結果

updateFoo APIを呼び出した結果を見てみます。

SELECT
    col_array,
    col_composite,
    col_composite_array
FROM
    foo
WHERE
    col_serial = 1;

3つの列が意図した値に更新されました。

image.png

image.png

再びBunとsqlcを比較:Bunは複合型の配列を割と簡単に更新できる

本シリーズの1番目の記事でBunとsqlcを比較しましたが、本記事でも追加で比較します。

前回(6月~8月)のチーム開発中に、sqlcから複合型の配列の更新を試したときに、記事を2つ書きました。

記事執筆時点で、sqlcは複合型をサポートしておらず、Goコードからでなくストアドプロシージャから複合型の配列を更新しました。
しかし本記事のGoコードで、Bunを使用したら複合型や複合型の配列を割と簡単に更新できました。
sqlcからBunに乗り換える理由が1つ増えたと思います。

※ 追記(2023-12-24)
以下にストアドプロシージャのメリットについて書きましたが、Bunでもネットワーク通信を減らす方法に気づきましたので、こちらの記事に書きました。
※ 追記終わり

ちなみに、sqlcのときに使ったストアドプロシージャにもメリットがあります。

  • ストアドプロシージャを使わない書き方

トランザクション内で複数のSQLを記述したら、アプリからSQLが発行されるたびにアプリとDBサーバー間で通信が発生します。

  • ストアドプロシージャを使うと

ストアドプロシージャはDBサーバー内で実行されるので、一度ストアドプロシージャを呼び出せば、まとめて通信レスで実行され、パフォーマンスが上がるでしょう。

しかしながら、開発者体験はストアドプロシージャよりもGo言語の方が圧倒的に良いので、パフォーマンスがクリティカルでない部分をGo言語で書ける選択肢があるのは、うれしいことだと思います。

INSERT文

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

insertFooハンドラの中身

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

	if _, err := db.NewInsert().
		Model((*dummyModel)(nil)).
		ModelTableExpr("foo").
		Value("col_boolean", "?", false).
		Value("col_boolean_null", "?", nil).
		Value("col_smallint", "?", 9999).
		Value("col_smallint_null", "?", nil).
		Value("col_int", "?", 999999999).
		Value("col_int_null", "?", nil).
		Value("col_bigint", "?", 999999999999999999).
		Value("col_bigint_null", "?", nil).
		Value("col_numeric", "?",
			"2.718281828459045235360287471352662497757247093699959574966967627724076630353547594571382178").
		Value("col_numeric_null", "?", nil).
		Value("col_real", "?", 2.71828182845904523536028747135266).
		Value("col_real_null", "?", nil).
		Value("col_double", "?", 2.71828182845904523536028747135266).
		Value("col_double_null", "?", nil).
		Value("col_text", "?", "Hello").
		Value("col_text_null", "?", nil).
		Value("col_char", "?", "z").
		Value("col_char_null", "?", nil).
		Value("col_date", "?", "2023-12-22").
		Value("col_date_null", "?", nil).
		Value("col_time_with", "?", "13:00").
		Value("col_time_with_null", "?", nil).
		Value("col_time", "?", "13:00").
		Value("col_time_null", "?", nil).
		Value("col_timestamp_with", "?", "2023-12-22 13:00:00.000000").
		Value("col_timestamp_with_null", "?", nil).
		Value("col_timestamp", "?", "2023-12-22 13:00:00.000000").
		Value("col_timestamp_null", "?", nil).
		Value("col_uuid", "GEN_RANDOM_UUID()").
		Value("col_uuid_null", "?", nil).
		Value("col_json", "?", httpMessage{Message: "Hello"}).
		Value("col_json_null", "?", map[string]int{"key": 777}).
		Value("col_array", "ARRAY[?, ?, ?]::INTEGER[]", 111, nil, 333).
		Value("col_array_null", "?", nil).
		Value("col_composite", "ROW(?, ?)::type_composite", 222, nil).
		Value("col_composite_null", "?", nil).
		Value("col_composite_array", "ARRAY[?, ROW(?, ?)]::type_composite[]", nil, 111, "Hello").
		Value("col_composite_array_null", "?", nil).
		Exec(ctx); err != nil {

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

Model周りの記述が、1番目の記事と同様に変ですみません。

Bunが生成したSQL

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

INSERT INTO foo (
    "col_boolean",
    "col_boolean_null",
    "col_smallint",
    "col_smallint_null",
    "col_int",
    "col_int_null",
    "col_bigint",
    "col_bigint_null",
    "col_numeric",
    "col_numeric_null",
    "col_real",
    "col_real_null",
    "col_double",
    "col_double_null",
    "col_text",
    "col_text_null",
    "col_char",
    "col_char_null",
    "col_date",
    "col_date_null",
    "col_time_with",
    "col_time_with_null",
    "col_time",
    "col_time_null",
    "col_timestamp_with",
    "col_timestamp_with_null",
    "col_timestamp",
    "col_timestamp_null",
    "col_uuid",
    "col_uuid_null",
    "col_json",
    "col_json_null",
    "col_array",
    "col_array_null",
    "col_composite",
    "col_composite_null",
    "col_composite_array",
    "col_composite_array_null"
)
VALUES (
    FALSE,
    NULL,
    9999,
    NULL,
    999999999,
    NULL,
    999999999999999999,
    NULL,
    '2.718281828459045235360287471352662497757247093699959574966967627724076630353547594571382178',
    NULL,
    2.718281828459045,
    NULL,
    2.718281828459045,
    NULL,
    'Hello',
    NULL,
    'z',
    NULL,
    '2023-12-22',
    NULL,
    '13:00',
    NULL,
    '13:00',
    NULL,
    '2023-12-22 13:00:00.000000',
    NULL,
    '2023-12-22 13:00:00.000000',
    NULL,
    GEN_RANDOM_UUID(),
    NULL,
    '{"message":"Hello"}',
    '{"key":777}',
    ARRAY[111, NULL, 333]::INTEGER[],
    NULL,
    ROW(222, NULL)::type_composite,
    NULL,
    ARRAY[NULL, ROW(111, 'Hello')]::type_composite[],
    NULL
)

insertFoo APIの実行結果

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

image.png

image.png

image.png

image.png

image.png

image.png

image.png

DELETE文

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

deleteFooハンドラの中身

ハンドラのクエリー部分を見ます。
col_serial列==最大値の行を削除する内容です。

	if _, err := db.NewDelete().
		Table("foo").
		Where("col_serial = (SELECT MAX(col_serial) FROM foo)").
		Exec(ctx); err != nil {

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

Bunが生成したSQL

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

DELETE FROM
    "foo"
WHERE
    (col_serial =
        (SELECT
            MAX(col_serial)
        FROM
            foo
        )
    )

deleteFoo APIの実行結果

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

image.png

image.png

まとめ

BunをDBファーストで使ったところ、PostgreSQLでも問題なく動いてくれました。
また、PostgreSQLの以下の型をBunでうまく扱えたので、sqlcから乗り換える理由が増えました。

  • 配列型
  • 複合型
  • 複合型の配列

おまけ

本記事では、結果表のJSONの列をGo言語の構造体にマッピングしましたが、構造体に入れなくて良い場合は、json.RawMessage型にマッピングすれば良さげです。
https://bun.uptrace.dev/postgres/postgres-data-types.html#jsonb

0
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
0
0