From 90580ecb3e3f0e957668bb85e0a756718f7d8ca4 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 02:40:20 +0200 Subject: [PATCH] =?UTF-8?q?refactor(room):=20fallbackToDestructiveMigratio?= =?UTF-8?q?n=20entfernen=20und=20Migrationstests=20vervollst=C3=A4ndigen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../db/KrisenvorratDatabaseMigrationTest.kt | 146 +++++++++++++++++- .../de/krisenvorrat/app/data/db/Migrations.kt | 8 + docs/migration-guide.md | 78 ++++++++++ 3 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 docs/migration-guide.md diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt index 8e5aa76..28d7c3f 100644 --- a/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt @@ -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() + 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() + 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() + 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() + } + } } diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt index 7974850..4882b8f 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt @@ -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( diff --git a/docs/migration-guide.md b/docs/migration-guide.md new file mode 100644 index 0000000..c52e1b1 --- /dev/null +++ b/docs/migration-guide.md @@ -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") +} +```