diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/api/JsonApi.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/api/JsonApi.scala index 1f1146afb..9ec795a2c 100644 --- a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/api/JsonApi.scala +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/api/JsonApi.scala @@ -5,7 +5,7 @@ import akka.http.scaladsl.model.headers.`Access-Control-Allow-Origin` import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import com.kenkoooo.atcoder.db.SqlClient -import com.kenkoooo.atcoder.model.ApiJsonSupport +import com.kenkoooo.atcoder.model.{ApiJsonSupport, UserInfo} import com.kenkoooo.atcoder.scraper.AtCoder.UserNameRegex /** @@ -48,6 +48,27 @@ class JsonApi(sqlClient: SqlClient) extends ApiJsonSupport { complete(sqlClient.loadUserSubmissions(users: _*).toList) } + } ~ pathPrefix("v2") { + path("results") { + parameters('users ? "") { users => + val userList = + users.split(",").map(_.trim).filter(_.nonEmpty).filter(_.matches(UserNameRegex)) + complete(sqlClient.loadUserSubmissions(userList: _*).toList) + } + } ~ path("user_info") { + parameters('user ? "") { userId => + val (point, pointRank) = sqlClient.pointAndRankOf(userId) + val (count, countRank) = sqlClient.countAndRankOf(userId) + val userInfo = UserInfo( + userId = userId, + acceptedCount = count, + acceptedCountRank = countRank, + ratedPointSum = point, + ratedPointSumRank = pointRank + ) + complete(userInfo) + } + } } } } diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/common/ReducedRanker.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/common/ReducedRanker.scala new file mode 100644 index 000000000..d5a9d6acd --- /dev/null +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/common/ReducedRanker.scala @@ -0,0 +1,15 @@ +package com.kenkoooo.atcoder.common + +object ReducedRanker { + implicit class ReducedRankerImplicit[T](val self: List[T]) { + def reduceToMap: Map[T, Int] = { + var map = Map[T, Int]() + for ((point, index) <- self.zipWithIndex) { + if (index == 0 || point != self(index - 1)) { + map += (point -> index) + } + } + map + } + } +} diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/AcceptedCountInfo.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/AcceptedCountInfo.scala new file mode 100644 index 000000000..22c486769 --- /dev/null +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/AcceptedCountInfo.scala @@ -0,0 +1,20 @@ +package com.kenkoooo.atcoder.db +import com.kenkoooo.atcoder.common.TypeAnnotations.UserId +import com.kenkoooo.atcoder.common.ReducedRanker._ +import com.kenkoooo.atcoder.model.AcceptedCount + +class AcceptedCountInfo(val list: List[AcceptedCount]) { + private val countMap: Map[UserId, Int] = + list.map(count => count.userId -> count.problemCount).toMap + private val countRank = list.map(_.problemCount).sorted.reverse.reduceToMap + + def countAndRankOf(userId: UserId): (Int, Int) = { + val count = countMap.getOrElse(userId, AcceptedCountInfo.MINIMUM_COUNT) + val rank = countRank.getOrElse(count, list.size) + (count, rank) + } +} + +object AcceptedCountInfo { + private val MINIMUM_COUNT = 0 +} diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/RatedPointSumInfo.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/RatedPointSumInfo.scala new file mode 100644 index 000000000..7ac3afade --- /dev/null +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/RatedPointSumInfo.scala @@ -0,0 +1,19 @@ +package com.kenkoooo.atcoder.db +import com.kenkoooo.atcoder.common.TypeAnnotations.UserId +import com.kenkoooo.atcoder.common.ReducedRanker._ +import com.kenkoooo.atcoder.model.RatedPointSum + +class RatedPointSumInfo(val list: List[RatedPointSum]) { + private val pointMap: Map[UserId, Double] = list.map(sum => sum.userId -> sum.pointSum).toMap + private val pointRank = list.map(_.pointSum).sorted.reverse.reduceToMap + + def pointAndRankOf(userId: UserId): (Double, Int) = { + val point = pointMap.getOrElse(userId, RatedPointSumInfo.MINIMUM_POINT) + val rank = pointRank.getOrElse(point, list.size) + (point, rank) + } +} + +object RatedPointSumInfo { + private val MINIMUM_POINT = 0.0 +} diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/SqlClient.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/SqlClient.scala index d5507acd0..0f69f71b2 100644 --- a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/SqlClient.scala +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/db/SqlClient.scala @@ -25,21 +25,22 @@ class SqlClient(url: String, user: String, password: String) extends Logging { private var _contests: Map[ContestId, Contest] = Map() private var _problems: Map[ProblemId, Problem] = Map() - private var _acceptedCounts: List[AcceptedCount] = List() private var _firstSubmissionCounts: List[FirstSubmissionCount] = List() private var _fastestSubmissionCounts: List[FastestSubmissionCount] = List() private var _shortestSubmissionCounts: List[ShortestSubmissionCount] = List() private var _mergedProblems: List[MergedProblem] = List() - private var _ratedPointSums: List[RatedPointSum] = List() private var _languageCounts: List[LanguageCount] = List() private var _predictedRatings: List[PredictedRating] = List() private var _lastReloaded: Long = 0 + private var ratedPointSumInfo = new RatedPointSumInfo(List()) + private var acceptedCountInfo = new AcceptedCountInfo(List()) + def contests: Map[String, Contest] = _contests def problems: Map[String, Problem] = _problems - def acceptedCounts: List[AcceptedCount] = _acceptedCounts + def acceptedCounts: List[AcceptedCount] = acceptedCountInfo.list def firstSubmissionCounts: List[FirstSubmissionCount] = _firstSubmissionCounts @@ -49,7 +50,7 @@ class SqlClient(url: String, user: String, password: String) extends Logging { def mergedProblems: List[MergedProblem] = _mergedProblems - def ratedPointSums: List[RatedPointSum] = _ratedPointSums + def ratedPointSums: List[RatedPointSum] = ratedPointSumInfo.list def languageCounts: List[LanguageCount] = _languageCounts @@ -57,6 +58,9 @@ class SqlClient(url: String, user: String, password: String) extends Logging { def lastReloadedTimeMillis: Long = _lastReloaded + def pointAndRankOf(userId: UserId): (Double, Int) = ratedPointSumInfo.pointAndRankOf(userId) + def countAndRankOf(userId: UserId): (Int, Int) = acceptedCountInfo.countAndRankOf(userId) + private[db] def executeAndLoadSubmission(builder: SQLBuilder[_]): List[Submission] = this.synchronized { DB.readOnly { implicit session => @@ -330,12 +334,12 @@ class SqlClient(url: String, user: String, password: String) extends Logging { _contests = loadRecords(Contest).map(s => s.id -> s).toMap _problems = loadRecords(Problem).map(s => s.id -> s).toMap - _acceptedCounts = loadRecords(AcceptedCount).toList + acceptedCountInfo = new AcceptedCountInfo(loadRecords(AcceptedCount).toList) _firstSubmissionCounts = loadRecords(FirstSubmissionCount).toList _shortestSubmissionCounts = loadRecords(ShortestSubmissionCount).toList _fastestSubmissionCounts = loadRecords(FastestSubmissionCount).toList _mergedProblems = loadMergedProblems().toList - _ratedPointSums = loadRecords(RatedPointSum).toList + ratedPointSumInfo = new RatedPointSumInfo(loadRecords(RatedPointSum).toList) _languageCounts = loadRecords(LanguageCount).toList _predictedRatings = loadRecords(PredictedRating).toList @@ -444,6 +448,16 @@ private object SqlClient { private val SubmissionSyntax = Submission.syntax("s") def concat(columns: SQLSyntax*): SQLSyntax = sqls"concat(${sqls.csv(columns: _*)})" + + def reduce[T](sortedList: List[T]): Map[T, Int] = { + var map = Map[T, Int]() + for ((point, index) <- sortedList.zipWithIndex) { + if (index == 0 || point != sortedList(index - 1)) { + map += (point -> index) + } + } + map + } } /** diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/ApiJsonSupport.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/ApiJsonSupport.scala index 8de581804..252bf782c 100644 --- a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/ApiJsonSupport.scala +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/ApiJsonSupport.scala @@ -56,4 +56,12 @@ trait ApiJsonSupport extends SprayJsonSupport with DefaultJsonProtocol { jsonFormat(LanguageCount.apply, "user_id", "language", "count") implicit val predictedRatingFormat: RootJsonFormat[PredictedRating] = jsonFormat(PredictedRating.apply, "user_id", "rating") + implicit val userInfoFormat: RootJsonFormat[UserInfo] = jsonFormat( + UserInfo.apply, + "user_id", + "accepted_count", + "accepted_count_rank", + "rated_point_sum", + "rated_point_sum_rank" + ) } diff --git a/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/UserInfo.scala b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/UserInfo.scala new file mode 100644 index 000000000..a9884189d --- /dev/null +++ b/atcoder-problems-backend/src/main/scala/com/kenkoooo/atcoder/model/UserInfo.scala @@ -0,0 +1,8 @@ +package com.kenkoooo.atcoder.model +import com.kenkoooo.atcoder.common.TypeAnnotations.UserId + +case class UserInfo(userId: UserId, + acceptedCount: Int, + acceptedCountRank: Int, + ratedPointSum: Double, + ratedPointSumRank: Int) diff --git a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/api/JsonApiTest.scala b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/api/JsonApiTest.scala index 34d6a27fa..348d1cabd 100644 --- a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/api/JsonApiTest.scala +++ b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/api/JsonApiTest.scala @@ -79,6 +79,8 @@ class JsonApiTest when(sql.languageCounts) .thenReturn(List(LanguageCount("user1", "Rust", 100), LanguageCount("user2", "Java", 200))) when(sql.predictedRatings).thenReturn(List(PredictedRating("kenkoooo", 3.14))) + when(sql.pointAndRankOf("kenkoooo")).thenReturn((1.0, 2)) + when(sql.countAndRankOf("kenkoooo")).thenReturn((3, 4)) } test("return 200 to new request") { @@ -227,4 +229,31 @@ class JsonApiTest responseAs[String] shouldBe """[{"user_id":"kenkoooo","rating":3.14}]""" } } + + test("submission api v2 with a user") { + val api = new JsonApi(sql) + + Get("/v2/results?users=kenkoooo") ~> api.routes ~> check { + status shouldBe OK + verify(sql, times(1)).loadUserSubmissions("kenkoooo") + } + } + + test("submission api v2 with multiple users") { + val api = new JsonApi(sql) + + Get("/v2/results?users=kenkoooo,kenkoooo1,kenkoooo2") ~> api.routes ~> check { + status shouldBe OK + verify(sql, times(1)).loadUserSubmissions("kenkoooo", "kenkoooo1", "kenkoooo2") + } + } + + test("user info API") { + val api = new JsonApi(sql) + + Get("/v2/user_info?user=kenkoooo") ~> api.routes ~> check { + status shouldBe OK + responseAs[String] shouldBe """{"accepted_count_rank":4,"rated_point_sum_rank":2,"rated_point_sum":1.0,"user_id":"kenkoooo","accepted_count":3}""" + } + } } diff --git a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/common/ReducedRankerTest.scala b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/common/ReducedRankerTest.scala new file mode 100644 index 000000000..d0cfc7878 --- /dev/null +++ b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/common/ReducedRankerTest.scala @@ -0,0 +1,11 @@ +package com.kenkoooo.atcoder.common +import com.kenkoooo.atcoder.common.ReducedRanker._ +import org.scalatest.{FunSuite, Matchers} + +class ReducedRankerTest extends FunSuite with Matchers { + test("reduce") { + val list = List(1.0, 1.0, 2.0, 1.0, 3.0, 1.0) + val map = list.sorted.reverse.reduceToMap + map shouldBe Map(1.0 -> 2, 2.0 -> 1, 3.0 -> 0) + } +} diff --git a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/AcceptedCountInfoTest.scala b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/AcceptedCountInfoTest.scala new file mode 100644 index 000000000..cb067fc02 --- /dev/null +++ b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/AcceptedCountInfoTest.scala @@ -0,0 +1,21 @@ +package com.kenkoooo.atcoder.db +import com.kenkoooo.atcoder.model.AcceptedCount +import org.scalatest.{FunSuite, Matchers} + +class AcceptedCountInfoTest extends FunSuite with Matchers { + test("construct") { + val info = new AcceptedCountInfo( + List( + AcceptedCount("user1", 5), + AcceptedCount("user2", 4), + AcceptedCount("user3", 4), + AcceptedCount("user4", 9) + ) + ) + info.countAndRankOf("user1") shouldBe (5, 1) + info.countAndRankOf("user2") shouldBe (4, 2) + info.countAndRankOf("user3") shouldBe (4, 2) + info.countAndRankOf("user4") shouldBe (9, 0) + info.countAndRankOf("unknown_user") shouldBe (0, 4) + } +} diff --git a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/RatedPointSumInfoTest.scala b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/RatedPointSumInfoTest.scala new file mode 100644 index 000000000..f6d6b4338 --- /dev/null +++ b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/db/RatedPointSumInfoTest.scala @@ -0,0 +1,21 @@ +package com.kenkoooo.atcoder.db +import com.kenkoooo.atcoder.model.RatedPointSum +import org.scalatest.{FunSuite, Matchers} + +class RatedPointSumInfoTest extends FunSuite with Matchers { + test("construct") { + val list = + List( + RatedPointSum("user1", 100), + RatedPointSum("user2", 50), + RatedPointSum("user3", 50), + RatedPointSum("user4", 20) + ) + val info = new RatedPointSumInfo(list) + info.pointAndRankOf("user1") shouldBe (100.0, 0) + info.pointAndRankOf("user2") shouldBe (50.0, 1) + info.pointAndRankOf("user3") shouldBe (50.0, 1) + info.pointAndRankOf("user4") shouldBe (20.0, 3) + info.pointAndRankOf("unknown_user") shouldBe (0.0, 4) + } +} diff --git a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/model/ApiJsonSupportTest.scala b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/model/ApiJsonSupportTest.scala index d0970542d..3f7914329 100644 --- a/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/model/ApiJsonSupportTest.scala +++ b/atcoder-problems-backend/src/test/scala/com/kenkoooo/atcoder/model/ApiJsonSupportTest.scala @@ -123,6 +123,16 @@ class ApiJsonSupportTest extends FunSuite with Matchers with ApiJsonSupport { | "title": "title", | "source_code_length": 5 |}""".stripMargin + } + test("convert user info to json") { + UserInfo("kenkoooo", 114, 514, 810.0, 893).toJson.prettyPrint shouldBe + """{ + | "accepted_count_rank": 514, + | "rated_point_sum_rank": 893, + | "rated_point_sum": 810.0, + | "user_id": "kenkoooo", + | "accepted_count": 114 + |}""".stripMargin } }