O Google tem o compromisso de promover a igualdade racial para as comunidades negras. Saiba como.

Como migrar bancos de dados Room

À medida que você adiciona e muda recursos no seu app, é necessário modificar as classes de entidade da Room para refletir essas mudanças. No entanto, é importante preservar os dados do usuário que já estão no banco de dados do dispositivo quando uma atualização de app muda o esquema dele.

A biblioteca de persistência Room é compatível com migrações incrementais com as classes Migration para atender a essa necessidade. Cada subclasse Migration define um caminho de migração entre um startVersion e um endVersion, substituindo o método Migration.migrate(). Quando uma atualização de app exige um upgrade da versão do banco de dados, a Room executa o método migrate() de uma ou mais subclasses Migration para migrar o banco de dados para a versão mais recente no tempo de execução:

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 referenciar constantes que representem as consultas.

Ao concluir o processo de migração, a Room valida o esquema para garantir que a migração seja bem-sucedida. Caso a Room encontre um problema, uma exceção contendo as informações não correspondentes é gerada.

Para ver mais informações, consulte Amostra de migração da Room (link em inglês) no GitHub.

Testar migrações

As migrações costumam ser complexas, e uma migração definida de forma incorreta pode causar falhas no seu app. Para preservar a estabilidade do seu app, é preciso testar suas migrações. A Room fornece um artefato Maven room-testing para auxiliar no processo de teste. No entanto, para que esse artefato funcione, é necessário exportar o esquema do seu banco de dados.

Exportar esquemas

A Room pode exportar as informações do esquema do banco de dados para um arquivo JSON no momento da compilação. Para exportar o esquema, defina a propriedade do processador de anotações room.schemaLocation no seu arquivo app/build.gradle:

build.gradle

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

Os arquivos JSON exportados representam o histórico do seu esquema do banco de dados. É necessário armazenar esses arquivos no sistema de controle de versão, porque ele permite que a Room crie versões mais antigas do banco de dados para testes.

Testar uma única migração

Antes de testar suas migrações, adicione o artefato Maven androidx.room:room-testing da Room às dependências de teste e adicione o local do esquema exportado como uma pasta de recursos:

build.gradle

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

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

O exemplo a seguir demonstra um teste para uma única migração.

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

Embora seja possível testar uma única migração incremental, é recomendável incluir um teste que abranja todas as migrações definidas para o banco de dados do seu app. Isso garante que não haja discrepâncias entre uma instância de banco de dados recém-criada e uma instância antiga que seguiu os caminhos de migração definidos.

O exemplo a seguir demonstra um teste para todas as migrações definidas.

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

Se a Room não conseguir encontrar um caminho de migração para fazer upgrade de um banco de dados já existente em um dispositivo para a versão atual, ocorrerá um IllegalStateException. Se for aceitável perder dados já existentes quando um caminho de migração estiver ausente, chame o método do builder 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();

Esse método instrui a Room a recriar de forma destrutiva as tabelas no banco de dados do app quando ela precisa executar uma migração incremental em que não há um caminho de migração definido.

Se você quiser que a Room volte para a recreação destrutiva em determinadas situações, existem seguintes alternativas para fallbackToDestructiveMigration():

  • 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 a Room retorne à recreação destrutiva apenas ao migrar de versões específicas.
  • Se quiser que a Room retorne à recreação destrutiva apenas ao migrar de uma versão de banco de dados posterior para uma anterior, use fallbackToDestructiveMigrationOnDowngrade().

Processar o valor padrão da coluna ao fazer upgrade para a Room 2.2.0

Na Room 2.2.0 e versões mais recentes, é possível definir um valor padrão para uma coluna usando a anotação @ColumnInfo(defaultValue = "..."). Nas versões anteriores à 2.2.0, a única maneira de definir um valor padrão para uma coluna é defini-la diretamente em uma instrução SQL executada, que cria um valor padrão desconhecido pela Room. Isso significa que, se um banco de dados tiver sido criado originalmente por uma versão da Room anterior à versão 2.2.0, o upgrade do app para usar a Room 2.2.0 talvez precisará que você disponibilize um caminho de migração especial para valores padrão definidos sem usar as APIs dele.

Por exemplo, suponha que a versão 1 de um banco de dados defina uma entidade Song:

Kotlin

// Song Entity, DB Version 1, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song Entity, DB Version 1, Room 2.1.0
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
}

Suponha também que a versão 2 do mesmo banco de dados adicione uma nova coluna NOT NULL e defina um caminho de migração da versão 1 para a versão 2:

Kotlin

// Song Entity, DB Version 2, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String,
    val tag: String // Added in version 2.
)

// Migration from 1 to 2, Room 2.1.0
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

// Song Entity, DB Version 2, Room 2.1.0
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
    @NonNull
    final String tag; // Added in version 2.
}

// Migration from 1 to 2, Room 2.1.0
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 ''");
    }
};

Isso causa uma discrepância na tabela subjacente entre as atualizações e as novas instalações do app. Como o valor padrão da coluna tag só é declarado no caminho de migração da versão 1 para a versão 2, os usuários que instalaram o app a partir da versão 2 não têm o valor padrão para tag no esquema do banco de dados.

Nas versões da Room anteriores à 2.2.0, essa discrepância é inofensiva. No entanto, se o app fizer upgrade para a Room 2.2.0 ou versões mais recentes e alterar a classe de entidade Song para incluir um valor padrão para tag usando a anotação @ColumnInfo, a Room poderá ver essa discrepância. Isso gera falhas nas validações de esquema.

Para garantir que o esquema do banco de dados seja consistente com todos os usuários quando os valores padrão da coluna são declarados nos caminhos de migração anteriores, faça o seguinte na primeira vez que fizer upgrade do app para usar a Room 2.2.0 ou versões mais recentes:

  1. Declare os valores padrão da coluna nas respectivas classes de entidade usando a anotação @ColumnInfo.
  2. Aumente o número da versão do banco de dados em um.
  3. Defina um caminho de migração para a nova versão que implementa a estratégia de queda e recriação para adicionar os valores padrão necessários às colunas já existentes.

O exemplo a seguir demonstra esse processo.

Kotlin

// Migration from 2 to 3, Room 2.2.0
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

// Migration from 2 to 3, Room 2.2.0
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");
    }
};