Bun を使ったmigration の実装例

2023/09/24に公開

はじめに

Go言語でORM を使う場合、gorm が有名ですが、最近はBun というORM も注目されています。
Bun は、migration の機能を持っていますが、生成されるmigrationファイルの内容は自分で書く必要があるため、TypeORMやDjangoと比較すると少し弱いと感じる面もあります。
Bun のリポジトリで実装例が紹介されているので、それを参考に今回はpostgresql を使用したmigration の実装例を紹介します。

詳細は公式のドキュメントを参照してください

Bun のmigration の実装例

migration を実行するためには2つのmain.go ファイルが必要になるため、この2つを作成します。

  • migrate コマンドを実行するためのmain.go
  • /migrations ディレクトリ内部でmigration ファイルを実行するためのmain.go

最終的に出来上がるディレクトリの構成は下記のようになります。

migrate
├── migrations
│    ├── {migrationコマンドで作成されたファイル}.go
│    ├── 20210831100001_create_books.go
│    └── main.go
└── main.go

migration コマンド実行用のmain.go を作成する


package main

import (
	"database/sql"
	"example/migrate/migrations"
	"fmt"
	"github.com/uptrace/bun/dialect/pgdialect"
	"github.com/uptrace/bun/driver/pgdriver"

	"github.com/uptrace/bun/extra/bundebug"
	"log"
	"os"
	"strings"

	"github.com/uptrace/bun/migrate"

	"github.com/urfave/cli/v2"

	"github.com/uptrace/bun"
)

func main() {
	dsn := "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable"

	// Open a database. Note that this is not an actual connection to the database.
	// 今回はpostgresql を使用するため、pgdriver を使用しています。
	// driver を間違えると、migration が正しく実行されないので注意してください。
	openDB := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

	// Create a Bun instance. We will use it to perform queries.
	db := bun.NewDB(openDB, pgdialect.New())
	// Enable query logging.
	db.AddQueryHook(bundebug.NewQueryHook(
		bundebug.WithEnabled(false),
		bundebug.FromEnv(""),
	))

	// Run migrations.
	app := &cli.App{
		Name: "bun",

		// Pass the database to the sub-commands.
		Commands: []*cli.Command{
			newDBCommand(migrate.NewMigrator(db, migrations.Migrations)),
		},
	}
	if err := app.Run(os.Args); err != nil {
		log.Fatal(err)
	}
}

// newDBCommand returns a new CLI command for database migrations.
func newDBCommand(migrator *migrate.Migrator) *cli.Command {
	return &cli.Command{
		Name:  "db",
		Usage: "database migrations",
		Subcommands: []*cli.Command{
			{
				Name:  "init",
				Usage: "create migration tables",
				Action: func(c *cli.Context) error {
					return migrator.Init(c.Context)
				},
			},
			{
				Name:  "migrate",
				Usage: "migrate database",
				Action: func(c *cli.Context) error {
					if err := migrator.Lock(c.Context); err != nil {
						return err
					}
					defer migrator.Unlock(c.Context) //nolint:errcheck

					group, err := migrator.Migrate(c.Context)
					if err != nil {
						return err
					}
					if group.IsZero() {
						fmt.Printf("there are no new migrations to run (database is up to date)\n")
						return nil
					}
					fmt.Printf("migrated to %s\n", group)
					return nil
				},
			},
			{
				Name:  "rollback",
				Usage: "rollback the last migration group",
				Action: func(c *cli.Context) error {
					if err := migrator.Lock(c.Context); err != nil {
						return err
					}
					defer migrator.Unlock(c.Context) //nolint:errcheck

					group, err := migrator.Rollback(c.Context)
					if err != nil {
						return err
					}
					if group.IsZero() {
						fmt.Printf("there are no groups to roll back\n")
						return nil
					}
					fmt.Printf("rolled back %s\n", group)
					return nil
				},
			},
			{
				Name:  "lock",
				Usage: "lock migrations",
				Action: func(c *cli.Context) error {
					return migrator.Lock(c.Context)
				},
			},
			{
				Name:  "unlock",
				Usage: "unlock migrations",
				Action: func(c *cli.Context) error {
					return migrator.Unlock(c.Context)
				},
			},
			{
				Name:  "create_go",
				Usage: "create Go migration",
				Action: func(c *cli.Context) error {
					name := strings.Join(c.Args().Slice(), "_")
					mf, err := migrator.CreateGoMigration(c.Context, name)
					if err != nil {
						return err
					}
					fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path)
					return nil
				},
			},
			{
				Name:  "create_sql",
				Usage: "create up and down SQL migrations",
				Action: func(c *cli.Context) error {
					name := strings.Join(c.Args().Slice(), "_")
					files, err := migrator.CreateSQLMigrations(c.Context, name)
					if err != nil {
						return err
					}

					for _, mf := range files {
						fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path)
					}

					return nil
				},
			},
			{
				Name:  "status",
				Usage: "print migrations status",
				Action: func(c *cli.Context) error {
					ms, err := migrator.MigrationsWithStatus(c.Context)
					if err != nil {
						return err
					}
					fmt.Printf("migrations: %s\n", ms)
					fmt.Printf("unapplied migrations: %s\n", ms.Unapplied())
					fmt.Printf("last migration group: %s\n", ms.LastGroup())
					return nil
				},
			},
			{
				Name:  "mark_applied",
				Usage: "mark migrations as applied without actually running them",
				Action: func(c *cli.Context) error {
					group, err := migrator.Migrate(c.Context, migrate.WithNopMigration())
					if err != nil {
						return err
					}
					if group.IsZero() {
						fmt.Printf("there are no new migrations to mark as applied\n")
						return nil
					}
					fmt.Printf("marked as applied %s\n", group)
					return nil
				},
			},
		},
	}
}



/migrations ディレクトリ内部でmigration ファイルを実行するためのmain.go を作成する


package migrations

import "github.com/uptrace/bun/migrate"

var Migrations = migrate.NewMigrations()

func init() {
	if err := Migrations.DiscoverCaller(); err != nil {
		panic(err)
	}
}

動作確認方法

必要なファイルを作成したら、実際にコマンドが実行できるかどうかを確認します。

bun はdb に対してmigration の順番を管理するために、bun_migrations, bun_migration_locks というテーブルを作成します。
init コマンド を使用してテーブルを作成することで、コマンドが実行できるか?DB に対してSQL を実行できるか?を確認します。

$ go run {/migrations 配下のmain.go までのpath} db init

実行が完了した場合、下記のように bun_migrations, bun_migration_locks テーブルが作成されます。

img.png

migration ファイルの作成

コマンドの実行確認ができたら、次はmigration ファイルを使用したテーブルの作成を行います。

create_go コマンドを使用して、migration ファイルを作成します。
生成されるファイルは20230923093308_answer.go のように、{日付}_{名前}.go の形式で作成されます。

$ go run {/migrations 配下のmain.go までのpath} db create_go {migrationファイルの名前}

作成されたmigration ファイルは下記のようにupdown 用の関数を持った状態で、テンプレートが作成されます。
実際にテーブルに対して行う操作は、開発者が自分で実装する必要があります。
discussion でも自分で書き込むこと。として返答されています(https://github.com/uptrace/bun/discussions/433)

package migrations

import (
	"context"
	"fmt"

	"github.com/uptrace/bun"
)

func init() {
	Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
		fmt.Print(" [up migration] ")
		return nil
	}, func(ctx context.Context, db *bun.DB) error {
		fmt.Print(" [down migration] ")
		return nil
	})
}

テーブルを表現する構造体を定義する

migrate コマンドを実行する前に、作成するテーブル情報を持つ構造体を定義しましょう。
Bun は構造体のフィールドに付与されたタグをみて、テーブルの情報を作成します。
それぞれのフィールドに対して付与するタグは、下記のドキュメントに記載されているので、適宜参照してください。
model 作成ドキュメント

package model

import "github.com/uptrace/bun"

type User struct {
	bun.BaseModel `bun:"table:users,alias:u"` // テーブル名はusers, エイリアスはu

	ID       string `bun:"id,pk,type:uuid"` // id はpk でuuid 型
	Email    string `bun:"type:varchar(255)"` // email はvarchar(255) 型
	Password string `bun:"type:varchar(255)"` // password はvarchar(255) 型
}

migration ファイルにテーブルを作成する処理を実装する

User 構造体を参照し、user テーブルを作成するための処理を書いていきます。
up ではテーブルを作成し、downではテーブルを削除する処理を実装します。


package migrations

import (
	"context"
	"example/repository/model"
	"github.com/uptrace/bun"
)

func init() {
	Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
		_, err := db.NewCreateTable().
			// model.User は自分で定義したUser 構造体
			Model((*model.User)(nil)).
			Exec(ctx)
		if err != nil {
			return err
		}
		return nil
	}, func(ctx context.Context, db *bun.DB) error {
		_, err := db.NewDropTable().
			Model((*model.User)(nil)).
			IfExists().
			Exec(ctx)
		if err != nil {
			return err
		}
		return nil
	})
}

migrate コマンドの実行

$ go run {/migrations 配下のmain.go までのpath} db migrate

実行が完了した場合、下記のようにbun_migrations テーブルにmigration の情報が保存され、User テーブルが作成されます。

おわりに

今回は、Bun を使用したmigration の実装例を紹介しました。
Bun はmigration だけでなく、基本的な使用方法などもexample として紹介されているので、興味がある方はぜひ参考にしてみてください。
Bun の実装例は下記のリポジトリで確認できます。
https://github.com/uptrace/bun/tree/master/example

GitHubで編集を提案

Discussion