| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.arch.persistence.room.testing; |
| |
| import android.app.Instrumentation; |
| import android.arch.persistence.db.SupportSQLiteDatabase; |
| import android.arch.persistence.db.SupportSQLiteOpenHelper; |
| import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory; |
| import android.arch.persistence.room.DatabaseConfiguration; |
| import android.arch.persistence.room.Room; |
| import android.arch.persistence.room.RoomDatabase; |
| import android.arch.persistence.room.RoomOpenHelper; |
| import android.arch.persistence.room.migration.Migration; |
| import android.arch.persistence.room.migration.bundle.DatabaseBundle; |
| import android.arch.persistence.room.migration.bundle.EntityBundle; |
| import android.arch.persistence.room.migration.bundle.FieldBundle; |
| import android.arch.persistence.room.migration.bundle.ForeignKeyBundle; |
| import android.arch.persistence.room.migration.bundle.SchemaBundle; |
| import android.arch.persistence.room.util.TableInfo; |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.util.Log; |
| |
| import org.junit.rules.TestWatcher; |
| import org.junit.runner.Description; |
| |
| import java.io.File; |
| import java.io.FileNotFoundException; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.lang.ref.WeakReference; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * A class that can be used in your Instrumentation tests that can create the database in an |
| * older schema. |
| * <p> |
| * You must copy the schema json files (created by passing {@code room.schemaLocation} argument |
| * into the annotation processor) into your test assets and pass in the path for that folder into |
| * the constructor. This class will read the folder and extract the schemas from there. |
| * <pre> |
| * android { |
| * defaultConfig { |
| * javaCompileOptions { |
| * annotationProcessorOptions { |
| * arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] |
| * } |
| * } |
| * } |
| * sourceSets { |
| * androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) |
| * } |
| * } |
| * </pre> |
| */ |
| public class MigrationTestHelper extends TestWatcher { |
| private static final String TAG = "MigrationTestHelper"; |
| private final String mAssetsFolder; |
| private final SupportSQLiteOpenHelper.Factory mOpenFactory; |
| private List<WeakReference<SupportSQLiteDatabase>> mManagedDatabases = new ArrayList<>(); |
| private List<WeakReference<RoomDatabase>> mManagedRoomDatabases = new ArrayList<>(); |
| private boolean mTestStarted; |
| private Instrumentation mInstrumentation; |
| |
| /** |
| * Creates a new migration helper. It uses the Instrumentation context to load the schema |
| * (falls back to the app resources) and the target context to create the database. |
| * |
| * @param instrumentation The instrumentation instance. |
| * @param assetsFolder The asset folder in the assets directory. |
| */ |
| public MigrationTestHelper(Instrumentation instrumentation, String assetsFolder) { |
| this(instrumentation, assetsFolder, new FrameworkSQLiteOpenHelperFactory()); |
| } |
| |
| /** |
| * Creates a new migration helper. It uses the Instrumentation context to load the schema |
| * (falls back to the app resources) and the target context to create the database. |
| * |
| * @param instrumentation The instrumentation instance. |
| * @param assetsFolder The asset folder in the assets directory. |
| * @param openFactory Factory class that allows creation of {@link SupportSQLiteOpenHelper} |
| */ |
| public MigrationTestHelper(Instrumentation instrumentation, String assetsFolder, |
| SupportSQLiteOpenHelper.Factory openFactory) { |
| mInstrumentation = instrumentation; |
| if (assetsFolder.endsWith("/")) { |
| assetsFolder = assetsFolder.substring(0, assetsFolder.length() - 1); |
| } |
| mAssetsFolder = assetsFolder; |
| mOpenFactory = openFactory; |
| } |
| |
| @Override |
| protected void starting(Description description) { |
| super.starting(description); |
| mTestStarted = true; |
| } |
| |
| /** |
| * Creates the database in the given version. |
| * If the database file already exists, it tries to delete it first. If delete fails, throws |
| * an exception. |
| * |
| * @param name The name of the database. |
| * @param version The version in which the database should be created. |
| * @return A database connection which has the schema in the requested version. |
| * @throws IOException If it cannot find the schema description in the assets folder. |
| */ |
| @SuppressWarnings("SameParameterValue") |
| public SupportSQLiteDatabase createDatabase(String name, int version) throws IOException { |
| File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); |
| if (dbPath.exists()) { |
| Log.d(TAG, "deleting database file " + name); |
| if (!dbPath.delete()) { |
| throw new IllegalStateException("there is a database file and i could not delete" |
| + " it. Make sure you don't have any open connections to that database" |
| + " before calling this method."); |
| } |
| } |
| SchemaBundle schemaBundle = loadSchema(version); |
| RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); |
| DatabaseConfiguration configuration = new DatabaseConfiguration( |
| mInstrumentation.getTargetContext(), name, mOpenFactory, container, null, true, |
| true); |
| RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, |
| new CreatingDelegate(schemaBundle.getDatabase()), |
| schemaBundle.getDatabase().getIdentityHash()); |
| return openDatabase(name, version, roomOpenHelper); |
| } |
| |
| /** |
| * Runs the given set of migrations on the provided database. |
| * <p> |
| * It uses the same algorithm that Room uses to choose migrations so the migrations instances |
| * that are provided to this method must be sufficient to bring the database from current |
| * version to the desired version. |
| * <p> |
| * After the migration, the method validates the database schema to ensure that migration |
| * result matches the expected schema. Handling of dropped tables depends on the |
| * {@code validateDroppedTables} argument. If set to true, the verification will fail if it |
| * finds a table that is not registered in the Database. If set to false, extra tables in the |
| * database will be ignored (this is the runtime library behavior). |
| * |
| * @param name The database name. You must first create this database via |
| * {@link #createDatabase(String, int)}. |
| * @param version The final version after applying the migrations. |
| * @param validateDroppedTables If set to true, validation will fail if the database has |
| * unknown |
| * tables. |
| * @param migrations The list of available migrations. |
| * @throws IOException If it cannot find the schema for {@code toVersion}. |
| * @throws IllegalStateException If the schema validation fails. |
| */ |
| public SupportSQLiteDatabase runMigrationsAndValidate(String name, int version, |
| boolean validateDroppedTables, Migration... migrations) throws IOException { |
| File dbPath = mInstrumentation.getTargetContext().getDatabasePath(name); |
| if (!dbPath.exists()) { |
| throw new IllegalStateException("Cannot find the database file for " + name + ". " |
| + "Before calling runMigrations, you must first create the database via " |
| + "createDatabase."); |
| } |
| SchemaBundle schemaBundle = loadSchema(version); |
| RoomDatabase.MigrationContainer container = new RoomDatabase.MigrationContainer(); |
| container.addMigrations(migrations); |
| DatabaseConfiguration configuration = new DatabaseConfiguration( |
| mInstrumentation.getTargetContext(), name, mOpenFactory, container, null, true, |
| true); |
| RoomOpenHelper roomOpenHelper = new RoomOpenHelper(configuration, |
| new MigratingDelegate(schemaBundle.getDatabase(), validateDroppedTables), |
| schemaBundle.getDatabase().getIdentityHash()); |
| return openDatabase(name, version, roomOpenHelper); |
| } |
| |
| private SupportSQLiteDatabase openDatabase(String name, int version, |
| RoomOpenHelper roomOpenHelper) { |
| SupportSQLiteOpenHelper.Configuration config = |
| SupportSQLiteOpenHelper.Configuration |
| .builder(mInstrumentation.getTargetContext()) |
| .callback(roomOpenHelper) |
| .name(name) |
| .version(version) |
| .build(); |
| SupportSQLiteDatabase db = mOpenFactory.create(config).getWritableDatabase(); |
| mManagedDatabases.add(new WeakReference<>(db)); |
| return db; |
| } |
| |
| @Override |
| protected void finished(Description description) { |
| super.finished(description); |
| for (WeakReference<SupportSQLiteDatabase> dbRef : mManagedDatabases) { |
| SupportSQLiteDatabase db = dbRef.get(); |
| if (db != null && db.isOpen()) { |
| try { |
| db.close(); |
| } catch (Throwable ignored) { |
| } |
| } |
| } |
| for (WeakReference<RoomDatabase> dbRef : mManagedRoomDatabases) { |
| final RoomDatabase roomDatabase = dbRef.get(); |
| if (roomDatabase != null) { |
| roomDatabase.close(); |
| } |
| } |
| } |
| |
| /** |
| * Registers a database connection to be automatically closed when the test finishes. |
| * <p> |
| * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via |
| * {@link org.junit.Rule Rule} annotation. |
| * |
| * @param db The database connection that should be closed after the test finishes. |
| */ |
| public void closeWhenFinished(SupportSQLiteDatabase db) { |
| if (!mTestStarted) { |
| throw new IllegalStateException("You cannot register a database to be closed before" |
| + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" |
| + " test rule? (@Rule)"); |
| } |
| mManagedDatabases.add(new WeakReference<>(db)); |
| } |
| |
| /** |
| * Registers a database connection to be automatically closed when the test finishes. |
| * <p> |
| * This only works if {@code MigrationTestHelper} is registered as a Junit test rule via |
| * {@link org.junit.Rule Rule} annotation. |
| * |
| * @param db The RoomDatabase instance which holds the database. |
| */ |
| public void closeWhenFinished(RoomDatabase db) { |
| if (!mTestStarted) { |
| throw new IllegalStateException("You cannot register a database to be closed before" |
| + " the test starts. Maybe you forgot to annotate MigrationTestHelper as a" |
| + " test rule? (@Rule)"); |
| } |
| mManagedRoomDatabases.add(new WeakReference<>(db)); |
| } |
| |
| private SchemaBundle loadSchema(int version) throws IOException { |
| try { |
| return loadSchema(mInstrumentation.getContext(), version); |
| } catch (FileNotFoundException testAssetsIOExceptions) { |
| Log.w(TAG, "Could not find the schema file in the test assets. Checking the" |
| + " application assets"); |
| try { |
| return loadSchema(mInstrumentation.getTargetContext(), version); |
| } catch (FileNotFoundException appAssetsException) { |
| // throw the test assets exception instead |
| throw new FileNotFoundException("Cannot find the schema file in the assets folder. " |
| + "Make sure to include the exported json schemas in your test assert " |
| + "inputs. See " |
| + "https://developer.android.com/topic/libraries/architecture/" |
| + "room.html#db-migration-testing for details. Missing file: " |
| + testAssetsIOExceptions.getMessage()); |
| } |
| } |
| } |
| |
| private SchemaBundle loadSchema(Context context, int version) throws IOException { |
| InputStream input = context.getAssets().open(mAssetsFolder + "/" + version + ".json"); |
| return SchemaBundle.deserialize(input); |
| } |
| |
| private static TableInfo toTableInfo(EntityBundle entityBundle) { |
| return new TableInfo(entityBundle.getTableName(), toColumnMap(entityBundle), |
| toForeignKeys(entityBundle.getForeignKeys())); |
| } |
| |
| private static Set<TableInfo.ForeignKey> toForeignKeys( |
| List<ForeignKeyBundle> bundles) { |
| if (bundles == null) { |
| return Collections.emptySet(); |
| } |
| Set<TableInfo.ForeignKey> result = new HashSet<>(bundles.size()); |
| for (ForeignKeyBundle bundle : bundles) { |
| result.add(new TableInfo.ForeignKey(bundle.getTable(), |
| bundle.getOnDelete(), bundle.getOnUpdate(), |
| bundle.getColumns(), bundle.getReferencedColumns())); |
| } |
| return result; |
| } |
| |
| private static Map<String, TableInfo.Column> toColumnMap(EntityBundle entity) { |
| Map<String, TableInfo.Column> result = new HashMap<>(); |
| for (FieldBundle bundle : entity.getFields()) { |
| TableInfo.Column column = toColumn(entity, bundle); |
| result.put(column.name, column); |
| } |
| return result; |
| } |
| |
| private static TableInfo.Column toColumn(EntityBundle entity, FieldBundle field) { |
| return new TableInfo.Column(field.getColumnName(), field.getAffinity(), |
| field.isNonNull(), findPrimaryKeyPosition(entity, field)); |
| } |
| |
| private static int findPrimaryKeyPosition(EntityBundle entity, FieldBundle field) { |
| List<String> columnNames = entity.getPrimaryKey().getColumnNames(); |
| int i = 0; |
| for (String columnName : columnNames) { |
| i++; |
| if (field.getColumnName().equalsIgnoreCase(columnName)) { |
| return i; |
| } |
| } |
| return 0; |
| } |
| |
| class MigratingDelegate extends RoomOpenHelperDelegate { |
| private final boolean mVerifyDroppedTables; |
| |
| MigratingDelegate(DatabaseBundle databaseBundle, boolean verifyDroppedTables) { |
| super(databaseBundle); |
| mVerifyDroppedTables = verifyDroppedTables; |
| } |
| |
| @Override |
| protected void createAllTables(SupportSQLiteDatabase database) { |
| throw new UnsupportedOperationException("Was expecting to migrate but received create." |
| + "Make sure you have created the database first."); |
| } |
| |
| @Override |
| protected void validateMigration(SupportSQLiteDatabase db) { |
| final Map<String, EntityBundle> tables = mDatabaseBundle.getEntitiesByTableName(); |
| for (EntityBundle entity : tables.values()) { |
| final TableInfo expected = toTableInfo(entity); |
| final TableInfo found = TableInfo.read(db, entity.getTableName()); |
| if (!expected.equals(found)) { |
| throw new IllegalStateException( |
| "Migration failed. expected:" + expected + " , found:" + found); |
| } |
| } |
| if (mVerifyDroppedTables) { |
| // now ensure tables that should be removed are removed. |
| Cursor cursor = db.query("SELECT name FROM sqlite_master WHERE type='table'" |
| + " AND name NOT IN(?, ?, ?)", |
| new String[]{Room.MASTER_TABLE_NAME, "android_metadata", |
| "sqlite_sequence"}); |
| //noinspection TryFinallyCanBeTryWithResources |
| try { |
| while (cursor.moveToNext()) { |
| final String tableName = cursor.getString(0); |
| if (!tables.containsKey(tableName)) { |
| throw new IllegalStateException("Migration failed. Unexpected table " |
| + tableName); |
| } |
| } |
| } finally { |
| cursor.close(); |
| } |
| } |
| } |
| } |
| |
| static class CreatingDelegate extends RoomOpenHelperDelegate { |
| |
| CreatingDelegate(DatabaseBundle databaseBundle) { |
| super(databaseBundle); |
| } |
| |
| @Override |
| protected void createAllTables(SupportSQLiteDatabase database) { |
| for (String query : mDatabaseBundle.buildCreateQueries()) { |
| database.execSQL(query); |
| } |
| } |
| |
| @Override |
| protected void validateMigration(SupportSQLiteDatabase db) { |
| throw new UnsupportedOperationException("This open helper just creates the database but" |
| + " it received a migration request."); |
| } |
| } |
| |
| abstract static class RoomOpenHelperDelegate extends RoomOpenHelper.Delegate { |
| final DatabaseBundle mDatabaseBundle; |
| |
| RoomOpenHelperDelegate(DatabaseBundle databaseBundle) { |
| mDatabaseBundle = databaseBundle; |
| } |
| |
| @Override |
| protected void dropAllTables(SupportSQLiteDatabase database) { |
| throw new UnsupportedOperationException("cannot drop all tables in the test"); |
| } |
| |
| @Override |
| protected void onCreate(SupportSQLiteDatabase database) { |
| } |
| |
| @Override |
| protected void onOpen(SupportSQLiteDatabase database) { |
| } |
| } |
| } |