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:
parent
cb190e61e9
commit
2387c6ee5a
8 changed files with 354 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -27,5 +27,8 @@ desktop.ini
|
|||
# Temp-Dateien (Screenshots, Logs etc.)
|
||||
tmp/
|
||||
|
||||
# H2 Database files
|
||||
server/data/
|
||||
|
||||
# Copilot memories (session-only)
|
||||
memories/session/
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt
Normal file
41
server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue