隣接リストにて作成してしまった木構造のデータを、深さ付きの閉包テーブルにマイグレーションしたのでそのときの備忘録です。
そもそもの木構造の表現方法には触れず、あくまで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 closure " +
"(`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 closure " +
"(`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 closure (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 closure (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
が呼ばれた時点でマイグレートされると思います。
所感
- 基本的には公式のやり方でOKだが、ちょいちょいトラップがあるので要注意。
- 保持しているデータ量が多いと、再帰処理でSOFやOOMしたりするかも。
- 普通に再帰でクエリ発行した方が安全かもしれない。
次は、深さ付き閉包テーブルへのレコード追加処理のテストについて書きたいです。