Refactored search, added validation (w/ recursion checks)
This commit is contained in:
parent
3adeea3094
commit
bca47c30f6
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 }}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user