Android Room における隣接リストから閉包テーブルへのMigration
隣接リストにて作成してしまった木構造のデータを、深さ付きの閉包テーブルにマイグレーションしたのでそのときの備忘録です。
そもそもの木構造の表現方法には触れず、あくまでMigrationの手順についてのみ記載しています。
モチベーション
AndroidでTwitterAPI(Twitter4J)を利用したアプリを作っており、要件としてツイートとそれに連なるリプライをローカルに保持する必要がありました。
当初はあまり深く考えず、Tweet
オブジェクトにStatus#inReplyToStatusId
プロパティが含まれていることから、ローカルにpreviousId
としてこのIdを保持していました(後々めんどうになる予感はしていたが、当初は閉包テーブルを知らなかった)。
隣接リストでは、任意のツイートを含む、任意の深さの根~葉までの経路を取得することが困難であることが分かり、閉包テーブルにマイグレーションしたものです。
サンプルデータ
ベースとなる木構造はこちら。番号はTweetIdを表すものとします。
なお、便宜上親が存在しないツイートは previousId = -1
としています。
tweet
テーブル
tweetId | previousId |
---|---|
1 | -1 |
2 | 1 |
3 | 2 |
4 | 2 |
5 | 3 |
6 | 3 |
7 | 4 |
tweet
テーブルからclosure(閉包)
テーブルを作成し、最終的に次のようなデータを作ります(プライマリキーとするid
列は省略)。
親子関係の探索は深さ優先探索により実行しました。
closure
テーブル
ancestor | descendant | depth |
---|---|---|
1 | 1 | 0 |
1 | 2 | 1 |
1 | 3 | 2 |
1 | 4 | 2 |
1 | 5 | 3 |
1 | 6 | 3 |
1 | 7 | 3 |
2 | 2 | 0 |
2 | 3 | 1 |
2 | 4 | 1 |
2 | 5 | 2 |
2 | 6 | 2 |
2 | 7 | 2 |
3 | 3 | 0 |
3 | 5 | 1 |
3 | 6 | 1 |
4 | 4 | 0 |
4 | 7 | 1 |
5 | 5 | 0 |
6 | 6 | 0 |
7 | 7 | 0 |
自身への参照をdepth = 0
とし、直接の子をdepth = 1
、孫をdepth = 2
……とすることでデータを作成します。
実装
下準備
マイグレーションにあたり、スキーマのエクスポート及びテストを作成します。基本的には公式のやり方に従います。
スキーマをエクスポートする
テスト用データベースを作成するため、現在のスキーマをJSON形式で出力します。
スキーマの出力部分はjavaCompileOptions
の部分ですが、ついでにsourceSets
と依存関係も追加しておきます。
app.build.gradle
に下記を追記し、いったんビルドしておきます。
// app.build.gradle android { defaultConfig { javaCompileOptions { annotationProcessorOptions { arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] } } } sourceSets { // Adds exported schema location as test app assets. androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } } dependencies { // 公式では testImplementation だが、テストはInstrumentationテストで行うので androidTestImplementation にする(なぜ公式が testImplementation なのか謎。。。) androidTestImplementation "androidx.room:room-testing:${versions.room_testing}" }
ビルドするとapp/schemas/yourPackage.AppDatabase/1.json
が出力されます。
なお、この際RoomDatabase
のアノテーションにて、exportSchema = true
としておかないと正常に出力されないようです。
@Database( entities = [TweetEntity::class, ...], version = 1, exportSchema = true ) abstract class AppDatabase : RoomDatabase() {}
テストメソッドを作成
androidTest
パッケージにMigrationTest.kt
を作成します。
@RunWith(AndroidJUnit4::class) class MigrationTest { @Before fun setUp() { } @Test @Throws(IOException::class) fun migrate1To2Test() { } }
ここまでで下準備は完了です。
テストを実装する
テスト用データベースの初期化
テスト用データベースの初期化から始めます。
MigrationTestHelper
により、インメモリデータベースを生成します。
@RunWith(AndroidJUnit4::class) class MigrationTest { private val TEST_DB = "migration-test" @get:Rule val helper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() ) @Before fun setUp() { helper.createDatabase(TEST_DB, 1).apply { // 初期データを tweet に挿入 execSQL( "Insert into tweet (tweetId, previousId) " + "values (1 , -1), (2, 1), (3, 2), (4, 2), (5, 3), (6, 3), (7, 4)" ) close() } } }
次に、データベースのバージョンを1から2に上げる処理を規定したMigration
を作成します。
runMigrationsAndValidate
の引数にMigration
オブジェクトを渡してやることで、指定したバージョン(1→2)へのマイグレートを行うときの処理を指定します。
@RunWith(AndroidJUnit4::class) class MigrationTest { private val TEST_DB = "migration-test" @get:Rule val helper = MigrationTestHelper(...) @Test @Throws(IOException::class) fun migrate1To2() { // Re-open the database with version 2 and provide // MIGRATION_1_2 as the migration process. val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, Migrations.migrate1To2()) } } object Migrations { fun migration1To2(): Migration { return object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS tweet_tree_path " + "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`ancestor` INTEGER NOT NULL, " + "`descendant` INTEGER NOT NULL, " + "`depth` INTEGER NOT NULL)") } } } }
この時点でmigrate1To2
を実行すると、setUp()
でtweet
テーブルとデータが作成され、さらにMIGRATION_1_2
により空のclosure
テーブルが作成されます。
Migrations.migrate1To2
の実装
tweet
テーブルを基に、closure
テーブルにデータを作成する処理を定義します。
親子関係の探索は、再帰処理を用いた深さ優先探索により行います。
探索はmakeDescendantTree
にて実装します。
object Migrations { fun migrate1To2() { // seedId として渡されたIdを基に、子孫を深さ優先探索する。 // 返り値は List<Pair<previousId, depth>> fun makeDescendantTree( seedId: Long, list: List<Pair<Long, Long>> ): List<Pair<Long, Int>> { fun search(id: Long, depth: Int, result: MutableList<Pair<Long, Int>>) { list.filter { id == it.second }.let { if (it.isNotEmpty()) { result.addAll(it.map { pair -> Pair(pair.first, depth) }) it.forEach { pair -> search(pair.first, depth + 1, result) } } } } val result = mutableListOf<Pair<Long, Int>>() search(seedId, 1, result) return result } return object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("CREATE TABLE IF NOT EXISTS tweet_tree_path " + "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`ancestor` INTEGER NOT NULL, " + "`descendant` INTEGER NOT NULL, " + "`depth` INTEGER NOT NULL)") // Pair<tweetId, previousId> を格納するリスト val list = mutableListOf<Pair<Long, Long>>() // レコードを全件取得 val cursor = db.query("Select tweetId, previousId from tweet") cursor.moveToFirst() while (!cursor.isAfterLast) { val tweetId = cursor.getLong(0) val previousId = cursor.getLong(1) list.add(Pair(tweetId, previousId)) cursor.moveToNext() } cursor.close() // 実データの都合上、previousId 列にしか存在しない Id があるため、マージ&重複削除する val mergedIds = (list.map { it.first } + list.map { it.second }.filter { it != -1L }).distinct() // mergedIds を走査する mergedIds.forEach { seedId -> // 自身の参照を深さゼロで挿入 db.execSQL("Insert into tweet_tree_path (ancestor, descendant, depth) values($seedId, $seedId, 0)") // seedId の子孫(descendant)のリストを作成する val descendantTree = makeDescendantTree(seedId, list) // 生成された子孫リストを closure テーブルに挿入する descendantTree.forEach { println("| $seedId | ${it.first} | ${it.second} |") db.execSQL("Insert into tweet_tree_path (ancestor, descendant, depth) values($seedId, ${it.first}, ${it.second})") } } } } } }
seedId = 2
のときのmakeDescendantTree
の再帰処理はこんな感じです。
実行結果の確認
migrate1To2Test
に確認用のクエリを記述して、closure
に保存されているレコードを確認します。
適宜assert
してください。
@RunWith(AndroidJUnit4::class) class MigrationTest { @Test @Throws(IOException::class) fun migrate1To2() { // Re-open the database with version 2 and provide // MIGRATION_1_2 as the migration process. val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, Migrations.migrate1To2()) val cursor = db.query("Select * from closure order by ancestor ASC, depth ASC") cursor.moveToFirst() println("| id | ancestor | descendant | depth |") while (!cursor.isAfterLast) { println( "| ${cursor.getInt(0)} | ${cursor.getLong(1)} | ${cursor.getLong(2)} | ${cursor.getInt(3)} |" ) cursor.moveToNext() } } }
本番環境への移植
RoomDatabase
のRoom.databaseBuilder.addMigrations
にMigration
オブジェクトを指定します。
// closure テーブルは次のようなdata classにより定義 @Entity(tableName = "closure") data class ClosureEntity( @PrimaryKey(autoGenerate = true) val id: Int, val ancestor: Long, val descendant: Long, val depth: Int ) @Database( entities = [TweetEntity::class, ClosureEntity::class, ...], version = 2, exportSchema = true ) abstract class AppDatabase : RoomDatabase() { abstract fun service(): TwitterControlServiceDao companion object { private var INSTANCE: AppDatabase? = null private val lock = Any() fun getInstance(context: Context): AppDatabase = INSTANCE ?: synchronized(lock) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "AppDatabase.db" ) .addMigrations(Migrations.migrate1To2) // ← ここに追加 .build() .also { INSTANCE = it } } } }
アプリ起動後、AppDatabase.getInstance
が呼ばれた時点でマイグレートされると思います。
所感
次は、深さ付き閉包テーブルへのレコード追加処理のテストについて書きたいです。