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
This commit is contained in:
Jens Reinemann 2026-05-17 11:43:27 +02:00
parent 5e9c072b51
commit 1df2d1cff5
8 changed files with 509 additions and 582 deletions

View file

@ -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.

View file

@ -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

View file

@ -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')"
]
}
}

View file

@ -1,6 +1,5 @@
package de.krisenvorrat.app.data.db package de.krisenvorrat.app.data.db
import android.database.sqlite.SQLiteDatabase
import androidx.room.Room import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@ -10,22 +9,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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/`. * Ab Version 6 nutzt die App Room @AutoMigration. Alte manuelle Migrationen
* Künftige Migrationen (V2 V3 usw.) können deshalb MigrationTestHelper mit * (v1v5) wurden entfernt, da keine Rückwärtskompatibilität benötigt wird.
* `createDatabase(name, version)` nutzen, das die gespeicherte Schema-Datei * Neue AutoMigrations werden automatisch durch Room validiert.
* als Ausgangsbasis verwendet.
*
* Für V1 V2 existierte noch kein exportiertes Schema, daher wird die V1-DB
* hier manuell per SQLite-API aufgebaut.
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
internal class KrisenvorratDatabaseMigrationTest { internal class KrisenvorratDatabaseMigrationTest {
@ -43,379 +37,45 @@ internal class KrisenvorratDatabaseMigrationTest {
context.deleteDatabase(dbName) 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 @Test
fun migrate1To2_itemDataIsPreserved() { fun freshInstall_allTablesExist() {
createV1Database(includeTestData = true) val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java)
.build()
val db = openMigratedDb() try {
try { val tables = mutableListOf<String>()
val items = runBlocking { db.itemDao().getAll().first() } db.openHelper.writableDatabase.query(
"SELECT name FROM sqlite_master WHERE type='table'"
assertEquals("Ein Item muss nach der Migration erhalten sein", 1, items.size) ).use { cursor ->
val item = items[0] while (cursor.moveToNext()) {
assertEquals("item-uuid-1", item.id) tables.add(cursor.getString(0))
assertEquals("Apfel", item.name) }
assertEquals(52, item.kcalPerUnit) }
assertEquals("Testnotiz", item.notes)
assertEquals(5.0, item.quantity, 0.0) assertTrue("items Tabelle muss existieren", tables.contains("items"))
} finally { assertTrue("categories Tabelle muss existieren", tables.contains("categories"))
db.close() 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"))
@Test } finally {
fun migrate1To2_schemaIsCorrect() { db.close()
createV1Database() }
}
val db = openMigratedDb()
try { @Test
val columns = mutableListOf<String>() fun freshInstall_crudOperationsWork() {
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<String>()
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
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, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5)
.build() .build()
try { try {
// Tabellen anlegen und Basis-Operationen prüfen
runBlocking { runBlocking {
db.categoryDao().insert(CategoryEntity(name = "Testkat")) db.categoryDao().insert(CategoryEntity(name = "Testkat"))
db.locationDao().insert(LocationEntity(name = "Testort")) db.locationDao().insert(LocationEntity(name = "Testort"))
val cats = db.categoryDao().getAll().first() val cats = db.categoryDao().getAll().first()
assertEquals(1, cats.size) assertEquals(1, cats.size)
assertEquals("Testkat", cats[0].name)
} }
} finally { } finally {
db.close() db.close()
} }
} }
@Test
fun migrate2To3_pendingSyncOpsTableExists() {
createV2Database()
val db = openMigratedDbV3()
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("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<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.kcalPerUnit)
// 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

@ -18,7 +18,7 @@ import de.krisenvorrat.app.data.db.entity.SettingsEntity
@Database( @Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class], entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 5, version = 6,
exportSchema = true exportSchema = true
) )
@TypeConverters(LocalDateConverter::class) @TypeConverters(LocalDateConverter::class)

View file

@ -1,168 +1,16 @@
package de.krisenvorrat.app.data.db 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: * Ab Version 6 nutzt die App Room @AutoMigration für Schema-Änderungen.
* 1. Migration(X, Y)-Objekt hier ergänzen * Manuelle Migrationen werden nur noch benötigt, wenn AutoMigration nicht
* 2. DB-Version in [KrisenvorratDatabase] hochzählen * ausreicht (z.B. Table-Rebuild, Daten-Transformation).
* 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
*/
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. * Checkliste für neue Schema-Änderungen:
* Da minSdk = 26, wird die Tabelle neu erstellt und die Daten kopiert. * 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
*/ */
val MIGRATION_1_2 = object : Migration(1, 2) { internal object Migrations
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`)"
)
}
}
}

View file

@ -11,7 +11,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.db.KrisenvorratDatabase 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.CategoryDao
import de.krisenvorrat.app.data.db.dao.ItemDao import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao import de.krisenvorrat.app.data.db.dao.LocationDao
@ -30,7 +29,7 @@ internal object DatabaseModule {
fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase =
Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db")
.addCallback(DefaultDataCallback) .addCallback(DefaultDataCallback)
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5) .fallbackToDestructiveMigration()
.build() .build()
private object DefaultDataCallback : RoomDatabase.Callback() { private object DefaultDataCallback : RoomDatabase.Callback() {

View file

@ -2,66 +2,54 @@
## Grundsatz ## Grundsatz
**`fallbackToDestructiveMigration()` ist verboten.** Jede Schema-Änderung muss durch eine Ab Version 6 nutzt die App **Room @AutoMigration** für Schema-Änderungen.
explizite Migration abgedeckt sein, damit User-Daten bei App-Updates erhalten bleiben. 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 ## Aktuelle DB-Version
**Version 4** (Stand: Mai 2026) **Version 6** (Stand: Mai 2026)
| Migration | Änderung | Historische manuelle Migrationen (v1v5) wurden entfernt.
| --------- | ----------------------------------------------------- | Keine Rückwärtskompatibilität zu älteren DB-Versionen.
| 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 ## Checkliste für neue Schema-Änderungen
1. **Migration schreiben**: Neues `Migration(X, Y)`-Objekt in `Migrations.kt` ergänzen 1. **DB-Version hochzählen**: `version` in `KrisenvorratDatabase` anpassen
2. **DB-Version hochzählen**: `version` in `KrisenvorratDatabase` anpassen 2. **@AutoMigration ergänzen**: `autoMigrations = [AutoMigration(from = X, to = Y)]` in der `@Database`-Annotation
3. **Migration registrieren**: In `DatabaseModule.addMigrations()` eintragen 3. **Falls AutoMigration nicht reicht** (z.B. Column-Rename, Table-Rebuild):
4. **Test schreiben**: Migrationspfad in `KrisenvorratDatabaseMigrationTest` testen - `@RenameColumn` / `@DeleteColumn` AutoMigrationSpec schreiben
5. **Pflichtfelder**: Bei neuen Pflichtfeldern ohne sinnvollen Default → interaktiven MigrationScreen ergänzen - 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 ```kotlin
// In Migrations.kt // KrisenvorratDatabase.kt
val MIGRATION_4_5 = object : Migration(4, 5) { @Database(
override fun migrate(db: SupportSQLiteDatabase) { entities = [...],
// SQL-Statements für die Schema-Änderung version = 7,
db.execSQL("ALTER TABLE items ADD COLUMN new_column TEXT NOT NULL DEFAULT ''") 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: ## Beispiel: Spalte umbenennen
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 ```kotlin
@Test @Database(
fun migrate4To5_newColumnExists() { entities = [...],
createV4Database() version = 7,
val db = openMigratedDbV5() autoMigrations = [AutoMigration(from = 6, to = 7, spec = V6ToV7::class)],
try { exportSchema = true
// Schema prüfen )
// Daten prüfen abstract class KrisenvorratDatabase : RoomDatabase() {
} finally { @RenameColumn(tableName = "items", fromColumnName = "old_name", toColumnName = "new_name")
db.close() class V6ToV7 : AutoMigrationSpec
}
} }
``` ```