Android Roomを使っていてカラム定義が変更したくなったら

# Roomのマイグレーションはいけてない

Google謹製の永続化ライブラリである『[Room](https://developer.android.com/topic/libraries/architecture/room)』を使用してAndroidアプリの開発をしているのですが、このRoomってやつ比較的新しいライブラリであるせいか、マイグレーションの機能がイケてないです。。。やりたかったことは「REAL型のカラムをINTEGER型に変更する」ってことだったのですが、sqliteの「ALTER文でできるのはカラムの追加だけ」という仕様と絶妙なマリアージュを醸し出し、実現するのに結構苦労しました・・・。この記事はそのメモです。

# 解決手順

先に解決手順から書きます。

1. エンティティのプロパティの型を変更する（私の場合だとDouble?からInteger?に変更）
1. 1.によって発生するビルドエラーに対応する
1. `＠Database`の`version`をインクリメントする
1. マイグレーションコードを書く
    1. CREATE TABLE
    1. INSERT INTO ... SELECT
    1. DROP TABLE
    1. ALTER TABLE ... RENAME TO ...
    1. CREATE INDEX（インデックスがあれば）

このままだとあまりよく分からないと思いますので、各詳細を見ていきます。

#### 1. エンティティのプロパティの型を変更する

例えば次のような`Product`エンティティがあったとして、

```kotlin:Product.kt
@Entity(indices = [Index(value = ["name"])])
data class Product(
    @PrimaryKey(autoGenerate = true) var id: Int = 0,
    var name: String? = null,
    var price: Double? = null,
    ...
```

この`price`の`Double?`を`Int?`に変更する、といったことです。
まあここは当然ですよね。

#### 2. ビルドエラーに対応する

1.の変更で（普通は）ビルドエラーが発生するはずですので、ひとつずつ潰していきます。直し方は実装によるのでここでは割愛。

#### 3. `＠Database`の`version`をインクリメントする

たぶん、次のように`RoomDatabase`のサブ抽象クラスを定義してたりすると思います。

```kotlin:AppDatabase.kt
@Database(entities = [Product::class], version = 1)
abstract class AppDatabase : RoomDatabase() {

    companion object {

        @Volatile private var instance: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also { instance = it }
            }
        }

        private fun buildDatabase(context: Context): AppDatabase {
            return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "hogehoge-database")
                .build()
        }
    }
}
```

ここの`version`を一つ上げます。この例でいくと2にするってことです。データベースのスキーマバージョンを2にしますよってことですね。

#### 4. マイグレーションコードを書く

ここが一番面倒臭かった。Roomってマイグレーションに関してはほとんど何もしてくれません。結果的に生のSQLをゴリゴリ書く必要があります。sqliteの仕様で「カラムの変更、削除はできません」なので、やり方として、新しいテーブルを定義して、そこに現在のデータを移送してから旧テーブルを削除、その後新テーブルの名前を元通りにリネームしてやります。最後にこれが落とし穴なのですが、旧テーブル削除時にそのテーブルに張られていたインデックスも削除されるので、こいつを新テーブル生成後に作って上げないといけないということ。

まずCREATE TABLE文ですが、一から書くのはとても面倒だと思うので、Roomのスキーマ出力jsonを利用しましょう。`app/build.gradle`に次のような記述があると、ビルド時にスキーマ情報jsonを吐き出してくれます。

```gradle:app/build.gradle
android {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
```

これでビルドすると、`app/schemas`配下にスキーマ情報がjsonとして出力されます。ファイル名は *スキーマバージョン*.json となっています。

このjsonをのぞいてみると、`createSql`ってキーの中にCREATE TABLE文が定義されていると思います。そいつをコピって、必要な変更を施して使うとラクです。

で、実際のマイグレーションコードですが、次のようになります。先程の`AppDatabase.kt`の例でいくと

```kotlin:AppDatabase.kt
...
        private fun buildDatabase(context: Context): AppDatabase {
            val migration1to2 = object : Migration(1, 2) {
                override fun migrate(database: SupportSQLiteDatabase) {
                    database.execSQL("CREATE TABLE IF NOT EXISTS `適当なテーブル名` ...")
                }
            }

            return Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "onieasy-database")
                .addMigrations(migration1to2)
                .build()
        }
...
```

スキーマバージョンについては実際のものに置き換えてください。

あとでリネームするので`適当なテーブル名`でいいです。
CREATE TABLEの次は、

* INSERT INTO ... SELECT
* DROP TABLE
* ALTER TABLE ... RENAME TO

ですね。

```kotlin
            val migration1to2 = object : Migration(1, 2) {
                override fun migrate(database: SupportSQLiteDatabase) {
                    database.execSQL("CREATE TABLE IF NOT EXISTS 適当なテーブル名 ...")
                    database.execSQL("INSERT INTO 適当なテーブル名 SELECT * FROM 変更したいテーブル名")
                    database.execSQL("DROP TABLE 変更したいテーブル名")
                    database.execSQL("ALTER TABLE 適当なテーブル名 RENAME TO 変更したいテーブル名")
                }
            }
```

最後に、もともとインデックスが張ってあったなら、スキーマjsonの中からコピってきましょう。`indices`の中に`createSql`があるはずです。

```kotlin
                    database.execSQL("CREATE INDEX index_Product_name` ON `Product` (`name`)")
```

みたいな感じです。

ここまで一気にやってからビルドして、アプリを動かしてみます。正しく変更できていれば正常に動くはずです。

# なんでこんなややこしいねん

Roomはまだまだ発展途上なので、結構ハマりどころが多いと思います。最初私が思い付いたのは、Kotlinで新しいエンティティを定義して、その新エンティティにデータを移送すればいいんじゃないかということです。試してみたんですが、結果はアプリ起動せず・・・なんと、**Roomはスキーマバージョン1の場合（つまり最初の最初）だけはエンティティからテーブルを生成してくれますが、以降はテーブルを自動生成してくれないのです！** なんやねんそれ・・・

というわけでRoomを使う場合は、最初からきっちりテーブル設計しておかないと、あとあと面倒になるということですね、はい、勉強になりました。