Refactored search, added validation (w/ recursion checks)

This commit is contained in:
nise.moe 2024-02-25 02:00:45 +01:00
parent 3adeea3094
commit bca47c30f6
8 changed files with 619 additions and 646 deletions

View File

@ -1,599 +0,0 @@
package com.nisemoe.nise.controller
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format
import com.nisemoe.nise.service.AuthService
import org.jooq.*
import org.jooq.impl.DSL
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import kotlin.math.roundToInt
@RestController
class SearchController(
private val dslContext: DSLContext,
private val authService: AuthService,
) {
companion object {
const val RESULTS_PER_PAGE = 50
}
data class SearchResponse(
val scores: List<SearchResponseEntry>,
val pagination: SearchResponsePagination
)
data class SearchResponsePagination(
val currentPage: Int,
val pageSize: Int,
val totalResults: Int
) {
val totalPages: Int
get() = if (totalResults % pageSize == 0) totalResults / pageSize else totalResults / pageSize + 1
}
data class SearchResponseEntry(
// User fields
val user_id: Long?,
val user_username: String?,
val user_join_date: String?,
val user_country: String?,
val user_country_rank: Long?,
val user_rank: Long?,
val user_pp_raw: Double?,
val user_accuracy: Double?,
val user_playcount: Long?,
val user_total_score: Long?,
val user_ranked_score: Long?,
val user_seconds_played: Long?,
val user_count_300: Long?,
val user_count_100: Long?,
val user_count_50: Long?,
val user_count_miss: Int?,
// Score fields
val id: Int?,
val beatmap_id: Int?,
val count_300: Int?,
val count_100: Int?,
val count_50: Int?,
val count_miss: Int?,
val date: String?,
val max_combo: Int?,
val mods: Int?,
val perfect: Boolean?,
val pp: Double?,
val rank: String?,
val replay_id: Long?,
val score: Long?,
val ur: Double?,
val frametime: Double?,
val edge_hits: Int?,
val snaps: Int?,
val adjusted_ur: Double?,
val mean_error: Double?,
val error_variance: Double?,
val error_standard_deviation: Double?,
val minimum_error: Double?,
val maximum_error: Double?,
val error_range: Double?,
val error_coefficient_of_variation: Double?,
val error_kurtosis: Double?,
val error_skewness: Double?,
val keypresses_median_adjusted: Double?,
val keypresses_standard_deviation_adjusted: Double?,
val sliderend_release_median_adjusted: Double?,
val sliderend_release_standard_deviation_adjusted: Double?,
// Beatmap fields
val beatmap_artist: String?,
val beatmap_beatmapset_id: Int?,
val beatmap_creator: String?,
val beatmap_source: String?,
val beatmap_star_rating: Double?,
val beatmap_title: String?,
val beatmap_version: String?
)
data class SearchRequest(
val queries: List<SearchQuery>,
val sorting: SearchSorting,
val page: Int
)
data class SearchSorting(
val field: String,
val order: String
)
data class SearchQuery(
val logicalOperator: String,
val predicates: List<SearchPredicate>,
val childQueries: List<SearchQuery>?
)
data class SearchPredicate(
val field: SearchField,
val operator: SearchPredicateOperator,
val value: String
)
data class SearchPredicateOperator(
val operatorType: String,
val acceptsValues: String
)
data class SearchField(
val name: String,
val type: String
)
data class InternalSchemaField(
val name: String,
val shortName: String,
val category: Category,
val type: Type,
val active: Boolean,
val description: String,
val isPrivileged: Boolean = false,
val databaseField: Field<*>? = null
)
data class SchemaField(
val name: String,
val shortName: String,
val category: Category,
val type: Type,
val active: Boolean,
val description: String
)
// Define the Category and Type enums
enum class Category {
user, score, beatmap
}
enum class Type {
number, string, flag, grade, boolean
}
data class SearchSchema(
val fields: List<SchemaField>
)
@GetMapping("search")
fun getSearchSchema(): ResponseEntity<SearchSchema> {
val internalFields = listOf(
// User fields
InternalSchemaField("user_id", "ID", Category.user, Type.number, false, "unique identifier for a user"),
InternalSchemaField("user_username", "Username", Category.user, Type.string, true, "user's name"),
InternalSchemaField("user_join_date", "Join Date", Category.user, Type.string, false, "when the user joined"),
InternalSchemaField("user_country", "Country", Category.user, Type.flag, true, "user's country flag"),
InternalSchemaField("user_country_rank", "Country Rank", Category.user, Type.number, false, "ranking within user's country"),
InternalSchemaField("user_rank", "Rank", Category.user, Type.number, false, "global ranking"),
InternalSchemaField("user_pp_raw", "User PP", Category.user, Type.number, false, "performance points"),
InternalSchemaField("user_accuracy", "User Accuracy", Category.user, Type.number, false, "hit accuracy percentage"),
InternalSchemaField("user_playcount", "Playcount", Category.user, Type.number, false, "total plays"),
InternalSchemaField("user_total_score", "Total Score", Category.user, Type.number, false, "cumulative score"),
InternalSchemaField("user_ranked_score", "Ranked Score", Category.user, Type.number, false, "score from ranked maps"),
InternalSchemaField("user_seconds_played", "Play Time", Category.user, Type.number, false, "total play time in seconds"),
InternalSchemaField("user_count_300", "300s", Category.user, Type.number, false, "number of 300 hits"),
InternalSchemaField("user_count_100", "100s", Category.user, Type.number, false, "number of 100 hits"),
InternalSchemaField("user_count_50", "50s", Category.user, Type.number, false, "number of 50 hits"),
InternalSchemaField("user_count_miss", "Misses", Category.user, Type.number, false, "missed hits"),
// Score fields
InternalSchemaField("id", "ID", Category.score, Type.number, false, "unique identifier for a score"),
InternalSchemaField("beatmap_id", "Beatmap ID", Category.score, Type.number, false, "identifies the beatmap"),
InternalSchemaField("count_300", "300s", Category.score, Type.number, false, "number of 300 hits in score"),
InternalSchemaField("count_100", "100s", Category.score, Type.number, false, "number of 100 hits in score"),
InternalSchemaField("count_50", "50s", Category.score, Type.number, false, "number of 50 hits in score"),
InternalSchemaField("count_miss", "Misses", Category.score, Type.number, false, "missed hits in score"),
InternalSchemaField("date", "Date", Category.score, Type.string, true, "when score was achieved"),
InternalSchemaField("max_combo", "Max Combo", Category.score, Type.number, false, "highest combo in score"),
InternalSchemaField("mods", "Mods", Category.score, Type.number, false, "game modifiers used"),
InternalSchemaField("perfect", "Perfect", Category.score, Type.boolean, false, "if score is a full combo"),
InternalSchemaField("pp", "Score PP", Category.score, Type.number, true, "performance points for score"),
InternalSchemaField("rank", "Rank", Category.score, Type.grade, false, "score grade"),
InternalSchemaField("replay_id", "Replay ID", Category.score, Type.number, false, "identifier for replay"),
InternalSchemaField("score", "Score", Category.score, Type.number, false, "score value"),
InternalSchemaField("ur", "UR", Category.score, Type.number, false, "unstable rate"),
InternalSchemaField("frametime", "Frame Time", Category.score, Type.number, false, "median frame time during play"),
InternalSchemaField("edge_hits", "Edge Hits", Category.score, Type.number, false, "hits at the edge of the hitobject (<1px)"),
InternalSchemaField("snaps", "Snaps", Category.score, Type.number, false, "rapid cursor movements"),
InternalSchemaField("adjusted_ur", "Adj. UR", Category.score, Type.number, true, "adjusted unstable rate"),
InternalSchemaField("mean_error", "Mean Error", Category.score, Type.number, false, "average timing error"),
InternalSchemaField("error_variance", "Error Var.", Category.score, Type.number, false, "variability of error in scores"),
InternalSchemaField("error_standard_deviation", "Error SD", Category.score, Type.number, false, "standard deviation of error"),
InternalSchemaField("minimum_error", "Min Error", Category.score, Type.number, false, "smallest error recorded"),
InternalSchemaField("maximum_error", "Max Error", Category.score, Type.number, false, "largest error recorded"),
InternalSchemaField("error_range", "Error Range", Category.score, Type.number, false, "range between min and max error"),
InternalSchemaField("error_coefficient_of_variation", "Error CV", Category.score, Type.number, false, "relative variability of error"),
InternalSchemaField("error_kurtosis", "Kurtosis", Category.score, Type.number, false, "peakedness of error distribution"),
InternalSchemaField("error_skewness", "Skewness", Category.score, Type.number, false, "asymmetry of error distribution"),
InternalSchemaField("keypresses_median_adjusted", "KP Median Adj.", Category.score, Type.number, false, "median of adjusted keypresses", isPrivileged = true),
InternalSchemaField("keypresses_standard_deviation_adjusted", "KP std. Adj.", Category.score, Type.number, false, "std. dev of adjusted keypresses", isPrivileged = true),
InternalSchemaField("sliderend_release_median_adjusted", "Sliderend Median Adj.", Category.score, Type.number, false, "median of adjusted sliderend releases", isPrivileged = true),
InternalSchemaField("sliderend_release_standard_deviation_adjusted", "Sliderend std. Adj.", Category.score, Type.number, false, "std. dev of adjusted sliderend releases", isPrivileged = true),
// Beatmap fields
InternalSchemaField("beatmap_artist", "Artist", Category.beatmap, Type.string, false, "artist of the beatmap"),
InternalSchemaField("beatmap_beatmapset_id", "Set ID", Category.beatmap, Type.number, false, "id of the beatmap set"),
InternalSchemaField("beatmap_creator", "Creator", Category.beatmap, Type.string, false, "creator of the beatmap"),
InternalSchemaField("beatmap_source", "Source", Category.beatmap, Type.string, false, "source of the beatmap music"),
InternalSchemaField("beatmap_star_rating", "Stars", Category.beatmap, Type.number, false, "(★) difficulty rating of the beatmap"),
InternalSchemaField("beatmap_title", "Title", Category.beatmap, Type.string, false, "title of the beatmap"),
InternalSchemaField("beatmap_version", "Version", Category.beatmap, Type.string, false, "version or difficulty name of the beatmap")
)
// Map to SchemaField
val isUserAdmin = authService.isAdmin()
val fields = internalFields
.filter {
return@filter !(it.isPrivileged && !isUserAdmin)
}
.map {
SchemaField(
name = it.name,
shortName = it.shortName,
category = it.category,
type = it.type,
active = it.active,
description = it.description
)
}
val schema = SearchSchema(fields)
return ResponseEntity.ok(schema)
}
@PostMapping("search")
fun doSearch(@RequestBody request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
if (apiVersion.isBlank())
return ResponseEntity.badRequest().build()
// TODO: Validation
var baseQuery = DSL.noCondition()
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
val condition = buildCondition(query)
baseQuery = when (query.logicalOperator.lowercase()) {
"and" -> baseQuery.and(condition)
"or" -> baseQuery.or(condition)
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
val results = dslContext.select(
// User fields
USERS.USERNAME,
USERS.USER_ID,
USERS.JOIN_DATE,
USERS.COUNTRY,
USERS.COUNTRY_RANK,
USERS.RANK,
USERS.PP_RAW,
USERS.ACCURACY,
USERS.PLAYCOUNT,
USERS.TOTAL_SCORE,
USERS.RANKED_SCORE,
USERS.SECONDS_PLAYED,
USERS.COUNT_300,
USERS.COUNT_100,
USERS.COUNT_50,
// Scores fields
SCORES.ID,
SCORES.BEATMAP_ID,
SCORES.COUNT_300,
SCORES.COUNT_100,
SCORES.COUNT_50,
SCORES.COUNT_MISS,
SCORES.DATE,
SCORES.MAX_COMBO,
SCORES.MODS,
SCORES.PERFECT,
SCORES.PP,
SCORES.RANK,
SCORES.REPLAY_ID,
SCORES.SCORE,
SCORES.UR,
SCORES.FRAMETIME,
SCORES.EDGE_HITS,
SCORES.SNAPS,
SCORES.ADJUSTED_UR,
SCORES.MEAN_ERROR,
SCORES.ERROR_VARIANCE,
SCORES.ERROR_STANDARD_DEVIATION,
SCORES.MINIMUM_ERROR,
SCORES.MAXIMUM_ERROR,
SCORES.ERROR_RANGE,
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
SCORES.ERROR_KURTOSIS,
SCORES.ERROR_SKEWNESS,
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED,
SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED,
SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED,
// Beatmaps fields
BEATMAPS.ARTIST,
BEATMAPS.BEATMAPSET_ID,
BEATMAPS.CREATOR,
BEATMAPS.SOURCE,
BEATMAPS.STAR_RATING,
BEATMAPS.TITLE,
BEATMAPS.VERSION
)
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.apply {
if (request.sorting.field.isNotBlank())
orderBy(buildSorting(request.sorting))
}
.offset((request.page - 1) * RESULTS_PER_PAGE)
.limit(RESULTS_PER_PAGE)
.fetch()
// Get total results
val totalResults = dslContext.selectCount()
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.fetchOne(0, Int::class.java) ?: 0
val response = SearchResponse(
scores = mapRecordToScores(results),
pagination = SearchResponsePagination(
currentPage = request.page,
pageSize = RESULTS_PER_PAGE,
totalResults = totalResults
)
)
return ResponseEntity.ok(response)
}
private fun mapRecordToScores(results: Result<Record>): MutableList<SearchResponseEntry> =
results.map {
SearchResponseEntry(
// User fields
user_id = it.get(SCORES.USER_ID),
user_username = it.get(USERS.USERNAME),
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
user_country = it.get(USERS.COUNTRY),
user_country_rank = it.get(USERS.COUNTRY_RANK),
user_rank = it.get(USERS.RANK),
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
user_accuracy = it.get(USERS.ACCURACY),
user_playcount = it.get(USERS.PLAYCOUNT),
user_total_score = it.get(USERS.TOTAL_SCORE),
user_ranked_score = it.get(USERS.RANKED_SCORE),
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
user_count_300 = it.get(USERS.COUNT_300),
user_count_100 = it.get(USERS.COUNT_100),
user_count_50 = it.get(USERS.COUNT_50),
user_count_miss = it.get(SCORES.COUNT_MISS),
// Score fields
id = it.get(SCORES.ID),
beatmap_id = it.get(SCORES.BEATMAP_ID),
count_300 = it.get(SCORES.COUNT_300),
count_100 = it.get(SCORES.COUNT_100),
count_50 = it.get(SCORES.COUNT_50),
count_miss = it.get(SCORES.COUNT_MISS),
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
max_combo = it.get(SCORES.MAX_COMBO),
mods = it.get(SCORES.MODS),
perfect = it.get(SCORES.PERFECT),
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
rank = it.get(SCORES.RANK),
replay_id = it.get(SCORES.REPLAY_ID),
score = it.get(SCORES.SCORE),
ur = it.get(SCORES.UR),
frametime = it.get(SCORES.FRAMETIME),
edge_hits = it.get(SCORES.EDGE_HITS),
snaps = it.get(SCORES.SNAPS),
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
mean_error = it.get(SCORES.MEAN_ERROR),
error_variance = it.get(SCORES.ERROR_VARIANCE),
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
minimum_error = it.get(SCORES.MINIMUM_ERROR),
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
error_range = it.get(SCORES.ERROR_RANGE),
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
beatmap_artist = it.get(BEATMAPS.ARTIST),
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
beatmap_creator = it.get(BEATMAPS.CREATOR),
beatmap_source = it.get(BEATMAPS.SOURCE),
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
beatmap_title = it.get(BEATMAPS.TITLE),
beatmap_version = it.get(BEATMAPS.VERSION)
)
}
fun buildCondition(query: SearchQuery): Condition {
// Handle base predicates
var baseCondition = buildPredicateCondition(query.predicates.first())
query.predicates.drop(1).forEach { predicate ->
baseCondition = when (query.logicalOperator.lowercase()) {
"and" -> baseCondition.and(buildPredicateCondition(predicate))
"or" -> baseCondition.or(buildPredicateCondition(predicate))
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
// Handle child queries
if(query.childQueries.isNullOrEmpty())
return baseCondition
query.childQueries.forEach { childQuery ->
val childCondition = buildCondition(childQuery) // Recursively build condition for child queries
baseCondition = when (query.logicalOperator.lowercase()) {
"and" -> baseCondition.and(childCondition)
"or" -> baseCondition.or(childCondition)
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
return baseCondition
}
private fun buildSorting(sorting: SearchSorting): OrderField<*> {
val field = mapPredicateFieldToDatabaseField(sorting.field)
return when (sorting.order.lowercase()) {
"asc" -> field.asc()
"desc" -> field.desc()
else -> throw IllegalArgumentException("Invalid sorting order")
}
}
private fun buildPredicateCondition(predicate: SearchPredicate): Condition {
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
return when (predicate.field.type.lowercase()) {
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
"string" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"boolean" -> buildBooleanCondition(field as Field<Boolean>, predicate.operator.operatorType, predicate.value.toBoolean())
"flag" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"grade" -> buildGradeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
else -> throw IllegalArgumentException("Invalid field type")
}
}
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
return when (predicateName.lowercase()) {
// User fields
"user_id" -> USERS.USER_ID
"user_username" -> USERS.USERNAME
"user_join_date" -> USERS.JOIN_DATE
"user_country" -> USERS.COUNTRY
"user_country_rank" -> USERS.COUNTRY_RANK
"user_rank" -> USERS.RANK
"user_pp_raw" -> USERS.PP_RAW
"user_accuracy" -> USERS.ACCURACY
"user_playcount" -> USERS.PLAYCOUNT
"user_total_score" -> USERS.TOTAL_SCORE
"user_ranked_score" -> USERS.RANKED_SCORE
"user_seconds_played" -> USERS.SECONDS_PLAYED
"user_count_300" -> USERS.COUNT_300
"user_count_100" -> USERS.COUNT_100
"user_count_50" -> USERS.COUNT_50
// Score fields
"id" -> SCORES.ID
"beatmap_id" -> SCORES.BEATMAP_ID
"count_300" -> SCORES.COUNT_300
"count_100" -> SCORES.COUNT_100
"count_50" -> SCORES.COUNT_50
"count_miss" -> SCORES.COUNT_MISS
"date" -> SCORES.DATE
"max_combo" -> SCORES.MAX_COMBO
"mods" -> SCORES.MODS
"perfect" -> SCORES.PERFECT
"pp" -> SCORES.PP
"rank" -> SCORES.RANK
"replay_id" -> SCORES.REPLAY_ID
"score" -> SCORES.SCORE
"ur" -> SCORES.UR
"frametime" -> SCORES.FRAMETIME
"edge_hits" -> SCORES.EDGE_HITS
"snaps" -> SCORES.SNAPS
"adjusted_ur" -> SCORES.ADJUSTED_UR
"mean_error" -> SCORES.MEAN_ERROR
"error_variance" -> SCORES.ERROR_VARIANCE
"error_standard_deviation" -> SCORES.ERROR_STANDARD_DEVIATION
"minimum_error" -> SCORES.MINIMUM_ERROR
"maximum_error" -> SCORES.MAXIMUM_ERROR
"error_range" -> SCORES.ERROR_RANGE
"error_coefficient_of_variation" -> SCORES.ERROR_COEFFICIENT_OF_VARIATION
"error_kurtosis" -> SCORES.ERROR_KURTOSIS
"error_skewness" -> SCORES.ERROR_SKEWNESS
"keypresses_median_adjusted" -> SCORES.KEYPRESSES_MEDIAN_ADJUSTED
"keypresses_standard_deviation_adjusted" -> SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED
"sliderend_release_median_adjusted" -> SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED
"sliderend_release_standard_deviation_adjusted" -> SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED
// Beatmap fields
"beatmap_artist" -> BEATMAPS.ARTIST
"beatmap_beatmapset_id" -> BEATMAPS.BEATMAPSET_ID
"beatmap_creator" -> BEATMAPS.CREATOR
"beatmap_source" -> BEATMAPS.SOURCE
"beatmap_star_rating" -> BEATMAPS.STAR_RATING
"beatmap_title" -> BEATMAPS.TITLE
"beatmap_version" -> BEATMAPS.VERSION
else -> throw IllegalArgumentException("Invalid field name: $predicateName")
}
}
private fun buildBooleanCondition(field: Field<Boolean>, operator: String, value: Boolean): Condition {
return when (operator) {
"=" -> field.eq(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildGradeCondition(field: Field<String>, operator: String, value: String): Condition {
return when (value) {
"SS", "S", "A", "B", "C", "D" -> {
val valuesToMatch = when (value) {
"SS" -> listOf("Grade.SS", "Grade.SSH")
"S" -> listOf("Grade.S", "Grade.SH")
else -> listOf("Grade.$value")
}
when (operator) {
"=" -> field.`in`(valuesToMatch)
"!=" -> field.notIn(valuesToMatch)
else -> throw IllegalArgumentException("Invalid operator")
}
}
else -> throw IllegalArgumentException("Invalid grade value")
}
}
private fun buildNumberCondition(field: Field<Double>, operator: String, value: Double): Condition {
return when (operator) {
"=" -> field.eq(value)
">" -> field.gt(value)
"<" -> field.lt(value)
">=" -> field.ge(value)
"<=" -> field.le(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildStringCondition(field: Field<String>, operator: String, value: String): Condition {
return when (operator.lowercase()) {
"=" -> field.eq(value)
"contains" -> field.containsIgnoreCase(value)
"like" -> field.likeIgnoreCase(
// Escape special characters for LIKE if needed
value.replace("%", "\\%").replace("_", "\\_")
)
else -> throw IllegalArgumentException("Invalid operator")
}
}
}

View File

@ -0,0 +1,35 @@
package com.nisemoe.nise.search
import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import kotlin.reflect.KClass
@MustBeDocumented
@Constraint(validatedBy = [ChildQueriesDepthValidator::class])
@Target(allowedTargets = [AnnotationTarget.CLASS, AnnotationTarget.FIELD])
@Retention(AnnotationRetention.RUNTIME)
annotation class ValidChildQueriesDepth(
val message: String = "Exceeds maximum depth of child queries",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Any>> = []
)
class ChildQueriesDepthValidator : ConstraintValidator<ValidChildQueriesDepth, List<SearchController.SearchQuery>> {
override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) {
super.initialize(constraintAnnotation)
}
override fun isValid(queries: List<SearchController.SearchQuery>?, context: ConstraintValidatorContext): Boolean {
return queries?.all { validateChildQueriesDepth(it, 1) } ?: true
}
private fun validateChildQueriesDepth(query: SearchController.SearchQuery, currentDepth: Int): Boolean {
if (currentDepth > 10) return false
query.childQueries?.forEach {
if (!validateChildQueriesDepth(it, currentDepth + 1)) return false
}
return true
}
}

View File

@ -0,0 +1,148 @@
package com.nisemoe.nise.search
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
@RestController
class SearchController(
private val searchService: SearchService
) {
companion object {
const val RESULTS_PER_PAGE = 50
}
data class SearchResponse(
val scores: List<SearchResponseEntry>,
val pagination: SearchResponsePagination
)
data class SearchResponsePagination(
val currentPage: Int,
val pageSize: Int,
val totalResults: Int
) {
val totalPages: Int
get() = if (totalResults % pageSize == 0) totalResults / pageSize else totalResults / pageSize + 1
}
data class SearchResponseEntry(
// User fields
val user_id: Long?,
val user_username: String?,
val user_join_date: String?,
val user_country: String?,
val user_country_rank: Long?,
val user_rank: Long?,
val user_pp_raw: Double?,
val user_accuracy: Double?,
val user_playcount: Long?,
val user_total_score: Long?,
val user_ranked_score: Long?,
val user_seconds_played: Long?,
val user_count_300: Long?,
val user_count_100: Long?,
val user_count_50: Long?,
val user_count_miss: Int?,
// Score fields
val id: Int?,
val beatmap_id: Int?,
val count_300: Int?,
val count_100: Int?,
val count_50: Int?,
val count_miss: Int?,
val date: String?,
val max_combo: Int?,
val mods: Int?,
val perfect: Boolean?,
val pp: Double?,
val rank: String?,
val replay_id: Long?,
val score: Long?,
val ur: Double?,
val frametime: Double?,
val edge_hits: Int?,
val snaps: Int?,
val adjusted_ur: Double?,
val mean_error: Double?,
val error_variance: Double?,
val error_standard_deviation: Double?,
val minimum_error: Double?,
val maximum_error: Double?,
val error_range: Double?,
val error_coefficient_of_variation: Double?,
val error_kurtosis: Double?,
val error_skewness: Double?,
val keypresses_median_adjusted: Double?,
val keypresses_standard_deviation_adjusted: Double?,
val sliderend_release_median_adjusted: Double?,
val sliderend_release_standard_deviation_adjusted: Double?,
// Beatmap fields
val beatmap_artist: String?,
val beatmap_beatmapset_id: Int?,
val beatmap_creator: String?,
val beatmap_source: String?,
val beatmap_star_rating: Double?,
val beatmap_title: String?,
val beatmap_version: String?
)
data class SearchRequest(
@Valid @field:ValidChildQueriesDepth val queries: List<SearchQuery>,
@Valid val sorting: SearchSorting,
@field:Min(1) val page: Int
)
data class SearchSorting(
@field:NotBlank @field:Size(max = 300) val field: String,
@field:NotBlank @field:Size(max = 300) val order: String
)
data class SearchQuery(
@field:NotBlank @field:Size(max = 300) val logicalOperator: String,
@Valid val predicates: List<SearchPredicate>,
@Valid @field:ValidChildQueriesDepth val childQueries: List<SearchQuery>?
)
data class SearchPredicate(
@Valid val field: SearchField,
@Valid val operator: SearchPredicateOperator,
@field:NotBlank @field:Size(max = 300) val value: String
)
data class SearchPredicateOperator(
@field:NotBlank @field:Size(max = 300) val operatorType: String,
@field:NotBlank @field:Size(max = 300) val acceptsValues: String
)
data class SearchField(
@field:NotBlank @field:Size(max = 300) val name: String,
@field:NotBlank @field:Size(max = 300) val type: String
)
@PostMapping("search")
fun doSearch(@RequestBody @Valid request: SearchRequest, @RequestHeader("X-NISE-API") apiVersion: String): ResponseEntity<SearchResponse> {
if (apiVersion.isBlank())
return ResponseEntity.badRequest().build()
val response = this.searchService.search(request)
return ResponseEntity.ok(response)
}
}

View File

@ -0,0 +1,140 @@
package com.nisemoe.nise.search
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.service.AuthService
import org.jooq.Field
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class SearchSchemaController(
private val authService: AuthService,
) {
companion object {
val internalFields = listOf(
// User fields
InternalSchemaField("user_id", "ID", Category.user, Type.number, false, "unique identifier for a user", databaseField = USERS.USER_ID),
InternalSchemaField("user_username", "Username", Category.user, Type.string, true, "user's name", databaseField = USERS.USERNAME),
InternalSchemaField("user_join_date", "Join Date", Category.user, Type.string, false, "when the user joined", databaseField = USERS.JOIN_DATE),
InternalSchemaField("user_country", "Country", Category.user, Type.flag, true, "user's country flag", databaseField = USERS.COUNTRY),
InternalSchemaField("user_country_rank", "Country Rank", Category.user, Type.number, false, "ranking within user's country", databaseField = USERS.COUNTRY_RANK),
InternalSchemaField("user_rank", "Rank", Category.user, Type.number, false, "global ranking", databaseField = USERS.RANK),
InternalSchemaField("user_pp_raw", "User PP", Category.user, Type.number, false, "performance points", databaseField = USERS.PP_RAW),
InternalSchemaField("user_accuracy", "User Accuracy", Category.user, Type.number, false, "hit accuracy percentage", databaseField = USERS.ACCURACY),
InternalSchemaField("user_playcount", "Playcount", Category.user, Type.number, false, "total plays", databaseField = USERS.PLAYCOUNT),
InternalSchemaField("user_total_score", "Total Score", Category.user, Type.number, false, "cumulative score", databaseField = USERS.TOTAL_SCORE),
InternalSchemaField("user_ranked_score", "Ranked Score", Category.user, Type.number, false, "score from ranked maps", databaseField = USERS.RANKED_SCORE),
InternalSchemaField("user_seconds_played", "Play Time", Category.user, Type.number, false, "total play time in seconds", databaseField = USERS.SECONDS_PLAYED),
InternalSchemaField("user_count_300", "300s", Category.user, Type.number, false, "number of 300 hits", databaseField = USERS.COUNT_300),
InternalSchemaField("user_count_100", "100s", Category.user, Type.number, false, "number of 100 hits", databaseField = USERS.COUNT_100),
InternalSchemaField("user_count_50", "50s", Category.user, Type.number, false, "number of 50 hits", databaseField = USERS.COUNT_50),
InternalSchemaField("user_count_miss", "Misses", Category.user, Type.number, false, "missed hits"), // TODO: Why no miss count?
// Score fields
InternalSchemaField("id", "ID", Category.score, Type.number, false, "unique identifier for a score", databaseField = SCORES.ID),
InternalSchemaField("beatmap_id", "Beatmap ID", Category.score, Type.number, false, "identifies the beatmap", databaseField = SCORES.BEATMAP_ID),
InternalSchemaField("count_300", "300s", Category.score, Type.number, false, "number of 300 hits in score", databaseField = SCORES.COUNT_300),
InternalSchemaField("count_100", "100s", Category.score, Type.number, false, "number of 100 hits in score", databaseField = SCORES.COUNT_100),
InternalSchemaField("count_50", "50s", Category.score, Type.number, false, "number of 50 hits in score", databaseField = SCORES.COUNT_50),
InternalSchemaField("count_miss", "Misses", Category.score, Type.number, false, "missed hits in score", databaseField = SCORES.COUNT_MISS),
InternalSchemaField("date", "Date", Category.score, Type.string, true, "when score was achieved", databaseField = SCORES.DATE),
InternalSchemaField("max_combo", "Max Combo", Category.score, Type.number, false, "highest combo in score", databaseField = SCORES.MAX_COMBO),
InternalSchemaField("mods", "Mods", Category.score, Type.number, false, "game modifiers used", databaseField = SCORES.MODS),
InternalSchemaField("perfect", "Perfect", Category.score, Type.boolean, false, "if score is a full combo", databaseField = SCORES.PERFECT),
InternalSchemaField("pp", "Score PP", Category.score, Type.number, true, "performance points for score", databaseField = SCORES.PP),
InternalSchemaField("rank", "Rank", Category.score, Type.grade, false, "score grade", databaseField = SCORES.RANK),
InternalSchemaField("replay_id", "Replay ID", Category.score, Type.number, false, "identifier for replay", databaseField = SCORES.REPLAY_ID),
InternalSchemaField("score", "Score", Category.score, Type.number, false, "score value", databaseField = SCORES.SCORE),
InternalSchemaField("ur", "UR", Category.score, Type.number, false, "unstable rate", databaseField = SCORES.UR),
InternalSchemaField("frametime", "Frame Time", Category.score, Type.number, false, "median frame time during play", databaseField = SCORES.FRAMETIME),
InternalSchemaField("edge_hits", "Edge Hits", Category.score, Type.number, false, "hits at the edge of the hitobject (<1px)", databaseField = SCORES.EDGE_HITS),
InternalSchemaField("snaps", "Snaps", Category.score, Type.number, false, "rapid cursor movements", databaseField = SCORES.SNAPS),
InternalSchemaField("adjusted_ur", "Adj. UR", Category.score, Type.number, true, "adjusted unstable rate", databaseField = SCORES.ADJUSTED_UR),
InternalSchemaField("mean_error", "Mean Error", Category.score, Type.number, false, "average timing error", databaseField = SCORES.MEAN_ERROR),
InternalSchemaField("error_variance", "Error Var.", Category.score, Type.number, false, "variability of error in scores", databaseField = SCORES.ERROR_VARIANCE),
InternalSchemaField("error_standard_deviation", "Error SD", Category.score, Type.number, false, "standard deviation of error", databaseField = SCORES.ERROR_STANDARD_DEVIATION),
InternalSchemaField("minimum_error", "Min Error", Category.score, Type.number, false, "smallest error recorded", databaseField = SCORES.MINIMUM_ERROR),
InternalSchemaField("maximum_error", "Max Error", Category.score, Type.number, false, "largest error recorded", databaseField = SCORES.MAXIMUM_ERROR),
InternalSchemaField("error_range", "Error Range", Category.score, Type.number, false, "range between min and max error", databaseField = SCORES.ERROR_RANGE),
InternalSchemaField("error_coefficient_of_variation", "Error CV", Category.score, Type.number, false, "relative variability of error", databaseField = SCORES.ERROR_COEFFICIENT_OF_VARIATION),
InternalSchemaField("error_kurtosis", "Kurtosis", Category.score, Type.number, false, "peakedness of error distribution", databaseField = SCORES.ERROR_KURTOSIS),
InternalSchemaField("error_skewness", "Skewness", Category.score, Type.number, false, "asymmetry of error distribution", databaseField = SCORES.ERROR_SKEWNESS),
InternalSchemaField("keypresses_median_adjusted", "KP Median Adj.", Category.score, Type.number, false, "median of adjusted keypresses", isPrivileged = true, databaseField = SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
InternalSchemaField("keypresses_standard_deviation_adjusted", "KP std. Adj.", Category.score, Type.number, false, "std. dev of adjusted keypresses", isPrivileged = true, databaseField = SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
InternalSchemaField("sliderend_release_median_adjusted", "Sliderend Median Adj.", Category.score, Type.number, false, "median of adjusted sliderend releases", isPrivileged = true, databaseField = SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
InternalSchemaField("sliderend_release_standard_deviation_adjusted", "Sliderend std. Adj.", Category.score, Type.number, false, "std. dev of adjusted sliderend releases", isPrivileged = true, databaseField = SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
InternalSchemaField("beatmap_artist", "Artist", Category.beatmap, Type.string, false, "artist of the beatmap", databaseField = BEATMAPS.ARTIST),
InternalSchemaField("beatmap_beatmapset_id", "Set ID", Category.beatmap, Type.number, false, "id of the beatmap set", databaseField = BEATMAPS.BEATMAPSET_ID),
InternalSchemaField("beatmap_creator", "Creator", Category.beatmap, Type.string, false, "creator of the beatmap", databaseField = BEATMAPS.CREATOR),
InternalSchemaField("beatmap_source", "Source", Category.beatmap, Type.string, false, "source of the beatmap music", databaseField = BEATMAPS.SOURCE),
InternalSchemaField("beatmap_star_rating", "Stars", Category.beatmap, Type.number, false, "(★) difficulty rating of the beatmap", databaseField = BEATMAPS.STAR_RATING),
InternalSchemaField("beatmap_title", "Title", Category.beatmap, Type.string, false, "title of the beatmap", databaseField = BEATMAPS.TITLE),
InternalSchemaField("beatmap_version", "Version", Category.beatmap, Type.string, false, "version or difficulty name of the beatmap", databaseField = BEATMAPS.VERSION),
)
}
data class InternalSchemaField(
val name: String,
val shortName: String,
val category: Category,
val type: Type,
val active: Boolean,
val description: String,
val isPrivileged: Boolean = false,
val databaseField: Field<*>? = null
)
data class SchemaField(
val name: String,
val shortName: String,
val category: Category,
val type: Type,
val active: Boolean,
val description: String
)
enum class Category {
user, score, beatmap
}
enum class Type {
number, string, flag, grade, boolean
}
data class SearchSchema(
val fields: List<SchemaField>
)
@GetMapping("search/schema")
fun getSearchSchema(): ResponseEntity<SearchSchema> {
// Map to SchemaField
val isUserAdmin = authService.isAdmin()
val fields = internalFields
.filter {
return@filter !(it.isPrivileged && !isUserAdmin)
}
.map {
SchemaField(
name = it.name,
shortName = it.shortName,
category = it.category,
type = it.type,
active = it.active,
description = it.description
)
}
val schema = SearchSchema(fields)
return ResponseEntity.ok(schema)
}
}

View File

@ -0,0 +1,291 @@
package com.nisemoe.nise.search
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.generated.tables.references.USERS
import com.nisemoe.nise.Format
import org.jooq.*
import org.jooq.impl.DSL
import org.springframework.stereotype.Service
import kotlin.math.roundToInt
@Service
class SearchService(
private val dslContext: DSLContext
) {
fun search(request: SearchController.SearchRequest): SearchController.SearchResponse {
var baseQuery = DSL.noCondition()
for (query in request.queries.filter { it.predicates.isNotEmpty() }) {
val condition = buildCondition(query)
baseQuery = when (query.logicalOperator.lowercase()) {
"and" -> baseQuery.and(condition)
"or" -> baseQuery.or(condition)
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
val results = dslContext.select(
// User fields
USERS.USERNAME,
USERS.USER_ID,
USERS.JOIN_DATE,
USERS.COUNTRY,
USERS.COUNTRY_RANK,
USERS.RANK,
USERS.PP_RAW,
USERS.ACCURACY,
USERS.PLAYCOUNT,
USERS.TOTAL_SCORE,
USERS.RANKED_SCORE,
USERS.SECONDS_PLAYED,
USERS.COUNT_300,
USERS.COUNT_100,
USERS.COUNT_50,
// Scores fields
SCORES.ID,
SCORES.BEATMAP_ID,
SCORES.COUNT_300,
SCORES.COUNT_100,
SCORES.COUNT_50,
SCORES.COUNT_MISS,
SCORES.DATE,
SCORES.MAX_COMBO,
SCORES.MODS,
SCORES.PERFECT,
SCORES.PP,
SCORES.RANK,
SCORES.REPLAY_ID,
SCORES.SCORE,
SCORES.UR,
SCORES.FRAMETIME,
SCORES.EDGE_HITS,
SCORES.SNAPS,
SCORES.ADJUSTED_UR,
SCORES.MEAN_ERROR,
SCORES.ERROR_VARIANCE,
SCORES.ERROR_STANDARD_DEVIATION,
SCORES.MINIMUM_ERROR,
SCORES.MAXIMUM_ERROR,
SCORES.ERROR_RANGE,
SCORES.ERROR_COEFFICIENT_OF_VARIATION,
SCORES.ERROR_KURTOSIS,
SCORES.ERROR_SKEWNESS,
SCORES.KEYPRESSES_MEDIAN_ADJUSTED,
SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED,
SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED,
SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED,
// Beatmaps fields
BEATMAPS.ARTIST,
BEATMAPS.BEATMAPSET_ID,
BEATMAPS.CREATOR,
BEATMAPS.SOURCE,
BEATMAPS.STAR_RATING,
BEATMAPS.TITLE,
BEATMAPS.VERSION
)
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.apply {
if (request.sorting.field.isNotBlank())
orderBy(buildSorting(request.sorting))
}
.offset((request.page - 1) * SearchController.RESULTS_PER_PAGE)
.limit(SearchController.RESULTS_PER_PAGE)
.fetch()
// Get total results
val totalResults = dslContext.selectCount()
.from(SCORES)
.join(USERS).on(SCORES.USER_ID.eq(USERS.USER_ID))
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(baseQuery)
.fetchOne(0, Int::class.java) ?: 0
return SearchController.SearchResponse(
scores = mapRecordToScores(results),
pagination = SearchController.SearchResponsePagination(
currentPage = request.page,
pageSize = SearchController.RESULTS_PER_PAGE,
totalResults = totalResults
)
)
}
private fun mapRecordToScores(results: Result<Record>): MutableList<SearchController.SearchResponseEntry> =
results.map {
SearchController.SearchResponseEntry(
// User fields
user_id = it.get(SCORES.USER_ID),
user_username = it.get(USERS.USERNAME),
user_join_date = it.get(USERS.JOIN_DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
user_country = it.get(USERS.COUNTRY),
user_country_rank = it.get(USERS.COUNTRY_RANK),
user_rank = it.get(USERS.RANK),
user_pp_raw = it.get(USERS.PP_RAW)?.roundToInt()?.toDouble(),
user_accuracy = it.get(USERS.ACCURACY),
user_playcount = it.get(USERS.PLAYCOUNT),
user_total_score = it.get(USERS.TOTAL_SCORE),
user_ranked_score = it.get(USERS.RANKED_SCORE),
user_seconds_played = it.get(USERS.SECONDS_PLAYED),
user_count_300 = it.get(USERS.COUNT_300),
user_count_100 = it.get(USERS.COUNT_100),
user_count_50 = it.get(USERS.COUNT_50),
user_count_miss = it.get(SCORES.COUNT_MISS),
// Score fields
id = it.get(SCORES.ID),
beatmap_id = it.get(SCORES.BEATMAP_ID),
count_300 = it.get(SCORES.COUNT_300),
count_100 = it.get(SCORES.COUNT_100),
count_50 = it.get(SCORES.COUNT_50),
count_miss = it.get(SCORES.COUNT_MISS),
date = it.get(SCORES.DATE)?.let { it1 -> Format.formatLocalDateTime(it1) },
max_combo = it.get(SCORES.MAX_COMBO),
mods = it.get(SCORES.MODS),
perfect = it.get(SCORES.PERFECT),
pp = it.get(SCORES.PP)?.roundToInt()?.toDouble(),
rank = it.get(SCORES.RANK),
replay_id = it.get(SCORES.REPLAY_ID),
score = it.get(SCORES.SCORE),
ur = it.get(SCORES.UR),
frametime = it.get(SCORES.FRAMETIME),
edge_hits = it.get(SCORES.EDGE_HITS),
snaps = it.get(SCORES.SNAPS),
adjusted_ur = it.get(SCORES.ADJUSTED_UR),
mean_error = it.get(SCORES.MEAN_ERROR),
error_variance = it.get(SCORES.ERROR_VARIANCE),
error_standard_deviation = it.get(SCORES.ERROR_STANDARD_DEVIATION),
minimum_error = it.get(SCORES.MINIMUM_ERROR),
maximum_error = it.get(SCORES.MAXIMUM_ERROR),
error_range = it.get(SCORES.ERROR_RANGE),
error_coefficient_of_variation = it.get(SCORES.ERROR_COEFFICIENT_OF_VARIATION),
error_kurtosis = it.get(SCORES.ERROR_KURTOSIS),
error_skewness = it.get(SCORES.ERROR_SKEWNESS),
keypresses_median_adjusted = it.get(SCORES.KEYPRESSES_MEDIAN_ADJUSTED),
keypresses_standard_deviation_adjusted = it.get(SCORES.KEYPRESSES_STANDARD_DEVIATION_ADJUSTED),
sliderend_release_median_adjusted = it.get(SCORES.SLIDEREND_RELEASE_MEDIAN_ADJUSTED),
sliderend_release_standard_deviation_adjusted = it.get(SCORES.SLIDEREND_RELEASE_STANDARD_DEVIATION_ADJUSTED),
// Beatmap fields
beatmap_artist = it.get(BEATMAPS.ARTIST),
beatmap_beatmapset_id = it.get(BEATMAPS.BEATMAPSET_ID),
beatmap_creator = it.get(BEATMAPS.CREATOR),
beatmap_source = it.get(BEATMAPS.SOURCE),
beatmap_star_rating = it.get(BEATMAPS.STAR_RATING),
beatmap_title = it.get(BEATMAPS.TITLE),
beatmap_version = it.get(BEATMAPS.VERSION)
)
}
fun buildCondition(query: SearchController.SearchQuery): Condition {
// Handle base predicates
var baseCondition = buildPredicateCondition(query.predicates.first())
query.predicates.drop(1).forEach { predicate ->
baseCondition = when (query.logicalOperator.lowercase()) {
"and" -> baseCondition.and(buildPredicateCondition(predicate))
"or" -> baseCondition.or(buildPredicateCondition(predicate))
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
// Handle child queries
if(query.childQueries.isNullOrEmpty())
return baseCondition
query.childQueries.forEach { childQuery ->
val childCondition = buildCondition(childQuery) // Recursively build condition for child queries
baseCondition = when (query.logicalOperator.lowercase()) {
"and" -> baseCondition.and(childCondition)
"or" -> baseCondition.or(childCondition)
else -> throw IllegalArgumentException("Invalid logical operator")
}
}
return baseCondition
}
private fun buildSorting(sorting: SearchController.SearchSorting): OrderField<*> {
val field = mapPredicateFieldToDatabaseField(sorting.field)
return when (sorting.order.lowercase()) {
"asc" -> field.asc()
"desc" -> field.desc()
else -> throw IllegalArgumentException("Invalid sorting order")
}
}
private fun buildPredicateCondition(predicate: SearchController.SearchPredicate): Condition {
val field = mapPredicateFieldToDatabaseField(predicate.field.name)
return when (predicate.field.type.lowercase()) {
"number" -> buildNumberCondition(field as Field<Double>, predicate.operator.operatorType, predicate.value.toDouble())
"string" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"boolean" -> buildBooleanCondition(field as Field<Boolean>, predicate.operator.operatorType, predicate.value.toBoolean())
"flag" -> buildStringCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
"grade" -> buildGradeCondition(field as Field<String>, predicate.operator.operatorType, predicate.value)
else -> throw IllegalArgumentException("Invalid field type")
}
}
private fun mapPredicateFieldToDatabaseField(predicateName: String): Field<*> {
val databaseField = SearchSchemaController.internalFields.first {
it.name == predicateName && it.databaseField != null
}
return databaseField.databaseField!!
}
private fun buildBooleanCondition(field: Field<Boolean>, operator: String, value: Boolean): Condition {
return when (operator) {
"=" -> field.eq(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildGradeCondition(field: Field<String>, operator: String, value: String): Condition {
return when (value) {
"SS", "S", "A", "B", "C", "D" -> {
val valuesToMatch = when (value) {
"SS" -> listOf("Grade.SS", "Grade.SSH")
"S" -> listOf("Grade.S", "Grade.SH")
else -> listOf("Grade.$value")
}
when (operator) {
"=" -> field.`in`(valuesToMatch)
"!=" -> field.notIn(valuesToMatch)
else -> throw IllegalArgumentException("Invalid operator")
}
}
else -> throw IllegalArgumentException("Invalid grade value")
}
}
private fun buildNumberCondition(field: Field<Double>, operator: String, value: Double): Condition {
return when (operator) {
"=" -> field.eq(value)
">" -> field.gt(value)
"<" -> field.lt(value)
">=" -> field.ge(value)
"<=" -> field.le(value)
"!=" -> field.ne(value)
else -> throw IllegalArgumentException("Invalid operator")
}
}
private fun buildStringCondition(field: Field<String>, operator: String, value: String): Condition {
return when (operator.lowercase()) {
"=" -> field.eq(value)
"contains" -> field.containsIgnoreCase(value)
"like" -> field.likeIgnoreCase(
// Escape special characters for LIKE if needed
value.replace("%", "\\%").replace("_", "\\_")
)
else -> throw IllegalArgumentException("Invalid operator")
}
}
}

View File

@ -16,11 +16,6 @@
border: 1px solid rgba(179, 184, 195, 0.1); border: 1px solid rgba(179, 184, 195, 0.1);
} }
.score-entry {
cursor: pointer;
}
.score-entry:hover { .score-entry:hover {
background-color: rgba(179, 184, 195, 0.15); background-color: rgba(179, 184, 195, 0.15);
} }

View File

@ -95,7 +95,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let entry of response.scores" class="score-entry" [routerLink]="['/s/' + entry.replay_id]"> <tr *ngFor="let entry of response.scores" class="score-entry">
<td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px"> <td *ngFor="let column of fields" [hidden]="!column.active" class="text-center" style="line-height: 32px">
<ng-container *ngIf="column.type == 'number'"> <ng-container *ngIf="column.type == 'number'">
{{ getValue(entry, column.name) | number }} {{ getValue(entry, column.name) | number }}

View File

@ -24,7 +24,7 @@ interface SchemaResponse {
} }
interface SearchResponse { interface SearchResponse {
scores: SearchResponseEntry[]; scores: any[];
pagination: SearchPagination; pagination: SearchPagination;
} }
@ -40,43 +40,6 @@ interface Sorting {
order: 'ASC' | 'DESC'; order: 'ASC' | 'DESC';
} }
interface SearchResponseEntry {
// User fields
user_id?: number;
user_username?: string;
user_join_date?: string;
user_country?: string;
user_country_rank?: number;
user_rank?: number;
user_pp_raw?: number;
user_accuracy?: number;
user_playcount?: number;
user_total_score?: number;
user_ranked_score?: number;
user_seconds_played?: number;
user_count_300?: number;
user_count_100?: number;
user_count_50?: number;
user_count_miss?: number;
// Score fields
replay_id?: number;
date?: string;
beatmap_id?: number;
pp?: number;
frametime?: number;
ur?: number;
// Beatmap fields
beatmap_artist?: string;
beatmap_beatmapset_id?: number;
beatmap_creator?: string;
beatmap_source?: string;
beatmap_star_rating?: number;
beatmap_title?: string;
beatmap_version?: string;
}
@Component({ @Component({
selector: 'app-search', selector: 'app-search',
standalone: true, standalone: true,
@ -112,7 +75,7 @@ export class SearchComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.isLoadingSchema = true; this.isLoadingSchema = true;
this.httpClient.get<SchemaResponse>(`${environment.apiUrl}/search`,).subscribe({ this.httpClient.get<SchemaResponse>(`${environment.apiUrl}/search/schema`,).subscribe({
next: (response) => { next: (response) => {
this.fields = response.fields; this.fields = response.fields;
this.loadPreviousFromLocalStorage(); this.loadPreviousFromLocalStorage();
@ -258,8 +221,8 @@ export class SearchComponent implements OnInit {
} }
// Add this method to the SearchComponent class // Add this method to the SearchComponent class
getValue(entry: SearchResponseEntry, columnName: string): any { getValue(entry: any, columnName: string): any {
return entry[columnName as keyof SearchResponseEntry]; return entry[columnName as keyof any];
} }
protected readonly countryCodeToFlag = countryCodeToFlag; protected readonly countryCodeToFlag = countryCodeToFlag;