Реалізація міграції даних при оновленні мобільного додатку
Міграція схеми та міграція даних — різні завдання. Можна ідеально написати ALTER TABLE, але при цьому отримати биті дані у production. Це трапляється, коли змінюється не структура таблиці, а формат або семантика збережених значень: дати із Unix timestamp переходять у ISO 8601, суми із float у integer-cents, статуси зі числових кодів у строкові enum. Такі перетворення вимагають явної обробки для кожного рядка.
Що таке міграція даних на практиці
Конкретні сценарії з реальних проектів:
- Поле
amountзберігалося якREAL(double), потрібно перейти наINTEGERцентів, щоб уникнути помилок floating-point при порівнянні.100.10→10010. - Поле
statusбулоINTEGER(0, 1, 2), теперTEXT("pending", "active", "completed"). Потрібно смаппувати кожне число у рядок. - Поле
dateбуло Unix timestamp у секундах, у новій версії — мілісекунди.1700000000→1700000000000. - JSON, збережений у TEXT-колонці, змінив структуру: старий
{"items":[...]}→ новий{"data":{"list":[...]}}.
Кожен із цих випадків — трансформація рядків усієї таблиці всередину міграції.
Реалізація в Room (Android)
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// Конвертація суми з float у integer cents
db.execSQL("""
UPDATE transactions
SET amount_cents = CAST(ROUND(amount * 100) AS INTEGER)
""")
// Конвертація статусу з int у string
db.execSQL("UPDATE transactions SET status = 'pending' WHERE status_code = 0")
db.execSQL("UPDATE transactions SET status = 'active' WHERE status_code = 1")
db.execSQL("UPDATE transactions SET status = 'completed' WHERE status_code = 2")
// Конвертація timestamp з секунд у мілісекунди
db.execSQL("UPDATE events SET created_at = created_at * 1000 WHERE created_at < 9999999999")
}
}
Умова WHERE created_at < 9999999999 в останньому прикладі захищає від повторного застосування при помилковому повторному запуску — дата у мілісекундах завжди більше цього числа.
Batch-оновлення для великих таблиць
Якщо у таблиці мільйони рядків — оновлення одним UPDATE може зайняти десятки секунд і заблокувати запуск. Батчевий підхід:
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
var offset = 0
val batchSize = 1000
while (true) {
val updated = db.compileStatement("""
UPDATE transactions
SET metadata = transform_metadata(metadata)
WHERE id IN (
SELECT id FROM transactions
WHERE metadata_migrated = 0
LIMIT $batchSize
)
""").executeUpdateDelete()
if (updated == 0) break
}
}
}
Для дуже великих таблиць (500 000+ рядків) — міграцію краще робити лінивою: при першому обращенні до запису, а не у onUpgrade. Додати поле-флаг migrated INTEGER DEFAULT 0 та трансформувати при читанні.
Ленива міграція даних
Коли повна міграція займає занадто довго для блокуючого виконання при старті:
// iOS — ленива міграція при доступі до даних
func fetchTransaction(id: String) -> Transaction {
let raw = database.fetch(id: id)
if !raw.isMigrated {
let migrated = DataMigrator.migrate(raw)
database.save(migrated)
return migrated
}
return raw
}
Плюс: додаток стартує миттєво. Мінус: потрібно підтримувати обидва формати у коді, поки не всі записи мігровані. Фоновий WorkManager / BGProcessingTask поступово мігрує решту.
Тестування трансформацій
@Test
fun testAmountConversion() {
val helper = MigrationTestHelper(instrumentation, AppDatabase::class.java)
val db = helper.createDatabase("test.db", 3)
db.execSQL("INSERT INTO transactions (id, amount) VALUES ('t1', 100.10)")
db.close()
val migrated = helper.runMigrationsAndValidate("test.db", 4, true, MIGRATION_3_4)
val cursor = migrated.query("SELECT amount_cents FROM transactions WHERE id = 't1'")
cursor.moveToFirst()
assertEquals(10010, cursor.getInt(0))
}
Особлива увага — граничним випадкам: NULL значення, пусті рядки, неочікувані формати даних, які реальні користувачі можуть мати у базі.
Откат при помилці
SQLite підтримує транзакції — весь onUpgrade автоматично обертається у транзакцію у Room. Якщо що-небудь упаде — зміни відкочуються. На iOS з Core Data — аналогічно через NSMigrationManager.
Але откат не означає «додаток працює нормально» — при наступному запуску знову спробує мігрувати. Потрібна обробка помилок та відображення користувачу повідомлення про проблему.
Обсяг роботи
- Аудит даних у поточній БД: формати, виключення, NULL-значення
- Написання трансформацій з захистом від повторного застосування
- Batch-оновлення для великих таблиць
- Ленива міграція для критично великих обсягів
- Тести на граничних випадках
Строки
Прості UPDATE-трансформації (1–3 таблиці): 1 день. Складні перетворення JSON, ленива міграція з фоновим воркером: 2–4 дні.







