Home | History | Annotate | Download | only in testing
      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