社内で「sqldefで始める宣言的マイグレーション」という題のLTをした話

この記事は[viviONアドベントカレンダー2024](https://qiita.com/advent-calendar/2024/vivion)の24日目の記事です。

### TL;DR
- 宣言的マイグレーションは良いぞ
- 日々パイプラインで実行する継続的マイグレーションの用途以外にもsqldefの使い途を見つけた

### これは何
- viviONでは開発部共有会というイベントが毎週あり、エンジニアはそこで好きにLT・発表・共有をすることが出来ます
  - 自分も1年で3回ほどトークしています。その中で最近掲題のトピックについてLTをしたので、内容を社外向けにアレンジして転載します

### 宣言的マイグレーションって何[^1]

多くのプロジェクトでは、以下のような手続的マイグレーションでデータベーススキーマを管理しているかと思います。

```php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddColumnToTestsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('tests', function (Blueprint $table) {
            // カラムの追加処理
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('tests', function (Blueprint $table) {
            // カラムの削除処理
        });
    }
}
```

これはこれで便利ですし、全然これで良いとも思います。ですが強いて言えば次のような課題があると自分は考えています。
- 変更のたびに必ず手続きを意識しないといけないのが面倒くさい
- プロジェクトの成熟と共にマイグレーションファイルの数が膨大になってしまう
- ORMに強く依存しているプロジェクトの場合、生のSQLへの理解が浅くなりがちなので、手続き的にDDLを発行する世界で戸惑いがち

それに対して宣言的マイグレーションであれば上記のような問題は発生しません。

### 宣言的マイグレーションのツール

今回はsqldefを紹介したいのですが、sqldefはridgepoleにインスパイアされて作られたツールです。なので、何故ridgepoleがあるのにsqldefが作られたのかという流れで説明します。[^2]

**ridgepole**

これはRubyのgemです
https://github.com/ridgepole/ridgepole

スキーマの定義にRubyのDSLを使用します
```ruby
create_table "articles", force: :cascade do |t|
  t.string   "title"
  t.string   "text"
  t.datetime "created_at"
  t.datetime "updated_at"
end
```

これをSchemafileに書いて`ridgepole -—apply` するとarticlesテーブルが出来ます。ここまでは手続き的マイグレーションと同じですね。　

ではスキーマの変更をしたい時どうするのかというと、宣言的マイグレーションなのでマイグレーションファイルは要りません。単にこうして`ridgepole -—apply` し直すだけです

```ruby
create_table "articles", force: :cascade do |t|
  t.string   "title"
  t.text     "text"
  t.datetime "created_at"
  t.datetime "updated_at"
end
```

これでALTER文が発行されてマイグレーション出来ます。

自分が今まで参加したRailsのプロジェクトのうち半分はridgepoleを使っていました。Rubyistにとっては馴染みのあるツールなんじゃないかと思います。

一方で、Railsを使っていなければ自前でRubyの入ったコンテナを立てるなどしてちょっと頑張らないといけないし、普段Rubyを描かない人はRubyのDSLも書きたくないと思うので、Rubyist以外にとっては少しハードルの高いツールだと思います。

調べたところ、CakePHPのプロジェクトで利用している[事例](https://speakerdeck.com/itosho/phpapurikesiyonniridgepolewodao-ru-suru)などもあるようですが、マイノリティな印象ではあります。


**sqldef**
https://github.com/sqldef/sqldef

ridgepoleではスキーマの宣言をRubyのDSLで行う必要がありましたが、sqldefではSQLのCREATE文で同じことが出来ます。
CREATE文はもちろんのこと、スキーマ定義と実際のDBの差分を比較してALTER文やDROP文も発行してくれます！

使い方ですが、まずこういうSchemafileを用意して`mysqldef -udb-user mydb < schemafile.sql`します

```sql
create table users (
	user_id integer primary key,
	user_name varchar(100),
	email_address varchar(100)
);
```

これでテーブルが作成されましたね。次にスキーマを以下のように変更してみます。

```sql
create table users (
  user_id integer primary key,
  family_name varchar(100) not null,
  given_name varchar(100) not null,
  email_address varchar(254) not null
);
```

再度コマンドを実行してみましょう

```sql
$ mysqldef -udb-user mydb < schemafile.sql
-- Apply --
ALTER TABLE `users` ADD COLUMN `family_name` varchar(100) NOT NULL AFTER `user_id`;
ALTER TABLE `users` ADD COLUMN `given_name` varchar(100) NOT NULL AFTER `family_name`;
ALTER TABLE `users` CHANGE COLUMN `email_address` `email_address` varchar(254) NOT NULL;
ALTER TABLE `users` DROP COLUMN `user_name`;
```

ALTER文が発行されてスキーマが修正されました。「CREATE文だけでDDLを発行出来る」って言われてもピンと来ない方もいらっしゃるかと思いますが、こんな感じです。

また、sqldefの良いところとしてGo製であることが挙げられます。Go製なのでバイナリを生成することが容易で、実際GitHubのリリースページでバイナリが配布されているため、Goをインストールしなくてもバイナリポンでどこでも動かすことができます。ですので、業務で利用している環境・言語に縛られず、どんなプロジェクトにも導入することが可能です

### この前実務で使った話
この前実務で少しトリッキーな使い方をしたので紹介します。この記事の本題です。

自分の開発しているマイクロサービスで、本番環境が本格的に稼働する前だったために継続的マイグレーションを行なっておらず、マイグレーションファイルと実際のDBスキーマが乖離する事態になっていました。その状態でもゆるやかにユーザーが流入し、レコードが入ってきていました。
また、チームのポリシーでALTER TABLE文を発行するマイグレーションファイルの作成をしておらず、CREATE文を発行するマイグレーションファイルを毎回再作成していたため、マイグレーションが難しい状況でした。[^3]


素直には自分でALTER文を書くなりダンプしてゴニョゴニョしてリストアするなりしないといけないところですが、sqldefさえあればALTER文を自動で発行してくれます

```sql
root@sample_db:/app# mysqldef -h mysql_8 -u root -p sample_db root@sample_db:/app# mysqldef -h sample_host -u user -p sample_db --dry-run < schema.sql
-- dry run --
ALTER TABLE user_profiles CHANGE COLUMN user_level user_level enum('BEGINNER', 'INTERMEDIATE', 'ADVANCED') COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'User access level';
ALTER TABLE order_details CHANGE COLUMN transaction_id transaction_id varchar(255) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Unique identifier for transactions';
ALTER TABLE product_catalog CHANGE COLUMN product_code product_code varchar(50) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Code identifying each product';
ALTER TABLE product_catalog DROP PRIMARY KEY;
ALTER TABLE product_catalog ADD PRIMARY KEY (category_id, brand_id);
ALTER TABLE product_catalog ADD KEY availability_status (availability_status);
ALTER TABLE product_catalog ADD CONSTRAINT product_catalog_fk_1 FOREIGN KEY (availability_status) REFERENCES stock_status (status_code);
ALTER TABLE customer_records CHANGE COLUMN phone_number phone_number varchar(20) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Contact phone number';
ALTER TABLE customer_records CHANGE COLUMN postal_code postal_code varchar(10) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'ZIP or postal code';
ALTER TABLE transaction_logs CHANGE COLUMN transaction_ref transaction_ref varchar(40) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Reference for the transaction';
ALTER TABLE transaction_logs CHANGE COLUMN branch_code branch_code varchar(20) COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Branch identifier';
ALTER TABLE transaction_logs CHANGE COLUMN transaction_notes transaction_notes text COLLATE utf8mb4_general_ci NOT NULL COMMENT 'Notes related to the transaction';
ALTER TABLE transaction_logs CHANGE COLUMN payment_gateway payment_gateway varchar(50) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Gateway used for the payment';
ALTER TABLE transaction_logs ADD COLUMN fraud_check_flag tinyint(1) DEFAULT 0 COMMENT 'Indicator for fraud check' AFTER transaction_notes;
ALTER TABLE transaction_logs ADD COLUMN compliance_status varchar(20) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Compliance status of the transaction' AFTER fraud_check_flag;
ALTER TABLE transaction_logs ADD COLUMN audit_trail_id varchar(50) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Identifier for audit trail' AFTER compliance_status;
ALTER TABLE transaction_logs CHANGE COLUMN session_id session_id varchar(100) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Session identifier for tracking';
ALTER TABLE transaction_logs CHANGE COLUMN processor_id processor_id varchar(50) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Processor identifier for transactions';
ALTER TABLE transaction_logs CHANGE COLUMN invoice_id invoice_id varchar(50) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Linked invoice identifier';
ALTER TABLE transaction_logs CHANGE COLUMN reconciliation_code reconciliation_code varchar(30) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Code for reconciliation';
ALTER TABLE transaction_logs ADD COLUMN batch_reference varchar(50) COLLATE utf8mb4_general_ci DEFAULT null COMMENT 'Batch processing reference' AFTER reconciliation_code;
ALTER TABLE transaction_logs DROP INDEX unique_transaction_ref;
ALTER TABLE transaction_logs ADD UNIQUE KEY unique_transaction (user_id, transaction_ref, is_verified, is_processed);
ALTER TABLE product_catalog DROP INDEX category_index;
ALTER TABLE product_catalog DROP COLUMN product_code;
ALTER TABLE customer_records DROP INDEX customer_email;
ALTER TABLE transaction_logs DROP COLUMN legacy_code_1;
ALTER TABLE transaction_logs DROP COLUMN legacy_code_2;
ALTER TABLE transaction_logs DROP COLUMN obsolete_flag;
ALTER TABLE transaction_logs DROP COLUMN deprecated_id;
ALTER TABLE transaction_logs DROP COLUMN archived_status;
```
[^4]

これを手で書くのはちょっと辛いですよね。思いついて良かったです

注意点ですが、上記のクエリには一箇所だけ手で直した点があります。
InnoDBの仕様で、複合ユニーク制約の中に外部キーが含まれていると複合ユニーク制約をDROP出来ないようになっているため、まず外部キー制約をDROPしてから複合ユニーク制約を剥がす必要があるのですが、sqldefはそれに気がついてくれませんでした。

このようにsqldefは継続的マイグレーション以外の用途でも役に立つことがあるので、アプリケーションに組み込まないにしても覚えておくと役に立つ日が来るかもしれません

### いつもの
https://vivion.jp/recruit/


[^1]: 手続き的マイグレーションと宣言的マイグレーションという対比が一般的かどうかは微妙な気がしますが、この記事はそういう立て付けで書いているんだとご理解ください
[^2]: この辺の経緯やより踏み込んだ話が作者のk0kubunさんのブログに書いてあります https://k0kubun.hatenablog.com/entry/2018/08/25/114455
[^3]: 自分のチームでは手続き的マイグレーションを採用しています
[^4]: テーブル・カラムの名前はそのまま載せられないので適当に改変しています