From bca47c30f648cbe786aa7862233b2324cc82d9af Mon Sep 17 00:00:00 2001 From: "nise.moe" Date: Sun, 25 Feb 2024 02:00:45 +0100 Subject: [PATCH] Refactored search, added validation (w/ recursion checks) --- .../nise/controller/SearchController.kt | 599 ------------------ .../nise/search/ChildQueriesDepthValidator.kt | 35 + .../nisemoe/nise/search/SearchController.kt | 148 +++++ .../nise/search/SearchSchemaController.kt | 140 ++++ .../com/nisemoe/nise/search/SearchService.kt | 291 +++++++++ .../src/app/search/search.component.css | 5 - .../src/app/search/search.component.html | 2 +- .../src/app/search/search.component.ts | 45 +- 8 files changed, 619 insertions(+), 646 deletions(-) delete mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt create mode 100644 nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt deleted file mode 100644 index dbb3356..0000000 --- a/nise-backend/src/main/kotlin/com/nisemoe/nise/controller/SearchController.kt +++ /dev/null @@ -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, - 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, - val sorting: SearchSorting, - val page: Int - ) - - data class SearchSorting( - val field: String, - val order: String - ) - - data class SearchQuery( - val logicalOperator: String, - val predicates: List, - val childQueries: List? - ) - - 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 - ) - - @GetMapping("search") - fun getSearchSchema(): ResponseEntity { - 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 { - 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): MutableList = - 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, predicate.operator.operatorType, predicate.value.toDouble()) - "string" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) - "boolean" -> buildBooleanCondition(field as Field, predicate.operator.operatorType, predicate.value.toBoolean()) - "flag" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) - "grade" -> buildGradeCondition(field as Field, 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, operator: String, value: Boolean): Condition { - return when (operator) { - "=" -> field.eq(value) - "!=" -> field.ne(value) - else -> throw IllegalArgumentException("Invalid operator") - } - } - - private fun buildGradeCondition(field: Field, 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, 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, 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") - } - } - -} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt new file mode 100644 index 0000000..3dc4712 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/ChildQueriesDepthValidator.kt @@ -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> = [], + val payload: Array> = [] +) + +class ChildQueriesDepthValidator : ConstraintValidator> { + + override fun initialize(constraintAnnotation: ValidChildQueriesDepth?) { + super.initialize(constraintAnnotation) + } + + override fun isValid(queries: List?, 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 + } +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt new file mode 100644 index 0000000..1966a60 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchController.kt @@ -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, + 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, + @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, + @Valid @field:ValidChildQueriesDepth val childQueries: List? + ) + + 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 { + if (apiVersion.isBlank()) + return ResponseEntity.badRequest().build() + + val response = this.searchService.search(request) + + return ResponseEntity.ok(response) + } + + + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt new file mode 100644 index 0000000..2a7991b --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchSchemaController.kt @@ -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 + ) + + @GetMapping("search/schema") + fun getSearchSchema(): ResponseEntity { + // 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) + } + +} \ No newline at end of file diff --git a/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt new file mode 100644 index 0000000..8b8c0c7 --- /dev/null +++ b/nise-backend/src/main/kotlin/com/nisemoe/nise/search/SearchService.kt @@ -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): MutableList = + 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, predicate.operator.operatorType, predicate.value.toDouble()) + "string" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) + "boolean" -> buildBooleanCondition(field as Field, predicate.operator.operatorType, predicate.value.toBoolean()) + "flag" -> buildStringCondition(field as Field, predicate.operator.operatorType, predicate.value) + "grade" -> buildGradeCondition(field as Field, 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, operator: String, value: Boolean): Condition { + return when (operator) { + "=" -> field.eq(value) + "!=" -> field.ne(value) + else -> throw IllegalArgumentException("Invalid operator") + } + } + + private fun buildGradeCondition(field: Field, 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, 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, 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") + } + } + +} \ No newline at end of file diff --git a/nise-frontend/src/app/search/search.component.css b/nise-frontend/src/app/search/search.component.css index aaef020..97cf837 100644 --- a/nise-frontend/src/app/search/search.component.css +++ b/nise-frontend/src/app/search/search.component.css @@ -16,11 +16,6 @@ border: 1px solid rgba(179, 184, 195, 0.1); } -.score-entry { - cursor: pointer; - -} - .score-entry:hover { background-color: rgba(179, 184, 195, 0.15); } diff --git a/nise-frontend/src/app/search/search.component.html b/nise-frontend/src/app/search/search.component.html index 0d1f7bf..0b68a60 100644 --- a/nise-frontend/src/app/search/search.component.html +++ b/nise-frontend/src/app/search/search.component.html @@ -95,7 +95,7 @@ - + {{ getValue(entry, column.name) | number }} diff --git a/nise-frontend/src/app/search/search.component.ts b/nise-frontend/src/app/search/search.component.ts index 17d1ae4..ef09fa9 100644 --- a/nise-frontend/src/app/search/search.component.ts +++ b/nise-frontend/src/app/search/search.component.ts @@ -24,7 +24,7 @@ interface SchemaResponse { } interface SearchResponse { - scores: SearchResponseEntry[]; + scores: any[]; pagination: SearchPagination; } @@ -40,43 +40,6 @@ interface Sorting { 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({ selector: 'app-search', standalone: true, @@ -112,7 +75,7 @@ export class SearchComponent implements OnInit { ngOnInit(): void { this.isLoadingSchema = true; - this.httpClient.get(`${environment.apiUrl}/search`,).subscribe({ + this.httpClient.get(`${environment.apiUrl}/search/schema`,).subscribe({ next: (response) => { this.fields = response.fields; this.loadPreviousFromLocalStorage(); @@ -258,8 +221,8 @@ export class SearchComponent implements OnInit { } // Add this method to the SearchComponent class - getValue(entry: SearchResponseEntry, columnName: string): any { - return entry[columnName as keyof SearchResponseEntry]; + getValue(entry: any, columnName: string): any { + return entry[columnName as keyof any]; } protected readonly countryCodeToFlag = countryCodeToFlag;