diff --git a/gkd-db/build.gradle.kts b/gkd-db/build.gradle.kts index 1aa2cc3f..4fca9906 100644 --- a/gkd-db/build.gradle.kts +++ b/gkd-db/build.gradle.kts @@ -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) } diff --git a/gkd-db/src/androidTest/kotlin/li/gkd/db/AppDbMigrationTest.kt b/gkd-db/src/androidTest/kotlin/li/gkd/db/AppDbMigrationTest.kt new file mode 100644 index 00000000..ce5b25cc --- /dev/null +++ b/gkd-db/src/androidTest/kotlin/li/gkd/db/AppDbMigrationTest.kt @@ -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() + + 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() + } + } +} diff --git a/gkd-db/src/main/kotlin/li/gkd/db/AppDb.kt b/gkd-db/src/main/kotlin/li/gkd/db/AppDb.kt index 5b415dd2..4907cdc2 100644 --- a/gkd-db/src/main/kotlin/li/gkd/db/AppDb.kt +++ b/gkd-db/src/main/kotlin/li/gkd/db/AppDb.kt @@ -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 withTransaction(block: suspend () -> T): T = - database.withTransaction(block) + database.useWriterConnection { connection -> + connection.immediateTransaction { + block() + } + } } diff --git a/gkd-db/src/main/kotlin/li/gkd/db/SubsItem.kt b/gkd-db/src/main/kotlin/li/gkd/db/SubsItem.kt index c0aea145..b0125f53 100644 --- a/gkd-db/src/main/kotlin/li/gkd/db/SubsItem.kt +++ b/gkd-db/src/main/kotlin/li/gkd/db/SubsItem.kt @@ -70,7 +70,7 @@ data class SubsItem( fun query(): Flow> @Query("SELECT * FROM subs_item ORDER BY `order`") - fun queryAll(): List + suspend fun queryAll(): List @Query("DELETE FROM subs_item WHERE id IN (:ids)") suspend fun deleteById(vararg ids: Long): Int diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 57e50ada..6d88e82f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }