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 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; 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 6cded095f..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, } @@ -93,6 +94,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 +103,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(()) } 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, }) 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