From 2387c6ee5acf22da2c359f8296cd87370ad29e7d Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 20:15:07 +0200 Subject: [PATCH] feat(server): add Exposed ORM database layer with H2 server/db/Tables.kt: - Exposed table definitions for Categories, Locations, Items, Settings - Schema mirrors Room entities from app module - Foreign keys on Items referencing Categories and Locations server/db/DatabaseFactory.kt: - H2 file-based DB initialization (jdbc:h2:file:./data/krisenvorrat) - Parameterized for testability (in-memory DB for tests) - Schema auto-creation via SchemaUtils.create() server/repository/InventoryRepository.kt: - Full CRUD: saveInventory() and loadInventory() - Atomic replace via transaction (deleteAll + insert) - Direct mapping between Exposed rows and shared DTOs 4 repository tests with H2 in-memory covering: - Empty DB, full round-trip, overwrite, nullable fields Closes #41 --- .gitignore | 3 + gradle/libs.versions.toml | 5 + server/build.gradle.kts | 3 + .../de/krisenvorrat/server/Application.kt | 2 + .../krisenvorrat/server/db/DatabaseFactory.kt | 15 ++ .../de/krisenvorrat/server/db/Tables.kt | 41 +++++ .../server/repository/InventoryRepository.kt | 114 ++++++++++++ .../repository/InventoryRepositoryTest.kt | 171 ++++++++++++++++++ 8 files changed, 354 insertions(+) create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt create mode 100644 server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt create mode 100644 server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt diff --git a/.gitignore b/.gitignore index 2ec1159..5396c4c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,8 @@ desktop.ini # Temp-Dateien (Screenshots, Logs etc.) tmp/ +# H2 Database files +server/data/ + # Copilot memories (session-only) memories/session/ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c5cbc7a..529d19d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ kotlinxCoroutines = "1.9.0" mockk = "1.13.13" ktor = "3.1.2" logback = "1.5.18" +exposed = "0.58.0" +h2 = "2.3.232" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -54,6 +56,9 @@ ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serializatio ktor-server-config-yaml = { group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" } ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" } +exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" } +h2-database = { group = "com.h2database", name = "h2", version.ref = "h2" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 64a467e..dd3db3d 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { implementation(libs.ktor.server.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.logback.classic) + implementation(libs.exposed.core) + implementation(libs.exposed.jdbc) + implementation(libs.h2.database) testImplementation(libs.ktor.server.test.host) testImplementation(libs.junit) diff --git a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt index c355081..4b54a2f 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/Application.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/Application.kt @@ -1,5 +1,6 @@ package de.krisenvorrat.server +import de.krisenvorrat.server.db.DatabaseFactory import de.krisenvorrat.server.plugins.configureRouting import de.krisenvorrat.server.plugins.configureSerialization import io.ktor.server.application.* @@ -10,6 +11,7 @@ fun main(args: Array) { } internal fun Application.module() { + DatabaseFactory.init() configureSerialization() configureRouting() } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt new file mode 100644 index 0000000..d2b676d --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/DatabaseFactory.kt @@ -0,0 +1,15 @@ +package de.krisenvorrat.server.db + +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.transactions.transaction + +internal object DatabaseFactory { + + fun init(jdbcUrl: String = "jdbc:h2:file:./data/krisenvorrat", driver: String = "org.h2.Driver") { + Database.connect(jdbcUrl, driver) + transaction { + SchemaUtils.create(Categories, Locations, Items, Settings) + } + } +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt new file mode 100644 index 0000000..6600d6f --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt @@ -0,0 +1,41 @@ +package de.krisenvorrat.server.db + +import org.jetbrains.exposed.sql.Table + +internal object Categories : Table("categories") { + val id = integer("id").autoIncrement() + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) +} + +internal object Locations : Table("locations") { + val id = integer("id").autoIncrement() + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) +} + +internal object Items : Table("items") { + val id = varchar("id", 36) + val name = varchar("name", 255) + val categoryId = integer("category_id").references(Categories.id) + val quantity = double("quantity") + val unit = varchar("unit", 50) + val unitPrice = double("unit_price") + val kcalPer100g = integer("kcal_per_100g").nullable() + val expiryDate = varchar("expiry_date", 10).nullable() + val locationId = integer("location_id").references(Locations.id) + val minStock = double("min_stock") + val notes = text("notes") + val lastUpdated = long("last_updated") + + override val primaryKey = PrimaryKey(id) +} + +internal object Settings : Table("settings") { + val key = varchar("key", 255) + val value = text("value") + + override val primaryKey = PrimaryKey(key) +} diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt new file mode 100644 index 0000000..71d36ad --- /dev/null +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt @@ -0,0 +1,114 @@ +package de.krisenvorrat.server.repository + +import de.krisenvorrat.server.db.Categories +import de.krisenvorrat.server.db.Items +import de.krisenvorrat.server.db.Locations +import de.krisenvorrat.server.db.Settings +import de.krisenvorrat.shared.model.CategoryDto +import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto +import de.krisenvorrat.shared.model.LocationDto +import de.krisenvorrat.shared.model.SettingDto +import org.jetbrains.exposed.sql.deleteAll +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction + +internal class InventoryRepository { + + fun saveInventory(inventory: InventoryDto) { + transaction { + Items.deleteAll() + Settings.deleteAll() + Categories.deleteAll() + Locations.deleteAll() + + for (category in inventory.categories) { + Categories.insert { + it[id] = category.id + it[name] = category.name + } + } + + for (location in inventory.locations) { + Locations.insert { + it[id] = location.id + it[name] = location.name + } + } + + for (item in inventory.items) { + Items.insert { + it[id] = item.id + it[name] = item.name + it[categoryId] = item.categoryId + it[quantity] = item.quantity + it[unit] = item.unit + it[unitPrice] = item.unitPrice + it[kcalPer100g] = item.kcalPer100g + it[expiryDate] = item.expiryDate + it[locationId] = item.locationId + it[minStock] = item.minStock + it[notes] = item.notes + it[lastUpdated] = item.lastUpdated + } + } + + for (setting in inventory.settings) { + Settings.insert { + it[key] = setting.key + it[value] = setting.value + } + } + } + } + + fun loadInventory(): InventoryDto { + return transaction { + val categories = Categories.selectAll().map { + CategoryDto( + id = it[Categories.id], + name = it[Categories.name] + ) + } + + val locations = Locations.selectAll().map { + LocationDto( + id = it[Locations.id], + name = it[Locations.name] + ) + } + + val items = Items.selectAll().map { + ItemDto( + id = it[Items.id], + name = it[Items.name], + categoryId = it[Items.categoryId], + quantity = it[Items.quantity], + unit = it[Items.unit], + unitPrice = it[Items.unitPrice], + kcalPer100g = it[Items.kcalPer100g], + expiryDate = it[Items.expiryDate], + locationId = it[Items.locationId], + minStock = it[Items.minStock], + notes = it[Items.notes], + lastUpdated = it[Items.lastUpdated] + ) + } + + val settings = Settings.selectAll().map { + SettingDto( + key = it[Settings.key], + value = it[Settings.value] + ) + } + + InventoryDto( + categories = categories, + locations = locations, + items = items, + settings = settings + ) + } + } +} diff --git a/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt new file mode 100644 index 0000000..73f5652 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/repository/InventoryRepositoryTest.kt @@ -0,0 +1,171 @@ +package de.krisenvorrat.server.repository + +import de.krisenvorrat.server.db.DatabaseFactory +import de.krisenvorrat.shared.model.CategoryDto +import de.krisenvorrat.shared.model.InventoryDto +import de.krisenvorrat.shared.model.ItemDto +import de.krisenvorrat.shared.model.LocationDto +import de.krisenvorrat.shared.model.SettingDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class InventoryRepositoryTest { + + private lateinit var repository: InventoryRepository + + @Before + fun setUp() { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver" + ) + repository = InventoryRepository() + // Clear any leftover data from previous test + repository.saveInventory( + InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()) + ) + } + + @Test + fun test_loadInventory_emptyDatabase_returnsEmptyInventory() { + // When + val result = repository.loadInventory() + + // Then + assertEquals(emptyList(), result.categories) + assertEquals(emptyList(), result.locations) + assertEquals(emptyList(), result.items) + assertEquals(emptyList(), result.settings) + } + + @Test + fun test_saveAndLoad_fullInventory_roundTripsCorrectly() { + // Given + val inventory = createTestInventory() + + // When + repository.saveInventory(inventory) + val result = repository.loadInventory() + + // Then + assertEquals(2, result.categories.size) + assertEquals("Konserven", result.categories[0].name) + assertEquals("Getränke", result.categories[1].name) + + assertEquals(2, result.locations.size) + assertEquals("Keller", result.locations[0].name) + assertEquals("Speisekammer", result.locations[1].name) + + assertEquals(1, result.items.size) + val item = result.items[0] + assertEquals("item-1", item.id) + assertEquals("Dosenbrot", item.name) + assertEquals(1, item.categoryId) + assertEquals(5.0, item.quantity, 0.001) + assertEquals("Stück", item.unit) + assertEquals(3.99, item.unitPrice, 0.001) + assertEquals(250, item.kcalPer100g) + assertEquals("2027-06-15", item.expiryDate) + assertEquals(1, item.locationId) + assertEquals(2.0, item.minStock, 0.001) + assertEquals("Vollkornbrot in der Dose", item.notes) + assertEquals(1715000000L, item.lastUpdated) + + assertEquals(1, result.settings.size) + assertEquals("theme", result.settings[0].key) + assertEquals("dark", result.settings[0].value) + } + + @Test + fun test_saveInventory_overwritesExistingData() { + // Given + repository.saveInventory(createTestInventory()) + + val updatedInventory = InventoryDto( + categories = listOf(CategoryDto(id = 10, name = "Neu")), + locations = listOf(LocationDto(id = 10, name = "Garage")), + items = emptyList(), + settings = listOf(SettingDto(key = "lang", value = "de")) + ) + + // When + repository.saveInventory(updatedInventory) + val result = repository.loadInventory() + + // Then + assertEquals(1, result.categories.size) + assertEquals("Neu", result.categories[0].name) + assertEquals(1, result.locations.size) + assertEquals("Garage", result.locations[0].name) + assertEquals(0, result.items.size) + assertEquals(1, result.settings.size) + assertEquals("lang", result.settings[0].key) + } + + @Test + fun test_saveAndLoad_itemWithNullableFieldsNull_roundTripsCorrectly() { + // Given + val inventory = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Sonstiges")), + locations = listOf(LocationDto(id = 1, name = "Lager")), + items = listOf( + ItemDto( + id = "item-null", + name = "Kerzen", + categoryId = 1, + quantity = 10.0, + unit = "Stück", + unitPrice = 1.50, + kcalPer100g = null, + expiryDate = null, + locationId = 1, + minStock = 5.0, + notes = "", + lastUpdated = 1715000000L + ) + ), + settings = emptyList() + ) + + // When + repository.saveInventory(inventory) + val result = repository.loadInventory() + + // Then + val item = result.items[0] + assertNull(item.kcalPer100g) + assertNull(item.expiryDate) + } + + private fun createTestInventory(): InventoryDto = InventoryDto( + categories = listOf( + CategoryDto(id = 1, name = "Konserven"), + CategoryDto(id = 2, name = "Getränke") + ), + locations = listOf( + LocationDto(id = 1, name = "Keller"), + LocationDto(id = 2, name = "Speisekammer") + ), + items = listOf( + ItemDto( + id = "item-1", + name = "Dosenbrot", + categoryId = 1, + quantity = 5.0, + unit = "Stück", + unitPrice = 3.99, + kcalPer100g = 250, + expiryDate = "2027-06-15", + locationId = 1, + minStock = 2.0, + notes = "Vollkornbrot in der Dose", + lastUpdated = 1715000000L + ) + ), + settings = listOf( + SettingDto(key = "theme", value = "dark") + ) + ) +}