refactor(room): fallbackToDestructiveMigration entfernen und Migrationstests vervollständigen
Migrations.kt: KDoc für MIGRATION_2_3 und MIGRATION_3_4 ergänzt. KrisenvorratDatabaseMigrationTest: MIGRATION_3_4 in alle Testhelfer aufgenommen, createV3Database() + openMigratedDbV4() hinzugefügt, Tests für v3→v4 (messages-Tabelle) und v1→v4 Full-Path-Migration ergänzt. freshInstall-Test registriert jetzt alle Migrationen. docs/migration-guide.md: Entwickler-Leitfaden mit Checkliste, SQLite-Einschränkungen und Testanleitung. fallbackToDestructiveMigration() war bereits entfernt; dieses Ticket stellt sicher, dass alle Migrationspfade getestet und dokumentiert sind. Closes #71
This commit is contained in:
parent
f792213b1e
commit
90580ecb3e
3 changed files with 229 additions and 3 deletions
|
|
@ -107,7 +107,7 @@ internal class KrisenvorratDatabaseMigrationTest {
|
||||||
context,
|
context,
|
||||||
KrisenvorratDatabase::class.java,
|
KrisenvorratDatabase::class.java,
|
||||||
dbName
|
dbName
|
||||||
).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3).build()
|
).addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4).build()
|
||||||
|
|
||||||
private fun createV2Database() {
|
private fun createV2Database() {
|
||||||
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
|
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
|
||||||
|
|
@ -150,7 +150,62 @@ internal class KrisenvorratDatabaseMigrationTest {
|
||||||
context,
|
context,
|
||||||
KrisenvorratDatabase::class.java,
|
KrisenvorratDatabase::class.java,
|
||||||
dbName
|
dbName
|
||||||
).addMigrations(Migrations.MIGRATION_2_3).build()
|
).addMigrations(Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4).build()
|
||||||
|
|
||||||
|
private fun openMigratedDbV4() = Room.databaseBuilder(
|
||||||
|
context,
|
||||||
|
KrisenvorratDatabase::class.java,
|
||||||
|
dbName
|
||||||
|
).addMigrations(Migrations.MIGRATION_3_4).build()
|
||||||
|
|
||||||
|
private fun createV3Database() {
|
||||||
|
val dbFile = context.getDatabasePath(dbName).also { it.parentFile?.mkdirs() }
|
||||||
|
SQLiteDatabase.openOrCreateDatabase(dbFile, null).use { db ->
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE `categories` " +
|
||||||
|
"(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE `locations` " +
|
||||||
|
"(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `items` (
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`name` TEXT NOT NULL,
|
||||||
|
`category_id` INTEGER NOT NULL,
|
||||||
|
`quantity` REAL NOT NULL,
|
||||||
|
`unit` TEXT NOT NULL,
|
||||||
|
`unit_price` REAL NOT NULL,
|
||||||
|
`kcal_per_kg` INTEGER,
|
||||||
|
`expiry_date` TEXT,
|
||||||
|
`location_id` INTEGER NOT NULL,
|
||||||
|
`notes` TEXT NOT NULL,
|
||||||
|
`last_updated` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE `settings` " +
|
||||||
|
"(`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))"
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `pending_sync_ops` (
|
||||||
|
`id` TEXT NOT NULL,
|
||||||
|
`item_id` TEXT NOT NULL,
|
||||||
|
`operation` TEXT NOT NULL,
|
||||||
|
`payload` TEXT NOT NULL,
|
||||||
|
`created_at` INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(`id`)
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
db.version = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
|
|
@ -230,7 +285,7 @@ internal class KrisenvorratDatabaseMigrationTest {
|
||||||
fun freshInstall_worksWithoutMigration() {
|
fun freshInstall_worksWithoutMigration() {
|
||||||
// Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig
|
// 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)
|
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
// Tabellen anlegen und Basis-Operationen prüfen
|
// Tabellen anlegen und Basis-Operationen prüfen
|
||||||
|
|
@ -278,4 +333,89 @@ internal class KrisenvorratDatabaseMigrationTest {
|
||||||
db.close()
|
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.kcalPerKg)
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ internal object Migrations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 → V3:
|
||||||
|
* - Neue Tabelle `pending_sync_ops` für die Offline-Sync-Queue.
|
||||||
|
*/
|
||||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
@ -86,6 +90,10 @@ internal object Migrations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V3 → V4:
|
||||||
|
* - Neue Tabelle `messages` für die Chat-/Messaging-Funktion.
|
||||||
|
*/
|
||||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
|
|
|
||||||
78
docs/migration-guide.md
Normal file
78
docs/migration-guide.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Room-Migrationen – Entwickler-Leitfaden
|
||||||
|
|
||||||
|
## Grundsatz
|
||||||
|
|
||||||
|
**`fallbackToDestructiveMigration()` ist verboten.** Jede Schema-Änderung muss durch eine
|
||||||
|
explizite Migration abgedeckt sein, damit User-Daten bei App-Updates erhalten bleiben.
|
||||||
|
|
||||||
|
## Aktuelle DB-Version
|
||||||
|
|
||||||
|
**Version 4** (Stand: Mai 2026)
|
||||||
|
|
||||||
|
| Migration | Änderung |
|
||||||
|
|-----------|----------|
|
||||||
|
| v1 → v2 | `kcal_per_100g` → `kcal_per_kg`, `min_stock` entfernt |
|
||||||
|
| v2 → v3 | Tabelle `pending_sync_ops` hinzugefügt |
|
||||||
|
| v3 → v4 | Tabelle `messages` hinzugefügt |
|
||||||
|
|
||||||
|
## Checkliste für neue Schema-Änderungen
|
||||||
|
|
||||||
|
1. **Migration schreiben**: Neues `Migration(X, Y)`-Objekt in `Migrations.kt` ergänzen
|
||||||
|
2. **DB-Version hochzählen**: `version` in `KrisenvorratDatabase` anpassen
|
||||||
|
3. **Migration registrieren**: In `DatabaseModule.addMigrations()` eintragen
|
||||||
|
4. **Test schreiben**: Migrationspfad in `KrisenvorratDatabaseMigrationTest` testen
|
||||||
|
5. **Pflichtfelder**: Bei neuen Pflichtfeldern ohne sinnvollen Default → interaktiven MigrationScreen ergänzen
|
||||||
|
|
||||||
|
## Migration schreiben
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In Migrations.kt
|
||||||
|
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
// SQL-Statements für die Schema-Änderung
|
||||||
|
db.execSQL("ALTER TABLE items ADD COLUMN new_column TEXT NOT NULL DEFAULT ''")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wichtige SQLite-Einschränkungen
|
||||||
|
|
||||||
|
- **ALTER TABLE RENAME COLUMN** erst ab SQLite 3.25 (Android API 29). Bei minSdk < 29:
|
||||||
|
Tabelle neu erstellen, Daten kopieren, alte Tabelle löschen, neue umbenennen.
|
||||||
|
- **ALTER TABLE DROP COLUMN** erst ab SQLite 3.35 (Android API 34). Gleiche Workaround-Strategie.
|
||||||
|
- **Immer `CREATE TABLE IF NOT EXISTS`** für neue Tabellen verwenden.
|
||||||
|
- **Indizes** nach Table-Rebuild neu erstellen (`CREATE INDEX IF NOT EXISTS`).
|
||||||
|
|
||||||
|
## Tests schreiben
|
||||||
|
|
||||||
|
Jede Migration braucht mindestens:
|
||||||
|
|
||||||
|
1. **Schema-Test**: Prüfen, dass neue Spalten/Tabellen existieren und alte entfernt sind
|
||||||
|
2. **Daten-Test**: Prüfen, dass bestehende Daten nach der Migration erhalten sind
|
||||||
|
3. **Full-Path-Test**: Prüfen, dass v1 → aktuelle Version ohne Fehler durchläuft
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun migrate4To5_newColumnExists() {
|
||||||
|
createV4Database()
|
||||||
|
val db = openMigratedDbV5()
|
||||||
|
try {
|
||||||
|
// Schema prüfen
|
||||||
|
// Daten prüfen
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema-Export
|
||||||
|
|
||||||
|
Room exportiert Schemas automatisch nach `app/schemas/`. Diese JSON-Dateien werden
|
||||||
|
versioniert und können für `MigrationTestHelper` verwendet werden.
|
||||||
|
|
||||||
|
Konfiguration in `app/build.gradle.kts`:
|
||||||
|
```kotlin
|
||||||
|
ksp {
|
||||||
|
arg("room.schemaLocation", "$projectDir/schemas")
|
||||||
|
}
|
||||||
|
```
|
||||||
Loading…
Reference in a new issue