В этой статье рассмотрим миграции версий базы данных на примере использования 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 в таблицу базы данных. А если вы хотите овладеть Room, научится строить сложные БД с отношениями, миграциями и применять данную библиотеку в своих Android-приложениях, то приглашаю пройти онлайн – интенсив по Android-разработке с наставником, где вы прокачаетесь до middle за 2 месяца
Полный код проекта вы можете скачать по ссылке
Понравился материал? Подписывайся на канал в Telegram:
- в канале AndroidSchool.ru публикуются полезные материалы для Android-разработчика и скидки на продвинутые курсы
- новый чат @android_school_talk задаём вопросы и предлагаем идеи для улучшения курсов на AndroidSchool.ru
- Практический курс по программированию на RxJava 2.0 для начинающих на Stepik перейти по ссылке