diff --git a/.github/skills/android-db-migration/SKILL.md b/.github/skills/android-db-migration/SKILL.md new file mode 100644 index 0000000..f1be093 --- /dev/null +++ b/.github/skills/android-db-migration/SKILL.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c830b9..0744ded 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/7.json b/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/7.json new file mode 100644 index 0000000..faa7b0b --- /dev/null +++ b/app/schemas/de.bollwerk.app.data.db.BollwerkDatabase/7.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/bollwerk/app/data/db/BollwerkDatabaseMigrationTest.kt b/app/src/androidTest/java/de/bollwerk/app/data/db/BollwerkDatabaseMigrationTest.kt index d405e1c..a7fc0d6 100644 --- a/app/src/androidTest/java/de/bollwerk/app/data/db/BollwerkDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/de/bollwerk/app/data/db/BollwerkDatabaseMigrationTest.kt @@ -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 { } } } + diff --git a/app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt b/app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt index 3099702..7bc10d7 100644 --- a/app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt +++ b/app/src/main/java/de/bollwerk/app/data/db/BollwerkDatabase.kt @@ -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() { diff --git a/app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt b/app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt index 4e078fb..c2e4857 100644 --- a/app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt +++ b/app/src/main/java/de/bollwerk/app/di/DatabaseModule.kt @@ -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() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 064ce6c..c8cb64a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 136b0d0..1918740 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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) diff --git a/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt b/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt index 6227f4a..aa3d89f 100644 --- a/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt +++ b/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt @@ -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 } } } diff --git a/server/src/main/resources/db/migration/V1__initial_schema.sql b/server/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 0000000..11e7309 --- /dev/null +++ b/server/src/main/resources/db/migration/V1__initial_schema.sql @@ -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) +); diff --git a/server/src/main/resources/db/migration/V2__cleanup_user_id.sql b/server/src/main/resources/db/migration/V2__cleanup_user_id.sql new file mode 100644 index 0000000..9eb48e4 --- /dev/null +++ b/server/src/main/resources/db/migration/V2__cleanup_user_id.sql @@ -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;