From f4b5197b068cc4b6d798ceab1b4f0773f680e751 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Sat, 16 May 2026 14:52:06 +0200 Subject: [PATCH] infra: DB-Migration-Infrastruktur einrichten (#49) - fallbackToDestructiveMigration() entfernt (war inakzeptabel) - addMigrations(MIGRATION_1_2) in DatabaseModule eingetragen - Migrations.kt: Migration(1,2) mit Tabellen-Neubau fuer SQLite < 3.25 (kcal_per_100g -> kcal_per_kg, min_stock entfernt) - exportSchema = true + KSP-Argument room.schemaLocation = app/schemas/ - 2.json Schema-Snapshot eingecheckt (Basis fuer kuenftige Migrationen) - androidTest-Assets zeigen auf app/schemas/ (fuer MigrationTestHelper) - KrisenvorratDatabaseMigrationTest: 4 instrumentierte Tests - Datenerhalt nach Migration - Korrekte Spalten nach Migration - Indices nach Migration - Fresh-Install ohne Migration --- app/build.gradle.kts | 7 + .../2.json | 214 ++++++++++++++++++ .../db/KrisenvorratDatabaseMigrationTest.kt | 204 +++++++++++++++++ .../app/data/db/KrisenvorratDatabase.kt | 2 +- .../de/krisenvorrat/app/data/db/Migrations.kt | 71 ++++++ .../de/krisenvorrat/app/di/DatabaseModule.kt | 3 +- 6 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/2.json create mode 100644 app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt create mode 100644 app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c66ca1..9b3a4c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,13 @@ android { compose = true buildConfig = true } + sourceSets { + getByName("androidTest").assets.srcDirs("$projectDir/schemas") + } +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } dependencies { diff --git a/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/2.json b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/2.json new file mode 100644 index 0000000..680ea0d --- /dev/null +++ b/app/schemas/de.krisenvorrat.app.data.db.KrisenvorratDatabase/2.json @@ -0,0 +1,214 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "4b6e7b8b9387bc7884e449d148a05cdd", + "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_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 )", + "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": "kcalPerKg", + "columnName": "kcal_per_kg", + "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": [] + } + ], + "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, '4b6e7b8b9387bc7884e449d148a05cdd')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt new file mode 100644 index 0000000..1e98a75 --- /dev/null +++ b/app/src/androidTest/java/de/krisenvorrat/app/data/db/KrisenvorratDatabaseMigrationTest.kt @@ -0,0 +1,204 @@ +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 +import de.krisenvorrat.app.data.db.entity.CategoryEntity +import de.krisenvorrat.app.data.db.entity.LocationEntity +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. + * + * 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. + */ +@RunWith(AndroidJUnit4::class) +internal class KrisenvorratDatabaseMigrationTest { + + private val dbName = "krisenvorrat-migration-test.db" + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setUp() { + context.deleteDatabase(dbName) + } + + @After + fun tearDown() { + 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).build() + + // ------------------------------------------------------------------------- + // 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.kcalPerKg) + assertEquals("Testnotiz", item.notes) + assertEquals(5.0, item.quantity, 0.0) + } finally { + db.close() + } + } + + @Test + fun migrate1To2_schemaIsCorrect() { + createV1Database() + + val db = openMigratedDb() + try { + val columns = mutableListOf() + db.openHelper.writableDatabase.query("PRAGMA table_info(items)").use { cursor -> + while (cursor.moveToNext()) { + columns.add(cursor.getString(cursor.getColumnIndexOrThrow("name"))) + } + } + + assertFalse("min_stock darf nicht mehr existieren", columns.contains("min_stock")) + assertFalse("kcal_per_100g darf nicht mehr existieren", columns.contains("kcal_per_100g")) + assertTrue("kcal_per_kg muss existieren", columns.contains("kcal_per_kg")) + assertTrue("id muss existieren", columns.contains("id")) + assertTrue("name muss existieren", columns.contains("name")) + assertTrue("last_updated muss existieren", columns.contains("last_updated")) + } finally { + db.close() + } + } + + @Test + fun migrate1To2_indicesExist() { + createV1Database() + + val db = openMigratedDb() + try { + val indices = mutableListOf() + db.openHelper.writableDatabase.query("PRAGMA index_list(items)").use { cursor -> + while (cursor.moveToNext()) { + indices.add(cursor.getString(cursor.getColumnIndexOrThrow("name"))) + } + } + + assertTrue( + "index_items_category_id muss vorhanden sein", + indices.any { it.contains("category_id") } + ) + assertTrue( + "index_items_location_id muss vorhanden sein", + indices.any { it.contains("location_id") } + ) + } finally { + db.close() + } + } + + @Test + fun freshInstall_worksWithoutMigration() { + // Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig + val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java) + .addMigrations(Migrations.MIGRATION_1_2) + .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) + } + } finally { + db.close() + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt index 112701d..5226ecb 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/db/KrisenvorratDatabase.kt @@ -15,7 +15,7 @@ import de.krisenvorrat.app.data.db.entity.SettingsEntity @Database( entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class], version = 2, - exportSchema = false + exportSchema = true ) @TypeConverters(LocalDateConverter::class) internal abstract class KrisenvorratDatabase : RoomDatabase() { diff --git a/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt new file mode 100644 index 0000000..0add0e3 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/db/Migrations.kt @@ -0,0 +1,71 @@ +package de.krisenvorrat.app.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Enthält alle 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 + * + * 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`)" + ) + } + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt index 7e1c1e7..0659660 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/DatabaseModule.kt @@ -11,6 +11,7 @@ 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 @@ -27,7 +28,7 @@ internal object DatabaseModule { fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase = Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db") .addCallback(DefaultDataCallback) - .fallbackToDestructiveMigration() + .addMigrations(Migrations.MIGRATION_1_2) .build() private object DefaultDataCallback : RoomDatabase.Callback() {