👻

【Facebook/ent】ent生成のSQLと自作のSQLを共存させる

2023/12/02に公開

ent とは

Facebook 社製の Go のエンティティフレームワーク
https://entgo.io/ja/

ORM なので ent のスキーマを書けば SQL の生成まで行ってくれます。

2種類のマイグレーション

ent にはマイグレの方法が2種類用意されています。

この2種類です。

自作の SQL を差し込みたい

基本的には ent に搭載されている機能で賄えますが、各 SQL に関して全ての機能を網羅しているわけではありません。
時には、ent に未搭載の機能を使いたい時もあるかと思います。
その場合は自作の SQL を作成し、マイグレ時に差し込む必要があります。
上記2種類の各マイグレ方法で、自作と ent 共存に詰まったので共有します。

今回の実装例

今回の実装例として、「PostgreSQL において複数のテーブルで共通のシーケンスを使用したい」というケースを挙げます。

以下のテーブルがあるとします。

account_numberを自動インクリメントにしたい 且つ インクリメントの値は3つのテーブルで共通にしたい

users, admins, staffsに順に 1 つずつレコードを追加したとしたら、

  • users
    • id: 1
    • account_number: 1
    • name: ユーザー1
  • admins
    • id: 1
    • account_number: 2
    • name: 管理者1
  • staffs
    • id: 1
    • account_number: 3
    • name: スタッフ1

こんな感じでデータが入ることを目標にします。

用意する SQL

シーケンスとしてsequence_account_numberを用意します。
各テーブルの生成は ent に任せるので、ここではシーケンスの設定のみ行います。

CREATE SEQUENCE IF NOT EXISTS "sequence_account_number" START WITH 1 MINVALUE 1;

ALTER TABLE "users" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');
ALTER TABLE "admins" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');
ALTER TABLE "staffs" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');

オートマイグレーションでの共存

オートマイグレの時は ent の Apply フックを利用することで、SQL を差し込むことができます。

    client, err := ent.Open("postgres", "host=......")
    if err != nil {
        log.Fatalf("ポスグレへの接続に失敗しました: %v", err)
    }
    defer client.Close()
    // ...
    err = client.Schema.Create(
        ctx,
        schema.WithApplyHook(customApplyHook), // ここでapplyHookの関数を差し込む
        )
    if err != nil {
        log.Fatalf("スキーマ作成に失敗しました: %v", err)
    }

func customApplyHook(next schema.Applier) schema.Applier {
	return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *atlasMigrate.Plan) error {
		// SQLファイルの読み込み
		content, err := os.ReadFile("migrate/auto/add_sequence_account_number.sql") // 自作したSQLファイルのパス
		if err != nil {
			return err
		}

		// 読み込んだSQLファイルの内容を実行
		query := string(content)
		err = conn.Exec(ctx, query, []interface{}{}, nil)
		if err != nil {
			return err
		}

		return next.Apply(ctx, conn, plan)
	})
}

問題点

これで OK!かと思いきや問題点があります。
シーケンスの追加などは元々テーブルが作成されていることが前提なので、

    err = client.Schema.Create(
        ctx,
        schema.WithApplyHook(customApplyHook), // ここでapplyHookの関数を差し込む
        )

このタイミングではまだテーブルが作成されておらず、テーブルが見つからないというエラーが発生してしまいます。

解決法

アプリケーションの起動前に一度セットアップとして ApplyHook を除いたclient.Schema.Create()を行う必要があります。

func main() {
    client, err := ent.Open("postgres", "host=......")
    if err != nil {
        log.Fatalf("ポスグレへの接続に失敗しました: %v", err)
    }
    defer client.Close()
    // ...
    err = client.Schema.Create(ctx)
    if err != nil {
        log.Fatalf("事前のスキーマ作成に失敗しました: %v", err)
    }
    err = client.Schema.Create(
        ctx,
        schema.WithApplyHook(customApplyHook),
        )
    if err != nil {
        log.Fatalf("スキーマ作成に失敗しました: %v", err)
    }
}

というわけでメイン関数はこんな感じになります。
2回 Create メソッドを使ってるのがなんとも違和感がありますが仕方ないです。

バージョン管理型マイグレーションでの共存

バージョン管理型マイグレーションでは AtlasCLI を使って Ent スキーマに定義された migration ファイルを生成します。
ドキュメントに載っているので生成方法については割愛

migrate/versionディレクトリに SQL ファイルを生成します。
↓ のような SQL ファイルとatlas.sumファイルが生成されていると思います。

// migrate/version/202312021751_first_migration.sql

CREATE TABLE "users" (
    "id" BIGINT PRIMARY KEY,
    "name" VARCHAR(255),
    "account_number" BIGINT,
    "deleted_at" TIMESTAMP,
    "created_at" TIMESTAMP,
    "updated_at" TIMESTAMP
);

CREATE TABLE "admins" (
    "id" BIGINT PRIMARY KEY,
    "name" VARCHAR(255),
    "account_number" BIGINT,
    "deleted_at" TIMESTAMP,
    "created_at" TIMESTAMP,
    "updated_at" TIMESTAMP
);

CREATE TABLE "staffs" (
    "id" BIGINT PRIMARY KEY,
    "name" VARCHAR(255),
    "account_number" BIGINT,
    "deleted_at" TIMESTAMP,
    "created_at" TIMESTAMP,
    "updated_at" TIMESTAMP
);
// migrate/version/atlas.sum

h1:vj6fBSDiLEwe+jGdHQvM2NU8G70lAfXwmI+zkyrxMnk=
202312021751_first_migration.sql h1:wrm4K8GSucW6uMJX7XfmfoVPhyzz3vN5CnU1mam2Y4c=

では、自作の SQL ファイルもここに追加します。
新しい migration ファイルの生成は以下のコマンドでできます。

atlas migrate new add_sequence_account_number --dir "file://migrate/version"

migrate/version/202312021800_add_sequence_account_number.sql
こんな感じでファイルが作成されていると思うので、SQL を書いていきます。

// migrate/version/202312021800_add_sequence_account_number.sql

CREATE SEQUENCE IF NOT EXISTS "sequence_account_number" START WITH 1 MINVALUE 1;

ALTER TABLE "users" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');
ALTER TABLE "admins" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');
ALTER TABLE "staffs" ALTER COLUMN "account_number" SET DEFAULT nextval('sequence_account_number');

atlas.sumでマイグレーションを管理しているため、ハッシュ値を再計算してあげる必要があります。

atlas migrate hash --dir "file://migrate/version"

atlas.sumが以下のように更新されるかと思います。

// migrate/version/atlas.sum

h1:vj6fBSDiLEwe+jGdHQvM2NU8G70lAfXwmI+zkyrxMnk=
202312021751_first_migration.sql h1:wrm4K8GSucW6uMJX7XfmfoVPhyzz3vN5CnU1mam2Y4c=
202312021800_add_sequence_account_number h1:FO18ZEjsRb6FrWgnSBKsRyDsfIQ5XUTe9POBVvWgdhQ=

問題点

さて、ここで重要なのはent スキーマの内容は変わっていないということです。
ここで、ent スキーマにカラムを追加して再度 migration ファイルを生成してみましょう。

試しに、users テーブルに email カラムを追加してみます。
以下のような SQL が生成されます。

// migrate/version/202312021900_add_users_email_column.sql

ALTER TABLE "users" ALTER COLUMN "account_number" DROP DEFAULT;
ALTER TABLE "admins" ALTER COLUMN "account_number" DROP DEFAULT;
ALTER TABLE "staffs" ALTER COLUMN "account_number" DROP DEFAULT;

ALTER TABLE "users"
ADD COLUMN "email" VARCHAR(255) UNIQUE;

DROP DEFAULT
DROP DEFAULT
DROP DEFAULT

というわけで、ent スキーマは変わらないので、せっかく作ったデフォルト値を削除してしまいます。

解決法

ent のマイグレで参照するディレクトリ
自作の SQL を置くディレクトリ
この2つのディレクトリを別に管理してあげれば解決します。

ただ、実際にマイグレーションを apply する際には2つのディレクトリ両方を参照しなければなりません。
現状、atlas で複数ディレクトリを対象に apply する方法は無いです。
また、atlas.sumでのハッシュ値も統合する必要があります。

結論は以下です。

  • ent のマイグレはmigrate/version/entで管理
  • 自作の SQL はmigrate/version/customに置く
  • 2つのディレクトリの中のファイルをmigrate/version/allにコピー
    • ハッシュ値の再計算
    • apply 時はここを参照する

というわけで、スクリプトを書いてあげる必要があります。

// sync_migration_files.sh
#!/bin/bash

# ソースディレクトリ
SRC_DIRS=("./migrate/version/custom" "./migrate/version/ent")

# ターゲットディレクトリ
DEST_DIR="./migrate/version/all"

# ターゲットディレクトリが存在しなければ作成
mkdir -p $DEST_DIR
if [ $? -ne 0 ]; then
  echo "ディレクトリの作成に失敗しました: $DEST_DIR"
  exit 1
fi

# ファイルをコピー
for src in "${SRC_DIRS[@]}"; do
  cp $src/*.sql $DEST_DIR/
  if [ $? -ne 0 ]; then
  echo "ファイルのコピーに失敗しました。: $src/*.sql -> $DEST_DIR/"
  exit 1
fi
done

# allディレクトリに存在して、customやentからは消されたファイルを削除
for file in $DEST_DIR/*.sql; do
  filename=$(basename -- "$file")
  found=false

  for src in "${SRC_DIRS[@]}"; do
    if [ -f "$src/$filename" ]; then
      found=true
      break
    fi
  done

  if [ "$found" = false ]; then
    echo "ソースディレクトリに存在しないファイルを削除します。: $file"
    rm -f "$file"
    if [ $? -ne 0 ]; then
      echo "ファイルの削除に失敗しました: $file"
      exit 1
    fi
  fi
done

# allディレクトリのatlas.sumを再計算
atlas migrate hash \
	--dir "file://$DEST_DIR"
if [ $? -ne 0 ]; then
  echo "atlas.sumの再計算に失敗しました。"
  exit 1
fi

echo "マイグレーションファイルの生成に完了しました。"

apply や lint を行う際は、このスクリプトファイルを走らせてから行えば OK です。

Discussion