1 /* 2 * Copyright 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.room.testing 18 19 import androidx.room.DatabaseConfiguration 20 import androidx.room.RoomDatabase 21 import androidx.room.migration.AutoMigrationSpec 22 import androidx.room.migration.Migration 23 import androidx.room.migration.bundle.SchemaBundle 24 import androidx.room.util.findAndInstantiateDatabaseImpl 25 import androidx.sqlite.SQLiteConnection 26 import androidx.sqlite.SQLiteDriver 27 import java.nio.file.Path 28 import kotlin.io.path.inputStream 29 import kotlin.reflect.KClass 30 import kotlin.reflect.cast 31 import org.junit.rules.TestWatcher 32 import org.junit.runner.Description 33 34 /** 35 * A class that can help test and verify database creation and migration at different versions with 36 * different schemas. 37 * 38 * Common usage of this helper is to create a database at an older version first and then attempt a 39 * migration and validation: 40 * ``` 41 * @get:Rule 42 * val migrationTestHelper = MigrationTestHelper( 43 * schemaDirectoryPath = Path("schemas") 44 * driver = sqliteDriver, 45 * databaseClass = PetDatabase::class 46 * ) 47 * 48 * @Test 49 * fun migrationTest() { 50 * // Create the database at version 1 51 * val newConnection = migrationTestHelper.createDatabase(1) 52 * // Insert some data that should be preserved 53 * newConnection.execSQL("INSERT INTO Pet (id, name) VALUES (1, 'Tom')") 54 * newConnection.close() 55 * 56 * // Migrate the database to version 2 57 * val migratedConnection = 58 * migrationTestHelper.runMigrationsAndValidate(2, listOf(MIGRATION_1_2))) 59 * migratedConnection.prepare("SELECT * FROM Pet).use { stmt -> 60 * // Validates data is preserved between migrations. 61 * assertThat(stmt.step()).isTrue() 62 * assertThat(stmt.getText(1)).isEqualTo("Tom") 63 * } 64 * migratedConnection.close() 65 * } 66 * ``` 67 * 68 * The helper relies on exported schemas so [androidx.room.Database.exportSchema] should be enabled. 69 * Schema location should be configured via Room's Gradle Plugin (id 'androidx.room'): 70 * ``` 71 * room { 72 * schemaDirectory("$projectDir/schemas") 73 * } 74 * ``` 75 * 76 * The [schemaDirectoryPath] must match the exported schema location for this helper to properly 77 * create and validate schemas. 78 * 79 * @param schemaDirectoryPath The schema directory where schema files are exported. 80 * @param databasePath Name of the database. 81 * @param driver A driver that opens connection to a file database. A driver that opens connections 82 * to an in-memory database would be meaningless. 83 * @param databaseClass The [androidx.room.Database] annotated class. 84 * @param databaseFactory An optional factory function to create an instance of the [databaseClass]. 85 * Should be the same factory used when building the database via 86 * [androidx.room.Room.databaseBuilder]. 87 * @param autoMigrationSpecs The list of [androidx.room.ProvidedAutoMigrationSpec] instances for 88 * [androidx.room.AutoMigration]s that require them. 89 */ 90 actual class MigrationTestHelper( 91 private val schemaDirectoryPath: Path, 92 private val databasePath: Path, 93 private val driver: SQLiteDriver, 94 private val databaseClass: KClass<out RoomDatabase>, <lambda>null95 databaseFactory: () -> RoomDatabase = { findAndInstantiateDatabaseImpl(databaseClass.java) }, 96 private val autoMigrationSpecs: List<AutoMigrationSpec> = emptyList() 97 ) : TestWatcher() { 98 99 private val databaseInstance = databaseClass.cast(databaseFactory.invoke()) 100 private val managedConnections = mutableListOf<SQLiteConnection>() 101 102 /** 103 * Creates the database at the given version. 104 * 105 * Once a database is created it can further validate with [runMigrationsAndValidate]. 106 * 107 * @param version The version of the schema at which the database should be created. 108 * @return A database connection of the newly created database. 109 * @throws IllegalStateException If a new database was not created. 110 */ createDatabasenull111 actual fun createDatabase(version: Int): SQLiteConnection { 112 val schemaBundle = loadSchema(version) 113 val connection = 114 createDatabaseCommon( 115 schema = schemaBundle.database, 116 configurationFactory = ::createDatabaseConfiguration 117 ) 118 managedConnections.add(connection) 119 return connection 120 } 121 122 /** 123 * Runs the given set of migrations on the existing database once created via [createDatabase]. 124 * 125 * This function uses the same algorithm that Room performs to choose migrations such that the 126 * [migrations] instances provided must be sufficient to bring the database from current version 127 * to the desired version. If the database contains [androidx.room.AutoMigration]s, then those 128 * are already included in the list of migrations to execute if necessary. Note that provided 129 * manual migrations take precedence over auto migrations if they overlap in migration paths. 130 * 131 * Once migrations are done, this functions validates the database schema to ensure the 132 * migration performed resulted in the expected schema. 133 * 134 * @param version The final version the database should migrate to. 135 * @param migrations The list of migrations used to attempt the database migration. 136 * @throws IllegalStateException If the schema validation fails. 137 */ runMigrationsAndValidatenull138 actual fun runMigrationsAndValidate( 139 version: Int, 140 migrations: List<Migration>, 141 ): SQLiteConnection { 142 val schemaBundle = loadSchema(version) 143 val connection = 144 runMigrationsAndValidateCommon( 145 databaseInstance = databaseInstance, 146 schema = schemaBundle.database, 147 migrations = migrations, 148 autoMigrationSpecs = autoMigrationSpecs, 149 validateUnknownTables = false, 150 configurationFactory = ::createDatabaseConfiguration 151 ) 152 managedConnections.add(connection) 153 return connection 154 } 155 finishednull156 override fun finished(description: Description?) { 157 super.finished(description) 158 managedConnections.forEach(SQLiteConnection::close) 159 } 160 loadSchemanull161 private fun loadSchema(version: Int): SchemaBundle { 162 val databaseFQN = checkNotNull(databaseClass.qualifiedName) 163 val schemaPath = schemaDirectoryPath.resolve(databaseFQN).resolve("$version.json") 164 return schemaPath.inputStream().use { SchemaBundle.deserialize(it) } 165 } 166 createDatabaseConfigurationnull167 private fun createDatabaseConfiguration( 168 container: RoomDatabase.MigrationContainer, 169 ) = 170 DatabaseConfiguration( 171 name = databasePath.toString(), 172 migrationContainer = container, 173 callbacks = null, 174 journalMode = RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING, 175 requireMigration = true, 176 allowDestructiveMigrationOnDowngrade = false, 177 migrationNotRequiredFrom = null, 178 typeConverters = emptyList(), 179 autoMigrationSpecs = emptyList(), 180 allowDestructiveMigrationForAllTables = false, 181 sqliteDriver = driver, 182 queryCoroutineContext = null 183 ) 184 } 185