fbpx

Blog

Миграция версий базы данных в Room

В этой статье рассмотрим миграции версий базы данных на примере использования Room и научимся тестировать корректность миграций.

Для чего нужны миграции?

Начнём с того, что же такое миграция БД? Если кратко, то миграция – это переход от одной структуры БД к другой без потери консистентности данных. Простой пример. У вас есть приложение, которое помогает пользователю создавать список дел на день или неделю. Вскоре, после прочтения отзывов в Google Play, вы увидели, что пользователи хотели бы еще ставить дедлайн или дату к которой они должны закончить, ту или иную задачу. Недолго думая, вы создаёте еще одно поле и соответственно столбец и таким образом обновляете базу данных. Но теперь представьте, пользователи, уже использующие предыдущую версию вашего приложения после обновления на новую попытаются добавить дату к списку дел…. И если вы не подумали заранее об обновлении структуры старой базы данных на новую, то при попытке сохранить список дел с дедлайном ваше приложение упадёт с исключением. Всё дело в том, что схема базы данных предыдущего приложения не имеет столбец с датой. Поэтому при попытке сохранить задачу с датой, Room выбросит исключение о том, что такого столбца для записи даты не существует. Надеюсь, теперь стало ясно зачем нужны миграции, теперь, давайте на примере рассмотрим как это сделать.

Создание проекта и добавление необходимых библиотек

Создадите новый проект и добавьте следующие библиотеки:

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.core:core-ktx:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'

    // Room components
    implementation "androidx.room:room-runtime:$rootProject.roomVersion"
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
    kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.androidxArchVersion"

    // Testing
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"

    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"

    androidTestImplementation 'androidx.test:runner:1.2.0'
    androidTestImplementation "com.natpryce:hamkrest:1.7.0.0"
}

Создание базы данных и таблицы Task

Для начала, создадим базу данных Room, в которой будет храниться одна единственная сущность Task, для нашего приложения списка дел. Если вы не использовали Room до этого, то рекомендую ознакомиться с мини курсом по Room по ссылке. Код проекта вы можете скачать по ссылке на GitHub.

Для того, чтобы создать БД и таблицу, напишите следующий код

@Database(entities = [TaskEntity::class], version = 1)
abstract class ToDoDatabase : RoomDatabase() {
    companion object {

        fun newTestDatabase(context: Context) = Room.inMemoryDatabaseBuilder(
            context,
            ToDoDatabase::class.java
        ).build()
    }

    abstract fun tasks(): TaskEntity
}
@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey
    val id: String,
    val title: String,
    val shortDescription: String,
    val fullDescption: Int
)

Мы создали БД и сущность, которая будет соответствовать таблице tasks в нашей базе даных. Кроме этого необходимо создать Data Access Object (DAO) который будет использоваться для запросов получения данных и сохранения.

@Dao
interface TodoDao {
    @Query("SELECT * FROM tasks")
    fun loadAll(): List<TaskEntity>

    @Insert
    fun insert(task: TaskEntity)

    @Update
    fun update(task: TaskEntity)

    @Delete
    fun delete(vararg tasks: TaskEntity)
}

Готово! Мы создали простую БД для нашего будущего приложения и теперь давайте проверим, как все работает.

Пишем тесты для проверки работы БД

Теперь, давайте напишем тесты, чтобы проверить, что задачи вставляются в базу данных. Для этого в app/src/androidTest/java/ru/androidschool/migrations необходимо создать класс TodoDaoTest.kt в котором мы и будем создавать тесты. Обратите внимание, что класс тестов находится в androidTest. Создайте класс TodoDaoTest как описано ниже:

@RunWith(AndroidJUnit4::class)
class TodoDaoTest {

    private val db = Room.inMemoryDatabaseBuilder(
        InstrumentationRegistry.getInstrumentation().targetContext,
        ToDoDatabase::class.java
    ).build()

    private val underTest = db.tasks()
}

Теперь, давайте добавим тест, проверяющий вставку и удаление записи в таблицу, которая хранит задачи. Для этого добавьте метод insertAndDelete()

    @Test
    fun insertAndDelete() {
        // Проверяем что вначале таблица пуста
        assertThat(underTest.loadAll(), isEmpty)

        // Создаём задачу
        val entity = TaskEntity(
            id = UUID.randomUUID().toString(),
            title = "Купить молоко",
            shortDescription = "Купить молоко 1%",
            fullDescription = "Зайти в магазин по дороге на работу и купить молоко Весёлый молочник"
        )

        // Вставляем задачу в таблицу
        underTest.insert(entity)

        // Получаем все задачи из БД и проверяем что кол-во задач 1 и
        // эта задача является той же, что мы создали выше
        underTest.loadAll().let {
            assertThat(it, hasSize(equalTo(1)))
            assertThat(it[0], equalTo(entity))
        }

        // Удаляем
        underTest.delete(entity)

        // Проверяем что теперь таблица пуста
        assertThat(underTest.loadAll(), isEmpty)
    }

Далее, еще давайте проверим, что задача корректно обновляется в таблице.

    @Test
    fun update() {
        val entity = TaskEntity(
            id = UUID.randomUUID().toString(),
            title = "Написать статью",
            shortDescription = "Написать статью в блог",
            fullDescription = "Написать статью по миграциями БД в Room"
        )

        underTest.insert(entity)

        val updated = entity.copy(
            title = "Добавить код", shortDescription = "Добавить проект на GitHub",
            fullDescription = "Создать проект и опубликовать его на GitHub"
        )

        underTest.update(updated)

        underTest.loadAll().let {
            assertThat(it, hasSize(equalTo(1)))
            assertThat(it[0], equalTo(updated))
        }
    }

Запустите тесты и проверьте, что все работает корректно. Отлично! Все тесты пройдены, теперь самое время заняться миграциями.

Обновление структуры базы данных и создание миграции

Теперь, давайте добавим новое поле в класс задачи. Для простоты это будет поле Date, обозначающее крайний срок выполнения задачи. Добавьте в TaskEntity поле deadline: Date как показано ниже.

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey
    val id: String,
    val title: String,
    val shortDescription: String,
    val fullDescription: String,
    val deadline: Date
)

Однако тип Date является классом из пакета java.util.* и Room не сможет сохранить такой типа данных в таблице. Поэтому необходимо воспользоваться TypeConverter для конвертации Date в Long и обратно. Создайте класс DateConverter как показано ниже:

class DateConverter {
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return if (value == null) {
            null
        } else Date(value)
    }

    @TypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return (date?.time)
    }
}

Теперь, чтобы использовать такой конвертор при сохранении или чтении задачи, необходимо добавить его в БД. Добавьте @TypeConverters(DateConverter::class) в инициализацию БД.

@Database(entities = [TaskEntity::class], version = 1)
@TypeConverters(DateConverter::class)
abstract class ToDoDatabase : RoomDatabase() {
    companion object {

        fun newTestDatabase(context: Context) = Room.inMemoryDatabaseBuilder(
            context,
            ToDoDatabase::class.java
        ).build()
    }

    abstract fun tasks(): TodoDao
}

Так как мы обновили структуру таблицы, добавив поле deadline, нужно обновить версию БД

@Database(entities = [TaskEntity::class], version = 2)

Проверим, что наш конвертор работает, обновив тесты:

    @Test
    fun insertAndDelete() {
        // Проверяем что вначале таблица пуста
        assertThat(underTest.loadAll(), isEmpty)

        // Поставим дедлайн сегодня
        val date = Calendar.getInstance().time

        // Создаём задачу
        val entity = TaskEntity(
            id = UUID.randomUUID().toString(),
            title = "Купить молоко",
            shortDescription = "Купить молоко 1%",
            fullDescription = "Зайти в магазин по дороге на работу и купить молоко Весёлый молочник",
            deadline =  date
        )

        // Вставляем задачу в таблицу
        underTest.insert(entity)

        // Получаем все задачи из БД и проверяем что кол-во задач 1 и
        // эта задача является той же, что мы создали выше
        underTest.loadAll().let {
            assertThat(it, hasSize(equalTo(1)))
            assertThat(it[0], equalTo(entity))
        }

        // Удаляем
        underTest.delete(entity)

        // Проверяем что теперь таблица пуста
        assertThat(underTest.loadAll(), isEmpty)
    }

Добавляем миграцию

Теперь, нам необходимо добавить миграцию, чтобы пользователи, использующие старую версию приложения, после обновления могли тоже ставить дедлайны задачам. Для этого добавьте объект

@VisibleForTesting
internal val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE tasks ADD COLUMN deadline LONG")
    }
}

И обновите код создания БД, добавив миграцию

@Database(entities = [TaskEntity::class], version = 2)
@TypeConverters(DateConverter::class)
abstract class ToDoDatabase : RoomDatabase() {
    companion object {

        fun newTestDatabase(context: Context) = Room.inMemoryDatabaseBuilder(
            context,
            ToDoDatabase::class.java
        )
            .addMigrations(MIGRATION_1_2)
            .build()
    }

    abstract fun tasks(): TodoDao
}

Тестирование корректности миграции

Теперь, необходимо написать тесты, чтобы проверить правильно ли мы реализовали миграцию от версии 1 к версии 2. Но для начала, добавьте в build.gradle путь для сохранения схемы базы данных.

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

Эта опция позволит сохранить схему базы данных в папке с проектом. Что такое схема базы данных? Это описание структуры базы данных в формате json. Для нашей БД это выглядит так:

Пример схемы БД

Схема базы данных сохраняется для каждой версии БД и нужна для тестирования миграций. Так как Room генерирует схему для текущей базы данных, а для старой нет, то нам нужна схема старой, чтобы сравнить различия и написать тесты. Для этого как раз для каждой версии есть файл со схемой. Кроме того, для тестов нам необходимо добавить схему в assets, для этого допишите в том же build.gradle

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

В итоге ваш build.gradle без раздела dependencies должен выглядеть примерно так:

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "ru.androidschool.migrations"
        minSdkVersion 21
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

        javaCompileOptions {
            annotationProcessorOptions {
                arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
            }
        }
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    // other configuration (buildTypes, defaultConfig, etc.)
    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

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

Теперь давайте напишем тест для проверки миграции.

private const val DB_NAME = "ToDoDatabase.db"
private const val TEST_ID = 1337
private const val TEST_TITLE = "Test Title"
private const val TEST_SHORT_TEXT = "Test short_text"
private const val TEST_FULL_TEXT = "Test full text"

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    @get:Rule
    val migrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        ToDoDatabase::class.java.canonicalName
    )

    @Test
    fun test1To2() {
        // Создаём БД с версией 1
        val initialDb = migrationTestHelper.createDatabase(DB_NAME, 1)

        // Вставляем задачу
        initialDb.execSQL(
            "INSERT INTO tasks (id, title,  shortDescription,  fullDescription) VALUES (?, ?, ?, ?)",
            arrayOf(TEST_ID, TEST_TITLE, TEST_SHORT_TEXT, TEST_FULL_TEXT)
        )

        // Проверяем что созданная задача сохранилась
        initialDb.query("SELECT COUNT(*) FROM tasks").use {
            assertThat(it.count, equalTo(1))
            it.moveToFirst()
            assertThat(it.getInt(0), equalTo(1))
        }

        // Закрываем БД
        initialDb.close()

        // Запускаем миграцию схемы БД с версии с на 2
        val db = migrationTestHelper.runMigrationsAndValidate(
            DB_NAME,
            2,
            true,
            MIGRATION_1_2
        )
        
        // Проверяме что все отработало корректно
        // Поле Date теперь присутсвтует в таблице и должно быть пусто
        // Номер столбца соответсвует полю сущности в том порядке, в котором описана TaskEntity
        db.query("SELECT id, title, shortDescription, fullDescription, deadline FROM tasks")
            .use {
                assertThat(it.count, equalTo(1))
                it.moveToFirst()
                assertThat(it.getInt(0), equalTo(TEST_ID))
                assertThat(it.getString(1), equalTo(TEST_TITLE))
                assertThat(it.getString(2), equalTo(TEST_SHORT_TEXT))
                assertThat(it.getString(3), equalTo(TEST_FULL_TEXT))
                assertThat(it.getString(4), absent())
            }
    }
}

Вот и все! Мы создали простую БД, обновили схему и написали тесты для миграции со старой версии БД на новую с новым полем. Кроме того мы рассмотрели аннотацию TypeConverter, которая позволяет сохранять сложные типы, такие как Date в таблицу базы данных. Полный код проекта вы можете скачать по ссылке

Понравился материал? Подписывайся на канал в Telegram: