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