SQLiteOpenHelperを用いたDBのRoom移行〜NonPrimaryKeyの場合を添えて〜

#はじめに
息が長いプロダクトの場合、SQLiteOpenHelperを用いたSQLiteDatabaseがアプリ内にあることがあります。
現在はRoomが主流で、[公式ページ](https://developer.android.com/training/data-storage/room?hl=ja)でも以下のように記載があります。
>SQLite の代わりに Room を使用することを**強くおすすめします**。

メンテナンス性、安定性、モチベーションなど、どれを取ってもRoomの方が良いのですが、既存のSQLiteDatabaseをRoomに置き換えたい場合どうすればよいか？ということで試行錯誤したのでメモします。
Roomのバージョンは`2.2.5`です。

`注意`
※もしかしたら、特定の条件で移行がうまく行かないこともあるかもしれません。
※DBは様々な状態があり、クラッシュの幅も広いので、試す場合は自己責任でお願いいたします。

#移行手順
基本的には、同条件のRoom定義を作成すれば、そのままRoomで既存DBを扱うことが可能です。
しかし、既存テーブルにprimary keyがない場合には、マイグレーションが必要になります。

##同じ条件のRoom定義を作成する
既存SQLiteのcreate table文を見ながら、Roomの基本的な3クラスを作っていきます。

- Entity
- Dao
- Database

移行する既存DBは例として以下のようなものとします。

- DB名は、user.db
- テーブル名は、task_t

細かいところは適当です。

```sql:CreateTable文
create table task_t (
 _id integer primary key autoincrement not null,
 item_id text,
 item_description text,
 is_done integer not null default 0)
```

### Entity
Roomでのテーブル。

```kotlin:TaskEntity.kt
// テーブル名を合わせる
@Entity(tableName = "task_t")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true) // PrimaryKey, autoGenerateを合わせる
    val _id: Int, // カラム名・nonnullを合わせる
    val item_id: String?, // カラム名・nullableを合わせる
    @ColumnInfo(name = "item_description") // カラム名はColumnInfoで合わせて変数名を自由にしてもOK
    val itemDescription: String?, // nullableを合わせる
    @ColumnInfo(name = "is_done")
    val isDone: Boolean // integerをbooleanとして扱っていれば、Boolean型でOK
)
```

### Dao
Daoは好きに作ってOK

### Database
```kotlin:TaskDatabase.kt
// カラムの変更が無いならversionは同じでOK
@Database(entities = [TaskEntity::class], version = 1, exportSchema = false)
abstract class TaskDatabase : RoomDatabase() {
    companion object {
        // DB名は同じに。「.db」の有無とかは地味に注意。
        private const val DB_NAME = "task.db"

        fun createInstance(context: Context): TaskDatabase {
            // カラム変更有りでバージョンが上がるのであればマイグレーションを実装する必要がある。
            // バージョンが同じ場合マイグレーションは空のものも必要なさそう(私がやったときは不要でした)
            return Room.databaseBuilder(context, TaskDatabase::class.java, DB_NAME).build()
        }
    }

    abstract fun taskDao(): TaskDao
}
```

## あとは実行してトライアンドエラー
ビルドして実行し、読み書きしようとした際にクラッシュした場合は失敗しているのでエラーを確認します。
下記のエラーが出る場合、既存DBとRoom定義が異なっています。

> java.lang.IllegalStateException: Pre-packaged database has an invalid schema:task_t
Expected:
　TableInfo{...}
Found:
　TableInfo{...}

ExpectedがRoomのEntityに記載したテーブル定義、Foundが既存DBのテーブル定義です。
見比べてあってない部分を直しましょう。
これで、移行完了です。

##PrimaryKeyがない場合の移行
PrimaryKeyがない場合は、少々厄介です。
Entityに`@PrimaryKey`をつけないと、
`An entity must have at least 1 field annotated with @PrimaryKey`
と言われコンパイルエラーになります。
Roomは仕様上、PrimaryKeyが必ず必要なようです。
ですが、なにか既存カラムに`@PrimaryKey`をつけたり、EntityクラスにPrimaryKeyのカラム(フィールド)を増やすと今度は
`Pre-packaged database has an invalid schema`
実行時に怒られます。(テーブル構成が変わってしまっているので当たり前)

こうなっては、マイグレーションを行って、既存テーブルにPrimaryKeyを追加するしかなさそうです。
Roomへの移行の際、既存DBからバージョンを上げることでRoomのmigrationを通すことが出来るので、`alter table`などでPrimaryKeyを追加できれば良さそう。
と思いきや、SQLiteは仕様上、後からPrimaryKeyを変更することが出来ないようでした。

>Unlike other database systems e.g., MySQL and PostgreSQL, you cannot use the ALTER TABLE statement to add a primary key to an existing table.
https://www.sqlitetutorial.net/sqlite-primary-key/

上記サイトで、対処方法も記載されていて、参考になりました。
簡単に言うと、テーブルの作り直しです。

1. 既存テーブルをold_tableにrename
2. primarykeyを追加したテーブルをcreatetable
3. old_tableの値を全てtableに追加

### primarykeyの移行手順
例として、先程の例からprimarykeyカラムをなくしたテーブルを想定します。

```sql:CreateTable文
create table task_t (
 item_id text,
 item_description text,
 is_done integer not null default 0)
```

作成するRoom関連クラスは基本的にはPrimaryKeyがある場合と同じですが、少し変更します。
####Entity

- A：PrimaryKeyのカラムを新たに追加する場合は、Entityクラスに追加(先ほどと同じクラス)
- B：既存カラムにnon-nullカラムがあり、PrimaryKeyをつけるのであれば、そのカラムに`@PrimaryKey`を追加

####Database
A・Bで既存データを戻す部分が変わります。

```kotlin:TaskDatabase.kt
// 既存DBからバージョンを1上げる
@Database(entities = [TaskEntity::class], version = 2, exportSchema = false)
abstract class TaskDatabase : RoomDatabase() {
    companion object {
        private const val DB_NAME = "task.db"

        fun createInstance(context: Context): TaskDatabase {
            return Room.databaseBuilder(context, TaskDatabase::class.java, DB_NAME)
                .addMigrations(object : Migration(1, 2) {
                    override fun migrate(database: SupportSQLiteDatabase) {
                        // 既存テーブルをrename
                        database.execSQL("alter table task_t rename to old_task_t")
                        // PrimaryKeyを追加した新規テーブル作成
                        database.execSQL("create table...")

                        // 既存データを戻す
                        // A：新規PrimaryKeyカラムを追加した場合
                        database.execSQL("insert into task_t select null, * from task_t")
                        // B：既存カラムにPrimaryKeyを追加した場合
                        database.execSQL("insert into task_t select * from task_t")
                    }
                }).build()
        }
    }
}
```
AutoIncrementカラムを追加した場合は、nullを入れることで自動採番してくれます。
`null, *`となっていますが、`create table`のカラム順で最初にAutoIncrementを追加した例なので最初にnullを入れています。

これでRoomでの初回アクセス時にマイグレーションが走り、RoomでDBを扱えるようになります。

### 余談：せっかくcreate tableするなら過去を精算する
`create table`し直すなら、Roomの定義はきれいにしてしまってもいいのでは？ということで、その手順を記載します。
想定テーブル(PrimaryKeyのないテーブル)

```sql:CreateTable文
create table task_t (
 item_id text,
 item_description text,
 is_done integer not null default 0)
```
これをPrimaryKey(_id)を追加して移行すると、下記のようなEntityになります。

```kotlin:TaskEntity.kt
@Entity(tableName = "task_t")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    val _id: Int,
    @ColumnInfo(name = "item_id")
    val itemId: String?,
    @ColumnInfo(name = "item_description")
    val itemDescription: String?,
    @ColumnInfo(name = "is_done")
    val isDone: Boolean
)
```
きれいにしたい点として、下記があるとします。

- item_idは実はnonnullがいい
- そしてitem_idをPrimaryKeyにしたい
- item_descriptionは実際使ってないので消したい

PrimaryKeyがない時点で`create table`することは確定なので、理想のRoomEntityに寄せてみましょう！

```kotlin:理想のEntity
@Entity(tableName = "task_t")
data class TaskEntity(
    @PrimaryKey(autoGenerate = true)
    @ColumnInfo(name = "item_id")
    val itemId: String,
    @ColumnInfo(name = "is_done")
    val isDone: Boolean
)
```

####基本的な流れ
Entityは出来たので、マイグレーション処理を考えていきます。

1. 既存テーブルをold_tableにrename
2. 理想のテーブルをcreate tableする
3. old_tableの値を全て必要に応じて入れる

Databaseのmigrateでいくつか処理を行うので、OldDbMigrationクラスを作成しそこで行うことにします。

```kotlin:TaskDatabase.kt

@Database(entities = [TaskEntity::class], version = 2, exportSchema = false)
abstract class TaskDatabase : RoomDatabase() {
    companion object {
        private const val DB_NAME = "task.db"

        fun createInstance(context: Context): TaskDatabase {
            return Room.databaseBuilder(context, TaskDatabase::class.java, DB_NAME)
                .addMigrations(object : Migration(1, 2) {
                    override fun migrate(database: SupportSQLiteDatabase) {
                        // ここでマイグレーション処理
                        OldDbMigration.migrate(database)
                    }
                }).build()
        }
    }

    abstract fun taskDao() : TaskDao
}
```

```kotlin:OldDbMigration.kt
object OldDbMigration {
    private const val TASK_T_CREATE =
        // TaskDatabase_Implから持ってこられる(後述)
        "CREATE TABLE IF NOT EXISTS `task_t` (`item_id` TEXT PRIMARY KEY AUTOINCREMENT NOT NULL, `is_done` INTEGER NOT NULL)"
        // TaskDao_Implから持ってこられる(後述)
    private const val TASK_T_INSERT =
        "INSERT OR ABORT INTO `task_t` (`item_id`,`is_done`) VALUES (?,?)"
    private const val OLD_TASK_T = "old_task_t"

    fun migrate(database: SupportSQLiteDatabase) {
        // 既存テーブルをrename
        database.execSQL("alter table task_t rename to $OLD_TASK_T")
        // 理想のEntityテーブル作成
        database.execSQL(TASK_T_CREATE)

        // 古いデータを全件取得
        val cursor = database.query("select * from $OLD_TASK_T")
        cursor.use {
            while (cursor.moveToNext()) {
                runCatching {
                    val itemId = cursor.getString(0)
                    val isDone = cursor.getInt(2) != 0
                    // 古いデータを新しいテーブルにinsert
                    database.execSQL(
                        TASK_T_INSERT,
                        arrayOf(itemId, isDone)
                    )
                }
            }
        }
    }
}
```
理想のEntityの`create table`は、Entity/Databaseを作った状態でビルドすると出来る`TaskDatabase_Impl`から拝借できます。

```java:TaskDatabase_Impl.java
@Generated("androidx.room.RoomProcessor")
@SuppressWarnings({"unchecked", "deprecation"})
public final class TaskDatabase_Impl extends TaskDatabase {
  private volatile TaskDao _taskDao;

  @Override
  protected SupportSQLiteOpenHelper createOpenHelper(DatabaseConfiguration configuration) {
    final SupportSQLiteOpenHelper.Callback _openCallback = new RoomOpenHelper(configuration, new RoomOpenHelper.Delegate(2) {
      @Override
      public void createAllTables(SupportSQLiteDatabase _db) {
        // ↓これが自動生成されたcreate table文
        _db.execSQL("CREATE TABLE IF NOT EXISTS `task_t` (`item_id` TEXT PRIMARY KEY AUTOINCREMENT NOT NULL, `is_done` INTEGER NOT NULL)");
        ...
```
同じく、insert文に関しても、Daoを作ってDatabaseにメソッドを追加した状態でビルドすると出来る`TaskDao_Impl`から拝借できます。
Daoに`@Insert`のメソッドを作り、Databaseにdao取得関数を作る必要はあります。

```kotlin:TaskDao.kt
@Dao
interface TaskDao {
    @Insert
    fun insert(entity: TaskEntity)
}
```

```java:TaskDao_Impl.java
@Generated("androidx.room.RoomProcessor")
@SuppressWarnings({"unchecked", "deprecation"})
public final class TaskDao_Impl implements TaskDao {
  private final RoomDatabase __db;

  private final EntityInsertionAdapter<TaskEntity> __insertionAdapterOfTaskEntity;

  public TaskDao_Impl(RoomDatabase __db) {
    this.__db = __db;
    this.__insertionAdapterOfTaskEntity = new EntityInsertionAdapter<TaskEntity>(__db) {
      @Override
      public String createQuery() {
        // ↓これが自動生成されたinsert文
        return "INSERT OR ABORT INTO `task_t` (`item_id`,`is_done`) VALUES (?,?)";
      }
```
今回の例ように2カラム分であれば自力でSQL文を作ってもいいですが、新規インストール者はRoomのこれらの生成文で作られたDBを使うので、合わせておくのが楽で安全かと思います。

PrimaryKeyだけでなくいくつか変更したいことがあるなら、この方法を取らなくても

- SQLiteOpenHelperを使ってRoomに流すマイグレーションを自作
- 既存DB向けのRoomEntityと理想のRoomEntityを作りマイグレーションを自作

などの選択肢はありますが、Roomのmigration内でやるのは難しいので、ここに負債を押し込めたい場合は上記の方法が良さそうです。

#さいごに
欲を出して余談までやっていたらRoomの生成コードと戯れることになりました。
ともあれ、既存のSQLiteDatabaseのRoomへの移行は割と簡単に出来ることが分かりました。
誰かのお役に立てれば幸いです。