From 696ec73079a301813bab577a039b5ac6e252ea65 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 24 May 2025 22:40:23 +0100 Subject: [PATCH 1/5] Add a materialized view for caching player ranks This allow us to add player ranks to the FullPlayer objects, which will simplify the stats viewer frontend significantly. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- ...250524213558_materialized_ranking.down.sql | 16 ++++++++++++ ...20250524213558_materialized_ranking.up.sql | 25 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 migrations/20250524213558_materialized_ranking.down.sql create mode 100644 migrations/20250524213558_materialized_ranking.up.sql diff --git a/migrations/20250524213558_materialized_ranking.down.sql b/migrations/20250524213558_materialized_ranking.down.sql new file mode 100644 index 000000000..0e3d2778e --- /dev/null +++ b/migrations/20250524213558_materialized_ranking.down.sql @@ -0,0 +1,16 @@ +-- Add down migration script here + +CREATE OR REPLACE VIEW ranked_players AS +SELECT + ROW_NUMBER() OVER(ORDER BY players.score DESC, id) AS index, + RANK() OVER(ORDER BY players.score DESC) AS rank, + id, name, players.score, subdivision, + nationalities.iso_country_code, + nationalities.nation, + nationalities.continent +FROM players + LEFT OUTER JOIN nationalities + ON players.nationality = nationalities.iso_country_code +WHERE NOT players.banned AND players.score > 0.0; + +DROP MATERIALIZED VIEW player_ranks; \ No newline at end of file diff --git a/migrations/20250524213558_materialized_ranking.up.sql b/migrations/20250524213558_materialized_ranking.up.sql new file mode 100644 index 000000000..817c7272d --- /dev/null +++ b/migrations/20250524213558_materialized_ranking.up.sql @@ -0,0 +1,25 @@ +-- Add up migration script here + +CREATE MATERIALIZED VIEW player_ranks AS + SELECT + RANK() OVER (ORDER BY score DESC) as rank, + id + FROM players + WHERE + score != 0 AND NOT banned; + +CREATE UNIQUE INDEX player_ranks_id_idx ON player_ranks(id); + + +CREATE OR REPLACE VIEW ranked_players AS +SELECT + ROW_NUMBER() OVER(ORDER BY rank, id) AS index, + rank, + id, name, players.score, subdivision, + nationalities.iso_country_code, + nationalities.nation, + nationalities.continent +FROM players +LEFT OUTER JOIN nationalities + ON players.nationality = nationalities.iso_country_code +NATURAL JOIN player_ranks; \ No newline at end of file From b053f7a7af9aae6960731cf6bfdf87055c7a7a0d Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 24 May 2025 22:43:59 +0100 Subject: [PATCH 2/5] refresh player_ranks view appropriately It needs to be refreshed whenever someone's score changes. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-demonlist/src/player/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pointercrate-demonlist/src/player/mod.rs b/pointercrate-demonlist/src/player/mod.rs index 6cded095f..2f9fc4936 100644 --- a/pointercrate-demonlist/src/player/mod.rs +++ b/pointercrate-demonlist/src/player/mod.rs @@ -93,6 +93,7 @@ impl DatabasePlayer { sqlx::query!("UPDATE nationalities SET score = coalesce(score_of_nation(nationalities.iso_country_code), 0) FROM players WHERE players.id = $1 AND players.nationality = nationalities.iso_country_code", self.id).execute(&mut *connection).await?; sqlx::query!("UPDATE subdivisions SET score = coalesce(score_of_subdivision(subdivisions.nation, subdivisions.iso_code), 0) FROM players WHERE players.id = $1 AND players.nationality = subdivisions.nation AND players.subdivision = subdivisions.iso_code", self.id).execute(&mut *connection).await?; + sqlx::query!("REFRESH MATERIALIZED VIEW CONCURRENTLY player_ranks;").execute(&mut *connection).await?; Ok(new_score.score) } @@ -101,6 +102,7 @@ impl DatabasePlayer { pub async fn recompute_scores(connection: &mut PgConnection) -> Result<(), CoreError> { sqlx::query!("SELECT recompute_player_scores();").execute(&mut *connection).await?; sqlx::query!("SELECT recompute_nation_scores();").execute(&mut *connection).await?; - sqlx::query!("SELECT recompute_subdivision_scores();").execute(connection).await?; + sqlx::query!("SELECT recompute_subdivision_scores();").execute(&mut *connection).await?; + sqlx::query!("REFRESH MATERIALIZED VIEW CONCURRENTLY player_ranks;").execute(&mut *connection).await?; Ok(()) } From 556377bb753d5ab2efb9526f867b595168d60bd7 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 24 May 2025 22:53:57 +0100 Subject: [PATCH 3/5] add a test for player merging I thought that deletes on the players table might get messed up because the materialized view has an index, but seems like this is not the case. Good to have a simple test for the merging functionality anyway though. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- .../tests/demonlist/player/mod.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pointercrate-test/tests/demonlist/player/mod.rs b/pointercrate-test/tests/demonlist/player/mod.rs index 7bee2d71e..f392846c8 100644 --- a/pointercrate-test/tests/demonlist/player/mod.rs +++ b/pointercrate-test/tests/demonlist/player/mod.rs @@ -4,7 +4,9 @@ use pointercrate_demonlist::{ LIST_HELPER, LIST_MODERATOR, }; use rocket::http::Status; +use serde_json::json; use sqlx::{PgConnection, Pool, Postgres}; +use pointercrate_demonlist::record::RecordStatus; mod score; @@ -325,3 +327,37 @@ async fn test_players_pagination(pool: Pool) { }) ); } + + +#[sqlx::test(migrations = "../migrations")] +async fn test_player_merge(pool: Pool) { + let (client, mut connection) = pointercrate_test::demonlist::setup_rocket(pool).await; + let moderator = pointercrate_test::user::system_user_with_perms(LIST_MODERATOR, &mut connection).await; + + /* + * We're creating two players with approved records on the same demon (but different progress) and then rename them to have the same name + * This should merge the two records (keeping the higher progress) and delete one of the player objects. + */ + + let player1 = DatabasePlayer::by_name_or_create("stardust1971", &mut connection).await.unwrap(); + let player2 = DatabasePlayer::by_name_or_create("stardust1972", &mut connection).await.unwrap(); + + let demon1 = pointercrate_test::demonlist::add_demon("Bloodbath", 1, 87, player1.id, player1.id, &mut connection).await; + + pointercrate_test::demonlist::add_simple_record(90, player1.id, demon1, RecordStatus::Approved, &mut connection).await; + pointercrate_test::demonlist::add_simple_record(95, player2.id, demon1, RecordStatus::Approved, &mut connection).await; + + let patched: FullPlayer = client.patch_player(player2.id, &moderator, json!{{"name": "stardust1971"}}) + .await + .get_success_result() + .await; + + assert_eq!(patched.records.len(), 1); + assert_eq!(patched.records[0].progress, 95); + assert_eq!(patched.player.base.id, player2.id); + + client.get(&format!("/api/v1/players/{}/", player1.id)) + .expect_status(Status::NotFound) + .execute() + .await; +} \ No newline at end of file From 9d8f3fcb34de47866f843fbdee20d06ba0336194 Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sat, 24 May 2025 23:03:13 +0100 Subject: [PATCH 4/5] return player rank in `Player` objects This will include it in all pagination responses (we already join with the nationalities table, so surely this can't be that bad), but more importantly, we return it in the GET for individual players, which allows us to simplify the stats viewer a bit. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- pointercrate-demonlist/sql/paginate_players_by_id.sql | 9 +++++---- pointercrate-demonlist/src/player/get.rs | 5 +++-- pointercrate-demonlist/src/player/mod.rs | 1 + pointercrate-demonlist/src/player/paginate.rs | 4 ++-- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pointercrate-demonlist/sql/paginate_players_by_id.sql b/pointercrate-demonlist/sql/paginate_players_by_id.sql index 73ff565fc..a1ddd12cb 100644 --- a/pointercrate-demonlist/sql/paginate_players_by_id.sql +++ b/pointercrate-demonlist/sql/paginate_players_by_id.sql @@ -1,13 +1,14 @@ -SELECT id, players.name::TEXT, banned, nationalities.nation::TEXT, iso_country_code::TEXT, subdivision::TEXT AS iso_code, subdivisions.name AS subdivision_name, players.score +SELECT players.id, players.name::TEXT, banned, nationalities.nation::TEXT, iso_country_code::TEXT, subdivision::TEXT AS iso_code, subdivisions.name AS subdivision_name, players.score, player_ranks.rank FROM players LEFT OUTER JOIN nationalities ON nationality = iso_country_code LEFT OUTER JOIN subdivisions ON iso_code = subdivision AND subdivisions.nation = nationality -WHERE (id < $1 OR $1 IS NULL) - AND (id > $2 OR $2 IS NULL) +LEFT OUTER JOIN player_ranks ON player_ranks.id = players.id +WHERE (players.id < $1 OR $1 IS NULL) + AND (players.id > $2 OR $2 IS NULL) AND (players.name = $3::CITEXT OR $3 is NULL) AND (STRPOS(players.name, $4::CITEXT) > 0 OR $4 is NULL) AND (banned = $5 OR $5 IS NULL) AND (nationality = $6 OR iso_country_code = $6 OR (nationality IS NULL AND $7) OR ($6 IS NULL AND NOT $7)) AND (subdivision = $8 OR $8 IS NULL) -ORDER BY id {} +ORDER BY players.id {} LIMIT $9 \ No newline at end of file diff --git a/pointercrate-demonlist/src/player/get.rs b/pointercrate-demonlist/src/player/get.rs index 5ce02f0b1..14ad3677f 100644 --- a/pointercrate-demonlist/src/player/get.rs +++ b/pointercrate-demonlist/src/player/get.rs @@ -26,8 +26,8 @@ impl Player { pub async fn by_id(id: i32, connection: &mut PgConnection) -> Result { let result = sqlx::query!( - r#"SELECT id, players.name, banned, players.score, nationalities.nation::text, iso_country_code::text, iso_code::text as subdivision_code, subdivisions.name::text as subdivision_name FROM players LEFT OUTER JOIN nationalities ON - players.nationality = nationalities.iso_country_code LEFT OUTER JOIN subdivisions ON players.subdivision = subdivisions.iso_code WHERE id = $1 AND (subdivisions.nation=nationalities.iso_country_code or players.subdivision is null)"#, + r#"SELECT players.id, players.name, banned, players.score, nationalities.nation::text, iso_country_code::text, iso_code::text as subdivision_code, subdivisions.name::text as subdivision_name, player_ranks.rank FROM players LEFT OUTER JOIN nationalities ON + players.nationality = nationalities.iso_country_code LEFT OUTER JOIN subdivisions ON players.subdivision = subdivisions.iso_code LEFT OUTER JOIN player_ranks ON player_ranks.id = players.id WHERE players.id = $1 AND (subdivisions.nation=nationalities.iso_country_code or players.subdivision is null)"#, id ) .fetch_one(connection) @@ -58,6 +58,7 @@ impl Player { banned: row.banned, }, score: row.score, + rank: row.rank, nationality, }) }, diff --git a/pointercrate-demonlist/src/player/mod.rs b/pointercrate-demonlist/src/player/mod.rs index 2f9fc4936..07c067bfc 100644 --- a/pointercrate-demonlist/src/player/mod.rs +++ b/pointercrate-demonlist/src/player/mod.rs @@ -59,6 +59,7 @@ pub struct Player { /// * Player banned /// * Player objects merged pub score: f64, + pub rank: Option, pub nationality: Option, } diff --git a/pointercrate-demonlist/src/player/paginate.rs b/pointercrate-demonlist/src/player/paginate.rs index 3b3103796..b282bc105 100644 --- a/pointercrate-demonlist/src/player/paginate.rs +++ b/pointercrate-demonlist/src/player/paginate.rs @@ -100,6 +100,7 @@ impl Paginatable for Player { banned: row.get("banned"), }, score: row.get("score"), + rank: row.get("rank"), nationality, }) } @@ -145,7 +146,6 @@ impl PaginationQuery for RankingPagination { #[derive(Debug, Serialize)] pub struct RankedPlayer { - rank: i64, #[serde(skip)] index: i64, #[serde(flatten)] @@ -198,11 +198,11 @@ impl Paginatable for RankedPlayer { banned: false, }, score: row.get("score"), + rank: row.get("rank"), nationality, }; players.push(RankedPlayer { - rank: row.get("rank"), index: row.get("index"), player, }) From 9183cd76798e98b994e2018c541d528322f0a7ba Mon Sep 17 00:00:00 2001 From: stadust <43299462+stadust@users.noreply.github.com> Date: Sun, 25 May 2025 13:15:46 +0100 Subject: [PATCH 5/5] js: use API response fields for rank/score in individual statsviewer With both scores and ranks cached these days, the individual stats viewer can stop parsing them from the html of the selection panel, and instead just grab them from the API response. This means that now the individual stats viewer actually supports .selectArbitrary for selecting players not currently visible in the pagination panel. Signed-off-by: stadust <43299462+stadust@users.noreply.github.com> --- .../static/js/modules/statsviewer.js | 9 --------- .../static/js/statsviewer/individual.js | 4 +++- .../static/js/statsviewer/nation.js | 4 ++++ 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/pointercrate-demonlist-pages/static/js/modules/statsviewer.js b/pointercrate-demonlist-pages/static/js/modules/statsviewer.js index 3004afb01..6712a66d0 100644 --- a/pointercrate-demonlist-pages/static/js/modules/statsviewer.js +++ b/pointercrate-demonlist-pages/static/js/modules/statsviewer.js @@ -149,15 +149,6 @@ export class StatsViewer extends FilteredPaginator { main + " Main, " + extended + " Extended, " + legacy + " Legacy "; } - onReceive(response) { - super.onReceive(response); - - // Using currentlySelected is O.K. here, as selection via clicking li-elements is the only possibility (well, not for the nation based one, but oh well)! - this._rank.innerText = this.currentlySelected.dataset.rank; - this._score.innerHTML = - this.currentlySelected.getElementsByTagName("i")[0].innerHTML; - } - formatDemon(demon, link, dontStyle) { var element; diff --git a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js index 3ac7c4703..d27a02b9f 100644 --- a/pointercrate-demonlist-pages/static/js/statsviewer/individual.js +++ b/pointercrate-demonlist-pages/static/js/statsviewer/individual.js @@ -23,6 +23,9 @@ class IndividualStatsViewer extends StatsViewer { var playerData = response.data.data; + this._rank.innerText = playerData.rank; + this._score.innerText = playerData.score.toFixed(2); + this.setName(playerData.name, playerData.nationality); const selectedSort = this.demonSortingModeDropdown.selected; @@ -231,7 +234,6 @@ function generateStatsViewerPlayer(player) { li.className = "white hover"; li.dataset.id = player.id; - li.dataset.rank = player.rank; b.appendChild(document.createTextNode("#" + player.rank + " ")); i.appendChild(document.createTextNode(player.score.toFixed(2))); diff --git a/pointercrate-demonlist-pages/static/js/statsviewer/nation.js b/pointercrate-demonlist-pages/static/js/statsviewer/nation.js index 3704d460b..16c8650d8 100644 --- a/pointercrate-demonlist-pages/static/js/statsviewer/nation.js +++ b/pointercrate-demonlist-pages/static/js/statsviewer/nation.js @@ -21,6 +21,10 @@ class NationStatsViewer extends StatsViewer { onReceive(response) { super.onReceive(response); + this._rank.innerText = this.currentlySelected.dataset.rank; + this._score.innerHTML = + this.currentlySelected.getElementsByTagName("i")[0].innerHTML; + let nationData = response.data.data; let selectedSort = this.demonSortingModeDropdown.selected;