From bb02300362523de3a9d69f45cc67329df9ab7df2 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Mon, 12 Feb 2024 01:45:49 +0300 Subject: [PATCH 1/7] Add functionality to retrieve player's rank --- .../GetPlayerRank.cs | 28 +++++ .../ILeaderboardsClient.cs | 7 ++ .../LeaderboardsClientBase.cs | 35 ++++++ .../Abstract/ILeaderboardService.cs | 6 + .../Controllers/LeaderboardsController.cs | 15 +++ .../Implementation/BasicQueryBuilder.cs | 108 ++++++++++++++++++ .../Implementation/LeaderboardService.cs | 54 +++++++++ .../PlayerTests.cs | 35 ++++++ .../TestEnvironment.cs | 12 ++ 9 files changed, 300 insertions(+) create mode 100644 src/AndreyGames.Leaderboards.API/GetPlayerRank.cs create mode 100644 src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs diff --git a/src/AndreyGames.Leaderboards.API/GetPlayerRank.cs b/src/AndreyGames.Leaderboards.API/GetPlayerRank.cs new file mode 100644 index 0000000..1cc8982 --- /dev/null +++ b/src/AndreyGames.Leaderboards.API/GetPlayerRank.cs @@ -0,0 +1,28 @@ +namespace AndreyGames.Leaderboards.API +{ + /// + /// Get player's offset in the leaderboard + /// + public class GetPlayerRank : LeaderboardsCryptoRequest + { + /// + /// The game + /// + public string Game { get; set; } + + /// + /// The name of the player. + /// + public string PlayerName { get; set; } + + /// + /// Return only winners + /// + public bool WinnersOnly { get; set; } + + /// + /// Time frame for data + /// + public TimeFrame? Time { get; set; } + } +} \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs b/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs index 5e07f85..030809a 100644 --- a/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs +++ b/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs @@ -22,6 +22,13 @@ public interface ILeaderboardsClient /// Something went wrong on the server side. Task> GetPlayerScore(string game, string playerName, CancellationToken token = default); + /// + /// Returns player's rank position in the specified leaderboard. + /// + /// Something went wrong on the server side. + Task GetPlayerRank(string game, string playerName, bool winnersOnly = false, + TimeFrame? timeFrame = default, CancellationToken token = default); + /// /// Get leaderboard for the game /// diff --git a/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs b/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs index e6a5ef2..1d900a2 100644 --- a/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs +++ b/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs @@ -35,6 +35,10 @@ protected abstract Task> GetPlayerScore(string ful LeaderboardsCryptoRequest request, CancellationToken token = default); + protected abstract Task GetPlayerRank(string fullUrl, + LeaderboardsCryptoRequest request, + CancellationToken token = default); + protected abstract Task GetLeaderboard(string fullUrl, LeaderboardsCryptoRequest request, CancellationToken token = default); @@ -109,6 +113,37 @@ public Task> GetPlayerScore(string game, string pl return GetPlayerScore(url, request, token); } + public Task GetPlayerRank(string game, + string playerName, + bool winnersOnly = false, + TimeFrame? timeFrame = default, + CancellationToken token = default) + { + const string path = "/score/rank"; + var url = CreateUrl(path); + + LogFormat( + "Executing GetPlayerOffset command on URL '{0}', game=[{1}], winnersOnly=[{2}], timeFrame=[{3}]", + url, game, winnersOnly, timeFrame); + + var json = SerializeJsonBytes(new GetPlayerRank + { + Game = game, + PlayerName = playerName, + WinnersOnly = winnersOnly, + Time = timeFrame, + }); + + var request = new LeaderboardsCryptoRequest + { + Body = _cryptoService.EncryptAsBase64(json, + _password, + _seed), + }; + + return GetPlayerRank(url, request, token); + } + public Task GetLeaderboard(string game, bool winnersOnly = false, TimeFrame? timeFrame = default, diff --git a/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs index 18ef241..28ec41b 100644 --- a/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs @@ -18,6 +18,12 @@ Task GetLeaderboard(string game, int? offset = null, int? limit = null); + Task GetPlayerRank(string game, + string playerName, + DateTime? start = default, + DateTime? end = default, + bool onlyWinners = false); + Task> GetScoreForPlayer(string game, string playerName); Task PutPlayerScore(string game, DateTime date, string playerName, long score, bool isWinner = false, bool isFraud = false); diff --git a/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs b/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs index a433a9f..dc9b853 100644 --- a/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs +++ b/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs @@ -76,5 +76,20 @@ await _leaderboardService.PutPlayerScore(request.Game, return new LeaderboardApiResponse(); } + + [HttpPost("score/rank")] + public async Task GetPlayerRank([FromBody] GetPlayerRank request) + { + var startDate = _timeFrameConverter.GetStartDate(request.Time ?? TimeFrame.Infinite); + var endDate = _timeFrameConverter.GetEndDate(request.Time ?? TimeFrame.Infinite); + + var data = await _leaderboardService.GetPlayerRank(request.Game, + request.PlayerName, + startDate, + endDate, + request.WinnersOnly); + + return new LeaderboardApiResponse(data); + } } } \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs new file mode 100644 index 0000000..b472699 --- /dev/null +++ b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Dapper; +using Microsoft.EntityFrameworkCore; + +namespace AndreyGames.Leaderboards.Service.Implementation +{ + internal sealed class BasicQueryBuilder + { + private readonly DbContext _dbContext; + private readonly Dictionary _arbitraryParams = new(); + private readonly List<(string Field, object Value)> _ands = new(); + private readonly List<(string Field, object Value1, object Value2)> _andBetweens = new(); + + private string _template; + + public static string WherePlaceholder => "%%WHERE%%"; + + private BasicQueryBuilder(DbContext dbContext) + { + _dbContext = dbContext; + } + + public static BasicQueryBuilder New(DbContext dbContext) => new(dbContext); + + public BasicQueryBuilder WithQueryTemplate(string template) + { + _template = template; + return this; + } + + public BasicQueryBuilder WithArbitraryParameter(string name, object value) + { + _arbitraryParams[name] = value; + return this; + } + + public BasicQueryBuilder And(string field, object value) + { + _ands.Add((field, value)); + return this; + } + + public BasicQueryBuilder AndBetween(string field, object value1, object value2) + { + _andBetweens.Add((field, value1, value2)); + return this; + } + + public async Task> Execute() + { + var query = BuildQuery(); + var parameters = BuildParameters(); + + return await _dbContext.Database.GetDbConnection().QueryAsync(query, parameters); + } + + private string BuildQuery() + { + var all = new List(); + + var ands = string.Join(" AND ", + _ands.Select(x => x.Field) + .Select(x => $"\"{x}\" = @{x}")); + + if (!string.IsNullOrWhiteSpace(ands)) + { + all.Add(ands); + } + + var andBetweens = string.Join(" AND ", + _andBetweens.Select(x => x.Field) + .Select(x => $"\"{x}\" BETWEEN @{x}1 AND @{x}2")); + + if (!string.IsNullOrWhiteSpace(andBetweens)) + { + all.Add(andBetweens); + } + + var where = string.Join(" AND ", all); + return _template.Replace(WherePlaceholder, where); + } + + private DynamicParameters BuildParameters() + { + var dict = new Dictionary(); + foreach (var tuple in _ands) + { + dict[$"@{tuple.Field}"] = tuple.Value; + } + + foreach (var tuple in _andBetweens) + { + dict[$"@{tuple.Field}1"] = tuple.Value1; + dict[$"@{tuple.Field}2"] = tuple.Value2; + } + + foreach (var param in _arbitraryParams) + { + dict[$"@{param.Key}"] = param.Value; + } + + return new DynamicParameters(dict); + } + } +} \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs index b578aa9..9eed658 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs @@ -101,6 +101,60 @@ public async Task GetLeaderboard(string game, DateTime? start = }; } + public async Task GetPlayerRank(string game, string playerName, DateTime? start = default, DateTime? end = default, + bool onlyWinners = false) + { + if (start.HasValue && end.HasValue) + { + if (start.Value > end.Value) + { + throw new InvalidTimeframeException(); + } + } + + var leaderboard = await _context + .Leaderboards + .FirstOrDefaultAsync(x => x.Game == game && x.IsActive); + + if (leaderboard is null) + { + throw new LeaderboardNotFound(game); + } + + var wherePlaceholder = BasicQueryBuilder.WherePlaceholder; + + var queryTemplate = + $@"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" + FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderBoardId AND ({wherePlaceholder})) + SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE ""PlayerName"" = @PlayerName"; + + var queryBuilder = BasicQueryBuilder + .New(_context) + .WithQueryTemplate(queryTemplate) + .WithArbitraryParameter("LeaderBoardId", leaderboard.Id) + .WithArbitraryParameter("PlayerName", playerName); + + if (onlyWinners) + { + queryBuilder = queryBuilder.And("IsWinner", true); + } + + if (start.HasValue && end.HasValue) + { + queryBuilder = queryBuilder.AndBetween("Timestamp", start.Value, end.Value); + } + + var results = await queryBuilder.Execute(); + + return results.Select(x => new LeaderboardEntry + { + IsWinner = x.IsWinner, + Rank = x.Rank, + Score = x.Score, + Name = playerName, + }).FirstOrDefault(); + } + private class ScoreItem { public long Score { get; set; } diff --git a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs index 8528c71..06b0c27 100644 --- a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs +++ b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Bogus; using Shouldly; @@ -138,5 +139,39 @@ public async void AddScore_WhenWinnerAndNot_ShouldHaveBothResults() actualScores.ShouldContain(x => !x.IsWinner && x.Score == score1); actualScores.ShouldContain(x => x.IsWinner && x.Score == score2); } + + [Fact] + public async void AddScores_ThenGetPlayerRank_ShouldBeSameAsInLeaderboard() + { + var game = CreateGameName(); + + var players = Enumerable + .Range(0, _faker.Random.Int(10, 100)) + .Select(_ => new Faker()) + .Select(faker => $"{faker.Person.FullName} {faker.Person.DateOfBirth}") + .ToDictionary(x => x, + _ => new + { + Score = _faker.Random.Int(0, 1000000), + IsWinner = _faker.Random.Bool() + }); + + var client = _testEnvironment.CreateLeaderboardsClient(); + var clock = _testEnvironment.Clock; + + await client.AddLeaderboard(game); + foreach (var player in players) + { + await client.AddOrUpdateScore(game, player.Key, player.Value.Score, player.Value.IsWinner, false); + clock.CurrentTime = clock.CurrentTime.AddMinutes(1); + } + + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner); + var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count); + + playerRank.Rank.ShouldBeGreaterThan(0); + playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); + } } } \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs index 1e43872..a7b6c55 100644 --- a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs +++ b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs @@ -77,6 +77,11 @@ protected override Task> GetPlayerScore(string ful return Post>(fullUrl, request, token); } + protected override Task GetPlayerRank(string fullUrl, LeaderboardsCryptoRequest request, CancellationToken token = default) + { + return Post(fullUrl, request, token); + } + protected override Task GetLeaderboard(string fullUrl, LeaderboardsCryptoRequest request, CancellationToken token = default) { return Post(fullUrl, request, token); @@ -123,6 +128,13 @@ public Task> GetPlayerScore(string game, string pl return _leaderboardsClientImplementation.GetPlayerScore(game, playerName, token); } + public Task GetPlayerRank(string game, string playerName, bool winnersOnly = false, TimeFrame? timeFrame = default, + CancellationToken token = default) + { + return _leaderboardsClientImplementation.GetPlayerRank(game, playerName, winnersOnly, timeFrame, + token); + } + public Task GetLeaderboard(string game, bool winnersOnly = false, TimeFrame? timeFrame = default, int? offset = null, int? limit = null, From cf9821ec572291ef90df5b569ebe2a007ed7e375 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Mon, 12 Feb 2024 14:14:24 +0300 Subject: [PATCH 2/7] Bumped package version --- .../AndreyGames.Leaderboards.API.csproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj index c0afa7a..890dfaa 100644 --- a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj +++ b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj @@ -5,7 +5,7 @@ latest true AndreyGames.Leaderboards.API - Sharkadi Andrey (andrey.games) (c) 2023 + Sharkadi Andrey (andrey.games) (c) 2023-2024 https://github.com/sharkadi-a/leaderboards https://github.com/sharkadi-a/leaderboards GIT @@ -16,9 +16,9 @@ Fixing metadata for ProGet andrey.games leaderboards api - 1.8.0 - 1.8.0 - 1.8.0 + 1.9.0 + 1.9.0 + 1.9.0 From eda964fd1a58926e2925d30bc82e9e65f9606882 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Wed, 21 Feb 2024 00:26:29 +0100 Subject: [PATCH 3/7] Logging improvements --- .../AndreyGames.Leaderboards.API.csproj | 6 ++-- .../ApiException.cs | 8 ++++- .../Implementation/LeaderboardService.cs | 4 +-- .../Middleware/RequestLoggingMiddleware.cs | 20 ++++------- .../PlayerTests.cs | 35 +++++++++++++++++++ .../TestConfigFileBuilder.cs | 8 ++++- .../TestEnvironment.cs | 8 +++-- 7 files changed, 66 insertions(+), 23 deletions(-) diff --git a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj index 890dfaa..6fe92c4 100644 --- a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj +++ b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj @@ -16,9 +16,9 @@ Fixing metadata for ProGet andrey.games leaderboards api - 1.9.0 - 1.9.0 - 1.9.0 + 1.10.0 + 1.10.0 + 1.10.0 diff --git a/src/AndreyGames.Leaderboards.API/ApiException.cs b/src/AndreyGames.Leaderboards.API/ApiException.cs index 287305d..76a485f 100644 --- a/src/AndreyGames.Leaderboards.API/ApiException.cs +++ b/src/AndreyGames.Leaderboards.API/ApiException.cs @@ -9,6 +9,11 @@ namespace AndreyGames.Leaderboards.API /// public sealed class ApiException : Exception { + /// + /// Original request URL where this exception happened + /// + public string Url { get; } + /// /// Message /// @@ -31,8 +36,9 @@ public sealed class ApiException : Exception ? DateTime.ParseExact(Data[nameof(Timestamp)].ToString(), "O", CultureInfo.InvariantCulture) : null; - public ApiException(string message, IDictionary data) + public ApiException(string url, string message, IDictionary data) { + Url = url; Message = message; Data = data; } diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs index 9eed658..f17b5d1 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs @@ -125,13 +125,13 @@ public async Task GetPlayerRank(string game, string playerName var queryTemplate = $@"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" - FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderBoardId AND ({wherePlaceholder})) + FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderboardId AND ({wherePlaceholder})) SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE ""PlayerName"" = @PlayerName"; var queryBuilder = BasicQueryBuilder .New(_context) .WithQueryTemplate(queryTemplate) - .WithArbitraryParameter("LeaderBoardId", leaderboard.Id) + .WithArbitraryParameter("LeaderboardId", leaderboard.Id) .WithArbitraryParameter("PlayerName", playerName); if (onlyWinners) diff --git a/src/AndreyGames.Leaderboards.Service/Middleware/RequestLoggingMiddleware.cs b/src/AndreyGames.Leaderboards.Service/Middleware/RequestLoggingMiddleware.cs index eaff520..99785fe 100644 --- a/src/AndreyGames.Leaderboards.Service/Middleware/RequestLoggingMiddleware.cs +++ b/src/AndreyGames.Leaderboards.Service/Middleware/RequestLoggingMiddleware.cs @@ -23,10 +23,13 @@ public async Task Invoke(HttpContext context) try { await _next(context); - } - catch (BusinessLogicException) - { + _logger.LogInformation( + "{method} {fullUrl} {url} => {statusCode}", + context.Request?.Method, + context.Request?.GetDisplayUrl(), + context.Request?.Path.Value, + context.Response?.StatusCode); } catch (Exception ex) { @@ -36,17 +39,8 @@ public async Task Invoke(HttpContext context) context.Request?.GetDisplayUrl(), context.Request?.Path.Value, context.Response?.StatusCode, - ex.Message); - - return; + ex); } - - _logger.LogInformation( - "{method} {fullUrl} {url} => {statusCode}", - context.Request?.Method, - context.Request?.GetDisplayUrl(), - context.Request?.Path.Value, - context.Response?.StatusCode); } } } \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs index 06b0c27..ad1c558 100644 --- a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs +++ b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using AndreyGames.Leaderboards.API; using Bogus; using Shouldly; using Xunit; @@ -170,6 +171,40 @@ public async void AddScores_ThenGetPlayerRank_ShouldBeSameAsInLeaderboard() var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner); var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count); + playerRank.Rank.ShouldBeGreaterThan(0); + playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); + } + + [Fact] + public async void AddScores_ThenGetPlayerRankInTimeFrame_ShouldBeSameAsInLeaderboard() + { + var game = CreateGameName(); + + var players = Enumerable + .Range(0, _faker.Random.Int(10, 100)) + .Select(_ => new Faker()) + .Select(faker => $"{faker.Person.FullName} {faker.Person.DateOfBirth}") + .ToDictionary(x => x, + _ => new + { + Score = _faker.Random.Int(0, 1000000), + IsWinner = _faker.Random.Bool() + }); + + var client = _testEnvironment.CreateLeaderboardsClient(); + var clock = _testEnvironment.Clock; + + await client.AddLeaderboard(game); + foreach (var player in players) + { + await client.AddOrUpdateScore(game, player.Key, player.Value.Score, player.Value.IsWinner, false); + clock.CurrentTime = clock.CurrentTime.AddMinutes(1); + } + + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner, TimeFrame.Week); + var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count, timeFrame: TimeFrame.Week); + playerRank.Rank.ShouldBeGreaterThan(0); playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); } diff --git a/src/AndreyGames.Leaderboards.Tests/TestConfigFileBuilder.cs b/src/AndreyGames.Leaderboards.Tests/TestConfigFileBuilder.cs index e73802a..35f8e3f 100644 --- a/src/AndreyGames.Leaderboards.Tests/TestConfigFileBuilder.cs +++ b/src/AndreyGames.Leaderboards.Tests/TestConfigFileBuilder.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; @@ -37,6 +38,11 @@ public TestConfigFileBuilder UseConnectionString(string value) public Stream Build() { + if (string.IsNullOrWhiteSpace(_connectionString)) + { + throw new ArgumentException("Connection string is empty, test require connection string to DB."); + } + var obj = new { Auth = _users.Select(x => new diff --git a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs index a7b6c55..a2f7200 100644 --- a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs +++ b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs @@ -53,7 +53,7 @@ async protected Task Post(string fullUrl, TRequest r var obj = JObject.Parse(await httpResponseMessage.Content.ReadAsStringAsync()); var dict = obj["data"].ToDictionary(x => x.First.Value(), x => x.Last.Value()); - throw new ApiException(obj["message"].Value(), dict); + throw new ApiException(fullUrl, obj["message"].Value(), dict); } async protected Task Post(string fullUrl, TRequest request, CancellationToken token) @@ -64,7 +64,7 @@ async protected Task Post(string fullUrl, TRequest request, Cancellati var obj = JObject.Parse(await httpResponseMessage.Content.ReadAsStringAsync()); var dict = obj["data"].ToDictionary(x => x.First.Value(), x => x.Last.Value()); - throw new ApiException(obj["message"].Value(), dict); + throw new ApiException(fullUrl, obj["message"].Value(), dict); } protected override Task AddLeaderboard(string fullUrl, LeaderboardsCryptoRequest request, CancellationToken token = default) @@ -166,7 +166,9 @@ public ILeaderboardsClient CreateLeaderboardsClient() protected override void ConfigureWebHost(IWebHostBuilder builder) { - var connectionString = Environment.GetEnvironmentVariable("TEST_DB_CONNECTION_STRING"); + var connectionString = Environment.GetEnvironmentVariable("TEST_DB_CONNECTION_STRING") + ?? throw new InvalidOperationException("TEST_DB_CONNECTION_STRING environment variable must be set"); + const string vector = "Test"; var config = new TestConfigFileBuilder() From 141eecf6695cedb3ac00bb3305416896f1100d10 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Wed, 28 Feb 2024 00:25:32 +0100 Subject: [PATCH 4/7] Working on additional methods for leaderboard --- .../AndreyGames.Leaderboards.API.csproj | 7 +- ...tPlayerRank.cs => GetPlayerRankRequest.cs} | 7 +- .../GetPlayerScoreRequest.cs | 5 + .../ILeaderboardsClient.cs | 15 +- .../LeaderboardsClientBase.cs | 13 +- .../Abstract/ILeaderboardService.cs | 3 +- .../Controllers/LeaderboardsController.cs | 5 +- .../Implementation/BasicQueryBuilder.cs | 13 ++ .../Implementation/LeaderboardService.cs | 42 ++++-- .../Properties/launchSettings.json | 2 +- .../PlayerTests.cs | 139 +++++++++++++++++- .../TestEnvironment.cs | 9 +- 12 files changed, 228 insertions(+), 32 deletions(-) rename src/AndreyGames.Leaderboards.API/{GetPlayerRank.cs => GetPlayerRankRequest.cs} (72%) diff --git a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj index 6fe92c4..7d9c3ff 100644 --- a/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj +++ b/src/AndreyGames.Leaderboards.API/AndreyGames.Leaderboards.API.csproj @@ -15,10 +15,9 @@ andrey.games Leaderboards API Client Fixing metadata for ProGet andrey.games leaderboards api - - 1.10.0 - 1.10.0 - 1.10.0 + 1.11.0 + 1.11.0 + 1.11.0 diff --git a/src/AndreyGames.Leaderboards.API/GetPlayerRank.cs b/src/AndreyGames.Leaderboards.API/GetPlayerRankRequest.cs similarity index 72% rename from src/AndreyGames.Leaderboards.API/GetPlayerRank.cs rename to src/AndreyGames.Leaderboards.API/GetPlayerRankRequest.cs index 1cc8982..fcb2330 100644 --- a/src/AndreyGames.Leaderboards.API/GetPlayerRank.cs +++ b/src/AndreyGames.Leaderboards.API/GetPlayerRankRequest.cs @@ -3,7 +3,7 @@ /// /// Get player's offset in the leaderboard /// - public class GetPlayerRank : LeaderboardsCryptoRequest + public class GetPlayerRankRequest : LeaderboardsCryptoRequest { /// /// The game @@ -14,6 +14,11 @@ public class GetPlayerRank : LeaderboardsCryptoRequest /// The name of the player. /// public string PlayerName { get; set; } + + /// + /// If true, player will be searched in a case-insensitive way + /// + public bool CaseInsensitive { get; set; } /// /// Return only winners diff --git a/src/AndreyGames.Leaderboards.API/GetPlayerScoreRequest.cs b/src/AndreyGames.Leaderboards.API/GetPlayerScoreRequest.cs index 9c9b656..b0edb3a 100644 --- a/src/AndreyGames.Leaderboards.API/GetPlayerScoreRequest.cs +++ b/src/AndreyGames.Leaderboards.API/GetPlayerScoreRequest.cs @@ -14,5 +14,10 @@ public class GetPlayerScoreRequest : LeaderboardsCryptoRequest /// Player name to return the score for /// public string PlayerName { get; set; } + + /// + /// If true, player will be searched in a case-insensitive way + /// + public bool CaseInsensitive { get; set; } } } \ No newline at end of file diff --git a/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs b/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs index 030809a..ed7a906 100644 --- a/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs +++ b/src/AndreyGames.Leaderboards.API/ILeaderboardsClient.cs @@ -15,19 +15,26 @@ public interface ILeaderboardsClient /// /// Something went wrong on the server side. Task AddLeaderboard(string game, CancellationToken token = default); - + /// /// Get player's score for the game /// /// Something went wrong on the server side. - Task> GetPlayerScore(string game, string playerName, CancellationToken token = default); + Task> GetPlayerScore(string game, + string playerName, + bool caseInsensitive = false, + CancellationToken token = default); /// /// Returns player's rank position in the specified leaderboard. /// /// Something went wrong on the server side. - Task GetPlayerRank(string game, string playerName, bool winnersOnly = false, - TimeFrame? timeFrame = default, CancellationToken token = default); + Task GetPlayerRank(string game, + string playerName, + bool caseInsensitive = false, + bool winnersOnly = false, + TimeFrame? timeFrame = default, + CancellationToken token = default); /// /// Get leaderboard for the game diff --git a/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs b/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs index 1d900a2..cb490b6 100644 --- a/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs +++ b/src/AndreyGames.Leaderboards.API/LeaderboardsClientBase.cs @@ -89,7 +89,9 @@ public Task AddLeaderboard(string game, CancellationToken token = default) return AddLeaderboard(url, request, token); } - public Task> GetPlayerScore(string game, string playerName, + public Task> GetPlayerScore(string game, + string playerName, + bool caseInsensitive = false, CancellationToken token = default) { const string path = "/score/get"; @@ -100,7 +102,8 @@ public Task> GetPlayerScore(string game, string pl var json = SerializeJsonBytes(new GetPlayerScoreRequest { Game = game, - PlayerName = playerName + PlayerName = playerName, + CaseInsensitive = caseInsensitive, }); var request = new LeaderboardsCryptoRequest @@ -109,12 +112,13 @@ public Task> GetPlayerScore(string game, string pl _password, _seed), }; - + return GetPlayerScore(url, request, token); } public Task GetPlayerRank(string game, string playerName, + bool caseInsensitive = false, bool winnersOnly = false, TimeFrame? timeFrame = default, CancellationToken token = default) @@ -126,10 +130,11 @@ public Task GetPlayerRank(string game, "Executing GetPlayerOffset command on URL '{0}', game=[{1}], winnersOnly=[{2}], timeFrame=[{3}]", url, game, winnersOnly, timeFrame); - var json = SerializeJsonBytes(new GetPlayerRank + var json = SerializeJsonBytes(new GetPlayerRankRequest { Game = game, PlayerName = playerName, + CaseInsensitive = caseInsensitive, WinnersOnly = winnersOnly, Time = timeFrame, }); diff --git a/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs index 28ec41b..a6e8c85 100644 --- a/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Abstract/ILeaderboardService.cs @@ -20,11 +20,12 @@ Task GetLeaderboard(string game, Task GetPlayerRank(string game, string playerName, + bool caseInsensitive, DateTime? start = default, DateTime? end = default, bool onlyWinners = false); - Task> GetScoreForPlayer(string game, string playerName); + Task> GetScoreForPlayer(string game, string playerName, bool caseInsensitive); Task PutPlayerScore(string game, DateTime date, string playerName, long score, bool isWinner = false, bool isFraud = false); } diff --git a/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs b/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs index dc9b853..3d173a9 100644 --- a/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs +++ b/src/AndreyGames.Leaderboards.Service/Controllers/LeaderboardsController.cs @@ -60,7 +60,7 @@ public async Task GetLeaderboard([FromBody] GetLeaderboa public async Task GetPlayerScore([FromBody] GetPlayerScoreRequest request) { return new LeaderboardApiResponse( - await _leaderboardService.GetScoreForPlayer(request.Game, request.PlayerName)); + await _leaderboardService.GetScoreForPlayer(request.Game, request.PlayerName, request.CaseInsensitive)); } [HttpPost("score/put")] @@ -78,13 +78,14 @@ await _leaderboardService.PutPlayerScore(request.Game, } [HttpPost("score/rank")] - public async Task GetPlayerRank([FromBody] GetPlayerRank request) + public async Task GetPlayerRank([FromBody] GetPlayerRankRequest request) { var startDate = _timeFrameConverter.GetStartDate(request.Time ?? TimeFrame.Infinite); var endDate = _timeFrameConverter.GetEndDate(request.Time ?? TimeFrame.Infinite); var data = await _leaderboardService.GetPlayerRank(request.Game, request.PlayerName, + request.CaseInsensitive, startDate, endDate, request.WinnersOnly); diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs index b472699..c97f614 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs @@ -15,6 +15,7 @@ internal sealed class BasicQueryBuilder private readonly List<(string Field, object Value1, object Value2)> _andBetweens = new(); private string _template; + private string _envelope; public static string WherePlaceholder => "%%WHERE%%"; @@ -36,6 +37,12 @@ public BasicQueryBuilder WithArbitraryParameter(string name, object valu _arbitraryParams[name] = value; return this; } + + public BasicQueryBuilder WithEnvelope(string formattedEnvelope) + { + _envelope = formattedEnvelope; + return this; + } public BasicQueryBuilder And(string field, object value) { @@ -80,6 +87,12 @@ private string BuildQuery() } var where = string.Join(" AND ", all); + + if (!string.IsNullOrEmpty(_envelope) && !string.IsNullOrWhiteSpace(where)) + { + where = string.Format(_envelope, where); + } + return _template.Replace(WherePlaceholder, where); } diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs index f17b5d1..e5fd9e0 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs @@ -101,7 +101,7 @@ public async Task GetLeaderboard(string game, DateTime? start = }; } - public async Task GetPlayerRank(string game, string playerName, DateTime? start = default, DateTime? end = default, + public async Task GetPlayerRank(string game, string playerName, bool caseInsensitive, DateTime? start = default, DateTime? end = default, bool onlyWinners = false) { if (start.HasValue && end.HasValue) @@ -123,14 +123,20 @@ public async Task GetPlayerRank(string game, string playerName var wherePlaceholder = BasicQueryBuilder.WherePlaceholder; - var queryTemplate = + var queryTemplateCaseSensitive = $@"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" - FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderboardId AND ({wherePlaceholder})) + FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderboardId {wherePlaceholder}) SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE ""PlayerName"" = @PlayerName"; + var queryTemplateCaseInsensitive = + $@"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" + FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderboardId {wherePlaceholder}) + SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE LOWER(""PlayerName"") = LOWER(@PlayerName)"; + var queryBuilder = BasicQueryBuilder .New(_context) - .WithQueryTemplate(queryTemplate) + .WithQueryTemplate(caseInsensitive ? queryTemplateCaseInsensitive : queryTemplateCaseSensitive) + .WithEnvelope("AND ({0})") .WithArbitraryParameter("LeaderboardId", leaderboard.Id) .WithArbitraryParameter("PlayerName", playerName); @@ -148,10 +154,16 @@ public async Task GetPlayerRank(string game, string playerName return results.Select(x => new LeaderboardEntry { - IsWinner = x.IsWinner, + IsWinner = x.IsWinner, Rank = x.Rank, Score = x.Score, Name = playerName, + }).DefaultIfEmpty(new LeaderboardEntry + { + IsWinner = false, + Name = playerName, + Rank = 0, + Score = 0, }).FirstOrDefault(); } @@ -162,7 +174,7 @@ private class ScoreItem public bool IsWinner { get; set; } } - public async Task> GetScoreForPlayer(string game, string playerName) + public async Task> GetScoreForPlayer(string game, string playerName, bool caseInsensitive) { var leaderboard = await _context .Leaderboards @@ -173,19 +185,29 @@ public async Task> GetScoreForPlayer(string game, throw new LeaderboardNotFound(game); } - var entry = leaderboard.Entries.FirstOrDefault(x => x.PlayerName == playerName); + var entry = caseInsensitive + ? leaderboard.Entries.FirstOrDefault(x => x.PlayerName.ToLower() == playerName.ToLower()) + : leaderboard.Entries.FirstOrDefault(x => x.PlayerName == playerName); if (entry is null) { return ArraySegment.Empty; } + const string caseSensitiveQuery = + @"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" + FROM ""Entries"" WHERE ""LeaderboardId"" = @id) + SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE ""PlayerName"" = @name"; + + const string caseInsensitiveQuery = + @"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" + FROM ""Entries"" WHERE ""LeaderboardId"" = @id) + SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE LOWER(""PlayerName"") = LOWER(@name)"; + var scores = await _context.Database .GetDbConnection() .QueryAsync( - @"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" - FROM ""Entries"" WHERE ""LeaderboardId"" = @id) - SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"" FROM entries WHERE ""PlayerName"" = @name", + caseInsensitive ? caseInsensitiveQuery : caseSensitiveQuery, new { id = leaderboard.Id, diff --git a/src/AndreyGames.Leaderboards.Service/Properties/launchSettings.json b/src/AndreyGames.Leaderboards.Service/Properties/launchSettings.json index f982f73..c72332b 100644 --- a/src/AndreyGames.Leaderboards.Service/Properties/launchSettings.json +++ b/src/AndreyGames.Leaderboards.Service/Properties/launchSettings.json @@ -20,7 +20,7 @@ "dotnetRunMessages": "true", "launchBrowser": true, "launchUrl": "swagger", - "applicationUrl": "https://localhost:5001;http://localhost:5000", + "applicationUrl": "http://localhost:5000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs index ad1c558..863b599 100644 --- a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs +++ b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs @@ -20,6 +20,9 @@ public PlayerTests(TestEnvironment testEnvironment) private string CreateGameName() => $"{_faker.Company.CompanyName()} {_faker.Commerce.Ean13()}"; + private string RandomizeLetters(string input) => + new(input.Select(x => _faker.Random.Bool() ? char.ToUpper(x) : char.ToLower(x)).ToArray()); + [Fact] public async void CreateLeaderboard_ShouldSucceed() { @@ -54,6 +57,40 @@ public async void AddScore_ThenGetScore_ShouldBeEqual() actualScores.Count.ShouldBe(1); actualScores.Single().Score.ShouldBe(score); + } + + [Fact] + public async void AddScore_ThenGetScoreCaseInsensitive_ShouldBeEqual() + { + var game = CreateGameName(); + var player = _faker.Person.FullName; + var score = (int)_faker.Finance.Amount(100, 10000, 0); + var client = _testEnvironment.CreateLeaderboardsClient(); + + await client.AddLeaderboard(game); + await client.AddOrUpdateScore(game, player, score, false, false); + + var actualScores = await client.GetPlayerScore(game, RandomizeLetters(player), true); + + actualScores.Count.ShouldBe(1); + actualScores.Single().Score.ShouldBe(score); + actualScores.Single().Name = player; + } + + [Fact] + public async void AddScore_WhenPlayerNameHasWrongCase_ThenGetScore_ShouldNotBeEqual() + { + var game = CreateGameName(); + var player = _faker.Person.FullName; + var score = (int)_faker.Finance.Amount(100, 10000, 0); + var client = _testEnvironment.CreateLeaderboardsClient(); + + await client.AddLeaderboard(game); + await client.AddOrUpdateScore(game, player, score, false, false); + + var actualScores = await client.GetPlayerScore(game, RandomizeLetters(player), false); + + actualScores.Count.ShouldBe(0); } [Fact] @@ -171,10 +208,76 @@ public async void AddScores_ThenGetPlayerRank_ShouldBeSameAsInLeaderboard() var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner); var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count); + playerRank.Rank.ShouldBeGreaterThan(0); + playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); + } + + [Fact] + public async void AddScores_ThenGetPlayerRankCaseInsensitive_ShouldBeSameAsInLeaderboard() + { + var game = CreateGameName(); + + var players = Enumerable + .Range(0, _faker.Random.Int(10, 100)) + .Select(_ => new Faker()) + .Select(faker => $"{faker.Person.FullName} {faker.Person.DateOfBirth}") + .ToDictionary(x => x, + _ => new + { + Score = _faker.Random.Int(0, 1000000), + IsWinner = _faker.Random.Bool() + }); + + var client = _testEnvironment.CreateLeaderboardsClient(); + var clock = _testEnvironment.Clock; + + await client.AddLeaderboard(game); + foreach (var player in players) + { + await client.AddOrUpdateScore(game, player.Key, player.Value.Score, player.Value.IsWinner, false); + clock.CurrentTime = clock.CurrentTime.AddMinutes(1); + } + + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var playerRank = await client.GetPlayerRank(game, randomPlayer.Key.ToUpper(), caseInsensitive: true, winnersOnly: randomPlayer.Value.IsWinner); + var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count); + playerRank.Rank.ShouldBeGreaterThan(0); playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); } + [Fact] + public async void AddScores_WhenPlayerHasWrongCase_ThenGetPlayerRankInTimeFrame_ShouldNotBeInLeaderboard() + { + var game = CreateGameName(); + + var players = Enumerable + .Range(0, _faker.Random.Int(10, 100)) + .Select(_ => new Faker()) + .Select(faker => $"{faker.Person.FullName} {faker.Person.DateOfBirth}") + .ToDictionary(x => x, + _ => new + { + Score = _faker.Random.Int(0, 1000000), + IsWinner = _faker.Random.Bool() + }); + + var client = _testEnvironment.CreateLeaderboardsClient(); + var clock = _testEnvironment.Clock; + + await client.AddLeaderboard(game); + foreach (var player in players) + { + await client.AddOrUpdateScore(game, player.Key, player.Value.Score, player.Value.IsWinner, false); + clock.CurrentTime = clock.CurrentTime.AddMinutes(1); + } + + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var playerRank = await client.GetPlayerRank(game, RandomizeLetters(randomPlayer.Key), winnersOnly: randomPlayer.Value.IsWinner, timeFrame: TimeFrame.Week); + + playerRank.Rank.ShouldBe(0); + } + [Fact] public async void AddScores_ThenGetPlayerRankInTimeFrame_ShouldBeSameAsInLeaderboard() { @@ -202,7 +305,41 @@ public async void AddScores_ThenGetPlayerRankInTimeFrame_ShouldBeSameAsInLeaderb } var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); - var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner, TimeFrame.Week); + var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, winnersOnly: randomPlayer.Value.IsWinner, timeFrame: TimeFrame.Week); + var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count, timeFrame: TimeFrame.Week); + + playerRank.Rank.ShouldBeGreaterThan(0); + playerRank.Rank.ShouldBe(wholeLeaderboard.Entries.Single(x => x.Name == randomPlayer.Key).Rank); + } + + [Fact] + public async void AddScores_ThenGetPlayerRankInTimeFrameCaseInsensitive_ShouldBeSameAsInLeaderboard() + { + var game = CreateGameName(); + + var players = Enumerable + .Range(0, _faker.Random.Int(10, 100)) + .Select(_ => new Faker()) + .Select(faker => $"{faker.Person.FullName} {faker.Person.DateOfBirth}") + .ToDictionary(x => x, + _ => new + { + Score = _faker.Random.Int(0, 1000000), + IsWinner = _faker.Random.Bool() + }); + + var client = _testEnvironment.CreateLeaderboardsClient(); + var clock = _testEnvironment.Clock; + + await client.AddLeaderboard(game); + foreach (var player in players) + { + await client.AddOrUpdateScore(game, player.Key, player.Value.Score, player.Value.IsWinner, false); + clock.CurrentTime = clock.CurrentTime.AddMinutes(1); + } + + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var playerRank = await client.GetPlayerRank(game, RandomizeLetters(randomPlayer.Key), caseInsensitive: true, winnersOnly: randomPlayer.Value.IsWinner, timeFrame: TimeFrame.Week); var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count, timeFrame: TimeFrame.Week); playerRank.Rank.ShouldBeGreaterThan(0); diff --git a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs index a2f7200..88205d1 100644 --- a/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs +++ b/src/AndreyGames.Leaderboards.Tests/TestEnvironment.cs @@ -122,16 +122,17 @@ public async Task AddLeaderboard(string game, CancellationToken token = default) _leaderboards.Add(game); } - public Task> GetPlayerScore(string game, string playerName, + public Task> GetPlayerScore(string game, string playerName, + bool caseInsensitive, CancellationToken token = default) { - return _leaderboardsClientImplementation.GetPlayerScore(game, playerName, token); + return _leaderboardsClientImplementation.GetPlayerScore(game, playerName, caseInsensitive, token); } - public Task GetPlayerRank(string game, string playerName, bool winnersOnly = false, TimeFrame? timeFrame = default, + public Task GetPlayerRank(string game, string playerName, bool caseInsensitive, bool winnersOnly = false, TimeFrame? timeFrame = default, CancellationToken token = default) { - return _leaderboardsClientImplementation.GetPlayerRank(game, playerName, winnersOnly, timeFrame, + return _leaderboardsClientImplementation.GetPlayerRank(game, playerName, caseInsensitive, winnersOnly, timeFrame, token); } From 38e3b488a59bdcc4ee17ca28f15145c27039e23b Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Thu, 29 Feb 2024 01:21:45 +0100 Subject: [PATCH 5/7] Get leaderboard request uses sql text instead of EFCore entities --- .../Implementation/BasicQueryBuilder.cs | 23 +++++++++- .../Implementation/LeaderboardService.cs | 44 +++++++++---------- 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs index c97f614..0d6279e 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/BasicQueryBuilder.cs @@ -16,8 +16,12 @@ internal sealed class BasicQueryBuilder private string _template; private string _envelope; + + private int? _limit, _offset; public static string WherePlaceholder => "%%WHERE%%"; + + public static string PagingPlaceholder => "%%PAGING%%"; private BasicQueryBuilder(DbContext dbContext) { @@ -43,6 +47,13 @@ public BasicQueryBuilder WithEnvelope(string formattedEnvelope) _envelope = formattedEnvelope; return this; } + + public BasicQueryBuilder WithPaging(int? limit = default, int? offset = default) + { + _limit = limit; + _offset = offset; + return this; + } public BasicQueryBuilder And(string field, object value) { @@ -93,7 +104,17 @@ private string BuildQuery() where = string.Format(_envelope, where); } - return _template.Replace(WherePlaceholder, where); + var resultQuery = _template.Replace(WherePlaceholder, where); + + if (_offset.HasValue || _limit.HasValue) + { + var paging = (_limit.HasValue ? $" LIMIT {_limit} " : "") + + (_offset.HasValue ? $" OFFSET {_offset} " : ""); + + resultQuery = resultQuery.Replace(PagingPlaceholder, paging); + } + + return resultQuery; } private DynamicParameters BuildParameters() diff --git a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs index e5fd9e0..b2c911d 100644 --- a/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs +++ b/src/AndreyGames.Leaderboards.Service/Implementation/LeaderboardService.cs @@ -64,40 +64,38 @@ public async Task GetLeaderboard(string game, DateTime? start = var offsetValue = Math.Max(0, offset ?? 0); var limitValue = Math.Max(1, limit ?? 20); - IEnumerable entries = leaderboard.Entries; + var wherePlaceholder = BasicQueryBuilder.WherePlaceholder; + var pagingPlaceholder = BasicQueryBuilder.PagingPlaceholder; - if (start.HasValue) - { - entries = entries.Where(x => x.Timestamp >= start.Value); - } + var queryTemplate = + $@"WITH entries as (SELECT ""Entries"".*, row_number() OVER (ORDER BY ""Score"" DESC, ""Timestamp"" DESC) as ""RowNumber"" + FROM ""Entries"" WHERE ""LeaderboardId"" = @LeaderboardId {wherePlaceholder}) + SELECT ""RowNumber"" as ""Rank"", ""Score"", ""IsWinner"", ""PlayerName"" as ""Name"" FROM entries {pagingPlaceholder}"; + + var queryBuilder = BasicQueryBuilder + .New(_context) + .WithQueryTemplate(queryTemplate) + .WithEnvelope("AND ({0})") + .WithArbitraryParameter("LeaderboardId", leaderboard.Id) + .WithPaging(limitValue, offsetValue); - if (end.HasValue) + if (onlyWinners) { - entries = entries.Where(x => x.Timestamp < end.Value); + queryBuilder = queryBuilder.And("IsWinner", true); } - if (onlyWinners) + if (start.HasValue && end.HasValue) { - entries = entries.Where(x => x.IsWinner); + queryBuilder = queryBuilder.AndBetween("Timestamp", start.Value, end.Value); } - entries = entries.OrderByDescending(x => x.Score) - .ThenByDescending(x => x.Timestamp) - .Skip(offsetValue) - .Take(limitValue); - - var counter = offsetValue; + var results = await queryBuilder.Execute(); + var entries = results.ToArray(); return new LeaderboardView { - Offset = offsetValue, - Entries = entries.Select(x => new LeaderboardEntry - { - Name = x.PlayerName, - Score = x.Score, - Rank = ++counter, - IsWinner = x.IsWinner, - }).ToArray(), + Offset = entries.LastOrDefault()?.Rank ?? 0, + Entries = entries, }; } From 982d9d7552e818abdbcde3f09f87eabe9e64d5a9 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Thu, 29 Feb 2024 23:11:04 +0100 Subject: [PATCH 6/7] Fixed API requests --- src/AndreyGames.Leaderboards.Tests/PlayerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs index 863b599..accc0c3 100644 --- a/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs +++ b/src/AndreyGames.Leaderboards.Tests/PlayerTests.cs @@ -205,7 +205,7 @@ public async void AddScores_ThenGetPlayerRank_ShouldBeSameAsInLeaderboard() } var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); - var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, randomPlayer.Value.IsWinner); + var playerRank = await client.GetPlayerRank(game, randomPlayer.Key, winnersOnly: randomPlayer.Value.IsWinner); var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count); playerRank.Rank.ShouldBeGreaterThan(0); @@ -338,7 +338,7 @@ public async void AddScores_ThenGetPlayerRankInTimeFrameCaseInsensitive_ShouldBe clock.CurrentTime = clock.CurrentTime.AddMinutes(1); } - var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count)); + var randomPlayer = players.ElementAt(_faker.Random.Int(0, players.Count - 1)); var playerRank = await client.GetPlayerRank(game, RandomizeLetters(randomPlayer.Key), caseInsensitive: true, winnersOnly: randomPlayer.Value.IsWinner, timeFrame: TimeFrame.Week); var wholeLeaderboard = await client.GetLeaderboard(game, winnersOnly: randomPlayer.Value.IsWinner, limit: players.Count, timeFrame: TimeFrame.Week); From db85fc6be45f49cbfeb66ab3a421774740791ef3 Mon Sep 17 00:00:00 2001 From: Sharkadi Andrey Date: Fri, 1 Mar 2024 00:54:46 +0100 Subject: [PATCH 7/7] Migration for insensitive player name search --- ...225828_AddCaseInsensitiveIndex.Designer.cs | 94 +++++++++++++++++++ .../20240229225828_AddCaseInsensitiveIndex.cs | 19 ++++ 2 files changed, 113 insertions(+) create mode 100644 src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.Designer.cs create mode 100644 src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.cs diff --git a/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.Designer.cs b/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.Designer.cs new file mode 100644 index 0000000..d2eb4b6 --- /dev/null +++ b/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.Designer.cs @@ -0,0 +1,94 @@ +// +using System; +using AndreyGames.Leaderboards.Service; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace AndreyGames.Leaderboards.Service.Migrations +{ + [DbContext(typeof(LeaderboardContext))] + [Migration("20240229225828_AddCaseInsensitiveIndex")] + partial class AddCaseInsensitiveIndex + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Relational:MaxIdentifierLength", 63) + .HasAnnotation("ProductVersion", "5.0.17") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + modelBuilder.Entity("AndreyGames.Leaderboards.Service.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("IsWinner") + .HasColumnType("boolean"); + + b.Property("LeaderboardId") + .HasColumnType("integer"); + + b.Property("PlayerName") + .HasColumnType("text"); + + b.Property("Score") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp without time zone"); + + b.HasKey("Id"); + + b.HasIndex("LeaderboardId", "IsWinner", "PlayerName") + .IsUnique(); + + b.HasIndex("LeaderboardId", "IsWinner", "Timestamp", "PlayerName") + .IsUnique(); + + b.ToTable("Entries"); + }); + + modelBuilder.Entity("AndreyGames.Leaderboards.Service.Models.Leaderboard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Game") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Game", "IsActive") + .IsUnique(); + + b.ToTable("Leaderboards"); + }); + + modelBuilder.Entity("AndreyGames.Leaderboards.Service.Models.Entry", b => + { + b.HasOne("AndreyGames.Leaderboards.Service.Models.Leaderboard", "Leaderboard") + .WithMany("Entries") + .HasForeignKey("LeaderboardId"); + + b.Navigation("Leaderboard"); + }); + + modelBuilder.Entity("AndreyGames.Leaderboards.Service.Models.Leaderboard", b => + { + b.Navigation("Entries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.cs b/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.cs new file mode 100644 index 0000000..838dfeb --- /dev/null +++ b/src/AndreyGames.Leaderboards.Service/Migrations/20240229225828_AddCaseInsensitiveIndex.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace AndreyGames.Leaderboards.Service.Migrations +{ + public partial class AddCaseInsensitiveIndex : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("CREATE INDEX IX_Entries_LeaderboardId_LowerPlayerName ON \"Entries\" (\"LeaderboardId\", LOWER(\"PlayerName\"));"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Entries_LeaderboardId_LowerPlayerName", + table: "Entries"); + } + } +}