Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests.
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
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:
+54
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+150
-110
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+118
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user