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
This commit is contained in:
Jens Reinemann 2026-05-14 20:15:07 +02:00
parent cb190e61e9
commit 2387c6ee5a
8 changed files with 354 additions and 0 deletions

3
.gitignore vendored
View file

@ -27,5 +27,8 @@ desktop.ini
# Temp-Dateien (Screenshots, Logs etc.)
tmp/
# H2 Database files
server/data/
# Copilot memories (session-only)
memories/session/

View file

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

View file

@ -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)

View file

@ -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<String>) {
}
internal fun Application.module() {
DatabaseFactory.init()
configureSerialization()
configureRouting()
}

View file

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

View file

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

View file

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

View file

@ -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<CategoryDto>(), result.categories)
assertEquals(emptyList<LocationDto>(), result.locations)
assertEquals(emptyList<ItemDto>(), result.items)
assertEquals(emptyList<SettingDto>(), 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")
)
)
}