Site icon AppTractor

Миграция продакшен приложения из Room в SQLDelight

Kotlin multiplatform (KMP), ранее известный как KMM, является интересной темой для многих Android-разработчиков, стремящихся обеспечить работу своих приложений на других платформах, например iOS.

Но одной из первых проблем, с которой они сталкиваются, являются популярные библиотеки, используемые почти в каждом Android-приложении, которые, как выясняется, не поддерживают KMP, и которые необходимо заменить на чисто Kotlin-библиотеки, чтобы начать свое путешествие по KMP. Одной из таких библиотек является Room, которая используется для простого управления локальной базой данных.

Поэтому в данном случае единственным вариантом является переход на другую библиотеку, и одним из хороших вариантов является SQLDelight.

Эта статья — ваше руководство по миграции продакшен приложения с реальной локальной базой данных с Room на SQLDelight без потери сохраненных данных.

Я исхожу из реального опыта, полученного в моем проекте с открытым исходным кодом Prayer Companion.

1. Внедрение SQLDelight

Прежде всего, необходимо внедрить в проект зависимости SQLDelight.

В приложении build.gradle.kts добавьте следующие строки (обратитесь к документации по SQLDelight для получения правильных версий):

plugins {
  id("app.cash.sqldelight") version "2.0.0"
}

dependencies {
  implementation("app.cash.sqldelight:android-driver:2.0.0")
  implementation("app.cash.sqldelight:coroutines-extensions:2.0.0")
}

Затем синхронизируйте проект и в разделе dependencies добавьте следующий код для инициирования класса Database, имя базы данных может быть любым подходящим, это будет имя сгенерированного класса, с которым вы будете взаимодействовать в своем коде, а затем имя пакета вашего приложения

Примечание: name — это не имя файла базы данных, оно будет задано позже.

sqldelight {
    databases {
        create("MyAppDatabase") {
            packageName.set("com.domain.app")
        }
    }
}

Имя пакета можно найти в том же файле класса, что и значение applicationId в блоке defaultConfig

2. Создание таблиц

SQLDelight использует другой способ создания таблиц, чем Room, вместо создания класса Entity и автогенерации таблицы, здесь вам придется писать SQL-код самостоятельно, но он прост и предлагает автодополнение.

К счастью, переход из Room упрощает задачу, у нас уже где-то сгенерирована схема создания таблиц, чтобы найти ее, найдите в своем проекте класс с именем [имя класса вашей базы данных]_Impl.java, имя класса базы данных — это класс, который наследует RoomDatabase() в вашем проекте.

Чтобы увидеть сгенерированный класс, необходимо сначала собрать проект, он будет находиться в каталоге java (generated):

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

Схемы создания таблиц базы данных Room

Убедитесь, что схемы в SQLDelight написаны точно так же, как и в Room, иначе, скорее всего, возникнут ошибки.

Теперь, чтобы написать схему создания для SQLDelight, откройте проект, затем в app -> src -> main создайте новый каталог sqldelight, а внутри него — каталог tables:

В каталоге tables создайте для каждой таблицы новый файл с расширением .sq.

Этот файл будет предназначен для создания таблицы и запросов к ней. Например, файл с именем my_table.sq будет выглядеть следующим образом:

CREATE TABLE PrayersInfo (
    id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    prayer TEXT NOT NULL,
    dateTime TEXT NOT NULL,
    status TEXT
);

insert:
INSERT INTO PrayersInfo VALUES(:id,:prayer,:dateTime,:status);

getPrayers:
SELECT * FROM PrayersInfo WHERE dateTime >= :startDateTime 
AND dateTime <= :endDateTime;

getPrayer:
SELECT * FROM PrayersInfo WHERE prayer = :prayer 
AND dateTime >= :startOfDay AND dateTime <= :endOfDay;

getPrayersStatusesByDate:
SELECT status FROM PrayersInfo WHERE dateTime >= :startDateTime 
AND dateTime <= :endDateTime AND prayer != :excludedPrayer;

updatePrayerStatus:
UPDATE PrayersInfo SET status = :status WHERE dateTime >= :startOfDay 
AND dateTime <= :endOfDay AND prayer = :prayer;

delete:
DELETE FROM PrayersInfo WHERE dateTime >= :startDateTime 
AND dateTime <= :endDateTime;

Логика:

  1.  Схема создания таблицы
    Будет выполняться только один раз, вам не нужно беспокоиться об этом
  2. Все ваши запросы в следующей форме
    Примечание: ключевые слова написаны заглавными буквами, from не будет распознан, его нужно писать как FROM
    Примечание2: SQLDelight сгенерирует Kotlin-функции для использования в вашем проекте для всех этих запросов
functionName:
Query;

Затем создайте проект и приступайте к замене Room.

2.1. Перед заменой Room

Если вы работаете над уже выпущенным приложением, то, скорее всего, сейчас у вас номер версии больше 1.

В SQLDelight мы не указываем номер версии, как в Room,

SQLDelight генерирует номер версии в зависимости от количества файлов миграции + 1.

Чтобы разобраться с этим, в том же каталоге sqldelight, который мы создали выше, создайте новый каталог с названием migrations и создайте файл для каждой версии, с которой вы мигрировали, в следующей форме именования:

[version your migrating from].sqm

Таким образом, если вы находитесь на версии 3, вам необходимо создать два файла миграции 1.sqm и 2.sqm, и SQLDelight поймет, что вы находитесь на версии 3.

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

Обратитесь к документации SQLDelight, если у вас возникли трудности с написанием миграции

В моем случае я создал новую таблицу при переходе на версию 2, поэтому вот как выглядел 1.sqm для меня:

3. Заменить инициализацию базы данных

Найдите место, где вы создаете базу данных Room databaseBuilder, и замените ее на SQLDelight, обязательно реализуя класс Database из сгенерированных файлов (имя класса Database будет таким же, как вы написали в файле build.gradle.kts), а не через вашу старую базу данных Room.

ВАЖНО: используйте такое же точное имя базы данных (в кавычках, например, «prayer-companion»), как и имя файла вашей базы данных, иначе SQLDelight создаст новую базу данных с пустыми данными.

val driver: SqlDriver = AndroidSqliteDriver(PrayerCompanionDatabase.Schema, applicationContext, "prayer-companion")
return PrayerCompanionDatabase(driver)

Затем удалите класс RoomDatabase, он вам больше не нужен.

3.1. Перенос интерфейса Dao

Я хотел сохранить все в проекте неизменным, поэтому вместо того, чтобы заменять интерфейс классом, я просто создал новый класс Dao, наследующий от интерфейса Dao.

Таким образом, для этого Dao @Dao interface PrayersInfoDao я создал новый класс class PrayersInfoDaoImpl: PrayersInfo, затем я сгенерировал все функции точно так же, как они есть в интерфейсе.

В конструкторе можно инжектировать/передать сгенерированный класс Database:

class PrayersInfoDaoImpl @Inject constructor(
    db: PrayerCompanionDatabase
) : PrayersInfoDao { ... }

Затем получить ссылку на запросы к таблице, которые генерирует для вас SQLDelight:

class PrayersInfoDaoImpl @Inject constructor(
    db: PrayerCompanionDatabase
) : PrayersInfoDao { 

    private val queries = db.prayersInfoQueries
}

Далее использование запросов очень простое, запросы, которые вы написали в файле .sq, теперь готовы к использованию, для вставки строки, например, вы напишете что-то вроде этого:

override fun insert(prayerInfo: PrayerInfoEntity) {
    queries.insert(
        id = null,
        prayer = prayerInfo.prayer.name,
        dateTime = converters.localDateToString(prayerInfo.dateTime),
        status = prayerInfo.status.name
    )
}

Примечание: id является null, поэтому мы можем позволить SQLDelight выполнить автоинкремент.

Однако при работе с запросами возникают некоторые сложности

a. Чтение строк

Чтение строк может быть не совсем интуитивным:

override fun getPrayers(
    startDateTime: LocalDateTime,
    endDateTime: LocalDateTime
): List<PrayerInfoEntity> {
    return queries.getPrayers(
        startDateTime = converters.localDateToString(startDateTime),
        endDateTime = converters.localDateToString(endDateTime)
    )
}

В приведенном ниже случае вызов getPrayers вернет Query<PrayersInfo>, в то время как ожидаемым результатом будет List<PrayerInfoEntity>, чтобы сначала сопоставить класс данных с классом сущности, для каждого запроса есть две функции запроса, одна, как описано выше, только с необходимыми параметрами, а другая — с дополнительным параметром отображения функции, который можно использовать для сопоставления класса данных с классом сущности:

Я всегда предпочитаю не использовать сгенерированные классы данных, так как в дальнейшем становится сложнее что-либо изменить или добавить вспомогательные функции.

Использование второй функции будет выглядеть примерно так — обычная mapper функция на выбранный вами класс данных:

override fun getPrayers(
    startDateTime: LocalDateTime,
    endDateTime: LocalDateTime
): List<PrayerInfoEntity> {
    return queries.getPrayers(
        startDateTime = converters.localDateToString(startDateTime),
        endDateTime = converters.localDateToString(endDateTime)
    ) { id, prayer, dateTime, status ->
        // mapping PrayersInfo to PrayerInfoEntity
        PrayerInfoEntity(
            id = id.toInt(),
            prayer = Prayer.valueOf(prayer),
            dateTime = converters.stringToLocalDate(dateTime),
            status = status?.let { status -> PrayerStatus.valueOf(status) }
                ?: PrayerStatus.None
        )
    }
}

А для превращения Query<T> в List<T> вызовем .executeAsList():

override fun getPrayers(
    startDateTime: LocalDateTime,
    endDateTime: LocalDateTime
): List<PrayerInfoEntity> {
    return queries.getPrayers(
        startDateTime = converters.localDateToString(startDateTime),
        endDateTime = converters.localDateToString(endDateTime)
    ) { id, prayer, dateTime, status ->
        PrayerInfoEntity(
            id = id.toInt(),
            prayer = Prayer.valueOf(prayer),
            dateTime = converters.stringToLocalDate(dateTime),
            status = status?.let { status -> PrayerStatus.valueOf(status) }
                ?: PrayerStatus.None
        )
    }.executeAsList()
    // transforming Query into List 👆
}

b. Чтение строк в Flow

Чтобы читать строки из таблицы в потоке, убедитесь, что у вас реализована зависимость расширений корутинов для SQLDelight:

implementation("app.cash.sqldelight:coroutines-extensions:2.0.0")

Тогда вместо вызова executeAsList мы вызываем asFlow(), а затем mapToList(DispatcherContext):

override fun getPrayersFlow(
    startDateTime: LocalDateTime,
    endDateTime: LocalDateTime
): Flow<List<PrayerInfoEntity>> {
    return queries.getPrayers(
        startDateTime = converters.localDateToString(startDateTime),
        endDateTime = converters.localDateToString(endDateTime)
    ) { id, prayer, dateTime, status ->
        PrayerInfoEntity(
            id = id.toInt(),
            prayer = Prayer.valueOf(prayer),
            dateTime = converters.stringToLocalDate(dateTime),
            status = status?.let { PrayerStatus.valueOf(it) } ?: PrayerStatus.None
        )
    }.asFlow().mapToList(Dispatchers.IO)
    // transform into a flow list 👆
}

 c. Вставка списка

Обычно для вставки нескольких строк сразу у нас есть функция insertAll, но, к сожалению, SQLDelight не поддерживает ее из коробки, поэтому нам придется делать это самостоятельно.

В этом случае мы используем функцию queries.transaction { } для вставки всех строк или ни одной в случае ошибки, что будет выглядеть примерно так:

override fun insertAll(prayersInfo: List<PrayerInfoEntity>) {
    queries.transaction {
        prayersInfo.forEach {
            queries.insert(
                id = null,
                prayer = it.prayer.name,
                dateTime = converters.localDateToString(it.dateTime),
                status = it.status.name
            )
        }
    }
}

А если вы хотите получать информацию об ошибке, то можно добавить это:

override fun insertAll(prayersInfo: List<PrayerInfoEntity>) {
    queries.transaction {
        // if the transaction failed and rolled back, throw an exception
        afterRollback { throw Exception("Failed to inserting prayers info ") }
        prayersInfo.forEach {
            queries.insert(
                id = null,
                prayer = it.prayer.name,
                dateTime = converters.localDateToString(it.dateTime),
                status = it.status.name
            )
        }
    }
}

Вот и все, дальше вам нужно только:

  1. Удалить все аннотации Room из интерфейсов Dao
  2. Удалить все аннотации Room из классов данных Entities
  3. Удалить все зависимости Room из файлов gradle.build.kts
  4. Инжектировать/передать новый класс [Dao]Impl в репозиторий, который использовал интерфейс Dao, и все должно работать точно так же.

Запустите приложение и убедитесь, что все ваши данные сохранились!

Полный код реальной миграции приложения можно посмотреть в пул-реквесте PrayerCompanion.

Источник

Exit mobile version