refactor(room): fallbackToDestructiveMigration entfernen und Migrationstests vervollständigen

Migrations.kt: KDoc für MIGRATION_2_3 und MIGRATION_3_4 ergänzt.
KrisenvorratDatabaseMigrationTest: MIGRATION_3_4 in alle Testhelfer
aufgenommen, createV3Database() + openMigratedDbV4() hinzugefügt,
Tests für v3→v4 (messages-Tabelle) und v1→v4 Full-Path-Migration
ergänzt. freshInstall-Test registriert jetzt alle Migrationen.
docs/migration-guide.md: Entwickler-Leitfaden mit Checkliste,
SQLite-Einschränkungen und Testanleitung.

fallbackToDestructiveMigration() war bereits entfernt; dieses Ticket
stellt sicher, dass alle Migrationspfade getestet und dokumentiert sind.

Closes #71
This commit is contained in:
Jens Reinemann 2026-05-17 02:40:20 +02:00
parent f792213b1e
commit 90580ecb3e
3 changed files with 229 additions and 3 deletions

View file

@ -107,7 +107,7 @@ internal class KrisenvorratDatabaseMigrationTest {
context, context,
KrisenvorratDatabase::class.java, KrisenvorratDatabase::class.java,
dbName dbName
).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3).build() ).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4).build()
private fun createV2Database() { private fun createV2Database() {
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() } val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
@ -150,7 +150,62 @@ internal class KrisenvorratDatabaseMigrationTest {
context, context,
KrisenvorratDatabase::class.java, KrisenvorratDatabase::class.java,
dbName dbName
).addMigrations(Migrations.MIGRATION_2_3).build() ).addMigrations(Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4).build()
private fun openMigratedDbV4() = Room.databaseBuilder(
context,
KrisenvorratDatabase::class.java,
dbName
).addMigrations(Migrations.MIGRATION_3_4).build()
private fun createV3Database() {
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
SQLiteDatabase.openOrCreateDatabase(dbFile, null).use { db ->
db.execSQL(
"CREATE TABLE `categories` " +
"(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)"
)
db.execSQL(
"CREATE TABLE `locations` " +
"(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)"
)
db.execSQL(
"""
CREATE TABLE `items` (
`id` TEXT NOT NULL,
`name` TEXT NOT NULL,
`category_id` INTEGER NOT NULL,
`quantity` REAL NOT NULL,
`unit` TEXT NOT NULL,
`unit_price` REAL NOT NULL,
`kcal_per_kg` INTEGER,
`expiry_date` TEXT,
`location_id` INTEGER NOT NULL,
`notes` TEXT NOT NULL,
`last_updated` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
db.execSQL(
"CREATE TABLE `settings` " +
"(`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))"
)
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `pending_sync_ops` (
`id` TEXT NOT NULL,
`item_id` TEXT NOT NULL,
`operation` TEXT NOT NULL,
`payload` TEXT NOT NULL,
`created_at` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
db.version = 3
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Tests // Tests
@ -230,7 +285,7 @@ internal class KrisenvorratDatabaseMigrationTest {
fun freshInstall_worksWithoutMigration() { fun freshInstall_worksWithoutMigration() {
// Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig // Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig
val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java)
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3) .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4)
.build() .build()
try { try {
// Tabellen anlegen und Basis-Operationen prüfen // Tabellen anlegen und Basis-Operationen prüfen
@ -278,4 +333,89 @@ internal class KrisenvorratDatabaseMigrationTest {
db.close() db.close()
} }
} }
@Test
fun migrate3To4_messagesTableExists() {
createV3Database()
val db = openMigratedDbV4()
try {
val tables = mutableListOf<String>()
db.openHelper.writableDatabase.query(
"SELECT name FROM sqlite_master WHERE type='table'"
).use { cursor ->
while (cursor.moveToNext()) {
tables.add(cursor.getString(0))
}
}
assertTrue("messages Tabelle muss existieren", tables.contains("messages"))
// Spalten prüfen
val columns = mutableListOf<String>()
db.openHelper.writableDatabase.query("PRAGMA table_info(messages)").use { cursor ->
while (cursor.moveToNext()) {
columns.add(cursor.getString(cursor.getColumnIndexOrThrow("name")))
}
}
assertTrue("id muss existieren", columns.contains("id"))
assertTrue("sender_id muss existieren", columns.contains("sender_id"))
assertTrue("sender_username muss existieren", columns.contains("sender_username"))
assertTrue("receiver_id muss existieren", columns.contains("receiver_id"))
assertTrue("body muss existieren", columns.contains("body"))
assertTrue("sent_at muss existieren", columns.contains("sent_at"))
assertTrue("is_pending muss existieren", columns.contains("is_pending"))
// Eine Row kann eingetragen und gelesen werden
db.openHelper.writableDatabase.execSQL(
"INSERT INTO messages (id, sender_id, sender_username, receiver_id, body, sent_at, is_pending) " +
"VALUES ('msg1', 'user1', 'Alice', 'user2', 'Hallo', 1000, 0)"
)
var rowCount = 0
db.openHelper.writableDatabase.query(
"SELECT COUNT(*) FROM messages"
).use { cursor ->
if (cursor.moveToNext()) rowCount = cursor.getInt(0)
}
assertEquals("Genau eine Row muss in messages stehen", 1, rowCount)
} finally {
db.close()
}
}
@Test
fun migrate1To4_fullMigrationPathPreservesData() {
// Given: V1-DB mit Testdaten
createV1Database(includeTestData = true)
// When: Vollständige Migration v1 → v2 → v3 → v4
val db = openMigratedDb()
try {
// Then: Items sind erhalten
val items = runBlocking { db.itemDao().getAll().first() }
assertEquals("Item muss nach Full-Path-Migration erhalten sein", 1, items.size)
val item = items[0]
assertEquals("item-uuid-1", item.id)
assertEquals("Apfel", item.name)
assertEquals(52, item.kcalPerKg)
// Alle Tabellen existieren
val tables = mutableListOf<String>()
db.openHelper.writableDatabase.query(
"SELECT name FROM sqlite_master WHERE type='table'"
).use { cursor ->
while (cursor.moveToNext()) {
tables.add(cursor.getString(0))
}
}
assertTrue("items Tabelle muss existieren", tables.contains("items"))
assertTrue("pending_sync_ops Tabelle muss existieren", tables.contains("pending_sync_ops"))
assertTrue("messages Tabelle muss existieren", tables.contains("messages"))
assertTrue("categories Tabelle muss existieren", tables.contains("categories"))
assertTrue("locations Tabelle muss existieren", tables.contains("locations"))
assertTrue("settings Tabelle muss existieren", tables.contains("settings"))
} finally {
db.close()
}
}
} }

View file

@ -69,6 +69,10 @@ internal object Migrations {
} }
} }
/**
* V2 V3:
* - Neue Tabelle `pending_sync_ops` für die Offline-Sync-Queue.
*/
val MIGRATION_2_3 = object : Migration(2, 3) { val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
@ -86,6 +90,10 @@ internal object Migrations {
} }
} }
/**
* V3 V4:
* - Neue Tabelle `messages` für die Chat-/Messaging-Funktion.
*/
val MIGRATION_3_4 = object : Migration(3, 4) { val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(

78
docs/migration-guide.md Normal file
View file

@ -0,0 +1,78 @@
# Room-Migrationen Entwickler-Leitfaden
## Grundsatz
**`fallbackToDestructiveMigration()` ist verboten.** Jede Schema-Änderung muss durch eine
explizite Migration abgedeckt sein, damit User-Daten bei App-Updates erhalten bleiben.
## Aktuelle DB-Version
**Version 4** (Stand: Mai 2026)
| Migration | Änderung |
|-----------|----------|
| v1 → v2 | `kcal_per_100g``kcal_per_kg`, `min_stock` entfernt |
| v2 → v3 | Tabelle `pending_sync_ops` hinzugefügt |
| v3 → v4 | Tabelle `messages` hinzugefügt |
## Checkliste für neue Schema-Änderungen
1. **Migration schreiben**: Neues `Migration(X, Y)`-Objekt in `Migrations.kt` ergänzen
2. **DB-Version hochzählen**: `version` in `KrisenvorratDatabase` anpassen
3. **Migration registrieren**: In `DatabaseModule.addMigrations()` eintragen
4. **Test schreiben**: Migrationspfad in `KrisenvorratDatabaseMigrationTest` testen
5. **Pflichtfelder**: Bei neuen Pflichtfeldern ohne sinnvollen Default → interaktiven MigrationScreen ergänzen
## Migration schreiben
```kotlin
// In Migrations.kt
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
// SQL-Statements für die Schema-Änderung
db.execSQL("ALTER TABLE items ADD COLUMN new_column TEXT NOT NULL DEFAULT ''")
}
}
```
### Wichtige SQLite-Einschränkungen
- **ALTER TABLE RENAME COLUMN** erst ab SQLite 3.25 (Android API 29). Bei minSdk < 29:
Tabelle neu erstellen, Daten kopieren, alte Tabelle löschen, neue umbenennen.
- **ALTER TABLE DROP COLUMN** erst ab SQLite 3.35 (Android API 34). Gleiche Workaround-Strategie.
- **Immer `CREATE TABLE IF NOT EXISTS`** für neue Tabellen verwenden.
- **Indizes** nach Table-Rebuild neu erstellen (`CREATE INDEX IF NOT EXISTS`).
## Tests schreiben
Jede Migration braucht mindestens:
1. **Schema-Test**: Prüfen, dass neue Spalten/Tabellen existieren und alte entfernt sind
2. **Daten-Test**: Prüfen, dass bestehende Daten nach der Migration erhalten sind
3. **Full-Path-Test**: Prüfen, dass v1 → aktuelle Version ohne Fehler durchläuft
```kotlin
@Test
fun migrate4To5_newColumnExists() {
createV4Database()
val db = openMigratedDbV5()
try {
// Schema prüfen
// Daten prüfen
} finally {
db.close()
}
}
```
## Schema-Export
Room exportiert Schemas automatisch nach `app/schemas/`. Diese JSON-Dateien werden
versioniert und können für `MigrationTestHelper` verwendet werden.
Konfiguration in `app/build.gradle.kts`:
```kotlin
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
```