1 /* 2 * Copyright (C) 2017 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 android.app.Instrumentation; 20 import android.content.Context; 21 import android.database.Cursor; 22 import android.util.Log; 23 24 import androidx.room.DatabaseConfiguration; 25 import androidx.room.Room; 26 import androidx.room.RoomDatabase; 27 import androidx.room.RoomOpenHelper; 28 import androidx.room.migration.Migration; 29 import androidx.room.migration.bundle.DatabaseBundle; 30 import androidx.room.migration.bundle.EntityBundle; 31 import androidx.room.migration.bundle.FieldBundle; 32 import androidx.room.migration.bundle.ForeignKeyBundle; 33 import androidx.room.migration.bundle.IndexBundle; 34 import androidx.room.migration.bundle.SchemaBundle; 35 import androidx.room.util.TableInfo; 36 import androidx.sqlite.db.SupportSQLiteDatabase; 37 import androidx.sqlite.db.SupportSQLiteOpenHelper; 38 import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; 39 40 import org.junit.rules.TestWatcher; 41 import org.junit.runner.Description; 42 43 import java.io.File; 44 import java.io.FileNotFoundException; 45 import java.io.IOException; 46 import java.io.InputStream; 47 import java.lang.ref.WeakReference; 48 import java.util.ArrayList; 49 import java.util.Collections; 50 import java.util.HashMap; 51 import java.util.HashSet; 52 import java.util.List; 53 import java.util.Map; 54 import java.util.Set; 55 56 /** 57 * A class that can be used in your Instrumentation tests that can create the database in an 58 * older schema. 59 * <p> 60 * You must copy the schema json files (created by passing {@code room.schemaLocation} argument 61 * into the annotation processor) into your test assets and pass in the path for that folder into 62 * the constructor. This class will read the folder and extract the schemas from there. 63 * <pre> 64 * android { 65 * defaultConfig { 66 * javaCompileOptions { 67 * annotationProcessorOptions { 68 * arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] 69 * } 70 * } 71 * } 72 * sourceSets { 73 * androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) 74 * } 75 * } 76 * </pre> 77 */ 78 public class MigrationTestHelper extends TestWatcher { 79 private static final String TAG = "MigrationTestHelper"; 80 private final String mAssetsFolder; 81 private final SupportSQLiteOpenHelper.Factory mOpenFactory; 82 private List<WeakReference<SupportSQLiteDatabase>> mManagedDatabases = new ArrayList<>(); 83 private List<WeakReference<RoomDatabase>> mManagedRoomDatabases = new ArrayList<>(); 84 private boolean mTestStarted; 85 private Instrumentation mInstrumentation; 86 87 /** 88 * Creates a new migration helper. It uses the Instrumentation context to load the schema 89 * (falls back to the app resources) and the target context to create the database. 90 * 91 * @param instrumentation The instrumentation instance. 92 * @param assetsFolder The asset folder in the assets directory. 93 */ 94 public MigrationTestHelper(Instrumentation instrumentation, String assetsFolder) { 95 this(instrumentation, assetsFolder, new FrameworkSQLiteOpenHelperFactory()); 96 } 97 98 /** 99 * Creates a new migration helper. It uses the Instrumentation context to load the schema 100 * (falls back to the app resources) and the target context to create the database. 101 * 102 * @param instrumentation The instrumentation instance. 103 * @param assetsFolder The asset folder in the assets directory. 104 * @param openFactory Factory class that allows creation of {@link SupportSQLiteOpenHelper} 105 */ 106 public MigrationTestHelper(Instrumentation instrumentation, String assetsFolder, 107 SupportSQLiteOpenHelper.Factory openFactory) { 108 mInstrumentation = instrumentation; 109 if (assetsFolder.endsWith("/")) { 110 assetsFolder = assetsFolder.substring(0, assetsFolder.length() - 1); 111 } 112 mAssetsFolder = assetsFolder; 113 mOpenFactory = openFactory; 114 } 115 116 @Override 117 protected void starting(Description description) { 118 super.starting(description); 119 mTestStarted = true; 120 } 121 122 /** 123 * Creates the database in the given version. 124 * If the database file already exists, it tries to delete it first. If delete fails, throws 125 * an exception. 126 * 127 * @param name The name of the database. 128 * @param version The version in which the database should be created. 129 * @return A database connection which has the schema in the requested version. 130 * @throws IOException If it cannot find the schema description in the assets folder. 131 */ 132 @SuppressWarnings("SameParameterValue") 133 public SupportSQLiteDatabase createDatabase(String name, int version) throws IOException { 134 File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); 135 if (dbPath.exists()) { 136 Log.d(TAG, "deleting database file " + name); 137 if (!dbPath.delete()) { 138 throw new IllegalStateException("there is a database file and i could not delete" 139 + " it. Make sure you don't have any open connections to that database" 140 + " before calling this method."); 141 } 142 } 143 SchemaBundle schemaBundle = loadSchema(version); 144 RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); 145 DatabaseConfiguration configuration = new DatabaseConfiguration( 146 mInstrumentation.getTargetContext(), name, mOpenFactory, container, null, true, 147 RoomDatabase.JournalMode.TRUNCATE, true, Collections.<Integer>emptySet()); 148 RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, 149 new CreatingDelegate(schemaBundle.getDatabase()), 150 schemaBundle.getDatabase().getIdentityHash(), 151 // we pass the same hash twice since an old schema does not necessarily have 152 // a legacy hash and we would not even persist it. 153 schemaBundle.getDatabase().getIdentityHash()); 154 return openDatabase(name, roomOpenHelper); 155 } 156 157 /** 158 * Runs the given set of migrations on the provided database. 159 * <p> 160 * It uses the same algorithm that Room uses to choose migrations so the migrations instances 161 * that are provided to this method must be sufficient to bring the database from current 162 * version to the desired version. 163 * <p> 164 * After the migration, the method validates the database schema to ensure that migration 165 * result matches the expected schema. Handling of dropped tables depends on the 166 * {@code validateDroppedTables} argument. If set to true, the verification will fail if it 167 * finds a table that is not registered in the Database. If set to false, extra tables in the 168 * database will be ignored (this is the runtime library behavior). 169 * 170 * @param name The database name. You must first create this database via 171 * {@link #createDatabase(String, int)}. 172 * @param version The final version after applying the migrations. 173 * @param validateDroppedTables If set to true, validation will fail if the database has 174 * unknown 175 * tables. 176 * @param migrations The list of available migrations. 177 * @throws IOException If it cannot find the schema for {@code toVersion}. 178 * @throws IllegalStateException If the schema validation fails. 179 */ 180 public SupportSQLiteDatabase runMigrationsAndValidate(String name, int version, 181 boolean validateDroppedTables, Migration... migrations) throws IOException { 182 File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); 183 if (!dbPath.exists()) { 184 throw new IllegalStateException("Cannot find the database file for " + name + ". " 185 + "Before calling runMigrations, you must first create the database via " 186 + "createDatabase."); 187 } 188 SchemaBundle schemaBundle = loadSchema(version); 189 RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); 190 container.addMigrations(migrations); 191 DatabaseConfiguration configuration = new DatabaseConfiguration( 192 mInstrumentation.getTargetContext(), name, mOpenFactory, container, null, true, 193 RoomDatabase.JournalMode.TRUNCATE, true, Collections.<Integer>emptySet()); 194 RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, 195 new MigratingDelegate(schemaBundle.getDatabase(), validateDroppedTables), 196 // we pass the same hash twice since an old schema does not necessarily have 197 // a legacy hash and we would not even persist it. 198 schemaBundle.getDatabase().getIdentityHash(), 199 schemaBundle.getDatabase().getIdentityHash()); 200 return openDatabase(name, roomOpenHelper); 201 } 202 203 private SupportSQLiteDatabase openDatabase(String name, RoomOpenHelper roomOpenHelper) { 204 SupportSQLiteOpenHelper.Configuration config = 205 SupportSQLiteOpenHelper.Configuration 206 .builder(mInstrumentation.getTargetContext()) 207 .callback(roomOpenHelper) 208 .name(name) 209 .build(); 210 SupportSQLiteDatabase db = mOpenFactory.create(config).getWritableDatabase(); 211 mManagedDatabases.add(new WeakReference<>(db)); 212 return db; 213 } 214 215 @Override 216 protected void finished(Description description) { 217 super.finished(description); 218 for (WeakReference<SupportSQLiteDatabase> dbRef : mManagedDatabases) { 219 SupportSQLiteDatabase db = dbRef.get(); 220 if (db != null && db.isOpen()) { 221 try { 222 db.close(); 223 } catch (Throwable ignored) { 224 } 225 } 226 } 227 for (WeakReference<RoomDatabase> dbRef : mManagedRoomDatabases) { 228 final RoomDatabase roomDatabase = dbRef.get(); 229 if (roomDatabase != null) { 230 roomDatabase.close(); 231 } 232 } 233 } 234 235 /** 236 * Registers a database connection to be automatically closed when the test finishes. 237 * <p> 238 * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via 239 * {@link org.junit.Rule Rule} annotation. 240 * 241 * @param db The database connection that should be closed after the test finishes. 242 */ 243 public void closeWhenFinished(SupportSQLiteDatabase db) { 244 if (!mTestStarted) { 245 throw new IllegalStateException("You cannot register a database to be closed before" 246 + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" 247 + " test rule? (@Rule)"); 248 } 249 mManagedDatabases.add(new WeakReference<>(db)); 250 } 251 252 /** 253 * Registers a database connection to be automatically closed when the test finishes. 254 * <p> 255 * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via 256 * {@link org.junit.Rule Rule} annotation. 257 * 258 * @param db The RoomDatabase instance which holds the database. 259 */ 260 public void closeWhenFinished(RoomDatabase db) { 261 if (!mTestStarted) { 262 throw new IllegalStateException("You cannot register a database to be closed before" 263 + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" 264 + " test rule? (@Rule)"); 265 } 266 mManagedRoomDatabases.add(new WeakReference<>(db)); 267 } 268 269 private SchemaBundle loadSchema(int version) throws IOException { 270 try { 271 return loadSchema(mInstrumentation.getContext(), version); 272 } catch (FileNotFoundException testAssetsIOExceptions) { 273 Log.w(TAG, "Could not find the schema file in the test assets. Checking the" 274 + " application assets"); 275 try { 276 return loadSchema(mInstrumentation.getTargetContext(), version); 277 } catch (FileNotFoundException appAssetsException) { 278 // throw the test assets exception instead 279 throw new FileNotFoundException("Cannot find the schema file in the assets folder. " 280 + "Make sure to include the exported json schemas in your test assert " 281 + "inputs. See " 282 + "https://developer.android.com/topic/libraries/architecture/" 283 + "room.html#db-migration-testing for details. Missing file: " 284 + testAssetsIOExceptions.getMessage()); 285 } 286 } 287 } 288 289 private SchemaBundle loadSchema(Context context, int version) throws IOException { 290 InputStream input = context.getAssets().open(mAssetsFolder + "/" + version + ".json"); 291 return SchemaBundle.deserialize(input); 292 } 293 294 private static TableInfo toTableInfo(EntityBundle entityBundle) { 295 return new TableInfo(entityBundle.getTableName(), toColumnMap(entityBundle), 296 toForeignKeys(entityBundle.getForeignKeys()), toIndices(entityBundle.getIndices())); 297 } 298 299 private static Set<TableInfo.Index> toIndices(List<IndexBundle> indices) { 300 if (indices == null) { 301 return Collections.emptySet(); 302 } 303 Set<TableInfo.Index> result = new HashSet<>(); 304 for (IndexBundle bundle : indices) { 305 result.add(new TableInfo.Index(bundle.getName(), bundle.isUnique(), 306 bundle.getColumnNames())); 307 } 308 return result; 309 } 310 311 private static Set<TableInfo.ForeignKey> toForeignKeys( 312 List<ForeignKeyBundle> bundles) { 313 if (bundles == null) { 314 return Collections.emptySet(); 315 } 316 Set<TableInfo.ForeignKey> result = new HashSet<>(bundles.size()); 317 for (ForeignKeyBundle bundle : bundles) { 318 result.add(new TableInfo.ForeignKey(bundle.getTable(), 319 bundle.getOnDelete(), bundle.getOnUpdate(), 320 bundle.getColumns(), bundle.getReferencedColumns())); 321 } 322 return result; 323 } 324 325 private static Map<String, TableInfo.Column> toColumnMap(EntityBundle entity) { 326 Map<String, TableInfo.Column> result = new HashMap<>(); 327 for (FieldBundle bundle : entity.getFields()) { 328 TableInfo.Column column = toColumn(entity, bundle); 329 result.put(column.name, column); 330 } 331 return result; 332 } 333 334 private static TableInfo.Column toColumn(EntityBundle entity, FieldBundle field) { 335 return new TableInfo.Column(field.getColumnName(), field.getAffinity(), 336 field.isNonNull(), findPrimaryKeyPosition(entity, field)); 337 } 338 339 private static int findPrimaryKeyPosition(EntityBundle entity, FieldBundle field) { 340 List<String> columnNames = entity.getPrimaryKey().getColumnNames(); 341 int i = 0; 342 for (String columnName : columnNames) { 343 i++; 344 if (field.getColumnName().equalsIgnoreCase(columnName)) { 345 return i; 346 } 347 } 348 return 0; 349 } 350 351 static class MigratingDelegate extends RoomOpenHelperDelegate { 352 private final boolean mVerifyDroppedTables; 353 354 MigratingDelegate(DatabaseBundle databaseBundle, boolean verifyDroppedTables) { 355 super(databaseBundle); 356 mVerifyDroppedTables = verifyDroppedTables; 357 } 358 359 @Override 360 protected void createAllTables(SupportSQLiteDatabase database) { 361 throw new UnsupportedOperationException("Was expecting to migrate but received create." 362 + "Make sure you have created the database first."); 363 } 364 365 @Override 366 protected void validateMigration(SupportSQLiteDatabase db) { 367 final Map<String, EntityBundle> tables = mDatabaseBundle.getEntitiesByTableName(); 368 for (EntityBundle entity : tables.values()) { 369 final TableInfo expected = toTableInfo(entity); 370 final TableInfo found = TableInfo.read(db, entity.getTableName()); 371 if (!expected.equals(found)) { 372 throw new IllegalStateException( 373 "Migration failed. expected:" + expected + " , found:" + found); 374 } 375 } 376 if (mVerifyDroppedTables) { 377 // now ensure tables that should be removed are removed. 378 Cursor cursor = db.query("SELECT name FROM sqlite_master WHERE type='table'" 379 + " AND name NOT IN(?, ?, ?)", 380 new String[]{Room.MASTER_TABLE_NAME, "android_metadata", 381 "sqlite_sequence"}); 382 //noinspection TryFinallyCanBeTryWithResources 383 try { 384 while (cursor.moveToNext()) { 385 final String tableName = cursor.getString(0); 386 if (!tables.containsKey(tableName)) { 387 throw new IllegalStateException("Migration failed. Unexpected table " 388 + tableName); 389 } 390 } 391 } finally { 392 cursor.close(); 393 } 394 } 395 } 396 } 397 398 static class CreatingDelegate extends RoomOpenHelperDelegate { 399 400 CreatingDelegate(DatabaseBundle databaseBundle) { 401 super(databaseBundle); 402 } 403 404 @Override 405 protected void createAllTables(SupportSQLiteDatabase database) { 406 for (String query : mDatabaseBundle.buildCreateQueries()) { 407 database.execSQL(query); 408 } 409 } 410 411 @Override 412 protected void validateMigration(SupportSQLiteDatabase db) { 413 throw new UnsupportedOperationException("This open helper just creates the database but" 414 + " it received a migration request."); 415 } 416 } 417 418 abstract static class RoomOpenHelperDelegate extends RoomOpenHelper.Delegate { 419 final DatabaseBundle mDatabaseBundle; 420 421 RoomOpenHelperDelegate(DatabaseBundle databaseBundle) { 422 super(databaseBundle.getVersion()); 423 mDatabaseBundle = databaseBundle; 424 } 425 426 @Override 427 protected void dropAllTables(SupportSQLiteDatabase database) { 428 throw new UnsupportedOperationException("cannot drop all tables in the test"); 429 } 430 431 @Override 432 protected void onCreate(SupportSQLiteDatabase database) { 433 } 434 435 @Override 436 protected void onOpen(SupportSQLiteDatabase database) { 437 } 438 } 439 } 440