В этой статье рассмотрим миграции версий базы данных на примере использования 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 перейти по ссылке
