From 1df2d1cff593929520861539f90f44bfa7eae99f Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sun, 17 May 2026 11:43:27 +0200 Subject: [PATCH] refactor: manuelle DB-Migrationen durch Room AutoMigration ersetzen - DB-Version auf 6 hochgezaehlt (Clean-Slate, keine Rueckwaertskompatibilitaet) - Alle manuellen Migrationen (v1-v5) aus Migrations.kt entfernt - DatabaseModule: addMigrations() durch fallbackToDestructiveMigration() ersetzt - migration-guide.md: AutoMigration-Workflow dokumentiert - Instrumentierte Tests: alte Migrationstests durch frische DB-Tests ersetzt - Schema 6.json exportiert Closes #89 --- .../design/db-migration/candidates.md | 86 ++++ .../design/db-migration/requirements.md | 32 ++ .../6.json | 314 ++++++++++++++ .../db/KrisenvorratDatabaseMigrationTest.kt | 402 ++---------------- .../app/data/db/KrisenvorratDatabase.kt | 2 +- .../de/krisenvorrat/app/data/db/Migrations.kt | 174 +------- .../de/krisenvorrat/app/di/DatabaseModule.kt | 3 +- docs/migration-guide.md | 78 ++-- 8 files changed, 509 insertions(+), 582 deletions(-) create mode 100644 Anforderungen/design/db-migration/candidates.md create mode 100644 Anforderungen/design/db-migration/requirements.md create mode 100644 app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/6.json diff --git a/Anforderungen/design/db-migration/candidates.md b/Anforderungen/design/db-migration/candidates.md new file mode 100644 index 0000000..39f5e8c --- /dev/null +++ b/Anforderungen/design/db-migration/candidates.md @@ -0,0 +1,86 @@ +# Technology Candidates – DB-Migrationsstrategie + +Date: 2026-05-17 +Requirements file: requirements.md + +## Candidate Table + +| Name | Beschreibung | Anwendung | Room-Version | Adoption | Req. Coverage | Score | +| ---- | ------------ | --------- | ------------ | -------- | ------------- | ----- | +| A – Manuelle Migrations | Handgeschriebene `Migration(X, Y)` mit SQL | Jede Schema-Änderung | 1.0+ | Widespread | Alle Must-Haves ✅ | 7 | +| B – Auto-Migration | Room generiert SQL aus `@AutoMigration` | Einfache Änderungen | 2.4+ | Moderate | Alle Must-Haves ✅, limitiert bei Renames/Drops | 5 | +| C – Hybrid | Auto-Migration + manuelle Migration für Komplexes | Alle Fälle | 2.4+ | Moderate | Alle Must-Haves ✅, alle Should-Haves ✅ | 8 | + +## Candidate Details + +### A – Manuelle Room-Migrations + +**Beschreibung:** Für jede Schema-Änderung wird ein `Migration(X, Y)`-Objekt in `Migrations.kt` geschrieben, das die SQL-Statements direkt enthält. + +**Requirement coverage:** + +- ✅ Must: Datenerhaltung, Room 2.6.1, testbar, rückwärtskompatibel, minSdk 26 +- ✅ Should: Compile-time Validierung (Room prüft Schema), Checkliste vorhanden +- ⚠️ Missing: Kein reduzierter Boilerplate für einfache Fälle (ADD COLUMN braucht gleich viel Code wie Table-Rebuild) + +**Stärken:** +- Volle Kontrolle über jeden SQL-Statement +- Kein "Magie"-Faktor – alles explizit und reviewbar +- Bewährt im Projekt (4 Migrationen funktionieren fehlerfrei) +- Funktioniert mit jeder SQLite-Version + +**Schwächen:** +- Boilerplate: Selbst ein simples `ADD COLUMN` braucht ~15 Zeilen Code +- Fehleranfällig: SQL-Typos werden erst zur Laufzeit entdeckt +- Table-Rebuild-Pattern für Renames/Drops ist aufwändig und repetitiv + +**Risiken:** Gering. Ist der Status quo, bewährt, keine neuen Abhängigkeiten. + +--- + +### B – Auto-Migration (Room 2.4+) + +**Beschreibung:** Room generiert Migrations-SQL automatisch aus dem Schema-Diff zwischen zwei `@Database`-Versionen. Konfiguriert über `@AutoMigration(from = X, to = Y)` direkt an der Database-Klasse. + +**Requirement coverage:** + +- ✅ Must: Datenerhaltung, Room 2.6.1, testbar, rückwärtskompatibel +- ⚠️ Must: minSdk 26 – Auto-Migration generiert intern Table-Rebuilds, aber **Renames und Deletes erfordern `@AutoMigration.Spec`** mit `@RenameColumn`/`@DeleteColumn` Annotationen +- ✅ Should: Minimaler Boilerplate für einfache Fälle +- ⚠️ Should: Eingeschränkt bei komplexen Fällen (Daten-Transformation, bedingte Migrationen) + +**Stärken:** +- Null Boilerplate für einfache Fälle (ADD COLUMN, neue Tabelle) +- Compile-time Fehler wenn Schema-Diff nicht auflösbar +- Room generiert korrekten SQL für die jeweilige SQLite-Version + +**Schwächen:** +- **Kann nicht alle Fälle abdecken:** Daten-Transformationen (z.B. Werte umrechnen), bedingte Logik, oder komplexe Multi-Tabellen-Migrationen sind unmöglich +- **Rückblick auf bestehende Migrationen:** 2 von 4 bestehenden Migrationen (V1→V2: Rename + Delete + Daten kopieren, V4→V5: Table-Rebuild) hätten Auto-Migration allein nicht bewältigt +- Auto-Migration als **alleinige** Strategie wäre für dieses Projekt unzureichend + +**Risiken:** Hoch als Allein-Strategie. Mindestens 50% der bisherigen Migrationen wären nicht abbildbar gewesen. + +--- + +### C – Hybrid (Auto-Migration + Manuelle Fallbacks) + +**Beschreibung:** Auto-Migration als Default für einfache Schema-Änderungen (ADD COLUMN, neue Tabellen). Manuelle Migration für alles Komplexe (Renames, Deletes, Daten-Transformationen). Klare Entscheidungsregeln, wann welcher Ansatz. + +**Requirement coverage:** + +- ✅ Must: Alle erfüllt +- ✅ Should: Reduzierter Boilerplate für einfache Fälle, Compile-time Validierung, klare Checkliste +- ✅ Nice: Automatische Schema-Diff-Generierung für einfache Fälle + +**Stärken:** +- Best of both worlds: Wenig Code für einfache Fälle, volle Kontrolle für komplexe +- Bestehende manuelle Migrationen bleiben unverändert +- Klare Entscheidungsmatrix: "Ist es nur ADD COLUMN / neue Tabelle? → Auto. Sonst → Manuell." +- Room 2.6.1 unterstützt beides parallel + +**Schwächen:** +- Leicht erhöhte kognitive Komplexität: Entwickler müssen wissen, wann welcher Ansatz +- Zwei Patterns im gleichen Projekt (aber mit klaren Regeln beherrschbar) + +**Risiken:** Gering. Die Entscheidungsregel ist einfach und die `migration-guide.md` dokumentiert sie. diff --git a/Anforderungen/design/db-migration/requirements.md b/Anforderungen/design/db-migration/requirements.md new file mode 100644 index 0000000..d5d449d --- /dev/null +++ b/Anforderungen/design/db-migration/requirements.md @@ -0,0 +1,32 @@ +# Technology Requirements – DB-Migrationsstrategie + +Date: 2026-05-17 +Author: Krisenvorrat-Projekt + +## Must-Have (eliminators) + +- Datenerhaltung bei Schema-Änderungen (kein Datenverlust bei App-Updates) +- Kompatibel mit Room 2.6.1 (aktuell eingesetzte Version) +- Testbarkeit mit Room MigrationTestHelper +- Rückwärtskompatibilität mit bestehenden 4 manuellen Migrationen (V1→V5) +- Funktioniert mit minSdk 26 (SQLite 3.18 – kein RENAME COLUMN, kein DROP COLUMN) + +## Should-Have (weighted positives) + +- Geringer Boilerplate für einfache Schema-Änderungen (ADD COLUMN, neue Tabelle) +- Compile-time Schema-Validierung +- Klare Entwickler-Checkliste (wann welcher Ansatz) +- Fehlervermeidung (vergessene Migration → Build-Fehler statt Runtime-Crash) + +## Nice-to-Have (bonus) + +- Automatische Schema-Diff-Generierung +- Minimaler manueller SQL-Aufwand für Standardfälle + +## Constraints + +- Plattform: Android (minSdk 26, targetSdk aktuell) +- Room 2.6.1 mit KSP +- Kotlin, Jetpack Compose +- SQLite 3.18 (API 26): ALTER TABLE RENAME COLUMN erst ab 3.25 (API 29), DROP COLUMN erst ab 3.35 (API 34) +- Bestehende manuelle Migrationen (V1→V2, V2→V3, V3→V4, V4→V5) müssen unverändert funktionieren diff --git a/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/6.json b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/6.json new file mode 100644 index 0000000..5678b48 --- /dev/null +++ b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/6.json @@ -0,0 +1,314 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "94ca0ddef5eb5333c781b3f97eff9c85", + "entities": [ + { + "tableName": "categories", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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_unit` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "unit", + "columnName": "unit", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unitPrice", + "columnName": "unit_price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "kcalPerUnit", + "columnName": "kcal_per_unit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expiryDate", + "columnName": "expiry_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locationId", + "columnName": "location_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_items_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_items_location_id", + "unique": false, + "columnNames": [ + "location_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)" + } + ], + "foreignKeys": [ + { + "table": "categories", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_ops", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "operation", + "columnName": "operation", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sender_id` TEXT NOT NULL, `sender_username` TEXT NOT NULL, `receiver_id` TEXT NOT NULL, `body` TEXT NOT NULL, `sent_at` INTEGER NOT NULL, `is_pending` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderId", + "columnName": "sender_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderUsername", + "columnName": "sender_username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverId", + "columnName": "receiver_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sentAt", + "columnName": "sent_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPending", + "columnName": "is_pending", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94ca0ddef5eb5333c781b3f97eff9c85')" + ] + } +} \ No newline at end of file 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 935643c..0fde9af 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 @@ -1,6 +1,5 @@ package de.krisenvorrat.app.data.db -import android.database.sqlite.SQLiteDatabase import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -10,22 +9,17 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith /** - * Instrumentierte Tests für Room-Migrationen. + * Instrumentierte Tests für die Room-Datenbank. * - * Hinweis: Ab V2 exportiert Room das Schema als JSON nach `app/schemas/`. - * Künftige Migrationen (V2 → V3 usw.) können deshalb MigrationTestHelper mit - * `createDatabase(name, version)` nutzen, das die gespeicherte Schema-Datei - * als Ausgangsbasis verwendet. - * - * Für V1 → V2 existierte noch kein exportiertes Schema, daher wird die V1-DB - * hier manuell per SQLite-API aufgebaut. + * Ab Version 6 nutzt die App Room @AutoMigration. Alte manuelle Migrationen + * (v1–v5) wurden entfernt, da keine Rückwärtskompatibilität benötigt wird. + * Neue AutoMigrations werden automatisch durch Room validiert. */ @RunWith(AndroidJUnit4::class) internal class KrisenvorratDatabaseMigrationTest { @@ -43,379 +37,45 @@ internal class KrisenvorratDatabaseMigrationTest { context.deleteDatabase(dbName) } - // ------------------------------------------------------------------------- - // Hilfsmethoden - // ------------------------------------------------------------------------- - - /** - * Legt eine V1-Datenbank auf dem Gerät an. - * Schema: items mit kcal_per_100g + min_stock (vor dem Umbenennen/Löschen). - */ - private fun createV1Database(includeTestData: Boolean = false) { - 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_100g` INTEGER, - `expiry_date` TEXT, - `location_id` INTEGER NOT NULL, - `min_stock` REAL 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`))" - ) - if (includeTestData) { - db.execSQL("INSERT INTO `categories` (id, name) VALUES (1, 'Lebensmittel')") - db.execSQL("INSERT INTO `locations` (id, name) VALUES (1, 'Keller')") - db.execSQL( - """ - INSERT INTO `items` - (id, name, category_id, quantity, unit, unit_price, - kcal_per_100g, expiry_date, location_id, min_stock, notes, last_updated) - VALUES - ('item-uuid-1', 'Apfel', 1, 5.0, 'kg', 2.50, - 52, NULL, 1, 1.0, 'Testnotiz', 1700000000000) - """.trimIndent() - ) - } - db.version = 1 - } - } - - private fun openMigratedDb() = Room.databaseBuilder( - context, - KrisenvorratDatabase::class.java, - dbName - ).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5).build() - - private fun createV2Database() { - 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.version = 2 - } - } - - private fun openMigratedDbV3() = Room.databaseBuilder( - context, - KrisenvorratDatabase::class.java, - dbName - ).addMigrations(Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5).build() - - private fun openMigratedDbV4() = Room.databaseBuilder( - context, - KrisenvorratDatabase::class.java, - dbName - ).addMigrations(Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5).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 - // ------------------------------------------------------------------------- - @Test - fun migrate1To2_itemDataIsPreserved() { - createV1Database(includeTestData = true) - - val db = openMigratedDb() - try { - val items = runBlocking { db.itemDao().getAll().first() } - - assertEquals("Ein Item muss nach der Migration erhalten sein", 1, items.size) - val item = items[0] - assertEquals("item-uuid-1", item.id) - assertEquals("Apfel", item.name) - assertEquals(52, item.kcalPerUnit) - assertEquals("Testnotiz", item.notes) - assertEquals(5.0, item.quantity, 0.0) - } finally { - db.close() - } - } - - @Test - fun migrate1To2_schemaIsCorrect() { - createV1Database() - - val db = openMigratedDb() - try { - val columns = mutableListOf() - db.openHelper.writableDatabase.query("PRAGMA table_info(items)").use { cursor -> - while (cursor.moveToNext()) { - columns.add(cursor.getString(cursor.getColumnIndexOrThrow("name"))) - } - } - - assertFalse("min_stock darf nicht mehr existieren", columns.contains("min_stock")) - assertFalse("kcal_per_100g darf nicht mehr existieren", columns.contains("kcal_per_100g")) - assertTrue("kcal_per_unit muss existieren", columns.contains("kcal_per_unit")) - assertTrue("id muss existieren", columns.contains("id")) - assertTrue("name muss existieren", columns.contains("name")) - assertTrue("last_updated muss existieren", columns.contains("last_updated")) - } finally { - db.close() - } - } - - @Test - fun migrate1To2_indicesExist() { - createV1Database() - - val db = openMigratedDb() - try { - val indices = mutableListOf() - db.openHelper.writableDatabase.query("PRAGMA index_list(items)").use { cursor -> - while (cursor.moveToNext()) { - indices.add(cursor.getString(cursor.getColumnIndexOrThrow("name"))) - } - } - - assertTrue( - "index_items_category_id muss vorhanden sein", - indices.any { it.contains("category_id") } - ) - assertTrue( - "index_items_location_id muss vorhanden sein", - indices.any { it.contains("location_id") } - ) - } finally { - db.close() - } - } - - @Test - fun freshInstall_worksWithoutMigration() { - // Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig + fun freshInstall_allTablesExist() { + val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) + .build() + 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("items Tabelle muss existieren", tables.contains("items")) + assertTrue("categories Tabelle muss existieren", tables.contains("categories")) + assertTrue("locations Tabelle muss existieren", tables.contains("locations")) + assertTrue("settings Tabelle muss existieren", tables.contains("settings")) + assertTrue("pending_sync_ops Tabelle muss existieren", tables.contains("pending_sync_ops")) + assertTrue("messages Tabelle muss existieren", tables.contains("messages")) + } finally { + db.close() + } + } + + @Test + fun freshInstall_crudOperationsWork() { val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) - .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5) .build() try { - // Tabellen anlegen und Basis-Operationen prüfen runBlocking { db.categoryDao().insert(CategoryEntity(name = "Testkat")) db.locationDao().insert(LocationEntity(name = "Testort")) val cats = db.categoryDao().getAll().first() assertEquals(1, cats.size) + assertEquals("Testkat", cats[0].name) } } finally { db.close() } } - - @Test - fun migrate2To3_pendingSyncOpsTableExists() { - createV2Database() - - val db = openMigratedDbV3() - 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("pending_sync_ops Tabelle muss existieren", tables.contains("pending_sync_ops")) - - // Eine Row kann eingetragen und gelesen werden - db.openHelper.writableDatabase.execSQL( - "INSERT INTO pending_sync_ops (id, item_id, operation, payload, created_at) " + - "VALUES ('op1', 'item1', 'PATCH', '{\"key\":\"val\"}', 1000)" - ) - var rowCount = 0 - db.openHelper.writableDatabase.query( - "SELECT COUNT(*) FROM pending_sync_ops" - ).use { cursor -> - if (cursor.moveToNext()) rowCount = cursor.getInt(0) - } - assertEquals("Genau eine Row muss in pending_sync_ops stehen", 1, rowCount) - } finally { - 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.kcalPerUnit) - - // 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/KrisenvorratDatabase.kt b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt index d8b2bc4..a6cbfcb 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt @@ -18,7 +18,7 @@ import de.krisenvorrat.app.data.db.entity.SettingsEntity @Database( entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class], - version = 5, + version = 6, exportSchema = true ) @TypeConverters(LocalDateConverter::class) 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 9dee48c..8422056 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 @@ -1,168 +1,16 @@ package de.krisenvorrat.app.data.db -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - /** - * Enthält alle Room-Migrationen der Krisenvorrat-Datenbank. + * Room-Migrationen der Krisenvorrat-Datenbank. * - * Checkliste für jede neue Schema-Änderung: - * 1. Migration(X, Y)-Objekt hier ergänzen - * 2. DB-Version in [KrisenvorratDatabase] hochzählen - * 3. Migration in [de.krisenvorrat.app.di.DatabaseModule].addMigrations() eintragen - * 4. Migrationspfad in [de.krisenvorrat.app.data.db.KrisenvorratDatabaseMigrationTest] testen - * 5. Bei neuen Pflichtfeldern ohne sinnvollen Default: interaktiven MigrationScreen ergänzen + * Ab Version 6 nutzt die App Room @AutoMigration für Schema-Änderungen. + * Manuelle Migrationen werden nur noch benötigt, wenn AutoMigration nicht + * ausreicht (z.B. Table-Rebuild, Daten-Transformation). + * + * Checkliste für neue Schema-Änderungen: + * 1. DB-Version in [KrisenvorratDatabase] hochzählen + * 2. @AutoMigration(from = X, to = Y) in der @Database-Annotation ergänzen + * 3. Falls AutoMigration nicht reicht: Migration(X, Y) hier ergänzen + * und in [de.krisenvorrat.app.di.DatabaseModule].addMigrations() eintragen */ -internal object Migrations { - - /** - * V1 → V2: - * - `kcal_per_100g` umbenannt in `kcal_per_kg` - * - `min_stock`-Spalte entfernt - * - * SQLite unterstützt erst ab 3.25 (API 29) ALTER TABLE … RENAME COLUMN. - * Da minSdk = 26, wird die Tabelle neu erstellt und die Daten kopiert. - */ - val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE `items_new` ( - `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`), - FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE, - FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE - ) - """.trimIndent() - ) - db.execSQL( - """ - INSERT INTO `items_new` - (id, name, category_id, quantity, unit, unit_price, - kcal_per_kg, expiry_date, location_id, notes, last_updated) - SELECT - id, name, category_id, quantity, unit, unit_price, - kcal_per_100g, expiry_date, location_id, notes, last_updated - FROM `items` - """.trimIndent() - ) - db.execSQL("DROP TABLE `items`") - db.execSQL("ALTER TABLE `items_new` RENAME TO `items`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `items` (`category_id`)" - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `items` (`location_id`)" - ) - } - } - - /** - * 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( - """ - 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() - ) - } - } - - /** - * 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( - """ - CREATE TABLE IF NOT EXISTS `messages` ( - `id` TEXT NOT NULL, - `sender_id` TEXT NOT NULL, - `sender_username` TEXT NOT NULL, - `receiver_id` TEXT NOT NULL, - `body` TEXT NOT NULL, - `sent_at` INTEGER NOT NULL, - `is_pending` INTEGER NOT NULL, - PRIMARY KEY(`id`) - ) - """.trimIndent() - ) - } - } - - /** - * V4 → V5: - * - `kcal_per_kg` umbenannt in `kcal_per_unit` - * - * SQLite unterstützt erst ab 3.25 (API 29) ALTER TABLE … RENAME COLUMN. - * Da minSdk = 26, wird die Tabelle neu erstellt und die Daten kopiert. - */ - val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE `items_new` ( - `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_unit` INTEGER, - `expiry_date` TEXT, - `location_id` INTEGER NOT NULL, - `notes` TEXT NOT NULL, - `last_updated` INTEGER NOT NULL, - PRIMARY KEY(`id`), - FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE, - FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) - ON UPDATE NO ACTION ON DELETE CASCADE - ) - """.trimIndent() - ) - db.execSQL( - """ - INSERT INTO `items_new` - (id, name, category_id, quantity, unit, unit_price, - kcal_per_unit, expiry_date, location_id, notes, last_updated) - SELECT - id, name, category_id, quantity, unit, unit_price, - kcal_per_kg, expiry_date, location_id, notes, last_updated - FROM `items` - """.trimIndent() - ) - db.execSQL("DROP TABLE `items`") - db.execSQL("ALTER TABLE `items_new` RENAME TO `items`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `items` (`category_id`)" - ) - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `items` (`location_id`)" - ) - } - } -} +internal object Migrations diff --git a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt index 3003fdf..9d7b00f 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt @@ -11,7 +11,6 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import de.krisenvorrat.app.data.db.KrisenvorratDatabase -import de.krisenvorrat.app.data.db.Migrations import de.krisenvorrat.app.data.db.dao.CategoryDao import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.LocationDao @@ -30,7 +29,7 @@ internal object DatabaseModule { fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") .addCallback(DefaultDataCallback) - .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5) + .fallbackToDestructiveMigration() .build() private object DefaultDataCallback : RoomDatabase.Callback() { diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 3c0b92c..0b02cc7 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -2,66 +2,54 @@ ## Grundsatz -**`fallbackToDestructiveMigration()` ist verboten.** Jede Schema-Änderung muss durch eine -explizite Migration abgedeckt sein, damit User-Daten bei App-Updates erhalten bleiben. +Ab Version 6 nutzt die App **Room @AutoMigration** für Schema-Änderungen. +Room generiert die Migrationen automatisch – User müssen nichts tun. + +`fallbackToDestructiveMigration()` ist als Fallback aktiv: Kann Room keine +automatische Migration ableiten, wird die DB zurückgesetzt und neu erstellt. ## Aktuelle DB-Version -**Version 4** (Stand: Mai 2026) +**Version 6** (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 | +Historische manuelle Migrationen (v1–v5) wurden entfernt. +Keine Rückwärtskompatibilität zu älteren DB-Versionen. ## 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 +1. **DB-Version hochzählen**: `version` in `KrisenvorratDatabase` anpassen +2. **@AutoMigration ergänzen**: `autoMigrations = [AutoMigration(from = X, to = Y)]` in der `@Database`-Annotation +3. **Falls AutoMigration nicht reicht** (z.B. Column-Rename, Table-Rebuild): + - `@RenameColumn` / `@DeleteColumn` AutoMigrationSpec schreiben + - Oder manuelle `Migration(X, Y)` in `Migrations.kt` + `DatabaseModule.addMigrations()` +4. **Schema-Export prüfen**: JSON in `app/schemas/` wird automatisch generiert -## Migration schreiben +## Beispiel: Einfache Spalte hinzufügen ```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 ''") - } -} +// KrisenvorratDatabase.kt +@Database( + entities = [...], + version = 7, + autoMigrations = [AutoMigration(from = 6, to = 7)], + exportSchema = true +) ``` -### Wichtige SQLite-Einschränkungen +Fertig – Room erkennt die neue Spalte automatisch. -- **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 +## Beispiel: Spalte umbenennen ```kotlin -@Test -fun migrate4To5_newColumnExists() { - createV4Database() - val db = openMigratedDbV5() - try { - // Schema prüfen - // Daten prüfen - } finally { - db.close() - } +@Database( + entities = [...], + version = 7, + autoMigrations = [AutoMigration(from = 6, to = 7, spec = V6ToV7::class)], + exportSchema = true +) +abstract class KrisenvorratDatabase : RoomDatabase() { + @RenameColumn(tableName = "items", fromColumnName = "old_name", toColumnName = "new_name") + class V6ToV7 : AutoMigrationSpec } ```