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:
parent
f792213b1e
commit
90580ecb3e
3 changed files with 229 additions and 3 deletions
|
|
@ -107,7 +107,7 @@ internal class KrisenvorratDatabaseMigrationTest {
|
|||
context,
|
||||
KrisenvorratDatabase::class.java,
|
||||
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() {
|
||||
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
|
||||
|
|
@ -150,7 +150,62 @@ internal class KrisenvorratDatabaseMigrationTest {
|
|||
context,
|
||||
KrisenvorratDatabase::class.java,
|
||||
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
|
||||
|
|
@ -230,7 +285,7 @@ internal class KrisenvorratDatabaseMigrationTest {
|
|||
fun freshInstall_worksWithoutMigration() {
|
||||
// Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig
|
||||
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()
|
||||
try {
|
||||
// Tabellen anlegen und Basis-Operationen prüfen
|
||||
|
|
@ -278,4 +333,89 @@ internal class KrisenvorratDatabaseMigrationTest {
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
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) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
|
|
|
|||
78
docs/migration-guide.md
Normal file
78
docs/migration-guide.md
Normal 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")
|
||||
}
|
||||
```
|
||||
Loading…
Reference in a new issue