Skip to content

Commit

Permalink
Creates and joins against a view used to get the rank of a replay. (#508
Browse files Browse the repository at this point in the history
)

See #507. This new function is not yet used, but I figure we can replace
the existing annotate_with_rank() with it. The new approach allows for us to
more easily get ranks in situations where the required data isn't returned
in the query, which is nice. It's a bit slower, so we may need to
convert this to a "materialized view", but I think it's still acceptable
right now, especially with the new index.
  • Loading branch information
n-rook authored Aug 24, 2024
1 parent 2718efe commit da51b82
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 4.2.4 on 2024-08-18 22:49

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("replays", "0039_replaystage_th128_frozen_area"),
]

operations = [
migrations.CreateModel(
name="ReplayRank",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
],
options={
"db_table": "replays_rank",
"managed": False,
},
),
migrations.AddIndex(
model_name="replay",
index=models.Index(
fields=[
"replay_type",
"shot_id",
"difficulty",
"route_id",
"category",
"-score",
],
name="scoring_division",
),
),
]
31 changes: 31 additions & 0 deletions project/thscoreboard/replays/migrations/0041_replay_rank_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 4.2.4 on 2024-08-18 23:14

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("replays", "0040_replayrank_replay_scoring_division"),
]

operations = [
migrations.RunSQL(
sql="""
CREATE OR REPLACE VIEW replays_rank
AS
SELECT row_number() over () as id, replay, score, shot_id, difficulty, route_id, category, place
FROM (
SELECT id as replay, score, shot_id, difficulty, route_id, category, rank() OVER (PARTITION BY shot_id, difficulty, route_id, category ORDER BY score DESC, created, id) as place
FROM replays_replay
WHERE replay_type = 1 -- FULL_GAME
AND category = 1 -- STANDARD
) AS ranked
WHERE place <= 3
ORDER BY shot_id, difficulty, route_id, category, place desc
;
""",
reverse_sql="""
DROP VIEW replays_rank;
""",
)
]
85 changes: 84 additions & 1 deletion project/thscoreboard/replays/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Contains all of the models for the replay site."""


import dataclasses
import datetime
from typing import Optional
Expand Down Expand Up @@ -278,6 +277,21 @@ class Meta:
),
]

indexes = [
# Supports querying for the top scores in some field.
models.Index(
name="scoring_division",
fields=[
"replay_type",
"shot_id",
"difficulty",
"route_id",
"category",
"-score",
],
)
]

user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True
)
Expand Down Expand Up @@ -416,6 +430,21 @@ def GetNiceFilename(self, id: Optional[int]):

return f"{gamecode}_ud{rpy_id}.rpy"

def GetRank(self) -> int | None:
"""Returns this replay's rank if it is a top-ranking replay.
Only the top 3 replays (in a given field: the ranking is divided
by game, shot, difficulty, and so on) have a rank; for the others,
None is returned.
This method is instant if "rank_view" is selected, which is strongly
recommended.
"""
if hasattr(self, "rank_view"):
return self.rank_view.place
else:
return None

def SetFromReplayInfo(self, r: replay_parsing.ReplayInfo):
"""Set certain derived fields on this replay from parsed information.
Expand Down Expand Up @@ -455,6 +484,60 @@ def GetFormattedTimestampDate(self) -> Optional[str]:
return formats.date_format(self.timestamp, format=fmt)


class ReplayRank(models.Model):
"""Represents a replay's rank on the scoreboard.
Most replays are not listed here. Only top-3 replays in a field will have
rows in this view.
IMPORTANT NOTE: This model represents a view, not a table. It is defined
by the following query:
CREATE OR REPLACE VIEW replays_rank
AS
SELECT row_number() over () as id, replay, score, shot_id, difficulty, route_id, category, place
FROM (
SELECT id as replay, score, shot_id, difficulty, route_id, category, rank() OVER (PARTITION BY shot_id, difficulty, route_id, category ORDER BY score DESC, created, id) as place
FROM replays_replay
WHERE replay_type = 1 -- FULL_GAME
AND (category = 1 OR category = 2) -- STANDARD or TAS
) AS ranked
WHERE place <= 3
ORDER BY shot_id, difficulty, route_id, category, place desc
;
"""

class Meta:
db_table = "replays_rank"
managed = False

replay = models.OneToOneField(
"Replay",
db_column="replay",
on_delete=models.DO_NOTHING,
related_name="rank_view",
)
"""The replay being ranked."""

shot = models.ForeignKey("Shot", on_delete=models.DO_NOTHING)

difficulty = models.IntegerField()
"""The difficulty on which the player played."""

route = models.ForeignKey(
"Route", on_delete=models.DO_NOTHING, blank=True, null=True
)
"""The route on which the game was played."""

category = models.IntegerField(choices=Category.choices)

place = models.IntegerField("Place")
"""The replay's rank. 1 is first place, 2 is second, 3 is third.
In the case of a tie, the tied replays will all share the same place.
"""


class ReplayStage(models.Model):
"""Represents the end-of-stage data for a stage split for a given replay
The data may not directly correspond to how it is stored in-game, since some games store it differently
Expand Down
74 changes: 71 additions & 3 deletions project/thscoreboard/replays/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,19 @@ def testRanks(self):
score=800_000_000,
)

replays = models.Replay.objects.order_by("-score").annotate_with_rank().all()
replays = (
models.Replay.objects.order_by("-score")
.select_related("rank_view")
.annotate_with_rank()
.all()
)

self.assertEquals(replays[0].rank, 1)
self.assertEquals(replays[0].GetRank(), 1)
self.assertEquals(replays[1].rank, 2)
self.assertEquals(replays[1].GetRank(), 2)
self.assertEquals(replays[2].rank, 1)
self.assertEquals(replays[2].GetRank(), 1)

def testRanksTasReplay(self):
test_replays.CreateAsPublishedReplay(
Expand All @@ -149,6 +157,7 @@ def testRanksTasReplay(self):
replay = models.Replay.objects.order_by("-score").annotate_with_rank().first()

self.assertEquals(replay.rank, -1)
self.assertIsNone(replay.GetRank())

def testRanksBreakTiesUsingUploadDate(self):
with patch("replays.constant_helpers.CalculateReplayFileHash") as mocked_hash:
Expand Down Expand Up @@ -180,11 +189,19 @@ def testRanksBreakTiesUsingUploadDate(self):
),
)

replays = models.Replay.objects.order_by("created").annotate_with_rank().all()
replays = (
models.Replay.objects.select_related("rank_view")
.order_by("created")
.annotate_with_rank()
.all()
)

self.assertEquals(replays[0].rank, 1)
self.assertEquals(replays[0].GetRank(), 1)
self.assertEquals(replays[1].rank, 2)
self.assertEquals(replays[1].GetRank(), 2)
self.assertEquals(replays[2].rank, 3)
self.assertEquals(replays[2].GetRank(), 3)

def testStagePracticeReplaysAreUnranked(self) -> None:
test_replays.CreateAsPublishedReplay(
Expand All @@ -193,8 +210,59 @@ def testStagePracticeReplaysAreUnranked(self) -> None:
replay_type=models.ReplayType.STAGE_PRACTICE,
)

replay = models.Replay.objects.annotate_with_rank().first()
replay = (
models.Replay.objects.select_related("rank_view")
.annotate_with_rank()
.first()
)
self.assertEquals(replay.rank, -1)
self.assertIsNone(replay.GetRank())

def testRankCountsTasSeparately(self):
th05_mima = models.Shot.objects.get(
game_id=game_ids.GameIDs.TH05, shot_id="Mima"
)

test_replays.CreateReplayWithoutFile(
user=self.author,
difficulty=1,
shot=th05_mima,
score=10000,
category=models.Category.STANDARD,
)
test_replays.CreateReplayWithoutFile(
user=self.author,
difficulty=1,
shot=th05_mima,
score=7500,
category=models.Category.STANDARD,
)
test_replays.CreateReplayWithoutFile(
user=self.author,
difficulty=1,
shot=th05_mima,
score=20000,
category=models.Category.TAS,
)
replays = (
models.Replay.objects.filter(
category__in=[models.Category.STANDARD, models.Category.TAS]
)
.select_related("rank_view")
.order_by("created")
.all()
)

# Note that .annotate_with_rank() performs incorrectly with this query,
# which is why we're moving to the join-with-view implementation instead.
self.assertEqual(len(replays), 3)
(returned_standard_1, returned_standard_2, returned_tas) = replays
self.assertEqual(returned_standard_1.category, models.Category.STANDARD)
self.assertEqual(returned_standard_1.GetRank(), 1)
self.assertEqual(returned_standard_2.category, models.Category.STANDARD)
self.assertEqual(returned_standard_2.GetRank(), 2)
self.assertEqual(returned_tas.category, models.Category.TAS)
self.assertIsNone(returned_tas.GetRank())

def testReplayQueryForGhosts(self):
inactive_user = self.createUser("inactive")
Expand Down
33 changes: 33 additions & 0 deletions project/thscoreboard/replays/testing/test_replays.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import typing

from replays import create_replay
from replays import game_ids
from replays import models
from replays import replay_parsing

Expand Down Expand Up @@ -83,3 +84,35 @@ def CreateAsPublishedReplay(
created_timestamp=created_timestamp,
imported_username=imported_username,
)


def CreateReplayWithoutFile(
user,
shot,
difficulty,
score,
route=None,
category=models.Category.STANDARD,
comment="",
is_clear=True,
video_link="",
no_bomb=False,
miss_count=None,
replay_type: models.ReplayType = game_ids.ReplayTypes.FULL_GAME,
):
"""Create a replay without a file (for things like PC-98 replays)."""

return create_replay.PublishReplayWithoutFile(
user=user,
difficulty=difficulty,
shot=shot,
score=score,
route=route,
category=category,
comment=comment,
is_clear=is_clear,
video_link=video_link,
replay_type=replay_type,
no_bomb=no_bomb,
miss_count=miss_count,
)

0 comments on commit da51b82

Please sign in to comment.