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
This commit is contained in:
Jens Reinemann 2026-05-16 14:52:06 +02:00
parent 018d8dc7da
commit f4b5197b06
6 changed files with 499 additions and 2 deletions

View file

@ -41,6 +41,13 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
sourceSets {
getByName("androidTest").assets.srcDirs("$projectDir/schemas")
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
} }
dependencies { dependencies {

View file

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

View file

@ -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<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_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<String>()
db.openHelper.writableDatabase.query("PRAGMA index_list(items)").use { cursor ->
while (cursor.moveToNext()) {
indices.add(cursor.getString(cursor.getColumnIndexOrThrow("name")))
}
}
assertTrue(
"index_items_category_id muss vorhanden sein",
indices.any { it.contains("category_id") }
)
assertTrue(
"index_items_location_id muss vorhanden sein",
indices.any { it.contains("location_id") }
)
} finally {
db.close()
}
}
@Test
fun freshInstall_worksWithoutMigration() {
// Fresh-Install: Room.onCreate() läuft direkt, keine Migration nötig
val db = Room.inMemoryDatabaseBuilder(context, KrisenvorratDatabase::class.java)
.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()
}
}
}

View file

@ -15,7 +15,7 @@ import de.krisenvorrat.app.data.db.entity.SettingsEntity
@Database( @Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class], entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class],
version = 2, version = 2,
exportSchema = false exportSchema = true
) )
@TypeConverters(LocalDateConverter::class) @TypeConverters(LocalDateConverter::class)
internal abstract class KrisenvorratDatabase : RoomDatabase() { internal abstract class KrisenvorratDatabase : RoomDatabase() {

View file

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

View file

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