Migrar o banco de dados do Room

À medida que você adiciona e muda recursos no app, é necessário modificar as classes de entidade do Room e as tabelas de banco de dados para refletir essas mudanças. É 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.

O Room oferece suporte a opções automatizadas e manuais para a migração incremental. As migrações automáticas funcionam para a maioria das mudanças básicas de esquema, mas pode ser necessário definir manualmente os caminhos de migração em mudanças mais complexas.

Migrações automatizadas

Para declarar uma migração automática entre duas versões do banco de dados, adicione uma anotação @AutoMigration na propriedade autoMigrations em @Database:

Kotlin

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (from = 1, to = 2)
  ]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

Java

// Database class before the version update.
@Database(
  version = 1,
  entities = {User.class}
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = {User.class},
  autoMigrations = {
    @AutoMigration (from = 1, to = 2)
  }
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

Especificações da migração automática

Se o Room detectar mudanças de esquema ambíguas e não for possível gerar um plano de migração sem receber outras entradas, ele vai gerar um erro durante a compilação e solicitar que você implemente uma AutoMigrationSpec. Isso normalmente ocorre quando uma migração envolve uma destas ações:

  • Excluir ou renomear uma tabela.
  • Excluir ou renomear uma coluna.

Você pode usar a AutoMigrationSpec para fornecer ao Room as outras informações necessárias para gerar caminhos de migração corretamente. Defina uma classe estática que implemente a AutoMigrationSpec na classe RoomDatabase e inclua uma ou mais destas anotações:

Para usar a implementação de AutoMigrationSpec em uma migração automática, defina a propriedade spec na anotação @AutoMigration correspondente:

Kotlin

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  class MyAutoMigration : AutoMigrationSpec
  ...
}

Java

@Database(
  version = 2,
  entities = {AppUser.class},
  autoMigrations = {
    @AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration.class
    )
  }
)
public abstract class AppDatabase extends RoomDatabase {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  static class MyAutoMigration implements AutoMigrationSpec { }
  ...
}

Caso o app precise executar mais tarefas depois de concluir a migração automática, é possível implementar onPostMigrate(). Se você implementar esse método na AutoMigrationSpec, o Room vai chamar o método após a migração automática.

Migrações manuais

Nos casos em que uma migração envolve mudanças de esquema complexas, é possível que o Room não consiga gerar um caminho de migração adequado de forma automática. Por exemplo, se você for dividir os dados de uma tabela em duas, o Room não conseguirá determinar como realizar essa divisão. Nesses casos, é necessário definir manualmente um caminho de migração, implementando uma classe Migration.

Uma classe Migration define explicitamente um caminho de migração entre uma startVersion e uma endVersion, substituindo o método Migration.migrate(). Adicione as classes Migration ao builder do banco de dados usando o método addMigrations():

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();

Ao definir os caminhos de migração, é possível usar migrações automáticas para algumas versões e migrações manuais em outras. Caso você defina uma migração automática e uma manual para a mesma versão, o Room usa a migração manual.

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, é preciso testar suas migrações. O Room fornece um artefato Maven room-testing para auxiliar no processo de teste de migrações automáticas e manuais. No entanto, para que esse artefato funcione, é necessário exportar o esquema do banco de dados.

Exportar esquemas

O Room pode exportar as informações do esquema do banco de dados para um arquivo JSON durante a compilação.

Definir o local do esquema usando o plug-in do Gradle do Room

Para exportar o esquema com a versão 2.6.0 ou mais recente do Room, aplique o plug-in do Gradle do Room e use a extensão room para especificar o diretório do esquema.

No arquivo de build de nível superior do Gradle, defina o plug-in e a versão dele:

Groovy

plugins {
  id 'androidx.room' version '2.6.0' apply false
}

Kotlin

plugins {
  id("androidx.room") version "2.6.0" apply false
}

No arquivo de build do Gradle do projeto usando o Room, aplique o plug-in. Use a extensão room para configurar o local em que você quer que o Room grave esquemas de banco de dados.

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

room {
  schemaDirectory("$projectDir/schemas")
}

Os arquivos JSON exportados representam o histórico do esquema do banco de dados. Armazene esses arquivos no sistema de controle de versão para que o Room possa criar versões mais baixas do banco de dados para testes e ativar a geração de migração automática.

Definir o local do esquema usando a opção de processador de anotações

Para exportar o esquema na versão 2.5.2 ou anterior do Room, defina o argumento do processador de anotações room.schemaLocation usando um CommandLineArgumentProvider no arquivo app/build.gradle.

Primeiro, defina o CommandLineArgumentProvider que fornece a room.schemaLocation para o processador, conforme mostrado abaixo. Observe que o método asArguments() na classe de exemplo transmite -Aroom.schemaLocation=${schemaDir.path} para javac ou KAPT. Se você estiver usando KSP, mude para room.schemaLocation=${schemaDir.path}.

Groovy

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    // Note: If you're using KSP, change the line below to return
    // ["room.schemaLocation=${schemaDir.path}".toString()].
    return ["-Aroom.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    // Note: If you're using KSP, change the line below to return
    // listOf("room.schemaLocation=${schemaDir.path}").
    return listOf("-Aroom.schemaLocation=${schemaDir.path}")
  }
}

Em seguida, configure as opções de compilação para usar o RoomSchemaArgProvider, com o diretório de esquema especificado, verificando se ele existe primeiro:

Groovy

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

Kotlin

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

Os arquivos JSON exportados representam o histórico do esquema do banco de dados. Armazene esses arquivos no sistema de controle de versão, para que o Room possa criar versões mais antigas do banco para testes e ativar a geração de migração automática.

Testar uma única migração

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

build.gradle

Groovy

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

dependencies {
    ...
      androidTestImplementation "androidx.room:room-testing:2.6.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room:room-testing:2.6.1")
}

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 abaixo demonstra um teste para uma única migração.

Kotlin

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

    @get: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 {
            // Database has schema version 1. Insert some data using SQL queries.
            // You can't 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);

        // Database has schema version 1. Insert some data using SQL queries.
        // You can't 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, recomendamos incluir um teste que abranja todas as migrações definidas para o banco de dados do app. Isso garante que não haja discrepâncias entre uma instância de banco de dados recém-criada e outra 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)

    @get: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 validates the schema
        // once all migrations execute.
        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.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 validates 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 o Room não encontrar um caminho de migração para fazer upgrade de um banco de dados em um dispositivo para a versão atual, vai ocorrer uma IllegalStateException. Se for aceitável perder dados 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 o Room a recriar de forma destrutiva as tabelas no banco de dados do app quando ele precisar executar uma migração incremental em que não há um caminho de migração definido.

Se você quiser que o Room volte a usar a recriação destrutiva em algumas situações, confira estas alternativas para fallbackToDestructiveMigration():

  • Se ocorrerem erros em versões específicas do histórico de esquema que não possam ser resolvidos com caminhos de migração, use fallbackToDestructiveMigrationFrom(). Esse método indica que você quer que o Room use a recriação destrutiva apenas ao migrar de versões específicas.
  • Se quiser que o Room use a recriação destrutiva apenas ao migrar de uma versão de banco de dados mais recente para uma anterior, use fallbackToDestructiveMigrationOnDowngrade().

Processar os valores padrão da coluna ao fazer upgrade para o Room 2.2.0

No 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 é fazer isso diretamente em uma instrução SQL executada, que cria um valor padrão desconhecido pelo Room. Isso significa que, se um banco de dados tiver sido criado originalmente por uma versão do Room anterior à versão 2.2.0, o upgrade do app para usar o Room 2.2.0 talvez precise 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, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song entity, database 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, database 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, database 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 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 de tag no esquema do banco de dados.

Nas versões do Room anteriores à 2.2.0, essa discrepância é inofensiva. No entanto, se o app fizer upgrade para o Room 2.2.0 ou versões mais recentes e mudar a classe de entidade Song de modo a incluir um valor padrão para a tag usando a anotação @ColumnInfo, o 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 o 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 descarte e recriação (link em inglês) e adicione os valores padrão necessários às colunas atuais.

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");
    }
};