はじめに
息が長いプロダクトの場合、SQLiteOpenHelperを用いたSQLiteDatabaseがアプリ内にあることがあります。
現在はRoomが主流で、公式ページでも以下のように記載があります。
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
細かいところは適当です。
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でのテーブル。
// テーブル名を合わせる
@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
// カラムの変更が無いなら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/
上記サイトで、対処方法も記載されていて、参考になりました。
簡単に言うと、テーブルの作り直しです。
- 既存テーブルをold_tableにrename
- primarykeyを追加したテーブルをcreatetable
- old_tableの値を全てtableに追加
primarykeyの移行手順
例として、先程の例からprimarykeyカラムをなくしたテーブルを想定します。
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で既存データを戻す部分が変わります。
// 既存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のないテーブル)
create table task_t (
item_id text,
item_description text,
is_done integer not null default 0)
これをPrimaryKey(_id)を追加して移行すると、下記のようなEntityになります。
@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に寄せてみましょう!
@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は出来たので、マイグレーション処理を考えていきます。
- 既存テーブルをold_tableにrename
- 理想のテーブルをcreate tableする
- old_tableの値を全て必要に応じて入れる
Databaseのmigrateでいくつか処理を行うので、OldDbMigrationクラスを作成しそこで行うことにします。
@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
}
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
から拝借できます。
@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取得関数を作る必要はあります。
@Dao
interface TaskDao {
@Insert
fun insert(entity: TaskEntity)
}
@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への移行は割と簡単に出来ることが分かりました。
誰かのお役に立てれば幸いです。