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:
parent
5e9c072b51
commit
1df2d1cff5
8 changed files with 509 additions and 582 deletions
86
Anforderungen/design/db-migration/candidates.md
Normal file
86
Anforderungen/design/db-migration/candidates.md
Normal 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.
|
||||
32
Anforderungen/design/db-migration/requirements.md
Normal file
32
Anforderungen/design/db-migration/requirements.md
Normal 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
|
||||
|
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
internal object Migrations {
|
||||
|
||||
/**
|
||||
* V1 → V2:
|
||||
* - `kcal_per_100g` umbenannt in `kcal_per_kg`
|
||||
* - `min_stock`-Spalte entfernt
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
* 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
|
||||
*/
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue