日々是好日

プログラミングについてのあれこれ、ムダ知識など

Android Room における隣接リストから閉包テーブルへのMigration

隣接リストにて作成してしまった木構造のデータを、深さ付きの閉包テーブルにマイグレーションしたのでそのときの備忘録です。

そもそもの木構造の表現方法には触れず、あくまでMigrationの手順についてのみ記載しています。

モチベーション

AndroidでTwitterAPI(Twitter4J)を利用したアプリを作っており、要件としてツイートとそれに連なるリプライをローカルに保持する必要がありました。
当初はあまり深く考えず、TweetオブジェクトにStatus#inReplyToStatusIdプロパティが含まれていることから、ローカルにpreviousIdとしてこのIdを保持していました(後々めんどうになる予感はしていたが、当初は閉包テーブルを知らなかった)。

隣接リストでは、任意のツイートを含む、任意の深さの根~葉までの経路を取得することが困難であることが分かり、閉包テーブルにマイグレーションしたものです。

サンプルデータ

ベースとなる木構造はこちら。番号はTweetIdを表すものとします。

なお、便宜上親が存在しないツイートは previousId = -1 としています。

f:id:kcpoipoi:20210823123645p:plain

  • 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……とすることでデータを作成します。

実装

下準備

マイグレーションにあたり、スキーマのエクスポート及びテストを作成します。基本的には公式のやり方に従います。

developer.android.com

スキーマをエクスポートする

テスト用データベースを作成するため、現在のスキーマ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再帰処理はこんな感じです。

f:id:kcpoipoi:20210823232540p:plain

実行結果の確認

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()
    }
  }
}

本番環境への移植

RoomDatabaseRoom.databaseBuilder.addMigrationsMigrationオブジェクトを指定します。

// 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したりするかも。
  • 普通に再帰でクエリ発行した方が安全かもしれない。

次は、深さ付き閉包テーブルへのレコード追加処理のテストについて書きたいです。

参考としたページ

閉包テーブル

tociyuki.hatenablog.jp

blog.amedama.jp

teitei-tk.hatenablog.com

Roomのマイグレーション

developer.android.com

developer.android.com

github.com

star-zero.medium.com

qiita.com