Como migrar bancos de dados Room

À medida que você adiciona e altera recursos no seu app, é necessário modificar as classes de entidade para refletir essas alterações. Quando o usuário atualiza seu app para a versão mais recente, não é bom que ele perca todos os dados existentes, especialmente se não for possível recuperá-los a partir de um servidor remoto.

A biblioteca de persistência do Room permite que você crie classes Migration para preservar os dados do usuário dessa maneira. Cada classe Migration especifica uma startVersion e uma endVersion. No tempo de execução, o Room executa cada método migrate() da classe Migration, usando a ordem correta para migrar o banco de dados para uma versão mais recente.

Kotlin

    val MIGRATION_1_2 = object : Migration(1, 2) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
                    "PRIMARY KEY(`id`))")
        }
    }

    val MIGRATION_2_3 = object : Migration(2, 3) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
        }
    }

    Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
            .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()
    

Java

    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
                    + "`name` TEXT, PRIMARY KEY(`id`))");
        }
    };

    static final Migration MIGRATION_2_3 = new Migration(2, 3) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            database.execSQL("ALTER TABLE Book "
                    + " ADD COLUMN pub_year INTEGER");
        }
    };

    Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
            .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();
    

Cuidado: para manter sua lógica de migração funcionando da forma esperada, use consultas completas em vez de referências constantes que representam as consultas.

Após a conclusão do processo de migração, o Room valida o esquema para garantir que a migração tenha ocorrido corretamente. Caso o Room encontre um problema, uma exceção contendo as informações não correspondentes é gerada.

Testar migrações

Criar migrações não é trivial, e criações erradas podem causar falhas repetidas no app. Para preservar a estabilidade do app, teste suas migrações antecipadamente. O Room oferece um artefato de testes Maven para ajudar nesse processo. No entanto, para que esse artefato funcione, é necessário exportar o esquema do seu banco de dados.

Exportar esquemas

Após a compilação, o Room exporta as informações do esquema do banco de dados para um arquivo JSON. Para exportar o esquema, defina a propriedade do processador de anotações room.schemaLocation de forma correta no arquivo build.gradle, conforme mostrado no seguinte snippet de código:

build.gradle

    android {
        ...
        defaultConfig {
            ...
            javaCompileOptions {
                annotationProcessorOptions {
                    arguments = ["room.schemaLocation":
                                 "$projectDir/schemas".toString()]
                }
            }
        }
    }
    

Armazene os arquivos JSON exportados (que representam o histórico do esquema do seu banco de dados) no sistema de controle da versão, porque ele permite que o Room crie versões mais antigas do banco de dados para testes.

Para testar essas migrações, adicione o artefato Maven android.arch.persistence.room:testing do Room nas dependências de teste e adicione o local do esquema como uma pasta de recursos, conforme mostrado no seguinte snippet de código:

build.gradle

    android {
        ...
        sourceSets {
            androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
        }
    }
    

O pacote de testes fornece uma classe MigrationTestHelper, que pode ler esses arquivos de esquema. Ele também implementa a interface JUnit4 TestRule, para que ele possa gerenciar bancos de dados criados.

Um exemplo de teste de migração é exibido no seguinte snippet de código:

Kotlin

    @RunWith(AndroidJUnit4::class)
    class MigrationTest {
        private val TEST_DB = "migration-test"

        @Rule
        val helper: MigrationTestHelper = MigrationTestHelper(
                InstrumentationRegistry.getInstrumentation(),
                MigrationDb::class.java.canonicalName,
                FrameworkSQLiteOpenHelperFactory()
        )

        @Test
        @Throws(IOException::class)
        fun migrate1To2() {
            var db = helper.createDatabase(TEST_DB, 1).apply {
                // db has schema version 1. insert some data using SQL queries.
                // You cannot use DAO classes because they expect the latest schema.
                execSQL(...)

                // Prepare for the next version.
                close()
            }

            // Re-open the database with version 2 and provide
            // MIGRATION_1_2 as the migration process.
            db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

            // MigrationTestHelper automatically verifies the schema changes,
            // but you need to validate that the data was migrated properly.
        }
    }
    

Java

    @RunWith(AndroidJUnit4.class)
    public class MigrationTest {
        private static final String TEST_DB = "migration-test";

        @Rule
        public MigrationTestHelper helper;

        public MigrationTest() {
            helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                    MigrationDb.class.getCanonicalName(),
                    new FrameworkSQLiteOpenHelperFactory());
        }

        @Test
        public void migrate1To2() throws IOException {
            SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

            // db has schema version 1. insert some data using SQL queries.
            // You cannot use DAO classes because they expect the latest schema.
            db.execSQL(...);

            // Prepare for the next version.
            db.close();

            // Re-open the database with version 2 and provide
            // MIGRATION_1_2 as the migration process.
            db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

            // MigrationTestHelper automatically verifies the schema changes,
            // but you need to validate that the data was migrated properly.
        }
    }
    

Testar todas as migrações

O exemplo acima mostrou como testar uma migração incremental de uma versão para outra. Contudo, é recomendável fazer um teste que passe por todas as migrações. Esse tipo de teste é útil para capturar qualquer discrepância criada por um banco de dados que tenha passado pelo caminho de migração em comparação a um criado recentemente.

Um exemplo de teste de todas as migrações é mostrado no seguinte snippet de código:

Kotlin

    @RunWith(AndroidJUnit4::class)
    class MigrationTest {
        private val TEST_DB = "migration-test"

        // Array of all migrations
        private val ALL_MIGRATIONS = arrayOf(
                MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

        @Rule
        val helper: MigrationTestHelper = MigrationTestHelper(
                InstrumentationRegistry.getInstrumentation(),
                AppDatabase::class.java.canonicalName,
                FrameworkSQLiteOpenHelperFactory()
        )

        @Test
        @Throws(IOException::class)
        fun migrateAll() {
            // Create earliest version of the database.
            helper.createDatabase(TEST_DB, 1).apply {
                close()
            }

            // Open latest version of the database. Room will validate the schema
            // once all migrations execute.
            Room.databaseBuilder(
                    InstrumentationRegistry.getInstrumentation().getTargetContext(),
                    AppDatabase.class,
                    TEST_DB
            ).addMigrations(*ALL_MIGRATIONS).build().apply {
                getOpenHelper().getWritableDatabase()
                close()
            }
        }
    }
    

Java

    @RunWith(AndroidJUnit4.class)
    public class MigrationTest {
        private static final String TEST_DB = "migration-test";

        @Rule
        public MigrationTestHelper helper;

        public MigrationTest() {
            helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                    AppDatabase.class.getCanonicalName(),
                    new FrameworkSQLiteOpenHelperFactory());
        }

        @Test
        public void migrateAll() throws IOException {
            // Create earliest version of the database.
            SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
            db.close();

            // Open latest version of the database. Room will validate the schema
            // once all migrations execute.
            AppDatabase appDb = Room.databaseBuilder(
                    InstrumentationRegistry.getInstrumentation().getTargetContext(),
                    AppDatabase.class,
                    TEST_DB)
                    .addMigrations(ALL_MIGRATIONS).build()
            appDb.getOpenHelper().getWritableDatabase();
            appDb.close();
        }

        // Array of all migrations
        private static final Migration[] ALL_MIGRATIONS = new Migration[]{
                MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
    }
    

Processar os caminhos de migração ausentes corretamente

Depois de atualizar os esquemas do seu banco de dados, é possível que alguns bancos de dados no dispositivo ainda usem uma versão de esquema mais antiga. Se o Room não conseguir encontrar uma regra de migração para a atualização do banco de dados do dispositivo da versão mais antiga para a versão atual, ocorrerá uma IllegalStateException.

Para evitar que o app deixe de funcionar quando isso ocorrer, chame o criador de método fallbackToDestructiveMigration() ao criar o banco de dados:

Kotlin

    Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
            .fallbackToDestructiveMigration()
            .build()
    

Java

    Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
            .fallbackToDestructiveMigration()
            .build();
    

Ao incluir essa cláusula na lógica de criação do banco de dados do seu app, você solicita que o Room recrie as tabelas de banco de dados do app de forma destrutiva quando faltar um caminho de migração entre as versões do esquema.

A lógica de recriação substituta destrutiva inclui várias outras opções:

  • Se ocorrerem erros em versões específicas do histórico de esquema que não podem ser resolvidos com caminhos de migração, use fallbackToDestructiveMigrationFrom(). Esse método indica que você quer que o Room use a lógica substituta somente nos casos em que o banco de dados tenta migrar de uma dessas versões problemáticas.
  • Para executar uma recriação destrutiva somente ao tentar fazer downgrade de esquema, use fallbackToDestructiveMigrationOnDowngrade().

Como processar o valor padrão da coluna ao fazer upgrade para o Room 2.2.0

O Room 2.2.0 inclui compatibilidade com a definição de um valor padrão da coluna por @ColumnInfo(defaultValue = "..."). O valor padrão de uma coluna é uma parte importante do esquema do banco de dados e da sua entidade. Ele é validado pelo Room durante uma migração. Se seu banco de dados tiver sido criado anteriormente por uma versão do Room anterior à versão 2.2.0, talvez seja necessário fornecer uma migração para valores padrão adicionados previamente que não são conhecidos pelo Room.

Por exemplo, na primeira versão de um banco de dados, há uma entidade Song declarada como:

Entidade de música, versão 1 do banco de dados, Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
    }
    
Para uma segunda versão do mesmo banco de dados, uma nova coluna NOT NULL é adicionada:

Entidade da música, versão 2 do banco de dados, Room 2.1.0

Kotlin

    @Entity
    data class Song(
        @PrimaryKey
        val id: Long,
        val title: String,
        val tag: String // added in version 2
    )
    

Java

    @Entity
    public class Song {
        @PrimaryKey
        final long id;
        final String title;
        @NonNull
        final String tag; // added in version 2
    }
    
Em conjunto com uma migração da versão 1 para a versão 2:

Migração de 1 para 2, Room 2.1.0

Kotlin

    val MIGRATION_1_2 = object : Migration(1, 2) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL(
                "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''")
        }
    }
    

Java

    static final Migration MIGRATION_1_2 = new Migration(1, 2) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            database.execSQL(
                "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''");
        }
    };
    
Esse tipo de migração não afeta versões do Room anteriores à versão 2.2.0, mas causará problemas quando o Room for atualizado, e um valor padrão for adicionado à mesma coluna por @ColumnInfo. Ao usar ALTER TABLE, a entidade Song é alterada não só para conter a nova coluna tag, mas também para conter um valor padrão. No entanto, as versões do Room anteriores à versão 2.2.0 não são afetadas por essas alterações, o que resulta em uma incompatibilidade de esquema entre o usuário do app que fez uma instalação recente e o usuário que migrou da versão 1 para a 2. Especificamente, um banco de dados recém-criado na versão 2 não incluiria o valor padrão.

Para essa situação, uma migração precisa ser fornecida para que o esquema do banco de dados seja consistente entre os usuários do aplicativo, já que o Room 2.2.0 validará os valores padrão assim que forem definidos na classe da entidade. O tipo de migração necessário envolve:

  • declarar o valor padrão na classe de entidade usando @ColumnInfo;
  • aumentar a versão do banco de dados em um;
  • proporcionar uma migração que implemente a estratégia manter e recriar (link em inglês) que permite adicionar um valor padrão a uma coluna já criada.

O snippet de código a seguir mostra um exemplo de implementação de migração que mantém e recria a tabela Song.

Migração de 2 para 3, Room 2.2.0

Kotlin

    val MIGRATION_2_3 = object : Migration(2, 3) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("""
                    CREATE TABLE new_Song (
                        id INTEGER PRIMARY KEY NOT NULL,
                        name TEXT,
                        tag TEXT NOT NULL DEFAULT ''
                    )
                    """.trimIndent())
            database.execSQL("""
                    INSERT INTO new_Song (id, name, tag)
                    SELECT id, name, tag FROM Song
                    """.trimIndent())
            database.execSQL("DROP TABLE Song")
            database.execSQL("ALTER TABLE new_Song RENAME TO Song")
        }
    }
    

Java

    static final Migration MIGRATION_2_3 = new Migration(2, 3) {
        @Override
        public void migrate(SupportSQLiteDatabase database) {
            database.execSQL("CREATE TABLE new_Song (" +
                    "id INTEGER PRIMARY KEY NOT NULL," +
                    "name TEXT," +
                    "tag TEXT NOT NULL DEFAULT '')");
            database.execSQL("INSERT INTO new_Song (id, name, tag) " +
                    "SELECT id, name, tag FROM Song");
            database.execSQL("DROP TABLE Song");
            database.execSQL("ALTER TABLE new_Song RENAME TO Song");
        }
    };