feat: Migration-Safety – Room v7, AutoMigration, Flyway, kein fallbackToDestructiveMigration (#99)
- fallbackToDestructiveMigration() aus DatabaseModule entfernt - BollwerkDatabase auf Version 7 gebumpt - AutoMigration(from=5, to=6) und (from=6, to=7) definiert - MigrationTestHelper-Test migrate6To7_preservesData implementiert - 7.json Schema-Export generiert - Server: Flyway 9.22.3 integriert (baselineOnMigrate=true) - V1__initial_schema.sql + V2__cleanup_user_id.sql angelegt - Skill android-db-migration erstellt - versionCode 5 / versionName 1.4
This commit is contained in:
parent
3d7c01cef5
commit
045a4b7674
11 changed files with 689 additions and 10 deletions
219
.github/skills/android-db-migration/SKILL.md
vendored
Normal file
219
.github/skills/android-db-migration/SKILL.md
vendored
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
---
|
||||
name: android-db-migration
|
||||
description: >
|
||||
Room-Datenbankmigration in der Bollwerk-App: Schema-Version erhöhen, @AutoMigration hinzufügen,
|
||||
manuelle Migration schreiben, MigrationTestHelper-Tests erstellen, fallbackToDestructiveMigration
|
||||
entfernen/vermeiden. Nutze diesen Skill immer wenn es um Room-Schema-Änderungen, DB-Versionen,
|
||||
Migrationen, Spalten umbenennen/löschen, neue Tabellen oder Migrationstests geht.
|
||||
Trigger-Phrasen: "migration", "DB-Version", "Schema", "Room", "AutoMigration", "fallback",
|
||||
"Datenverlust", "Spalte hinzufügen/umbenennen", "BollwerkDatabase", "DatabaseModule".
|
||||
---
|
||||
|
||||
# Skill: Android DB-Migration (Room)
|
||||
|
||||
Vollständige Anleitung zur sicheren Room-Migration in der Bollwerk-App – vom Schema-Bump über @AutoMigration bis zum MigrationTestHelper-Test.
|
||||
|
||||
---
|
||||
|
||||
## Projektkontext
|
||||
|
||||
| Datei | Zweck |
|
||||
| --------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt` | `@Database`-Annotation, Versionsnummer, `autoMigrations`-Liste |
|
||||
| `app/src/main/java/de/bollwerk/app/data/db/Migrations.kt` | Manuelle `Migration(X, Y)`-Objekte (nur bei AutoMigration-Grenzen nötig) |
|
||||
| `app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt` | Room Builder – enthält **kein** `fallbackToDestructiveMigration()` |
|
||||
| `app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/` | Exportierte Schema-JSON-Dateien (auto-generiert via KSP) |
|
||||
| `app/src/androidTest/.../BollwerkDatabaseMigrationTest.kt` | `MigrationTestHelper`-Tests |
|
||||
|
||||
**KSP-Konfiguration** (`app/build.gradle.kts`):
|
||||
|
||||
```kotlin
|
||||
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
|
||||
```
|
||||
|
||||
```kotlin
|
||||
sourceSets { getByName("androidTest").assets.srcDirs("$projectDir/schemas") }
|
||||
```
|
||||
|
||||
→ Schema-JSONs werden bei jedem Build automatisch aktualisiert und sind als Assets für Migrationstests verfügbar.
|
||||
|
||||
---
|
||||
|
||||
## Goldene Regeln
|
||||
|
||||
1. **Niemals** `fallbackToDestructiveMigration()` verwenden – die App crasht bewusst, wenn kein Pfad definiert ist (Datenverlust bleibt sichtbar).
|
||||
2. **Jede** Versionserhöhung braucht einen `@AutoMigration`- oder manuellen `Migration`-Eintrag.
|
||||
3. **Immer** nach dem Schema-Bump bauen, damit die neue `N.json` generiert wird, bevor Tests geschrieben werden.
|
||||
4. Für Spalten-Umbenennungen **immer** `@RenameColumn`-Spec angeben (AutoMigration erkennt Rename nicht automatisch).
|
||||
|
||||
---
|
||||
|
||||
## Checkliste: Schema-Änderung
|
||||
|
||||
```
|
||||
[ ] 1. Entity-Datei anpassen (Spalte/Tabelle hinzufügen, umbenennen, löschen)
|
||||
[ ] 2. BollwerkDatabase.kt: version auf N+1 erhöhen
|
||||
[ ] 3. BollwerkDatabase.kt: autoMigrations um AutoMigration(from=N, to=N+1) ergänzen
|
||||
→ Bei Rename: spec = MeineRenameSpec::class angeben
|
||||
→ Bei komplexer Migration: manuelles Migration-Objekt in Migrations.kt
|
||||
[ ] 4. Build ausführen → N+1.json wird generiert
|
||||
[ ] 5. MigrationTestHelper-Test in BollwerkDatabaseMigrationTest.kt schreiben
|
||||
[ ] 6. Build + Tests grün
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BollwerkDatabase.kt – Muster
|
||||
|
||||
```kotlin
|
||||
@Database(
|
||||
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class,
|
||||
SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
|
||||
version = 8, // ← erhöhen
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 5, to = 6), // No-op (gleiche Schemas)
|
||||
AutoMigration(from = 6, to = 7), // No-op (Infrastruktur-Baseline)
|
||||
AutoMigration(from = 7, to = 8), // z.B. neue Spalte
|
||||
// AutoMigration(from = 8, to = 9, spec = MeineSpec::class) ← bei Rename
|
||||
]
|
||||
)
|
||||
@TypeConverters(LocalDateConverter::class)
|
||||
internal abstract class BollwerkDatabase : RoomDatabase() { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AutoMigration-Fälle
|
||||
|
||||
### Fall 1: Neue Spalte mit Default
|
||||
|
||||
```kotlin
|
||||
// Entity: neue Spalte mit @ColumnInfo(defaultValue = "")
|
||||
@ColumnInfo(name = "barcode", defaultValue = "") val barcode: String = ""
|
||||
|
||||
// @Database:
|
||||
AutoMigration(from = 7, to = 8)
|
||||
// Kein Spec nötig – Room erkennt ADD COLUMN automatisch
|
||||
```
|
||||
|
||||
### Fall 2: Spalte löschen
|
||||
|
||||
```kotlin
|
||||
// Spec-Klasse anlegen (in Migrations.kt oder eigenem File):
|
||||
@DeleteColumn(tableName = "items", columnName = "legacy_field")
|
||||
class Migration7To8Spec : AutoMigrationSpec
|
||||
|
||||
// @Database:
|
||||
AutoMigration(from = 7, to = 8, spec = Migration7To8Spec::class)
|
||||
```
|
||||
|
||||
### Fall 3: Spalte umbenennen
|
||||
|
||||
```kotlin
|
||||
@RenameColumn(tableName = "items", fromColumnName = "kcal_per_kg", toColumnName = "kcal_per_unit")
|
||||
class Migration7To8Spec : AutoMigrationSpec
|
||||
|
||||
// @Database:
|
||||
AutoMigration(from = 7, to = 8, spec = Migration7To8Spec::class)
|
||||
```
|
||||
|
||||
### Fall 4: Komplexe Migration (Daten transformieren)
|
||||
|
||||
Wenn AutoMigration nicht reicht (z. B. Daten kopieren, Tabellen zusammenführen):
|
||||
|
||||
```kotlin
|
||||
// In Migrations.kt:
|
||||
internal val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE items ADD COLUMN quantity_unit TEXT NOT NULL DEFAULT 'Stück'")
|
||||
db.execSQL("UPDATE items SET quantity_unit = unit WHERE unit IS NOT NULL")
|
||||
}
|
||||
}
|
||||
|
||||
// In DatabaseModule.kt:
|
||||
Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
|
||||
.addCallback(DefaultDataCallback)
|
||||
.addMigrations(MIGRATION_7_8)
|
||||
.build()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MigrationTestHelper-Test
|
||||
|
||||
```kotlin
|
||||
@get:Rule
|
||||
val migrationTestHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
BollwerkDatabase::class.java
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrate7To8_preservesData() {
|
||||
// 1. DB im alten Zustand anlegen und Testdaten einfügen
|
||||
migrationTestHelper.createDatabase(dbName, 7).use { db ->
|
||||
db.execSQL("INSERT INTO categories (name) VALUES ('Lebensmittel')")
|
||||
db.execSQL(
|
||||
"INSERT INTO items (id, name, category_id, quantity, unit, unit_price, " +
|
||||
"kcal_per_unit, expiry_date, location_id, notes, last_updated) " +
|
||||
"VALUES ('item-1', 'Wasser', 1, 10.0, 'Liter', 0.5, NULL, NULL, 1, '', 0)"
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Migration durchführen und Schema validieren
|
||||
migrationTestHelper.runMigrationsAndValidate(dbName, 8, true)
|
||||
.use { db ->
|
||||
// 3. Datenpersistenz prüfen
|
||||
db.query("SELECT id, name FROM items").use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("item-1", cursor.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** Die Schema-JSON-Dateien aus `app/schemas/` müssen als Assets im androidTest-Quellsatz liegen (bereits konfiguriert). Der `MigrationTestHelper` liest sie automatisch.
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fehler & Lösungen
|
||||
|
||||
| Fehler | Ursache | Lösung |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `IllegalStateException: A migration from X to Y was required but not found` | Version erhöht ohne AutoMigration-Eintrag | `@AutoMigration(from=X, to=Y)` in `@Database` eintragen |
|
||||
| `Cannot find implementation for AutoMigration` | Build noch nicht nach Schema-Änderung ausgeführt | `./gradlew assembleDebug` ausführen, `Y.json` muss existieren |
|
||||
| `Expected X migrations, found Y` | `addMigrations()` fehlt für manuelle Migration | In `DatabaseModule.kt` `.addMigrations(MIGRATION_X_Y)` eintragen |
|
||||
| `MigrationTestHelper: No schema file found for version X` | Schema-JSON für Version X existiert nicht in `app/schemas/` | Build mit Version X zuerst ausführen, JSON committen |
|
||||
| Rename-Spalte → Datenverlust | AutoMigration interpretiert Rename als DROP+ADD | `@RenameColumn`-Spec zwingend angeben |
|
||||
|
||||
---
|
||||
|
||||
## Aktuelle DB-Version
|
||||
|
||||
Aktuelle Produktionsversion: **7** (`BollwerkDatabase.kt`, `version = 7`)
|
||||
|
||||
Migrations-Pfade vorhanden:
|
||||
|
||||
- v5 → v6: No-op (identische Schemas, `@AutoMigration`)
|
||||
- v6 → v7: No-op (Infrastruktur-Baseline, `@AutoMigration`)
|
||||
|
||||
Nächste Version für neue Schema-Änderung: **v8**
|
||||
|
||||
---
|
||||
|
||||
## Hinweis: Server-Migration (Flyway)
|
||||
|
||||
Für Schema-Änderungen auf dem **Server** (PostgreSQL + Exposed):
|
||||
|
||||
- SQL-Skripte unter `server/src/main/resources/db/migration/`
|
||||
- Namenskonvention: `V{N}__{beschreibung}.sql`
|
||||
- Flyway ist integriert (`baselineOnMigrate = true`) – läuft beim Server-Start automatisch
|
||||
- Bestehende Datenbanken werden auf V1 gebaselined; neue Skripte werden einmalig ausgeführt
|
||||
|
||||
```sql
|
||||
-- Beispiel: server/src/main/resources/db/migration/V3__add_barcode_column.sql
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS barcode VARCHAR(100);
|
||||
```
|
||||
|
||||
`IF NOT EXISTS` / `IF EXISTS` in SQL verwenden, um Idempotenz zu gewährleisten.
|
||||
|
|
@ -15,8 +15,8 @@ android {
|
|||
applicationId = "de.bollwerk.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 4
|
||||
versionName = "1.3"
|
||||
versionCode = 5
|
||||
versionName = "1.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false")
|
||||
|
|
|
|||
314
app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/7.json
Normal file
314
app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/7.json
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 7,
|
||||
"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,7 @@
|
|||
package de.bollwerk.app.data.db
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.bollwerk.app.data.db.entity.CategoryEntity
|
||||
|
|
@ -11,6 +12,7 @@ import org.junit.After
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
|
|
@ -27,6 +29,12 @@ internal class BollwerkDatabaseMigrationTest {
|
|||
private val dbName = "bollwerk-migration-test.db"
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
@get:Rule
|
||||
val migrationTestHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
BollwerkDatabase::class.java
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context.deleteDatabase(dbName)
|
||||
|
|
@ -37,6 +45,36 @@ internal class BollwerkDatabaseMigrationTest {
|
|||
context.deleteDatabase(dbName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrate6To7_preservesAllData() {
|
||||
migrationTestHelper.createDatabase(dbName, 6).use { db ->
|
||||
db.execSQL("INSERT INTO categories (name) VALUES ('Lebensmittel')")
|
||||
db.execSQL("INSERT INTO locations (name) VALUES ('Keller')")
|
||||
db.execSQL(
|
||||
"INSERT INTO items (id, name, category_id, quantity, unit, unit_price, " +
|
||||
"kcal_per_unit, expiry_date, location_id, notes, last_updated) " +
|
||||
"VALUES ('item-1', 'Wasser', 1, 10.0, 'Liter', 0.5, NULL, NULL, 1, '', 0)"
|
||||
)
|
||||
}
|
||||
|
||||
migrationTestHelper.runMigrationsAndValidate(dbName, 7, true)
|
||||
.use { db ->
|
||||
db.query("SELECT id, name FROM items").use { cursor ->
|
||||
assertTrue("items Tabelle muss Datensätze haben", cursor.moveToFirst())
|
||||
assertEquals("item-1", cursor.getString(0))
|
||||
assertEquals("Wasser", cursor.getString(1))
|
||||
}
|
||||
db.query("SELECT name FROM categories").use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("Lebensmittel", cursor.getString(0))
|
||||
}
|
||||
db.query("SELECT name FROM locations").use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals("Keller", cursor.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshInstall_allTablesExist() {
|
||||
val db = Room.inMemoryDatabaseBuilder(context, BollwerkDatabase::class.java)
|
||||
|
|
@ -79,3 +117,4 @@ internal class BollwerkDatabaseMigrationTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.bollwerk.app.data.db
|
||||
|
||||
import androidx.room.AutoMigration
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
|
@ -18,8 +19,12 @@ import de.bollwerk.app.data.db.entity.SettingsEntity
|
|||
|
||||
@Database(
|
||||
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
|
||||
version = 6,
|
||||
exportSchema = true
|
||||
version = 7,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7)
|
||||
]
|
||||
)
|
||||
@TypeConverters(LocalDateConverter::class)
|
||||
internal abstract class BollwerkDatabase : RoomDatabase() {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ internal object DatabaseModule {
|
|||
fun provideDatabase(@ApplicationContext context: Context): BollwerkDatabase =
|
||||
Room.databaseBuilder(context, BollwerkDatabase::class.java, "bollwerk.db")
|
||||
.addCallback(DefaultDataCallback)
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
|
||||
private object DefaultDataCallback : RoomDatabase.Callback() {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ h2 = "2.3.232"
|
|||
postgresql = "42.7.4"
|
||||
hikaricp = "6.2.1"
|
||||
jbcrypt = "0.4"
|
||||
flyway = "9.22.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
|
|
@ -54,6 +55,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-
|
|||
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
|
||||
flyway-core = { group = "org.flywaydb", name = "flyway-core", version.ref = "flyway" }
|
||||
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
|
||||
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ dependencies {
|
|||
implementation(libs.exposed.jdbc)
|
||||
implementation(libs.postgresql)
|
||||
implementation(libs.hikaricp)
|
||||
implementation(libs.flyway.core)
|
||||
|
||||
testImplementation(libs.h2.database)
|
||||
testImplementation(libs.ktor.server.test.host)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package de.bollwerk.server.db
|
|||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.jetbrains.exposed.sql.SchemaUtils
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
|
|
@ -37,7 +38,9 @@ internal object DatabaseFactory {
|
|||
transactionIsolation = "TRANSACTION_REPEATABLE_READ"
|
||||
validate()
|
||||
}
|
||||
Database.connect(HikariDataSource(config))
|
||||
val dataSource = HikariDataSource(config)
|
||||
runFlyway(dataSource, jdbcUrl, dbUser, dbPassword)
|
||||
Database.connect(dataSource)
|
||||
} else {
|
||||
Database.connect(jdbcUrl, driver)
|
||||
}
|
||||
|
|
@ -49,9 +52,20 @@ internal object DatabaseFactory {
|
|||
seedAdmin(adminPassword)
|
||||
}
|
||||
|
||||
private fun runFlyway(dataSource: HikariDataSource, jdbcUrl: String, user: String, password: String) {
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.baselineOnMigrate(true)
|
||||
.baselineVersion("1")
|
||||
.load()
|
||||
.migrate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration: For each existing user without an inventoryId, create an inventory
|
||||
* and migrate their data from the old user_id column to the new inventory_id column.
|
||||
* The user_id column itself is cleaned up by Flyway V2__cleanup_user_id.sql.
|
||||
*/
|
||||
private fun migrateUserInventories() {
|
||||
transaction {
|
||||
|
|
@ -68,16 +82,15 @@ internal object DatabaseFactory {
|
|||
Users.update({ Users.id eq userId }) {
|
||||
it[Users.inventoryId] = inventoryId
|
||||
}
|
||||
// Migrate existing data from old user_id column to new inventory_id column.
|
||||
// The exec() calls are wrapped in try/catch because the user_id column may not
|
||||
// exist in fresh databases (only in upgraded databases from older schema).
|
||||
// Migrate existing data: set inventory_id where still null for this user.
|
||||
// Wrapped in try/catch because user_id column may already be dropped by Flyway V2.
|
||||
try {
|
||||
exec("UPDATE items SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
|
||||
exec("UPDATE categories SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
|
||||
exec("UPDATE locations SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
|
||||
exec("UPDATE settings SET inventory_id = '$inventoryId' WHERE user_id = '$userId' AND inventory_id IS NULL")
|
||||
} catch (_: Exception) {
|
||||
// user_id column doesn't exist – fresh database, nothing to migrate
|
||||
// user_id column already dropped by V2 migration or doesn't exist on fresh DB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
-- V1: Dokumentiert das initiale Datenbankschema (Baseline für bestehende Installationen).
|
||||
-- Dieses Skript wird auf neuen Datenbanken ausgeführt. Bestehende Datenbanken werden
|
||||
-- über Flyway baseline abgedeckt und führen dieses Skript NICHT aus.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventories (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
inventory_id VARCHAR(36),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (username)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
pk SERIAL,
|
||||
id INTEGER NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
inventory_id VARCHAR(36),
|
||||
PRIMARY KEY (pk)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
pk SERIAL,
|
||||
id INTEGER NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
inventory_id VARCHAR(36),
|
||||
PRIMARY KEY (pk)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
quantity DOUBLE PRECISION NOT NULL,
|
||||
unit VARCHAR(50) NOT NULL,
|
||||
unit_price DOUBLE PRECISION NOT NULL,
|
||||
kcal_per_unit INTEGER,
|
||||
expiry_date VARCHAR(10),
|
||||
location_id INTEGER NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
last_updated BIGINT NOT NULL,
|
||||
inventory_id VARCHAR(36),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id SERIAL,
|
||||
key VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
inventory_id VARCHAR(36),
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
sender_id VARCHAR(36) NOT NULL,
|
||||
receiver_id VARCHAR(36) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
sent_at BIGINT NOT NULL,
|
||||
delivered_at BIGINT,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deleted_items (
|
||||
id SERIAL,
|
||||
item_id VARCHAR(36) NOT NULL,
|
||||
inventory_id VARCHAR(36) NOT NULL,
|
||||
deleted_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- V2: Bereinigt veraltete user_id-Spalten aus der Multi-Tenant-Migration.
|
||||
-- Alle Daten wurden bereits über migrateUserInventories() nach inventory_id migriert.
|
||||
-- IF EXISTS verhindert Fehler bei Neuinstallationen, bei denen user_id nie existierte.
|
||||
|
||||
ALTER TABLE items DROP COLUMN IF EXISTS user_id;
|
||||
ALTER TABLE categories DROP COLUMN IF EXISTS user_id;
|
||||
ALTER TABLE locations DROP COLUMN IF EXISTS user_id;
|
||||
ALTER TABLE settings DROP COLUMN IF EXISTS user_id;
|
||||
Loading…
Reference in a new issue