refactor(db): prepare Room 3 driver migration

This commit is contained in:
二刺螈
2026-08-27 14:20:25 +08:00
parent 199f104848
commit bc002b5c56
5 changed files with 203 additions and 4 deletions

View File

@@ -7,6 +7,9 @@ plugins {
android {
namespace = "li.gkd.db"
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
room {
@@ -20,7 +23,12 @@ dependencies {
implementation(libs.androidx.room.ktx)
api(libs.androidx.room.paging)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.sqlite.framework)
api(libs.kotlinx.serialization.core)
implementation(libs.kotlinx.serialization.json)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}

View File

@@ -0,0 +1,177 @@
package li.gkd.db
import androidx.room.Room
import androidx.room.immediateTransaction
import androidx.room.testing.MigrationTestHelper
import androidx.room.useWriterConnection
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import androidx.sqlite.driver.AndroidSQLiteDriver
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppDbMigrationTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()
private val context = instrumentation.targetContext
private val databaseNames = mutableSetOf<String>()
private fun migrationHelper(databaseName: String): MigrationTestHelper {
databaseNames += databaseName
return MigrationTestHelper(
instrumentation,
context.getDatabasePath(databaseName),
AndroidSQLiteDriver(),
AppDb::class,
)
}
private fun openDatabase(databaseName: String): AppDb =
Room.databaseBuilder(
context,
AppDb::class.java,
context.getDatabasePath(databaseName).absolutePath,
)
.setDriver(AndroidSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
private fun SQLiteConnection.queryLong(sql: String): Long =
prepare(sql).use { statement ->
check(statement.step()) { "Query returned no rows: $sql" }
statement.getLong(0)
}
private fun SQLiteConnection.queryText(sql: String): String =
prepare(sql).use { statement ->
check(statement.step()) { "Query returned no rows: $sql" }
statement.getText(0)
}
@After
fun deleteDatabases() {
databaseNames.forEach(context::deleteDatabase)
}
@Test
fun everyExportedSchemaMigratesToVersion14() = runBlocking {
// Protects the persisted schema compatibility contract for every released version.
for (startVersion in 1 until 14) {
val helper = migrationHelper("all-migrations-$startVersion.db")
helper.createDatabase(startVersion).close()
helper.runMigrationsAndValidate(14, emptyList()).close()
}
}
@Test
fun migration9To10PreservesRenamedForeignIds() = runBlocking {
val helper = migrationHelper("migration-9-10.db")
helper.createDatabase(9).apply {
execSQL(
"""
INSERT INTO subs_config
(id, type, enable, subs_item_id, app_id, group_key, exclude)
VALUES (101, 2, 1, 77, 'sample.app', 5, '')
""".trimIndent()
)
execSQL(
"""
INSERT INTO category_config
(id, enable, subs_item_id, category_key)
VALUES (102, 1, 88, 6)
""".trimIndent()
)
close()
}
helper.runMigrationsAndValidate(10, emptyList()).use { connection ->
assertEquals(
77L,
connection.queryLong("SELECT subs_id FROM subs_config WHERE id = 101"),
)
assertEquals(
88L,
connection.queryLong("SELECT subs_id FROM category_config WHERE id = 102"),
)
}
}
@Test
fun migration10To11PreservesSnapshotDataOutsideDeletedColumns() = runBlocking {
val helper = migrationHelper("migration-10-11.db")
helper.createDatabase(10).apply {
execSQL(
"""
INSERT INTO snapshot
(id, app_id, activity_id, app_name, app_version_code,
app_version_name, screen_height, screen_width, is_landscape,
github_asset_id)
VALUES
(201, 'sample.app', 'sample.Activity', 'Old name', 12,
'1.2', 1920, 1080, 0, 301)
""".trimIndent()
)
close()
}
helper.runMigrationsAndValidate(11, emptyList()).use { connection ->
assertEquals(
"sample.app",
connection.queryText("SELECT app_id FROM snapshot WHERE id = 201"),
)
assertEquals(
1920L,
connection.queryLong("SELECT screen_height FROM snapshot WHERE id = 201"),
)
assertEquals(
301L,
connection.queryLong("SELECT github_asset_id FROM snapshot WHERE id = 201"),
)
}
}
@Test
fun driverDatabaseOpensVersion14AndRollsBackFailedTransaction() = runBlocking {
val databaseName = "driver-version-14.db"
val helper = migrationHelper(databaseName)
helper.createDatabase(14).apply {
execSQL(
"""
INSERT INTO subs_item
(id, ctime, mtime, enable, enable_update, `order`, update_url)
VALUES (42, 1, 1, 1, 1, 0, NULL)
""".trimIndent()
)
close()
}
val database = openDatabase(databaseName)
try {
assertEquals(listOf(42L), database.subsItemDao().queryAll().map { it.id })
var failed = false
try {
database.useWriterConnection { connection ->
connection.immediateTransaction {
database.subsItemDao().insert(SubsItem(id = 43, order = 1))
error("rollback")
}
}
} catch (_: IllegalStateException) {
failed = true
}
assertTrue(failed)
assertEquals(listOf(42L), database.subsItemDao().queryAll().map { it.id })
} finally {
database.close()
}
}
}

View File

@@ -9,8 +9,11 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.immediateTransaction
import androidx.room.migration.AutoMigrationSpec
import androidx.room.withTransaction
import androidx.room.useWriterConnection
import androidx.sqlite.driver.AndroidSQLiteDriver
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
@Database(
@@ -116,7 +119,10 @@ object Db {
applicationContext,
AppDb::class.java,
databasePath,
).build()
)
.setDriver(AndroidSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
}
}
@@ -135,5 +141,9 @@ object Db {
val a11yEventLogDao get() = database.a11yEventLogDao()
suspend fun <T> withTransaction(block: suspend () -> T): T =
database.withTransaction(block)
database.useWriterConnection { connection ->
connection.immediateTransaction {
block()
}
}
}

View File

@@ -70,7 +70,7 @@ data class SubsItem(
fun query(): Flow<List<SubsItem>>
@Query("SELECT * FROM subs_item ORDER BY `order`")
fun queryAll(): List<SubsItem>
suspend fun queryAll(): List<SubsItem>
@Query("DELETE FROM subs_item WHERE id IN (:ids)")
suspend fun deleteById(vararg ids: Long): Int

View File

@@ -6,6 +6,7 @@ agp = "9.3.2"
compose = "1.12.0"
nav3 = "1.1.6"
room = "2.8.4"
sqlite = "2.7.0"
paging = "3.5.1"
lifecycle = "2.11.0"
ktor = "3.5.2"
@@ -50,12 +51,15 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3" }
androidx-junit = "androidx.test.ext:junit:1.3.0"
androidx-test-runner = "androidx.test:runner:1.7.0"
androidx-annotation = "androidx.annotation:annotation:1.10.0"
androidx-espresso = "androidx.test.espresso:espresso-core:3.7.0"
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "sqlite" }
androidx-splashscreen = "androidx.core:core-splashscreen:1.2.0"
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }