kakakakakku blog

Weekly Tech Blog: Keep on Learning!

多くのデータベースに対応したマイグレーションツール migrate を MySQL で試した

データベースのマイグレーションを行うツール migrate を試した.migrate は MySQL / MariaDB / PostgreSQL / Amazon Redshift / MongoDB / Cassandra など,多くのデータベースに対応している.GitHub リポジトリを見るともっと多くのデータベースに対応していることがわかる.他にも GitHub や Amazon S3 に置かれたマイグレーションファイルを直接読み込む機能や Go ライブラリとしてアプリケーションに組み込める機能などもある.今回は MySQL を使ってマイグレーションの基本的な機能を試す❗️

github.com

インストール

macOS だと Homebrew を使って簡単に migrate CLI をインストールできる.今回は v4.15.2 を使う.

$ brew install golang-migrate

$ migrate --version
v4.15.2

$ migrate --help
Usage: migrate OPTIONS COMMAND [arg...]
       migrate [ -version | -help ]

Options:
  -source          Location of the migrations (driver://url)
  -path            Shorthand for -source=file://path
  -database        Run migrations against this database (driver://url)
  -prefetch N      Number of migrations to load in advance before executing (default 10)
  -lock-timeout N  Allow N seconds to acquire database lock (default 15)
  -verbose         Print verbose logging
  -version         Print version
  -help            Print usage

Commands:
  create [-ext E] [-dir D] [-seq] [-digits N] [-format] [-tz] NAME
       Create a set of timestamped up/down migrations titled NAME, in directory D with extension E.
       Use -seq option to generate sequential up/down migrations with N digits.
       Use -format option to specify a Go time format string. Note: migrations with the same time cause "duplicate migration version" error.
           Use -tz option to specify the timezone that will be used when generating non-sequential migrations (defaults: UTC).

  goto V       Migrate to version V
  up [N]       Apply all or N up migrations
  down [N] [-all]    Apply all or N down migrations
    Use -all to apply all down migrations
  drop [-f]    Drop everything inside database
    Use -f to bypass confirmation
  force V      Set version V but don't run migration (ignores dirty state)
  version      Print current migration version

Source drivers: godoc-vfs, gcs, s3, github-ee, go-bindata, file, bitbucket, github, gitlab
Database drivers: cockroach, firebirdsql, sqlserver, clickhouse, crdb-postgres, mysql, pgx, postgresql, redshift, spanner, cassandra, mongodb, mongodb+srv, stub, cockroachdb, firebird, neo4j, postgres

準備

検証環境として使う MySQL 5.7 コンテナを起動しておく.今回はサクッと試すために MYSQL_ALLOW_EMPTY_PASSWORD オプションなどを付けてある.またデータベースも作っておく.今回は MySQL 公式データセット を参考にするので,データベース名は world にしておく.

$ docker run --name mysql57 -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 -d mysql:5.7

$ docker exec -it mysql57 mysql -e 'CREATE DATABASE world'

最後にマイグレーションファイルを保存する db/migrate ディレクトリも作っておく.

$ mkdir -p db/migrate

🧩 マイグレーション : 1 回目

migrate では migrate create コマンドを使ってマイグレーションファイルを作る.--ext オプションで拡張子は .sql にする.--dir オプションでディレクトリも指定する.そして --seq オプションを付けると以下のようにファイル名に 000001 などの連番が自動採番される.今回は country テーブルを追加するためマイグレーション名も country にした.

up.sql には CREATE TABLEALTER TABLE ADD など「前に進める SQL」を書く.down.sql には DROP TABLEALTER TABLE DROP COLUMN など「戻す SQL」を書く.今回は MySQL サンプルを参考に country テーブルの操作を書いた.

$ migrate create --ext sql --dir db/migrate --seq country
/Users/kakakakakku/playground-migrate/db/migrate/000001_country.up.sql
/Users/kakakakakku/playground-migrate/db/migrate/000001_country.down.sql

もし --seq オプションを付けずにデフォルトのまま実行すると,以下のようにファイル名にタイムスタンプが付く.基本的には --seq オプションを付けるのが良さそう.もし並行開発をしてて連番が重複してしまう場合や例外的にマイグレーションファイルの順番を変えたり途中に追加してデータベースを再構築する場合に影響範囲を抑えるためにタイムスタンプを使うというのはありそう(通常は前に進めるため過去日付は無視される).

$ migrate create --ext sql --dir db/migrate country
/Users/kakakakakku/playground-migrate/db/migrate/20220925150000_country.up.sql
/Users/kakakakakku/playground-migrate/db/migrate/20220925150000_country.down.sql

📝 000001_country.up.sql

CREATE TABLE `country` (
  `Code` char(3) NOT NULL DEFAULT '',
  `Name` char(52) NOT NULL DEFAULT '',
  `Continent` enum('Asia','Europe','North America','Africa','Oceania','Antarctica','South America') NOT NULL DEFAULT 'Asia',
  `Region` char(26) NOT NULL DEFAULT '',
  `SurfaceArea` decimal(10,2) NOT NULL DEFAULT '0.00',
  `IndepYear` smallint DEFAULT NULL,
  `Population` int NOT NULL DEFAULT '0',
  `LifeExpectancy` decimal(3,1) DEFAULT NULL,
  `GNP` decimal(10,2) DEFAULT NULL,
  `GNPOld` decimal(10,2) DEFAULT NULL,
  `LocalName` char(45) NOT NULL DEFAULT '',
  `GovernmentForm` char(45) NOT NULL DEFAULT '',
  `HeadOfState` char(60) DEFAULT NULL,
  `Capital` int DEFAULT NULL,
  `Code2` char(2) NOT NULL DEFAULT '',
  PRIMARY KEY (`Code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

📝 000001_country.down.sql

DROP TABLE `country`;

マイグレーションを実行する

さっそく migrate up コマンドを使ってマイグレーションを実行する.--path オプションにはディレクトリを指定する.--database オプションには接続情報を指定する.すると,簡単に country テーブルを追加できた!ちなみにマイグレーションの実行状況は schema_migrations テーブルで管理されている.

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' up
1/u country (18.581046ms)

$ docker exec -it mysql57 mysql world -e 'SHOW TABLES;'
+-------------------+
| Tables_in_world   |
+-------------------+
| country           |
| schema_migrations |
+-------------------+

$ docker exec -it mysql57 mysql world -e 'SELECT * FROM schema_migrations;'
+---------+-------+
| version | dirty |
+---------+-------+
|       1 |     0 |
+---------+-------+

マイグレーションを実行する(戻す)

今度は migrate down コマンドを使ってマイグレーションを戻す.今回は引数に 1 を設定して1個戻した(ようするに country テーブルを消すということ).

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' down 1
1/d country (28.749487ms)

確認すると,ちゃんと country テーブルが消えている!

$ docker exec -it mysql57 mysql world -e 'SHOW TABLES;'
+-------------------+
| Tables_in_world   |
+-------------------+
| schema_migrations |
+-------------------+

🧩 マイグレーション : 2 回目

次は city テーブルを追加するためにマイグレーションファイルを作る.

$ migrate create --ext sql --dir db/migrate --seq city
/Users/kakakakakku/playground-migrate/db/migrate/000002_city.up.sql
/Users/kakakakakku/playground-migrate/db/migrate/000002_city.down.sql

📝 000002_city.up.sql

CREATE TABLE `city` (
  `ID` int NOT NULL AUTO_INCREMENT,
  `Name` char(35) NOT NULL DEFAULT '',
  `CountryCode` char(3) NOT NULL DEFAULT '',
  `District` char(20) NOT NULL DEFAULT '',
  `Population` int NOT NULL DEFAULT '0',
  PRIMARY KEY (`ID`),
  KEY `CountryCode` (`CountryCode`),
  CONSTRAINT `city_ibfk_1` FOREIGN KEY (`CountryCode`) REFERENCES `country` (`Code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

📝 000002_city.down.sql

DROP TABLE `city`;

マイグレーションを実行する

さっそく migrate up コマンドを使ってマイグレーションを実行する.現在は migrate down コマンドを使ってマイグレーションを戻した状態になっているため,今回は最新まで2個実行されている.

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' up
1/u country (39.376667ms)
2/u city (62.559554ms)

$ docker exec -it mysql57 mysql world -e 'SHOW TABLES;'
+-------------------+
| Tables_in_world   |
+-------------------+
| city              |
| country           |
| schema_migrations |
+-------------------+

🧩 マイグレーション : 3 回目

今度はテーブル追加ではなく country テーブルに Favorite カラムを追加するためにマイグレーションファイルを作る.

$ migrate create --ext sql --dir db/migrate --seq country_favorite
/Users/kakakakakku/playground-migrate/db/migrate/000003_favorite.up.sql
/Users/kakakakakku/playground-migrate/db/migrate/000003_favorite.down.sql

📝 000003_favorite.up.sql

ALTER TABLE `country` ADD `Favorite` BOOLEAN DEFAULT 0 NOT NULL AFTER `Code2`;

📝 000003_favorite.down.sql

ALTER TABLE `country` DROP COLUMN `Favorite`;

マイグレーションを実行する

期待通りに実行できた❗️

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' up
3/u country_favorite (40.044104ms)

$ docker exec -it mysql57 mysql world -e 'DESC country;'
+----------------+---------------------------------------------------------------------------------------+------+-----+---------+-------+
| Field          | Type                                                                                  | Null | Key | Default | Extra |
+----------------+---------------------------------------------------------------------------------------+------+-----+---------+-------+
| Code           | char(3)                                                                               | NO   | PRI |         |       |
| Name           | char(52)                                                                              | NO   |     |         |       |
| Continent      | enum('Asia','Europe','North America','Africa','Oceania','Antarctica','South America') | NO   |     | Asia    |       |
| Region         | char(26)                                                                              | NO   |     |         |       |
| SurfaceArea    | decimal(10,2)                                                                         | NO   |     | 0.00    |       |
| IndepYear      | smallint(6)                                                                           | YES  |     | NULL    |       |
| Population     | int(11)                                                                               | NO   |     | 0       |       |
| LifeExpectancy | decimal(3,1)                                                                          | YES  |     | NULL    |       |
| GNP            | decimal(10,2)                                                                         | YES  |     | NULL    |       |
| GNPOld         | decimal(10,2)                                                                         | YES  |     | NULL    |       |
| LocalName      | char(45)                                                                              | NO   |     |         |       |
| GovernmentForm | char(45)                                                                              | NO   |     |         |       |
| HeadOfState    | char(60)                                                                              | YES  |     | NULL    |       |
| Capital        | int(11)                                                                               | YES  |     | NULL    |       |
| Code2          | char(2)                                                                               | NO   |     |         |       |
| Favorite       | tinyint(1)                                                                            | NO   |     | 0       |       |
+----------------+---------------------------------------------------------------------------------------+------+-----+---------+-------+

🧩 コマンド確認 : down

migrate down コマンドを引数なく実行すると,全てのマイグレーションファイルを戻す挙動になる(ちゃんと y/N の確認はある).以下のように実行すると 3 → 2 → 1 と戻って,テーブルは全て消えた.

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' down
Are you sure you want to apply all down migrations? [y/N]
y
Applying all down migrations
3/d country_favorite (29.164971ms)
2/d city (42.015304ms)
1/d country (54.650307ms)

🧩 コマンド確認 : goto

migrate up コマンドを実行すると,基本的には最新のマイグレーションファイルまで実行する.もし「特定のマイグレーションファイルまで」実行する場合は migrate goto コマンドを使う.以下は migrate goto 2 を実行したところ.

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' goto 2
1/u country (17.119432ms)
2/u city (38.464471ms)

🧩 コマンド確認 : force

今度は「意図的に」誤ったマイグレーションファイルを作る.実際に開発中にはよくあることだと思う.今回は countrylanguage テーブルを作るために新しくマイグレーションファイルを作る.

$ migrate create --ext sql --dir db/migrate --seq countrylanguage
/Users/kakakakakku/playground-migrate/db/migrate/000004_countrylanguage.up.sql
/Users/kakakakakku/playground-migrate/db/migrate/000004_countrylanguage.down.sql

📝 000004_countrylanguage.up.sql

以下の SQL では意図的に外部キーのカラム名を Code ではなく CodeX にして間違えている.

CREATE TABLE `countrylanguage` (
  `CountryCode` char(3) NOT NULL DEFAULT '',
  `Language` char(30) NOT NULL DEFAULT '',
  `IsOfficial` enum('T','F') NOT NULL DEFAULT 'F',
  `Percentage` decimal(4,1) NOT NULL DEFAULT '0.0',
  PRIMARY KEY (`CountryCode`,`Language`),
  KEY `CountryCode` (`CountryCode`),
  CONSTRAINT `countryLanguage_ibfk_1` FOREIGN KEY (`CountryCode`) REFERENCES `country` (`CodeX`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

マイグレーションを実行する

migrate up コマンドを実行すると,以下のように期待通りにエラーが出る.もう1度 migrate up コマンドを実行すると,今度は Dirty database version 4. というエラーが出る.migrate では一度エラーが出るとそれ以上は進めないように実装されている.

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' up
3/u country_favorite (25.492145ms)
error: migration failed in line 0: CREATE TABLE `countrylanguage` (
  `CountryCode` char(3) NOT NULL DEFAULT '',
  `Language` char(30) NOT NULL DEFAULT '',
  `IsOfficial` enum('T','F') NOT NULL DEFAULT 'F',
  `Percentage` decimal(4,1) NOT NULL DEFAULT '0.0',
  PRIMARY KEY (`CountryCode`,`Language`),
  KEY `CountryCode` (`CountryCode`),
  CONSTRAINT `countryLanguage_ibfk_1` FOREIGN KEY (`CountryCode`) REFERENCES `country` (`CodeX`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 (details: Error 1215: Cannot add foreign key constraint)

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' up
error: Dirty database version 4. Fix and force version.

マイグレーションの実行状況を schema_migrations テーブルで確認すると,以下のように dirty = 1 になってしまっている.

$ docker exec -it mysql57 mysql world -e 'SELECT * FROM schema_migrations;'
+---------+-------+
| version | dirty |
+---------+-------+
|       4 |     1 |
+---------+-------+

そこで migrate force コマンドを実行すると dirty = 0 に戻せるので,以下のように正常に実行できている状態まで migrate force 3 コマンドでリセットしている.あとはマイグレーションファイルを修正して,もう1度 migrate up コマンドを実行すればリカバリ完了❗️

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' force 3

$ docker exec -it mysql57 mysql world -e 'SELECT * FROM schema_migrations;'
+---------+-------+
| version | dirty |
+---------+-------+
|       3 |     0 |
+---------+-------+

🧩 コマンド確認 : drop

日常的に実行するものではないけど,もしデータベースを完全に初期化する場合は migrate drop コマンドを使う.もしかしたら migrate down コマンドと似ているようにも思うけど,実装を確認すると MySQL の場合は SHOW TABLES LIKE '%' を実行して取得したテーブルに対して DROP TABLE IF EXISTS を実行している.よって,migrate drop コマンドはマイグレーションファイルの「戻す SQL」を実行するのではなく強制的に初期化していると言える.

github.com

$ migrate --path db/migrate --database 'mysql://root:@tcp(localhost:3306)/world' drop
Are you sure you want to drop the entire database schema? [y/N]
y
Dropping the entire database schema

🧩 コマンド確認 : up --source

migrate はマイグレーションファイルをファイルシステム以外から直接読み込む機能がある.例えば GitHub や Amazon S3 も対応してて便利!以下は migrate の GitHub リポジトリに含まれているマイグレーションファイルを直接 migrate up コマンドを使って実行している.ファイルシステムに何もマイグレーションファイルを置かずに簡単に test テーブルを追加できた!おー💡

github.com

$ migrate --source github://golang-migrate/migrate/database/mysql/examples/migrations --database 'mysql://root:@tcp(localhost:3306)/world' up
1/u init (29.417794ms)

$ docker exec -it mysql57 mysql world -e 'SHOW FULL COLUMNS FROM test;'
+-----------+-------------+-------------------+------+-----+---------+-------+---------------------------------+---------+
| Field     | Type        | Collation         | Null | Key | Default | Extra | Privileges                      | Comment |
+-----------+-------------+-------------------+------+-----+---------+-------+---------------------------------+---------+
| firstname | varchar(16) | latin1_swedish_ci | YES  |     | NULL    |       | select,insert,update,references |         |
+-----------+-------------+-------------------+------+-----+---------+-------+---------------------------------+---------+

まとめ

migrate 便利!

関連記事

kakakakakku.hatenablog.com