Flyway使い方メモ

[Java Magazine の第 17 号](http://www.oracle.com/technetwork/jp/articles/java/overview/index.html)でも紹介されている噂？の Flyway を使ってみる。

# Flyway とは
Flyway は、オープンソースのデータベースマイグレーションツール。

Flyway を使うことで、データベースの状態をバージョン管理できるようになる。

## Flyway （マイグレーションツール）を使う理由
データベースを使った開発をしていると、以下のような問題が往々にして発生する。

__よくある問題__

- あるデータベースの、現在の状態が分からない。
- あるパッチ用 SQL が、データベースに既に適用されているか分からない。
- 本番環境で緊急対応が必要になり適用したパッチが、テスト環境にも反映されているか分からない。
- 新しいデータベース環境を作成するときに、どの SQL を、どの順番で実行すればいいか分からない。

そんなときに、 Flyway のようなデータベースをバージョン管理するツールがあると便利。

パッチを適用したかどうかは、 Flyway が管理しているし、簡単に確認できる。
新しいデータベース環境も、 Flyway を実行すれば簡単に作成できる。

# Hello World
## インストール
### Flyway
[こちら](http://flywaydb.org/getstarted/download.html) から、`flyway-commandline-3.0.zip`をダウンロードする。

ダウンロードが完了したら、 zip を解凍する。

```text:解凍後の状態
|-bin/
|-conf/
|-jars/
|-sql/
|-flyway
|-flyway.cmd
|-LICENSE.txt
`-README.txt
```

以降、この解凍先フォルダを `%FLYWAY_DIR%` と表記する。

### データベース
HSQLDB を使用する。

[こちら](http://sourceforge.net/projects/hsqldb/files/hsqldb/hsqldb_2_3/) から、 `hsqldb-2.3.2.zip` をダウンロードする。

ダウンロードが完了したら、 zip を解凍し、 lib の下の `hsqldb.jar` を取得する。

### Java
割愛。

## マイグレーション
### SQL を作成する
```sql:V1__create_table.sql
CREATE TABLE HOGE (
    ID INT,
    VALUE VARCHAR(12)
);
```

### 設定ファイルを作成する
```properties:flyway.properties
flyway.user=SA
flyway.url=jdbc:hsqldb:file:./db/sample
```

### ファイルを配置する
以下のようにファイルを配置する。

|ファイル|配置先|
|:--|:--|
|V1__create_table.sql|%FLYWAY_DIR%\sql|
|flyway.properties|%FLYWAY_DIR%\conf|
|hsqldb.jar|%FLYWAY_DIR%\jars|


```text:フォルダ構成
%FLYWAY_DIR%/
  |-sql/
  |  `-V1__create_table.sql
  |-conf/
  |  `-flyway.properties
  |-jars/
  |  `-hsqldb.jar
  :
  :
```

### マイグレーションを実行する
コマンドラインから、 `%FLYWAY_DIR%` に移動し、以下のコマンドを実行する。

```text:
> flyway migrate
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)
Validated 1 migration (execution time 00:00.010s)
Creating Metadata table: "PUBLIC"."schema_version"
Current version of schema "PUBLIC": << Empty Schema >>
Migrating schema "PUBLIC" to version 1
Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.020s).
```

### データベースの様子
![flyway.JPG](https://qiita-image-store.s3.amazonaws.com/0/28302/78982f2e-1919-9bc8-9d1b-939e77d4a775.jpeg)

HOGE テーブルが作成されている。

### 現在のバージョンを確認する
以下のコマンドを実行する。

```text:
> flyway info
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)

+----------------+--------------+---------------------+---------+
| Version        | Description  | Installed on        | State   |
+----------------+--------------+---------------------+---------+
| 1              | create table | 2014-08-02 11:40:21 | Success |
+----------------+--------------+---------------------+---------+
```

### 説明
Flyway では、データベースに適用する各 SQL ファイルをバージョンごとに作成して、管理する。
ファイル名は書式が決められており、バージョン番号から始まる形で指定する。

バージョン番号は一意である必要があり、同じバージョンの SQL ファイルを用意することはできない。

`flyway migrate` コマンドで、マイグレーションが実行され、まだデータベースに適用されていないバージョンの SQL が適用される。
既に適用済みの SQL は、 Flyway が判断して適用しないようにしてくれる。

これによって、データベースのバージョン管理・マイグレーションがスムーズにできるようになる。

# バージョン番号について
## 書式
`V<Version>__<Description>.sql`

### `V`
SQL ファイルの先頭は、必ず `V` から始める。

### `<Version>`
バージョン番号。

半角数値と、ドット `.` またはアンダーバー `_` の組み合わせで指定する。

アンダーバーは、実行時にドットに変換される。

```text:例
2
2.1.0
3_1_2
```

### `__`
「バージョン番号」と「説明」とを区切る部分。

アンダーバーを２つ続ける。

### `<Description>`
説明。

そのバージョンの説明を記述する。

アンダーバーは実行時に空白スペースに置き換わる。

説明は、 `flyway info` コマンドを実行した時に `Description` として出力される。

## 例
```sql:V1.0__create_table.sql
CREATE TABLE HOGE (
    ID INT,
    VALUE VARCHAR(12)
);
```

```sql:V1.1__insert_data.sql
INSERT INTO HOGE VALUES (1, 'hoge');
```

```sql:V2_0__create_table.sql
CREATE TABLE FUGA (
    ID INT,
    VALUE VARCHAR(12)
);
```

```sql:V2_1__insert_data.sql
INSERT INTO FUGA VALUES (1, 'fuga');
```

```text:マイグレーションの実行
> flyway migrate
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)
Validated 4 migrations (execution time 00:00.010s)
Creating Metadata table: "PUBLIC"."schema_version"
Current version of schema "PUBLIC": << Empty Schema >>
Migrating schema "PUBLIC" to version 1.0
Migrating schema "PUBLIC" to version 1.1
Migrating schema "PUBLIC" to version 2.0
Migrating schema "PUBLIC" to version 2.1
Successfully applied 4 migrations to schema "PUBLIC" (execution time 00:00.090s).

> flyway info
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)

+----------------+--------------+---------------------+---------+
| Version        | Description  | Installed on        | State   |
+----------------+--------------+---------------------+---------+
| 1.0            | create table | 2014-08-02 12:16:28 | Success |
| 1.1            | insert data  | 2014-08-02 12:16:28 | Success |
| 2.0            | create table | 2014-08-02 12:16:28 | Success |
| 2.1            | insert data  | 2014-08-02 12:16:28 | Success |
+----------------+--------------+---------------------+---------+
```

# コマンド
Flyway には `migrate` や `info` のようにコマンドが用意されている。

以下が、用意されているコマンドの一覧。

|コマンド|内容|
|:--|:--|
|migrate|マイグレーションを実行する|
|clean|データベース上の全てのオブジェクトを削除する。これには、 SQL ファイルで定義されていない、手動で作成したオブジェクトも含まれる。|
|info|マイグレーションがどのバージョンまで実行されているかの情報を表示する。|
|validate|データベースのバージョンと、用意されている SQL ファイルのバージョンに差異が無いかを確認する。差異がある場合はエラーが発生する。|
|init|Flyway 用のメタデータテーブルだけを作成し、データベースのバージョン１にする（バージョン番号はオプションで変更可能）。|
|repair|状態が `Failed` になっているバージョンのメタデータを削除する（詳細後述）。|

## repair コマンド
あるバージョンの SQL ファイルがマイグレーションに失敗した場合、メタデータ上では、そのバージョンの Status が `Failed` になる。

```text:マイグレーションに失敗した状態のStatus
> flyway info
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)

+----------------+---------------+---------------------+---------+
| Version        | Description   | Installed on        | State   |
+----------------+---------------+---------------------+---------+
| 1.0            | create table  | 2014-08-02 12:32:26 | Success |
| 1.1            | insert data   | 2014-08-02 12:33:08 | Failed  |
| 2.0            | create table  |                     | Pending |
| 2.1            | insert data   |                     | Pending |
+----------------+---------------+---------------------+---------+
```

この状態では、たとえ問題の SQL ファイルを修正しても、 `migrate` を実行するとエラーになる。

エラーを取り除くためには、 `repair` コマンドを実行する。

```text:
> flyway repair
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)
Metadata table "PUBLIC"."schema_version" successfully repaired (execution time 00:00.000s).
Manual cleanup of the remaining effects the failed migration may still be required.

> flyway info
Flyway (Command-line Tool) v.3.0

Database: jdbc:hsqldb:file:./db/sample (HSQL Database Engine 2.3)

+----------------+--------------+---------------------+---------+
| Version        | Description  | Installed on        | State   |
+----------------+--------------+---------------------+---------+
| 1.0            | create table | 2014-08-02 12:32:26 | Success |
| 1.1            | insert data  |                     | Pending |
| 2.0            | create table |                     | Pending |
| 2.1            | insert data  |                     | Pending |
+----------------+--------------+---------------------+---------+
```

# 様々な実行方法
## コマンドラインから利用する
「Hello World」で説明した方法が、コマンドラインから実行する方法になる。

## Java プログラムに組み込んで利用する
Flyway は Java プログラムに組み込んで利用することができる。

### プログラム
```groovy:build.gradle
repositories {
    mavenCentral()
}

dependencies {
    compile 'org.hsqldb:hsqldb:2.3.2'
    compile 'org.flywaydb:flyway-core:3.0'
}
```

```java:Main.java
package sample.flyway;

import org.flywaydb.core.Flyway;

public class Main {
    
    public static void main(String[] args) throws Exception {
        Flyway flyway = new Flyway();
        
        flyway.setDataSource("jdbc:hsqldb:file:./db/sample", "SA", "");
        
        flyway.migrate();
    }
}
```

```text:フォルダ構成
`-src/
  `-main/
    |-java/
    |  `-sample/
    |    `-flyway/
    |      `-Main.java
    `-resources/
      `-db/
        `-migration/
          `-V1__create_table.sql
```

SQL ファイルは、 クラスパス以下の `db/migration` の下に配置する。

これを実行すると、マイグレーションが行われる。

## Gradle のプラグインとして利用する
```groovy:build.gradle
apply plugin: 'java'
apply plugin: 'flyway'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.hsqldb:hsqldb:2.3.2"
        classpath "org.flywaydb:flyway-gradle-plugin:3.0"
    }
}

flyway {
    user = 'SA'
    url = 'jdbc:hsqldb:file:./database/sample'
}
```

```text:フォルダ構成
|-build.gradle
`-src/
  `-main/
    `-resources/
      `-db/
        `-migration/
          `-V1__create_table.sql
```

```text:実行
> gradle flywayMigrate
:flywayMigrate
:compileJava UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:flywayMigrate

BUILD SUCCESSFUL

Total time: 4.084 secs

> gradle flywayInfo
:compileJava UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:flywayInfo
+----------------+----------------------------+---------------------+---------+
| Version        | Description                | Installed on        | State   |
+----------------+----------------------------+---------------------+---------+
| 1              | create table               | 2014-08-02 14:50:01 | Success |
+----------------+----------------------------+---------------------+---------+

BUILD SUCCESSFUL

Total time: 3.792 secs
```

`buildscript` で Flyway の Gradle プラグインを追加し、 `appliy plugin: 'flyway'` とすることで、 Gradle から Flyway を使えるようになる。

SQL ファイルは実行時にクラスパスが通っている場所から見て `db/migration` の下に配置する。
つまり、 `src/main/resources/db/migration` の下に配置すれば、 Flyway が検出できる（場所はオプションで変更可能）。

# Java でマイグレーションを実装する
LOB データを扱う必要があったり、マイグレーションの方法が複雑な場合は、 Java でマイグレーション処理を実装することができる。

ここでは、 Gradle プラグインと合わせて使用してみる。

## 実装方法
```groovy:build.gradle
apply plugin: 'java'
apply plugin: 'flyway'

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath "org.hsqldb:hsqldb:2.3.2"
        classpath "org.flywaydb:flyway-gradle-plugin:3.0"
    }
}

flyway {
    user = 'SA'
    url = 'jdbc:hsqldb:file:./database/sample'
}

repositories {
    mavenCentral()
}

dependencies {
    compile "org.flywaydb:flyway-gradle-plugin:3.0"
}
```

```java:V2__insert_data.java
package db.migration;

import java.sql.Connection;
import java.sql.Statement;

import org.flywaydb.core.api.migration.jdbc.JdbcMigration;

public class V2__insert_data implements JdbcMigration {

    @Override
    public void migrate(Connection connection) throws Exception {
        
        try (Statement statement = connection.createStatement();) {
            statement.executeUpdate("INSERT INTO HOGE VALUES (1, 'hoge')");
        }
        
    }
}
```

```text:フォルダ構成
|-build.gradle
`-src/
  `-main/
    |-java/
    | `-db/
    |   `-migration/
    |     `-V2__insert_data.java
    `-resources/
      `-db/
        `-migration/
          `-V1__create_table.sql
```

## 実行
```text:実行
> gradle flywayMigrate
:compileJava UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:flywayMigrate

BUILD SUCCESSFUL

Total time: 4.002 secs

> gradle flywayInfo
:compileJava UP-TO-DATE
:processResources UP-TO-DATE
:classes UP-TO-DATE
:compileTestJava UP-TO-DATE
:processTestResources UP-TO-DATE
:testClasses UP-TO-DATE
:flywayInfo
+----------------+----------------------------+---------------------+---------+
| Version        | Description                | Installed on        | State   |
+----------------+----------------------------+---------------------+---------+
| 1              | create table               | 2014-08-02 14:53:43 | Success |
| 2              | insert data                | 2014-08-02 14:53:43 | Success |
+----------------+----------------------------+---------------------+---------+

BUILD SUCCESSFUL

Total time: 4.01 secs
```

## 説明
Java でマイグレーションを実装する場合は、 SQL ファイルと同じように定められた書式でクラス名を定義する（`V2__insert_data.java`）。

マイグレーション用のクラスは、 `JdbcMigration` インターフェースを実装し、 `migrate()` メソッドでマイグレーションの処理を実装する。

# Android で使用する
Flyway は Android に組み込んで利用することもできる。

## 必要なファイル
flyway 本体（`flyway-core`）の他に [SQLDroid](https://github.com/SQLDroid/SQLDroid) という Android 用の JDBC ドライバが必要になる。

```groovy:build.gradle
dependencies {
    compile 'org.flywaydb:flyway-core:3.0'
    compile 'org.sqldroid:sqldroid:1.0.3'
}
```

## マイグレーション用の SQL ファイルを配置する
マイグレーション用の SQL ファイルは、 `assets/db/migration` の下に配置する。

![flyway.JPG](https://qiita-image-store.s3.amazonaws.com/0/28302/549a36ec-a354-36a3-fce9-2745e585ac34.jpeg)

```sql:V1__create_database.sql
CREATE TABLE SAMPLE_TABLE (
    ID INTEGER PRIMARY KEY AUTOINCREMENT,
    CODE,
    NAME
);

INSERT INTO SAMPLE_TABLE (CODE, NAME) VALUES ('hoge', 'HOGE');
INSERT INTO SAMPLE_TABLE (CODE, NAME) VALUES ('fuga', 'FUGA');
INSERT INTO SAMPLE_TABLE (CODE, NAME) VALUES ('piyo', 'PIYO');
```

## マイグレーション処理を実装する
```java:MainActivity.java
package com.example.flywaysample;

import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.android.ContextHolder;

import android.app.Activity;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.Bundle;
import android.util.Log;

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        // マイグレーション処理
        SQLiteDatabase db = this.openOrCreateDatabase("sample.db", MODE_PRIVATE, null);
        ContextHolder.setContext(this.getApplicationContext());
        
        Flyway flyway = new Flyway();
        flyway.setDataSource("jdbc:sqlite:" + db.getPath(), "", "");
        flyway.migrate();
        Log.v("FlywaySample", "migrate database.");
        
        // データ取得
        Cursor cursor = db.rawQuery("SELECT * FROM SAMPLE_TABLE", null);
        boolean next = cursor.moveToFirst();
        
        while (next) {
            int id = cursor.getInt(0);
            String code = cursor.getString(1);
            String name = cursor.getString(2);
            
            Log.v("FlywaySample", String.format("id=%d, code=%s, name=%s%n", id, code, name));
            
            next = cursor.moveToNext();
        }
        
        cursor.close();
        db.close();
    }
}
```

## 実行結果
```text:LogCat
10-12 07:50:40.385: V/FlywaySample(11986): migrate database.
10-12 07:50:40.385: V/FlywaySample(11986): id=1, code=hoge, name=HOGE
10-12 07:50:40.385: V/FlywaySample(11986): id=2, code=fuga, name=FUGA
10-12 07:50:40.395: V/FlywaySample(11986): id=3, code=piyo, name=PIYO
```

## ContextHolder.setContext() について
１点要注意なのが、

```java:
ContextHolder.setContext(this.getApplicationContext());
```

この部分。

`ContextHolder` は、名前の通り `Context` を保持しておくためのクラスで、 Flyway が DB マイグレーション時に `assets/db/migration` から SQL ファイルを取得するのに使用している。

なんだか嫌な臭いがプンプンした（static メソッドで Context を保存している）ので、実装を確認してみると、以下のようになっていた。

```java:ContextHolder.java（コメント除去）
package org.flywaydb.core.api.android;

import android.content.Context;

public class ContextHolder {
    private ContextHolder() {}

    private static Context context;

    public static Context getContext() {
        return context;
    }

    public static void setContext(Context context) {
        ContextHolder.context = context;
    }
}
```

static フィールドに `Context` を保存している。
つまり、<ins>もし `setContext()` に Activity のインスタンスを渡してしまうと Activity 終了後も参照が残ってしまい、メモリリークが発生する危険性がある</ins>。

マイグレーションが終わったら必ず `setContext(null)` するという手もあるが、最初から ApplicationContext を渡すようにするのがいいと思う。

Flyway の公式ドキュメントに書かれている通りに実装すると、 Acitivty のインスタンスを渡してしまうので危険です。

## SQLiteOpenHelper を使った実装
Activity に直接データベース処理を書くのはお行儀が悪いので、 `SQLiteOpenHelper` を使った実装に切り替えてみる。

### 実装
```java:SampleTable
package com.example.flywaysample;

public class SampleTable {
    
    private int id;
    private String code;
    private String name;

    public SampleTable(int id, String code, String name) {
        this.id = id;
        this.code = code;
        this.name = name;
    }

    @Override
    public String toString() {
        return "SampleTable [id=" + id + ", code=" + code + ", name=" + name + "]";
    }
}
```

```java:MyDatabaseHelper.java
package com.example.flywaysample;

import java.util.ArrayList;
import java.util.List;

import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.android.ContextHolder;

import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.util.Log;

public class MyDatabaseHelper extends SQLiteOpenHelper {
    
    private static final String DATABASE_NAME = "sample.db";
    private static final int DATABASE_VERSION = 1;
    
    public MyDatabaseHelper(Context context) {
        super(context, DATABASE_NAME, null, DATABASE_VERSION);
        
        SQLiteDatabase db = getWritableDatabase();
        ContextHolder.setContext(context.getApplicationContext());
        
        Flyway flyway = new Flyway();
        flyway.setDataSource("jdbc:sqlite:" + db.getPath(), "", "");
        flyway.migrate();
        
        Log.v("FlywaySample", "migrate database.");
        
        db.close();
    }
    
    public List<SampleTable> findAll() {
        SQLiteDatabase db = getReadableDatabase();

        Cursor cursor = db.rawQuery("SELECT * FROM SAMPLE_TABLE", null);
        boolean next = cursor.moveToFirst();
        
        List<SampleTable> list = new ArrayList<SampleTable>();
        
        while (next) {
            int id = cursor.getInt(0);
            String code = cursor.getString(1);
            String name = cursor.getString(2);
            
            list.add(new SampleTable(id, code, name));
            
            next = cursor.moveToNext();
        }
        
        cursor.close();
        db.close();
        
        return list;
    }
    
    @Override public void onCreate(SQLiteDatabase db) {/* no use */}
    @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {/* no use */}
}
```

```java:MainActivity.java
package com.example.flywaysample;

import java.util.List;

import android.app.Activity;
import android.os.Bundle;
import android.util.Log;

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        MyDatabaseHelper dbHelper = new MyDatabaseHelper(this);
        List<SampleTable> list = dbHelper.findAll();
        
        for (SampleTable sample : list) {
            Log.v("FlywaySample", sample.toString());
        }
    }
}
```

### 説明
- マイグレーション処理は Flyway が管理するので、 `onCreate()` と `onUpgrade()` は使用しない。
- 代わりに、コンストラクタの時点で `getWritableDatabase()` して、 Flyway でマイグレーションを行っている。




# 参考
- [Get Started - Flyway Database Migrations Made Easy.](http://flywaydb.org/getstarted/)
- [Documentation - Flyway Database Migrations Made Easy.](http://flywaydb.org/documentation/)