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
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
* (v1v5) 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<String>()
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
fun freshInstall_allTablesExist() {
val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java)
.build()
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("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<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(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 5,
version = 6,
exportSchema = true
)
@TypeConverters(LocalDateConverter::class)

View file

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

View file

@ -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() {

View file

@ -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 (v1v5) 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
}
```