Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2208,4 +2208,24 @@
<column name="engine_version" type="varchar(50)"/>
</addColumn>
</changeSet>
<changeSet id="0094" author="benckx">
<createTable tableName="reference_player_duplicate">
<column name="player_id" type="varchar(12)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="is_duplicate_of" type="varchar(12)">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="reference_player_duplicate"
baseColumnNames="player_id"
constraintName="ref_player_duplicate_player_id_fk"
referencedTableName="reference_player"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="reference_player_duplicate"
baseColumnNames="is_duplicate_of"
constraintName="ref_player_duplicate_is_duplicate_of_fk"
referencedTableName="reference_player"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
20 changes: 20 additions & 0 deletions webapp-dao-migration/src/main/resources/liquibase-changelog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2361,4 +2361,24 @@
<column name="engine_version" type="varchar(50)"/>
</addColumn>
</changeSet>
<changeSet id="0094" author="benckx">
<createTable tableName="reference_player_duplicate">
<column name="player_id" type="varchar(12)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="is_duplicate_of" type="varchar(12)">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint baseTableName="reference_player_duplicate"
baseColumnNames="player_id"
constraintName="ref_player_duplicate_player_id_fk"
referencedTableName="reference_player"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="reference_player_duplicate"
baseColumnNames="is_duplicate_of"
constraintName="ref_player_duplicate_is_duplicate_of_fk"
referencedTableName="reference_player"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package io.elephantchess.db.services
import io.elephantchess.db.dao.codegen.Tables.*
import io.elephantchess.db.dao.codegen.tables.daos.ReferencePlayerDao
import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayer
import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerDuplicate
import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerProfileEdit
import io.elephantchess.db.dao.codegen.tables.pojos.ReferencePlayerProfileEditSource
import io.elephantchess.db.dao.codegen.tables.records.ReferenceGameRecord
Expand Down Expand Up @@ -163,6 +164,47 @@ class ReferencePlayerDaoService(private val dslContext: DSLContext) {
.awaitMappedRecords()
}

suspend fun savePlayerDuplicate(playerId: String, isNewDuplicateOf: String) {
dslContext
.insertInto(REFERENCE_PLAYER_DUPLICATE)
.set(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID, playerId)
.set(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF, isNewDuplicateOf)
.onConflict(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID)
.doUpdate()
.set(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF, isNewDuplicateOf)
.awaitExecute()
}

suspend fun findConfirmedDuplicatesOf(canonicalPlayerId: String): List<ReferencePlayerDuplicate> {
return dslContext
.select(REFERENCE_PLAYER_DUPLICATE)
.from(REFERENCE_PLAYER_DUPLICATE)
.where(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF.eq(canonicalPlayerId))
.awaitMappedRecords()
}

suspend fun findCanonicalPlayerFor(duplicatePlayerId: String): String? {
return dslContext
.select(REFERENCE_PLAYER_DUPLICATE.IS_DUPLICATE_OF)
.from(REFERENCE_PLAYER_DUPLICATE)
.where(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID.eq(duplicatePlayerId))
.awaitSingleValue()
}

suspend fun deletePlayerDuplicate(playerId: String) {
dslContext
.deleteFrom(REFERENCE_PLAYER_DUPLICATE)
.where(REFERENCE_PLAYER_DUPLICATE.PLAYER_ID.eq(playerId))
.awaitExecute()
}

suspend fun listAllPlayerDuplicates(): List<ReferencePlayerDuplicate> {
return dslContext
.select(REFERENCE_PLAYER_DUPLICATE)
.from(REFERENCE_PLAYER_DUPLICATE)
.awaitMappedRecords()
}

suspend fun listByMostNumberOfGames(limit: Int): List<NumberOfGamesRecord> {
val gamesAsRed = DSL.`when`(REFERENCE_PLAYER.ID.eq(REFERENCE_GAME.RED_PLAYER), 1).otherwise(0)
val gamesAsBlack = DSL.`when`(REFERENCE_PLAYER.ID.eq(REFERENCE_GAME.BLACK_PLAYER), 1).otherwise(0)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.elephantchess.servicelayer.dto.admin

data class RegisterPlayerDuplicateRequest(
val playerId: String,
val isNewDuplicateOf: String
)

data class DeletePlayerDuplicateRequest(
val playerId: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.elephantchess.servicelayer.dto.admin

data class PlayerDuplicatesResponse(val entries: List<Entry>) {
data class Entry(
val playerId: String,
val playerCanonicalName: String,
val isDuplicateOf: String,
val canonicalPlayerCanonicalName: String
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,8 @@ class DatabaseService(
// fetch stats for player alone
val playerStats = referencePlayerDaoService.fetchGameStats(playerId)

// find possible duplicates
val duplicates = findPossibleDuplicates(playerId)
// find confirmed duplicates first; fall back to name-based heuristics
val duplicates = findConfirmedDuplicates(playerId).ifEmpty { findPossibleDuplicates(playerId) }

// fetch stats including duplicates if any exist
val statsWithDuplicates =
Expand Down Expand Up @@ -474,6 +474,12 @@ class DatabaseService(
)
}

private suspend fun findConfirmedDuplicates(playerId: String): List<ReferencePlayer> {
return referencePlayerDaoService
.findConfirmedDuplicatesOf(playerId)
.mapNotNull { duplicate -> referencePlayerDaoService.findPlayer(duplicate.playerId) }
}

private suspend fun findPossibleDuplicates(playerId: String): List<ReferencePlayer> {
val player = referencePlayerDaoService.findPlayer(playerId)
?: throw NotFoundException("player $playerId not found")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package io.elephantchess.servicelayer.services.admin

import io.elephantchess.db.services.ReferencePlayerDaoService
import io.elephantchess.servicelayer.dto.admin.PlayerDuplicatesResponse
import io.elephantchess.servicelayer.dto.database.DatabasePlayerProfileVersionHistoryEntry
import io.elephantchess.servicelayer.dto.database.DatabasePlayerVersionHistory
import io.elephantchess.servicelayer.exceptions.BadRequestException
import io.elephantchess.servicelayer.exceptions.NotFoundException
import io.elephantchess.servicelayer.services.UserCache

class AdminDatabaseService(
Expand Down Expand Up @@ -30,4 +33,55 @@ class AdminDatabaseService(
}
}

suspend fun listPlayerDuplicates(): PlayerDuplicatesResponse {
return referencePlayerDaoService
.listAllPlayerDuplicates()
.mapNotNull { duplicate ->
val player = referencePlayerDaoService.findPlayer(duplicate.playerId) ?: return@mapNotNull null
val canonicalPlayer =
referencePlayerDaoService.findPlayer(duplicate.isDuplicateOf) ?: return@mapNotNull null
PlayerDuplicatesResponse.Entry(
playerId = duplicate.playerId,
playerCanonicalName = player.canonicalName,
isDuplicateOf = duplicate.isDuplicateOf,
canonicalPlayerCanonicalName = canonicalPlayer.canonicalName
)
}
.let { entries -> PlayerDuplicatesResponse(entries) }
}

suspend fun registerPlayerDuplicate(playerId: String, isNewDuplicateOf: String) {
if (playerId == isNewDuplicateOf) {
throw BadRequestException("a player cannot be a duplicate of itself")
}
referencePlayerDaoService.findPlayer(playerId)
?: throw NotFoundException("player $playerId not found")
referencePlayerDaoService.findPlayer(isNewDuplicateOf)
?: throw NotFoundException("player $isNewDuplicateOf not found")

// prevent cycles: the canonical player must not itself be registered as a duplicate
val canonicalIsAlreadyDuplicate = referencePlayerDaoService.findCanonicalPlayerFor(isNewDuplicateOf)
if (canonicalIsAlreadyDuplicate != null) {
throw BadRequestException(
"player $isNewDuplicateOf is already registered as a duplicate of $canonicalIsAlreadyDuplicate; " +
"resolve that relationship first"
)
}

// prevent reverse cycle: the new duplicate must not already be the canonical for others
val playersAlreadyDuplicateOf = referencePlayerDaoService.findConfirmedDuplicatesOf(playerId)
if (playersAlreadyDuplicateOf.isNotEmpty()) {
throw BadRequestException(
"player $playerId is already listed as a canonical player for other duplicates; " +
"remove those relationships first"
)
}

referencePlayerDaoService.savePlayerDuplicate(playerId, isNewDuplicateOf)
}

suspend fun deletePlayerDuplicate(playerId: String) {
referencePlayerDaoService.deletePlayerDuplicate(playerId)
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.elephantchess.webapp.routing.api

import io.elephantchess.servicelayer.dto.admin.CreateUpcomingEventRequest
import io.elephantchess.servicelayer.dto.admin.DeletePlayerDuplicateRequest
import io.elephantchess.servicelayer.dto.admin.RegisterPlayerDuplicateRequest
import io.elephantchess.servicelayer.dto.admin.ToggleUpcomingEventRequest
import io.elephantchess.servicelayer.dto.admin.UpdateUpcomingEventRequest
import io.elephantchess.servicelayer.services.admin.*
Expand All @@ -25,6 +27,7 @@ fun Route.adminConsoleRoutes() {
adminExceptionRoutes()
adminNewsletterRoutes()
adminUpcomingEventsRoutes()
adminPlayerDuplicatesRoutes()
}
}

Expand Down Expand Up @@ -255,3 +258,23 @@ private fun Route.adminUpcomingEventsRoutes() {
}
}
}

private fun Route.adminPlayerDuplicatesRoutes() {
val adminDatabaseService by koin<AdminDatabaseService>()

get("/player-duplicates") {
requireAdminRole { adminDatabaseService.listPlayerDuplicates() }
}
post("/player-duplicates") {
requireAdminRole {
val request = call.receive<RegisterPlayerDuplicateRequest>()
adminDatabaseService.registerPlayerDuplicate(request.playerId, request.isNewDuplicateOf)
}
}
delete("/player-duplicates") {
requireAdminRole {
val request = call.receive<DeletePlayerDuplicateRequest>()
adminDatabaseService.deletePlayerDuplicate(request.playerId)
}
}
}