Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests.
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
This commit is contained in:
parent
eedce74a85
commit
8c2a82403e
|
|
@ -34,5 +34,6 @@ dependencies {
|
|||
implementation(libs.ktor.server.authJwt)
|
||||
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.ktor.server.tests)
|
||||
// Ktor 3.x Test-Host statt veraltetes tests-Artefakt
|
||||
testImplementation(libs.ktor.server.testHost)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@ dependencies {
|
|||
|
||||
// Testing
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.ktor.server.tests)
|
||||
// Ktor 3.x: Verwende das Test-Host-Artefakt statt des veralteten "ktor-server-tests-jvm"
|
||||
testImplementation(libs.ktor.server.testHost)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package at.mocode.masterdata.api
|
||||
|
||||
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
|
||||
import at.mocode.masterdata.api.rest.AltersklasseController
|
||||
import at.mocode.masterdata.api.rest.BundeslandController
|
||||
import at.mocode.masterdata.api.rest.CountryController
|
||||
import at.mocode.masterdata.api.rest.PlatzController
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.routing.routing
|
||||
|
||||
/**
|
||||
* Ktor-Modul für den Masterdata-Bounded-Context.
|
||||
*
|
||||
* - Installiert das IdempotencyPlugin (Header „Idempotency-Key“) global.
|
||||
* - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz).
|
||||
*/
|
||||
fun Application.masterdataApiModule(
|
||||
countryController: CountryController,
|
||||
bundeslandController: BundeslandController,
|
||||
altersklasseController: AltersklasseController,
|
||||
platzController: PlatzController
|
||||
) {
|
||||
// Installiere das Idempotency-Plugin global für alle Routen
|
||||
IdempotencyPlugin.install(this)
|
||||
|
||||
// Registriere die REST-Routen der Controller
|
||||
routing {
|
||||
with(countryController) { registerRoutes() }
|
||||
with(bundeslandController) { registerRoutes() }
|
||||
with(altersklasseController) { registerRoutes() }
|
||||
with(platzController) { registerRoutes() }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,182 @@
|
|||
package at.mocode.masterdata.api.plugins
|
||||
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.util.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Job
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Minimaler Ktor-Plugin-Skeleton für Idempotency-Key Verarbeitung.
|
||||
* Aktuell: Stellt den Header `Idempotency-Key` als Call-Attribut bereit.
|
||||
* Erweiterung (Cache/Short-Circuit) folgt im nächsten Schritt.
|
||||
* Ktor-Plugin für Idempotency-Key Verarbeitung mit leichtgewichtigem In-Memory-Cache (TTL, Default 5 Min).
|
||||
*
|
||||
* Verhalten:
|
||||
* - Liest Header "Idempotency-Key" für POST/PUT.
|
||||
* - Prüft Cache: bei Treffer (nicht abgelaufen) wird die zuvor gespeicherte Response sofort zurückgegeben (Short-Circuit).
|
||||
* - Nach erfolgreicher Antwort (200/201/204) wird – falls möglich – der serialisierte Response-Body inkl. Status/Headers im Cache abgelegt.
|
||||
*
|
||||
* Hinweise:
|
||||
* - Es werden ausschließlich Antworten zwischengespeichert, deren Body als Text oder ByteArray vorliegt (typisch für JSON).
|
||||
* - Kein Persistenz-Layer (lean MVP). Für Multi-Node/HA später Option B (idempotency_records) umsetzen.
|
||||
*/
|
||||
object IdempotencyPlugin {
|
||||
@JvmInline
|
||||
value class Configuration(val ttl: Duration = Duration.ofMinutes(5))
|
||||
|
||||
val IdempotencyKeyAttr: AttributeKey<String> = AttributeKey("IdempotencyKey")
|
||||
private val CacheAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CacheEntry>> = AttributeKey("IdempotencyCache")
|
||||
private val InflightAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CompletableDeferred<CacheEntry>>> = AttributeKey("IdempotencyInflight")
|
||||
private val LeaderFlagAttr: AttributeKey<Boolean> = AttributeKey("IdempotencyLeaderFlag")
|
||||
|
||||
private data class CacheEntry(
|
||||
val status: HttpStatusCode,
|
||||
val contentType: ContentType?,
|
||||
val body: ByteArray,
|
||||
val storedAtMillis: Long
|
||||
)
|
||||
|
||||
// Hinweis: Kein globaler Cache mehr! Der Cache ist pro Application-Instance gebunden,
|
||||
// um Test-Interferenzen und Cross-App-Leaks zu vermeiden.
|
||||
|
||||
fun install(application: Application, configuration: Configuration = Configuration()) {
|
||||
val ttlMillis = configuration.ttl.toMillis()
|
||||
|
||||
// Per-Application Cache initialisieren (falls nicht vorhanden)
|
||||
if (!application.attributes.contains(CacheAttr)) {
|
||||
application.attributes.put(CacheAttr, ConcurrentHashMap())
|
||||
}
|
||||
if (!application.attributes.contains(InflightAttr)) {
|
||||
application.attributes.put(InflightAttr, ConcurrentHashMap())
|
||||
}
|
||||
|
||||
// Vor der eigentlichen Verarbeitung: Cache prüfen und ggf. Short-Circuit
|
||||
application.intercept(ApplicationCallPipeline.Plugins) {
|
||||
val key = call.request.headers["Idempotency-Key"]?.trim()
|
||||
if (!key.isNullOrBlank()) {
|
||||
call.attributes.put(IdempotencyKeyAttr, key)
|
||||
val cache = application.attributes[CacheAttr]
|
||||
val entry = cache[key]
|
||||
if (entry != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - entry.storedAtMillis <= ttlMillis) {
|
||||
// Short-Circuit mit gecachter Antwort
|
||||
call.response.status(entry.status)
|
||||
val ct = entry.contentType ?: ContentType.Application.Json
|
||||
call.respondBytes(bytes = entry.body, contentType = ct)
|
||||
finish()
|
||||
return@intercept
|
||||
} else if (now - entry.storedAtMillis > ttlMillis) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent duplicate handling: warte auf in-flight Ergebnis
|
||||
val inflight = application.attributes[InflightAttr]
|
||||
val parentJob = call.coroutineContext[Job]
|
||||
val deferred = CompletableDeferred<CacheEntry>(parent = parentJob)
|
||||
val existing = inflight.putIfAbsent(key, deferred)
|
||||
if (existing != null) {
|
||||
// Follower: auf Ergebnis warten und sofort antworten
|
||||
try {
|
||||
val result = existing.await()
|
||||
call.response.status(result.status)
|
||||
val ct = result.contentType ?: ContentType.Application.Json
|
||||
call.respondBytes(bytes = result.body, contentType = ct)
|
||||
finish()
|
||||
return@intercept
|
||||
} catch (e: Exception) {
|
||||
// Leader wird Aufräumen übernehmen (invokeOnCompletion). Normale Verarbeitung zulassen.
|
||||
}
|
||||
} else {
|
||||
// Leader dieses Keys für diese Call-Pipeline markieren
|
||||
call.attributes.put(LeaderFlagAttr, true)
|
||||
// Sicherheitsnetz: Wenn der Call endet, aber kein Ergebnis gesetzt wurde,
|
||||
// verhindere hängende Deferreds und bereinige In-Flight-Eintrag.
|
||||
parentJob?.invokeOnCompletion { cause ->
|
||||
val d = inflight.remove(key) ?: return@invokeOnCompletion
|
||||
if (!d.isCompleted) {
|
||||
if (cause != null) d.completeExceptionally(cause)
|
||||
else d.completeExceptionally(IllegalStateException("Idempotency: call finished without completing result"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nach dem Serialisieren der Antwort: ggf. in Cache legen
|
||||
application.sendPipeline.intercept(ApplicationSendPipeline.After) { subject ->
|
||||
val key = call.attributes.getOrNull(IdempotencyKeyAttr) ?: return@intercept
|
||||
|
||||
val status = call.response.status() ?: return@intercept
|
||||
if (status != HttpStatusCode.OK && status != HttpStatusCode.Created && status != HttpStatusCode.NoContent) return@intercept
|
||||
|
||||
// Nur ausgewählte Content-Typen/Body-Formen cachen
|
||||
var bodyBytes: ByteArray? = null
|
||||
var contentType: ContentType? = null
|
||||
when (subject) {
|
||||
is OutgoingContent.ByteArrayContent -> {
|
||||
bodyBytes = subject.bytes()
|
||||
contentType = subject.contentType
|
||||
}
|
||||
is OutgoingContent.NoContent -> {
|
||||
bodyBytes = ByteArray(0)
|
||||
contentType = subject.contentType
|
||||
}
|
||||
is OutgoingContent.ReadChannelContent -> {
|
||||
// Nicht trivial ohne Consumption; überspringen
|
||||
}
|
||||
is OutgoingContent.WriteChannelContent -> {
|
||||
// Nicht trivial; überspringen
|
||||
}
|
||||
is TextContent -> {
|
||||
bodyBytes = subject.text.toByteArray(Charsets.UTF_8)
|
||||
contentType = subject.contentType
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyBytes != null) {
|
||||
val entry = CacheEntry(
|
||||
status = status,
|
||||
contentType = contentType,
|
||||
body = bodyBytes,
|
||||
storedAtMillis = System.currentTimeMillis()
|
||||
)
|
||||
val cache = application.attributes[CacheAttr]
|
||||
cache[key] = entry
|
||||
|
||||
// Wenn dieser Call der Leader war, vervollständige alle wartenden Requests
|
||||
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
|
||||
val inflight = application.attributes[InflightAttr]
|
||||
val deferred = inflight.remove(key)
|
||||
deferred?.complete(entry)
|
||||
}
|
||||
} else {
|
||||
// Kein cachebarer Body – in-flight ggf. bereinigen, damit Follower nicht ewig warten
|
||||
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
|
||||
val inflight = application.attributes[InflightAttr]
|
||||
inflight.remove(key)?.completeExceptionally(IllegalStateException("Idempotency: no cacheable body"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermöglicht das Leeren des per-Application-Caches (z.B. für Tests).
|
||||
*/
|
||||
fun clear(application: Application) {
|
||||
if (application.attributes.contains(CacheAttr)) {
|
||||
application.attributes[CacheAttr].clear()
|
||||
}
|
||||
if (application.attributes.contains(InflightAttr)) {
|
||||
val inflight = application.attributes[InflightAttr]
|
||||
// Alle offenen Deferreds abbrechen, um Leaks in Tests zu verhindern
|
||||
inflight.values.forEach { d -> if (!d.isCompleted) d.completeExceptionally(CancellationException("Idempotency: cleared")) }
|
||||
inflight.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
package at.mocode.masterdata.api
|
||||
|
||||
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.testing.*
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class IdempotencyPluginTest {
|
||||
|
||||
@Test
|
||||
fun `second POST with same Idempotency-Key returns cached response and skips handler`() = testApplication {
|
||||
val counter = AtomicInteger(0)
|
||||
|
||||
application {
|
||||
// Install plugin directly for focused unit test
|
||||
this@application.install(ContentNegotiation) { json() }
|
||||
IdempotencyPlugin.install(this@application)
|
||||
routing {
|
||||
post("/echo") {
|
||||
counter.incrementAndGet()
|
||||
// Simuliere erfolgreiche Erstellung mit 201 und JSON-Body (als String, um Serializer-Probleme zu vermeiden)
|
||||
val json = """{"ok":true,"count":${counter.get()}}"""
|
||||
call.respondText(text = json, contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val key = "test-key-123"
|
||||
val payload = """{"name":"Austria"}"""
|
||||
|
||||
val r1 = client.post("/echo") {
|
||||
header("Idempotency-Key", key)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(payload)
|
||||
}
|
||||
val r2 = client.post("/echo") {
|
||||
header("Idempotency-Key", key)
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(payload)
|
||||
}
|
||||
|
||||
assertThat(r1.status).isEqualTo(HttpStatusCode.Created)
|
||||
assertThat(r2.status).isEqualTo(HttpStatusCode.Created)
|
||||
assertThat(r2.bodyAsText()).isEqualTo(r1.bodyAsText())
|
||||
// Sicherstellen, dass der Handler nur einmal ausgeführt wurde
|
||||
assertThat(counter.get()).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
|
@ -121,8 +121,8 @@ class CreateAltersklasseUseCase(
|
|||
updatedAt = now
|
||||
)
|
||||
|
||||
// Save to repository
|
||||
val savedAltersklasse = altersklasseRepository.save(altersklasse)
|
||||
// Upsert anhand des natürlichen Schlüssels (altersklasseCode)
|
||||
val savedAltersklasse = altersklasseRepository.upsertByCode(altersklasse)
|
||||
return CreateAltersklasseResponse(
|
||||
altersklasse = savedAltersklasse,
|
||||
success = true
|
||||
|
|
|
|||
|
|
@ -117,8 +117,8 @@ class CreateBundeslandUseCase(
|
|||
updatedAt = now
|
||||
)
|
||||
|
||||
// Save to repository
|
||||
val savedBundesland = bundeslandRepository.save(bundesland)
|
||||
// Upsert anhand des natürlichen Schlüssels (landId + kuerzel)
|
||||
val savedBundesland = bundeslandRepository.upsertByLandIdAndKuerzel(bundesland)
|
||||
return CreateBundeslandResponse(
|
||||
bundesland = savedBundesland,
|
||||
success = true
|
||||
|
|
|
|||
|
|
@ -123,8 +123,8 @@ class CreateCountryUseCase(
|
|||
updatedAt = now
|
||||
)
|
||||
|
||||
// Save to repository
|
||||
val savedCountry = landRepository.save(country)
|
||||
// Upsert anhand des natürlichen Schlüssels (ISO Alpha-3)
|
||||
val savedCountry = landRepository.upsertByIsoAlpha3(country)
|
||||
return CreateCountryResponse(
|
||||
country = savedCountry,
|
||||
success = true
|
||||
|
|
|
|||
|
|
@ -115,8 +115,8 @@ class CreatePlatzUseCase(
|
|||
updatedAt = now
|
||||
)
|
||||
|
||||
// Save to repository
|
||||
val savedPlatz = platzRepository.save(platz)
|
||||
// Upsert anhand des natürlichen Schlüssels (turnierId + name)
|
||||
val savedPlatz = platzRepository.upsertByTurnierIdAndName(platz)
|
||||
return CreatePlatzResponse(
|
||||
platz = savedPlatz,
|
||||
success = true
|
||||
|
|
|
|||
|
|
@ -108,6 +108,13 @@ interface AltersklasseRepository {
|
|||
*/
|
||||
suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition
|
||||
|
||||
/**
|
||||
* Upsert basierend auf dem natürlichen Schlüssel Altersklassen-Code.
|
||||
* Existiert bereits ein Datensatz mit gleichem Code, wird er aktualisiert,
|
||||
* ansonsten wird ein neuer Datensatz eingefügt.
|
||||
*/
|
||||
suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition
|
||||
|
||||
/**
|
||||
* Deletes an age class by ID.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ interface BundeslandRepository {
|
|||
*/
|
||||
suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition
|
||||
|
||||
/**
|
||||
* Upsert basierend auf dem natürlichen Schlüssel (landId + kuerzel).
|
||||
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
|
||||
* ansonsten wird ein neuer Datensatz eingefügt.
|
||||
*/
|
||||
suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition
|
||||
|
||||
/**
|
||||
* Deletes a federal state by ID.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -77,6 +77,13 @@ interface LandRepository {
|
|||
*/
|
||||
suspend fun save(land: LandDefinition): LandDefinition
|
||||
|
||||
/**
|
||||
* Upsert basierend auf dem natürlichen Schlüssel ISO Alpha-3.
|
||||
* Existiert bereits ein Datensatz mit gleichem ISO Alpha-3 Code, wird er aktualisiert,
|
||||
* ansonsten wird ein neuer Datensatz eingefügt.
|
||||
*/
|
||||
suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition
|
||||
|
||||
/**
|
||||
* Deletes a country by ID.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -103,6 +103,13 @@ interface PlatzRepository {
|
|||
*/
|
||||
suspend fun save(platz: Platz): Platz
|
||||
|
||||
/**
|
||||
* Upsert basierend auf dem natürlichen Schlüssel (turnierId + name).
|
||||
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
|
||||
* ansonsten wird ein neuer Datensatz eingefügt.
|
||||
*/
|
||||
suspend fun upsertByTurnierIdAndName(platz: Platz): Platz
|
||||
|
||||
/**
|
||||
* Deletes a venue by ID.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -217,4 +217,58 @@ class AltersklasseRepositoryImpl : AltersklasseRepository {
|
|||
ageOk && geschlechtOk
|
||||
}.singleOrNull() ?: false
|
||||
}
|
||||
|
||||
override suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition = DatabaseFactory.dbQuery {
|
||||
// 1) Update anhand des natürlichen Schlüssels (altersklasse_code)
|
||||
val updated = AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
|
||||
it[bezeichnung] = altersklasse.bezeichnung
|
||||
it[minAlter] = altersklasse.minAlter
|
||||
it[maxAlter] = altersklasse.maxAlter
|
||||
it[stichtagRegelText] = altersklasse.stichtagRegelText
|
||||
it[sparteFilter] = altersklasse.sparteFilter?.name
|
||||
it[geschlechtFilter] = altersklasse.geschlechtFilter
|
||||
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
|
||||
it[istAktiv] = altersklasse.istAktiv
|
||||
it[updatedAt] = altersklasse.updatedAt
|
||||
}
|
||||
if (updated > 0) {
|
||||
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
|
||||
.map(::rowToAltersklasseDefinition)
|
||||
.single()
|
||||
} else {
|
||||
// 2) Insert versuchen
|
||||
try {
|
||||
AltersklasseTable.insert {
|
||||
it[id] = altersklasse.altersklasseId
|
||||
it[altersklasseCode] = altersklasse.altersklasseCode
|
||||
it[bezeichnung] = altersklasse.bezeichnung
|
||||
it[minAlter] = altersklasse.minAlter
|
||||
it[maxAlter] = altersklasse.maxAlter
|
||||
it[stichtagRegelText] = altersklasse.stichtagRegelText
|
||||
it[sparteFilter] = altersklasse.sparteFilter?.name
|
||||
it[geschlechtFilter] = altersklasse.geschlechtFilter
|
||||
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
|
||||
it[istAktiv] = altersklasse.istAktiv
|
||||
it[createdAt] = altersklasse.createdAt
|
||||
it[updatedAt] = altersklasse.updatedAt
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Race-Fallback bei Unique-Constraint
|
||||
AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
|
||||
it[bezeichnung] = altersklasse.bezeichnung
|
||||
it[minAlter] = altersklasse.minAlter
|
||||
it[maxAlter] = altersklasse.maxAlter
|
||||
it[stichtagRegelText] = altersklasse.stichtagRegelText
|
||||
it[sparteFilter] = altersklasse.sparteFilter?.name
|
||||
it[geschlechtFilter] = altersklasse.geschlechtFilter
|
||||
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
|
||||
it[istAktiv] = altersklasse.istAktiv
|
||||
it[updatedAt] = altersklasse.updatedAt
|
||||
}
|
||||
}
|
||||
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
|
||||
.map(::rowToAltersklasseDefinition)
|
||||
.single()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,4 +133,74 @@ class BundeslandRepositoryImpl : BundeslandRepository {
|
|||
BundeslandTable.selectAll().where { (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) }
|
||||
.count()
|
||||
}
|
||||
|
||||
override suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery {
|
||||
// 1) Update anhand des natürlichen Schlüssels (landId + kuerzel)
|
||||
val updated = if (bundesland.kuerzel == null) {
|
||||
// Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei none
|
||||
0
|
||||
} else {
|
||||
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
|
||||
it[landId] = bundesland.landId
|
||||
it[oepsCode] = bundesland.oepsCode
|
||||
it[iso3166_2_Code] = bundesland.iso3166_2_Code
|
||||
it[name] = bundesland.name
|
||||
it[kuerzel] = bundesland.kuerzel
|
||||
it[wappenUrl] = bundesland.wappenUrl
|
||||
it[istAktiv] = bundesland.istAktiv
|
||||
it[sortierReihenfolge] = bundesland.sortierReihenfolge
|
||||
it[updatedAt] = bundesland.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
BundeslandTable.selectAll()
|
||||
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
|
||||
.map(::rowToBundeslandDefinition)
|
||||
.single()
|
||||
} else {
|
||||
// 2) Insert versuchen
|
||||
try {
|
||||
BundeslandTable.insert {
|
||||
it[id] = bundesland.bundeslandId
|
||||
it[landId] = bundesland.landId
|
||||
it[oepsCode] = bundesland.oepsCode
|
||||
it[iso3166_2_Code] = bundesland.iso3166_2_Code
|
||||
it[name] = bundesland.name
|
||||
it[kuerzel] = bundesland.kuerzel
|
||||
it[wappenUrl] = bundesland.wappenUrl
|
||||
it[istAktiv] = bundesland.istAktiv
|
||||
it[sortierReihenfolge] = bundesland.sortierReihenfolge
|
||||
it[createdAt] = bundesland.createdAt
|
||||
it[updatedAt] = bundesland.updatedAt
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Race-Condition → erneut Update
|
||||
if (bundesland.kuerzel != null) {
|
||||
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
|
||||
it[landId] = bundesland.landId
|
||||
it[oepsCode] = bundesland.oepsCode
|
||||
it[iso3166_2_Code] = bundesland.iso3166_2_Code
|
||||
it[name] = bundesland.name
|
||||
it[kuerzel] = bundesland.kuerzel
|
||||
it[wappenUrl] = bundesland.wappenUrl
|
||||
it[istAktiv] = bundesland.istAktiv
|
||||
it[sortierReihenfolge] = bundesland.sortierReihenfolge
|
||||
it[updatedAt] = bundesland.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rückgabe des aktuellen Datensatzes: Falls Kuerzel null, greife auf ID zurück
|
||||
if (bundesland.kuerzel != null) {
|
||||
BundeslandTable.selectAll()
|
||||
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
|
||||
.map(::rowToBundeslandDefinition)
|
||||
.single()
|
||||
} else {
|
||||
BundeslandTable.selectAll().where { BundeslandTable.id eq bundesland.bundeslandId }
|
||||
.map(::rowToBundeslandDefinition)
|
||||
.single()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ package at.mocode.masterdata.infrastructure.persistence
|
|||
import at.mocode.masterdata.domain.model.LandDefinition
|
||||
import at.mocode.masterdata.domain.repository.LandRepository
|
||||
import at.mocode.core.utils.database.DatabaseFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.exposed.v1.core.ResultRow
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.or
|
||||
|
|
@ -13,6 +15,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
|
|||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.jetbrains.exposed.v1.core.eq
|
||||
import org.jetbrains.exposed.v1.core.like
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
|
|
@ -20,6 +23,11 @@ import kotlin.uuid.Uuid
|
|||
*/
|
||||
class LandRepositoryImpl : LandRepository {
|
||||
|
||||
companion object {
|
||||
// Per‑Key Locks, um konkurrierende Upserts auf denselben natürlichen Schlüssel zu serialisieren
|
||||
private val upsertLocks = ConcurrentHashMap<String, Any>()
|
||||
}
|
||||
|
||||
private fun rowToLandDefinition(row: ResultRow): LandDefinition {
|
||||
return LandDefinition(
|
||||
landId = row[LandTable.id],
|
||||
|
|
@ -138,4 +146,70 @@ class LandRepositoryImpl : LandRepository {
|
|||
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
|
||||
LandTable.selectAll().where { LandTable.istAktiv eq true }.count()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition {
|
||||
val naturalKey = land.isoAlpha3Code.uppercase()
|
||||
val lock = upsertLocks.computeIfAbsent(naturalKey) { Any() }
|
||||
|
||||
// Blockierend ausführen, damit kein suspend innerhalb synchronized stattfindet
|
||||
return withContext(Dispatchers.IO) {
|
||||
synchronized(lock) {
|
||||
DatabaseFactory.transaction {
|
||||
// 1) Versuche Update anhand natürlichem Schlüssel (ISO Alpha-3)
|
||||
val updated = LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
|
||||
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
|
||||
it[isoAlpha3Code] = naturalKey
|
||||
it[isoNumerischerCode] = land.isoNumerischerCode
|
||||
it[nameDeutsch] = land.nameDeutsch
|
||||
it[nameEnglisch] = land.nameEnglisch
|
||||
it[wappenUrl] = land.wappenUrl
|
||||
it[istEuMitglied] = land.istEuMitglied
|
||||
it[istEwrMitglied] = land.istEwrMitglied
|
||||
it[istAktiv] = land.istAktiv
|
||||
it[sortierReihenfolge] = land.sortierReihenfolge
|
||||
it[updatedAt] = land.updatedAt
|
||||
}
|
||||
if (updated == 0) {
|
||||
// 2) Kein Update → Insert versuchen
|
||||
try {
|
||||
LandTable.insert {
|
||||
it[id] = land.landId
|
||||
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
|
||||
it[isoAlpha3Code] = naturalKey
|
||||
it[isoNumerischerCode] = land.isoNumerischerCode
|
||||
it[nameDeutsch] = land.nameDeutsch
|
||||
it[nameEnglisch] = land.nameEnglisch
|
||||
it[wappenUrl] = land.wappenUrl
|
||||
it[istEuMitglied] = land.istEuMitglied
|
||||
it[istEwrMitglied] = land.istEwrMitglied
|
||||
it[istAktiv] = land.istAktiv
|
||||
it[sortierReihenfolge] = land.sortierReihenfolge
|
||||
it[createdAt] = land.createdAt
|
||||
it[updatedAt] = land.updatedAt
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Race-Condition (Unique-Constraint gegriffen) → erneut mit Update abrunden
|
||||
LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
|
||||
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
|
||||
it[isoAlpha3Code] = naturalKey
|
||||
it[isoNumerischerCode] = land.isoNumerischerCode
|
||||
it[nameDeutsch] = land.nameDeutsch
|
||||
it[nameEnglisch] = land.nameEnglisch
|
||||
it[wappenUrl] = land.wappenUrl
|
||||
it[istEuMitglied] = land.istEuMitglied
|
||||
it[istEwrMitglied] = land.istEwrMitglied
|
||||
it[istAktiv] = land.istAktiv
|
||||
it[sortierReihenfolge] = land.sortierReihenfolge
|
||||
it[updatedAt] = land.updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rückgabe des aktuellen Datensatzes
|
||||
LandTable.selectAll().where { LandTable.isoAlpha3Code eq naturalKey }
|
||||
.map(::rowToLandDefinition)
|
||||
.single()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,83 +22,75 @@ import kotlin.uuid.Uuid
|
|||
*/
|
||||
class PlatzRepositoryImpl : PlatzRepository {
|
||||
|
||||
private fun rowToPlatz(row: ResultRow): Platz {
|
||||
return Platz(
|
||||
id = row[PlatzTable.id],
|
||||
turnierId = row[PlatzTable.turnierId],
|
||||
name = row[PlatzTable.name],
|
||||
dimension = row[PlatzTable.dimension],
|
||||
boden = row[PlatzTable.boden],
|
||||
typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
|
||||
istAktiv = row[PlatzTable.istAktiv],
|
||||
sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
|
||||
createdAt = row[PlatzTable.createdAt],
|
||||
updatedAt = row[PlatzTable.updatedAt]
|
||||
)
|
||||
}
|
||||
private fun rowToPlatz(row: ResultRow): Platz {
|
||||
return Platz(
|
||||
id = row[PlatzTable.id],
|
||||
turnierId = row[PlatzTable.turnierId],
|
||||
name = row[PlatzTable.name],
|
||||
dimension = row[PlatzTable.dimension],
|
||||
boden = row[PlatzTable.boden],
|
||||
typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
|
||||
istAktiv = row[PlatzTable.istAktiv],
|
||||
sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
|
||||
createdAt = row[PlatzTable.createdAt],
|
||||
updatedAt = row[PlatzTable.updatedAt]
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { PlatzTable.id eq id }
|
||||
.map(::rowToPlatz)
|
||||
.singleOrNull()
|
||||
}
|
||||
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { PlatzTable.id eq id }
|
||||
.map(::rowToPlatz)
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
|
||||
if (activeOnly) {
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
}
|
||||
if (orderBySortierung) {
|
||||
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
|
||||
} else {
|
||||
query.orderBy(PlatzTable.name to SortOrder.ASC)
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
|
||||
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
|
||||
if (orderBySortierung) {
|
||||
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
|
||||
} else {
|
||||
query.orderBy(PlatzTable.name to SortOrder.ASC)
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val pattern = "%$searchTerm%"
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
query.limit(limit).map(::rowToPlatz)
|
||||
}
|
||||
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val pattern = "%$searchTerm%"
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
query.limit(limit).map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) {
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) {
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) {
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
|
||||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
|
||||
if (orderBySortierung) {
|
||||
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
|
||||
} else {
|
||||
query.orderBy(PlatzTable.name to SortOrder.ASC)
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
|
||||
if (orderBySortierung) {
|
||||
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
|
||||
} else {
|
||||
query.orderBy(PlatzTable.name to SortOrder.ASC)
|
||||
}
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
|
||||
override suspend fun findSuitableForDiscipline(
|
||||
requiredType: PlatzTypE,
|
||||
|
|
@ -110,23 +102,58 @@ class PlatzRepositoryImpl : PlatzRepository {
|
|||
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
query.map(::rowToPlatz)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
|
||||
val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any()
|
||||
if (exists) {
|
||||
PlatzTable.update({ PlatzTable.id eq platz.id }) {
|
||||
it[turnierId] = platz.turnierId
|
||||
it[name] = platz.name
|
||||
it[dimension] = platz.dimension
|
||||
it[boden] = platz.boden
|
||||
it[typ] = platz.typ.name
|
||||
it[istAktiv] = platz.istAktiv
|
||||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
platz
|
||||
} else {
|
||||
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
|
||||
val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any()
|
||||
if (exists) {
|
||||
PlatzTable.update({ PlatzTable.id eq platz.id }) {
|
||||
it[turnierId] = platz.turnierId
|
||||
it[name] = platz.name
|
||||
it[dimension] = platz.dimension
|
||||
it[boden] = platz.boden
|
||||
it[typ] = platz.typ.name
|
||||
it[istAktiv] = platz.istAktiv
|
||||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
platz
|
||||
} else {
|
||||
PlatzTable.insert {
|
||||
it[id] = platz.id
|
||||
it[turnierId] = platz.turnierId
|
||||
it[name] = platz.name
|
||||
it[dimension] = platz.dimension
|
||||
it[boden] = platz.boden
|
||||
it[typ] = platz.typ.name
|
||||
it[istAktiv] = platz.istAktiv
|
||||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[createdAt] = platz.createdAt
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
platz
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertByTurnierIdAndName(platz: Platz): Platz = DatabaseFactory.dbQuery {
|
||||
// 1) Versuch: Update anhand (turnierId + name)
|
||||
val updated = PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
|
||||
it[turnierId] = platz.turnierId
|
||||
it[name] = platz.name
|
||||
it[dimension] = platz.dimension
|
||||
it[boden] = platz.boden
|
||||
it[typ] = platz.typ.name
|
||||
it[istAktiv] = platz.istAktiv
|
||||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
if (updated > 0) {
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
|
||||
.map(::rowToPlatz)
|
||||
.single()
|
||||
} else {
|
||||
// 2) Kein Update → Insert versuchen
|
||||
try {
|
||||
PlatzTable.insert {
|
||||
it[id] = platz.id
|
||||
it[turnierId] = platz.turnierId
|
||||
|
|
@ -138,34 +165,47 @@ class PlatzRepositoryImpl : PlatzRepository {
|
|||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[createdAt] = platz.createdAt
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
platz
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
|
||||
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any()
|
||||
}
|
||||
|
||||
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
|
||||
}
|
||||
|
||||
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
|
||||
if (activeOnly) {
|
||||
query.andWhere { PlatzTable.istAktiv eq true }
|
||||
} catch (e: Exception) {
|
||||
// Race-Condition (Unique-Constraint gegriffen) → erneut via Update abrunden
|
||||
PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
|
||||
it[turnierId] = platz.turnierId
|
||||
it[name] = platz.name
|
||||
it[dimension] = platz.dimension
|
||||
it[boden] = platz.boden
|
||||
it[typ] = platz.typ.name
|
||||
it[istAktiv] = platz.istAktiv
|
||||
it[sortierReihenfolge] = platz.sortierReihenfolge
|
||||
it[updatedAt] = platz.updatedAt
|
||||
}
|
||||
query.count()
|
||||
}
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
|
||||
.map(::rowToPlatz)
|
||||
.single()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
|
||||
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück.
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }
|
||||
.map(::rowToPlatz)
|
||||
}
|
||||
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
|
||||
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
|
||||
}
|
||||
|
||||
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any()
|
||||
}
|
||||
|
||||
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
|
||||
}
|
||||
|
||||
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
|
||||
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
|
||||
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
|
||||
query.count()
|
||||
}
|
||||
|
||||
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
|
||||
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück.
|
||||
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }
|
||||
.map(::rowToPlatz)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
package at.mocode.masterdata.infrastructure.persistence
|
||||
|
||||
import at.mocode.masterdata.domain.model.LandDefinition
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class LandRepositoryImplTest {
|
||||
|
||||
private lateinit var repo: LandRepositoryImpl
|
||||
|
||||
@BeforeAll
|
||||
fun initDb() {
|
||||
// In-Memory H2 DB
|
||||
Database.connect("jdbc:h2:mem:landrepo;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
|
||||
transaction {
|
||||
SchemaUtils.create(LandTable)
|
||||
}
|
||||
repo = LandRepositoryImpl()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertByIsoAlpha3 performs update then insert semantics`() {
|
||||
runBlocking {
|
||||
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val id = Uuid.random()
|
||||
val base = LandDefinition(
|
||||
landId = id,
|
||||
isoAlpha2Code = "ZZ",
|
||||
isoAlpha3Code = "ZZY",
|
||||
isoNumerischerCode = null,
|
||||
nameDeutsch = "Testland",
|
||||
nameEnglisch = null,
|
||||
wappenUrl = null,
|
||||
istEuMitglied = null,
|
||||
istEwrMitglied = null,
|
||||
istAktiv = true,
|
||||
sortierReihenfolge = 1,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
|
||||
// 1) Insert path
|
||||
val saved1 = repo.upsertByIsoAlpha3(base)
|
||||
assertThat(saved1.isoAlpha3Code).isEqualTo("ZZY")
|
||||
|
||||
// 2) Update path (gleicher natürlicher Schlüssel, geänderte Werte)
|
||||
val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
||||
val saved2 = repo.upsertByIsoAlpha3(updated)
|
||||
assertThat(saved2.nameDeutsch).isEqualTo("Testland Neu")
|
||||
assertThat(saved2.sortierReihenfolge).isEqualTo(2)
|
||||
|
||||
// Stelle sicher, dass nur ein Datensatz existiert
|
||||
val count = transaction { LandTable.selectAll().count() }
|
||||
assertThat(count).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertByIsoAlpha3 handles race with two concurrent upserts on same natural key`() {
|
||||
runBlocking {
|
||||
// Sicherstellen, dass der Test mit leerer Tabelle startet
|
||||
transaction {
|
||||
SchemaUtils.drop(LandTable)
|
||||
SchemaUtils.create(LandTable)
|
||||
}
|
||||
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
|
||||
val base1 = LandDefinition(
|
||||
landId = Uuid.random(),
|
||||
isoAlpha2Code = "ZX",
|
||||
isoAlpha3Code = "ZXY",
|
||||
isoNumerischerCode = null,
|
||||
nameDeutsch = "RaceLand",
|
||||
nameEnglisch = null,
|
||||
wappenUrl = null,
|
||||
istEuMitglied = null,
|
||||
istEwrMitglied = null,
|
||||
istAktiv = true,
|
||||
sortierReihenfolge = 5,
|
||||
createdAt = now,
|
||||
updatedAt = now
|
||||
)
|
||||
val base2 = base1.copy(landId = Uuid.random(), nameDeutsch = "RaceLand Zwei", sortierReihenfolge = 6, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
|
||||
|
||||
// Feuere zwei parallele Upserts
|
||||
val d1 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base1) }
|
||||
val d2 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base2) }
|
||||
val r1 = d1.await()
|
||||
val r2 = d2.await()
|
||||
|
||||
// Beide Aufrufe dürfen nicht fehlschlagen und müssen denselben Datensatz (natürlicher Schlüssel) repräsentieren
|
||||
assertThat(r1.isoAlpha3Code).isEqualTo("ZXY")
|
||||
assertThat(r2.isoAlpha3Code).isEqualTo("ZXY")
|
||||
|
||||
// Es soll genau 1 Datensatz existieren
|
||||
val count = transaction { LandTable.selectAll().count() }
|
||||
assertThat(count).isEqualTo(1)
|
||||
|
||||
// Finaler Zustand entspricht einem der beiden Upserts (update hat "gewonnen"); Name ist entweder "RaceLand" oder "RaceLand Zwei"
|
||||
val finalRow = transaction { LandTable.selectAll().first() }
|
||||
val finalName = finalRow[LandTable.nameDeutsch]
|
||||
assertThat(finalName == "RaceLand" || finalName == "RaceLand Zwei").isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,12 @@ dependencies {
|
|||
implementation(libs.spring.boot.starter.actuator)
|
||||
//implementation(libs.springdoc.openapi.starter.webmvc.ui)
|
||||
|
||||
// Ktor Server (für SCS: eigener kleiner HTTP-Server pro Kontext)
|
||||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.netty)
|
||||
implementation(libs.ktor.server.contentNegotiation)
|
||||
implementation(libs.ktor.server.serialization.kotlinx.json)
|
||||
|
||||
// Datenbank-Abhängigkeiten
|
||||
implementation(libs.exposed.core)
|
||||
implementation(libs.exposed.dao)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
package at.mocode.masterdata.service.config
|
||||
|
||||
import at.mocode.masterdata.api.masterdataApiModule
|
||||
import at.mocode.masterdata.api.rest.AltersklasseController
|
||||
import at.mocode.masterdata.api.rest.BundeslandController
|
||||
import at.mocode.masterdata.api.rest.CountryController
|
||||
import at.mocode.masterdata.api.rest.PlatzController
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import io.ktor.server.netty.NettyApplicationEngine
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.DisposableBean
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
/**
|
||||
* Ktor-Server Bootstrap für den Masterdata-Bounded-Context (SCS-Architektur).
|
||||
*
|
||||
* - Startet einen eigenen Ktor Netty Server für diesen Kontext.
|
||||
* - Hängt das masterdataApiModule mit den via Spring bereitgestellten Controllern ein.
|
||||
* - Port ist konfigurierbar über SPRING-Config/ENV (Default 8091). Für Tests kann Port 0 genutzt werden.
|
||||
*/
|
||||
@Configuration
|
||||
class KtorServerConfiguration {
|
||||
|
||||
private val log = LoggerFactory.getLogger(KtorServerConfiguration::class.java)
|
||||
|
||||
@Bean(destroyMethod = "stop")
|
||||
fun ktorServer(
|
||||
@Value("\${masterdata.http.port:8091}") port: Int,
|
||||
countryController: CountryController,
|
||||
bundeslandController: BundeslandController,
|
||||
altersklasseController: AltersklasseController,
|
||||
platzController: PlatzController
|
||||
): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
|
||||
log.info("Starting Masterdata Ktor server on port {}", port)
|
||||
val engine = embeddedServer(Netty, port = port) {
|
||||
masterdataApiModule(
|
||||
countryController = countryController,
|
||||
bundeslandController = bundeslandController,
|
||||
altersklasseController = altersklasseController,
|
||||
platzController = platzController
|
||||
)
|
||||
}
|
||||
engine.start(wait = false)
|
||||
return engine
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package at.mocode.masterdata.service.api
|
||||
|
||||
import at.mocode.masterdata.service.MasterdataServiceApplication
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
@SpringBootTest(
|
||||
classes = [MasterdataServiceApplication::class],
|
||||
properties = [
|
||||
"spring.main.web-application-type=none",
|
||||
"masterdata.http.port=18091" // fixed port for tests to simplify port discovery
|
||||
]
|
||||
)
|
||||
@ActiveProfiles("test")
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class IdempotencyApiIntegrationTest {
|
||||
|
||||
private lateinit var baseUri: String
|
||||
|
||||
@BeforeAll
|
||||
fun setUp() {
|
||||
baseUri = "http://localhost:18091"
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun tearDown() {
|
||||
// Server lifecycle managed by Spring; no explicit stop here.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second POST with same Idempotency-Key returns identical response and does not create duplicate`() {
|
||||
val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()
|
||||
|
||||
val payload = """
|
||||
{
|
||||
"isoAlpha2Code": "ZZ",
|
||||
"isoAlpha3Code": "ZZY",
|
||||
"nameDeutsch": "Testland Idempotency"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val key = "itest-key-001"
|
||||
|
||||
val req1 = HttpRequest.newBuilder()
|
||||
.uri(URI.create("$baseUri/countries"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Idempotency-Key", key)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.build()
|
||||
|
||||
val res1 = sendWithRetry(client, req1)
|
||||
|
||||
val req2 = HttpRequest.newBuilder()
|
||||
.uri(URI.create("$baseUri/countries"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Idempotency-Key", key)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
||||
.build()
|
||||
|
||||
val res2 = sendWithRetry(client, req2)
|
||||
|
||||
// Beide Antworten müssen identisch sein (Status + Body)
|
||||
assertThat(res1.statusCode()).isIn(200, 201) // Created or OK
|
||||
assertThat(res2.statusCode()).isEqualTo(res1.statusCode())
|
||||
assertThat(res2.headers().firstValue("Content-Type").orElse("application/json")).contains("application/json")
|
||||
assertThat(res2.body()).isEqualTo(res1.body())
|
||||
|
||||
// Verifizieren, dass kein Duplikat erzeugt wurde:
|
||||
// GET /countries?search=Testland%20Idempotency&unpaged=true sollte genau 1 Element enthalten
|
||||
val resList = sendWithRetry(
|
||||
client,
|
||||
HttpRequest.newBuilder()
|
||||
.uri(URI.create("$baseUri/countries?search=Testland%20Idempotency&unpaged=true"))
|
||||
.GET()
|
||||
.build()
|
||||
)
|
||||
|
||||
assertThat(resList.statusCode()).isEqualTo(200)
|
||||
// sehr leichte Prüfung auf genau ein Ergebnis: zähle Vorkommen der isoAlpha3Code in JSON
|
||||
val occurrences = Regex("\"isoAlpha3Code\"\\s*:\\s*\"ZZY\"").findAll(resList.body()).count()
|
||||
assertThat(occurrences).isEqualTo(1)
|
||||
}
|
||||
|
||||
private fun sendWithRetry(client: HttpClient, request: HttpRequest, attempts: Int = 10, delayMillis: Long = 100): HttpResponse<String> {
|
||||
var lastEx: Exception? = null
|
||||
repeat(attempts) { idx ->
|
||||
try {
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
} catch (e: Exception) {
|
||||
lastEx = e
|
||||
if (idx == attempts - 1) throw e
|
||||
Thread.sleep(delayMillis)
|
||||
}
|
||||
}
|
||||
throw lastEx ?: IllegalStateException("unreachable")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user