diff --git a/CleanArchitecture.Api/Controllers/TenantController.cs b/CleanArchitecture.Api/Controllers/TenantController.cs index 0f8a78a..a5bf588 100644 --- a/CleanArchitecture.Api/Controllers/TenantController.cs +++ b/CleanArchitecture.Api/Controllers/TenantController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CleanArchitecture.Api.Models; using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; using CleanArchitecture.Domain.Notifications; using MediatR; @@ -31,11 +32,15 @@ public sealed class TenantController : ApiController [SwaggerResponse(200, "Request successful", typeof(ResponseMessage>))] public async Task GetAllTenantsAsync( [FromQuery] PageQuery query, - [FromQuery] string searchTerm = "") + [FromQuery] string searchTerm = "", + [FromQuery] bool includeDeleted = false, + [FromQuery] SortQuery? sortQuery = null) { var tenants = await _tenantService.GetAllTenantsAsync( query, - searchTerm); + includeDeleted, + searchTerm, + sortQuery); return Response(tenants); } diff --git a/CleanArchitecture.Api/Controllers/UserController.cs b/CleanArchitecture.Api/Controllers/UserController.cs index dc66b31..807a9b2 100644 --- a/CleanArchitecture.Api/Controllers/UserController.cs +++ b/CleanArchitecture.Api/Controllers/UserController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using CleanArchitecture.Api.Models; using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Domain.Notifications; using MediatR; @@ -31,11 +32,15 @@ public sealed class UserController : ApiController [SwaggerResponse(200, "Request successful", typeof(ResponseMessage>))] public async Task GetAllUsersAsync( [FromQuery] PageQuery query, - [FromQuery] string searchTerm = "") + [FromQuery] string searchTerm = "", + [FromQuery] bool includeDeleted = false, + [FromQuery] SortQuery? sortQuery = null) { var users = await _userService.GetAllUsersAsync( query, - searchTerm); + includeDeleted, + searchTerm, + sortQuery); return Response(users); } diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index 5448b13..14fc644 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -54,6 +54,7 @@ builder.Services.AddAuth(builder.Configuration); builder.Services.AddInfrastructure(builder.Configuration, "CleanArchitecture.Infrastructure"); builder.Services.AddQueryHandlers(); builder.Services.AddServices(); +builder.Services.AddSortProviders(); builder.Services.AddCommandHandlers(); builder.Services.AddNotificationHandlers(); builder.Services.AddApiUser(); diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs index b13ac69..a61f197 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using CleanArchitecture.Application.Queries.Tenants.GetAll; +using CleanArchitecture.Application.SortProviders; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces.Repositories; using MockQueryable.NSubstitute; @@ -16,8 +17,9 @@ public sealed class GetAllTenantsTestFixture : QueryHandlerBaseFixture public GetAllTenantsTestFixture() { TenantRepository = Substitute.For(); + var sortingProvider = new TenantViewModelSortProvider(); - QueryHandler = new GetAllTenantsQueryHandler(TenantRepository); + QueryHandler = new GetAllTenantsQueryHandler(TenantRepository, sortingProvider); } public Tenant SetupTenant(bool deleted = false) diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs index 9d745f6..91518e2 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs @@ -1,5 +1,6 @@ using System; using CleanArchitecture.Application.Queries.Users.GetAll; +using CleanArchitecture.Application.SortProviders; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Interfaces.Repositories; @@ -17,8 +18,9 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture public GetAllUsersTestFixture() { UserRepository = Substitute.For(); + var sortingProvider = new UserViewModelSortProvider(); - Handler = new GetAllUsersQueryHandler(UserRepository); + Handler = new GetAllUsersQueryHandler(UserRepository, sortingProvider); } public User SetupUserAsync() diff --git a/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs index 4747660..69807b5 100644 --- a/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs +++ b/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs @@ -24,7 +24,7 @@ public sealed class GetAllTenantsQueryHandlerTests }; var result = await _fixture.QueryHandler.Handle( - new GetAllTenantsQuery(query), + new GetAllTenantsQuery(query, false), default); _fixture.VerifyNoDomainNotification(); @@ -48,7 +48,7 @@ public sealed class GetAllTenantsQueryHandlerTests }; var result = await _fixture.QueryHandler.Handle( - new GetAllTenantsQuery(query), + new GetAllTenantsQuery(query, false), default); result.PageSize.Should().Be(query.PageSize); diff --git a/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs index 2a03ced..7e00001 100644 --- a/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs +++ b/CleanArchitecture.Application.Tests/Queries/Users/GetAllUsersQueryHandlerTests.cs @@ -24,7 +24,7 @@ public sealed class GetAllUsersQueryHandlerTests }; var result = await _fixture.Handler.Handle( - new GetAllUsersQuery(query, user.Email), + new GetAllUsersQuery(query, false, user.Email), default); _fixture.VerifyNoDomainNotification(); @@ -51,7 +51,7 @@ public sealed class GetAllUsersQueryHandlerTests }; var result = await _fixture.Handler.Handle( - new GetAllUsersQuery(query), + new GetAllUsersQuery(query, false), default); _fixture.VerifyNoDomainNotification(); diff --git a/CleanArchitecture.Application/Extensions/QueryableExtensions.cs b/CleanArchitecture.Application/Extensions/QueryableExtensions.cs new file mode 100644 index 0000000..55b9d53 --- /dev/null +++ b/CleanArchitecture.Application/Extensions/QueryableExtensions.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using CleanArchitecture.Application.ViewModels.Sorting; + +namespace CleanArchitecture.Application.Extensions; + +public static class QueryableExtensions +{ + public static IQueryable GetOrderedQueryable( + this IQueryable query, + SortQuery? sort, + ISortingExpressionProvider expressionProvider) + { + return GetOrderedQueryable(query, sort, expressionProvider.GetSortingExpressions()); + } + + public static IQueryable GetOrderedQueryable( + this IQueryable query, + SortQuery? sort, + Dictionary>> fieldExpressions) + { + if (sort is null || !sort.Parameters.Any()) + { + return query; + } + + var sorted = GetFirstOrderLevelQuery(query, sort.Parameters.First(), fieldExpressions); + + for (int i = 1; i < sort.Parameters.Count; i++) + { + sorted = GetMultiLevelOrderedQuery(sorted, sort.Parameters[i], fieldExpressions); + } + + return sorted; + } + + private static IOrderedQueryable GetFirstOrderLevelQuery( + IQueryable query, + SortParameter @param, + Dictionary>> fieldExpressions) + { + if (!fieldExpressions.TryGetValue(param.ParameterName, out var fieldExpression)) + { + throw new Exception($"{param.ParameterName} is not a sortable field"); + } + + return param.Order switch + { + SortOrder.Ascending => query.OrderBy(fieldExpression), + SortOrder.Descending => query.OrderByDescending(fieldExpression), + _ => throw new InvalidOperationException($"{param.Order} is not a supported value") + }; + } + + private static IOrderedQueryable GetMultiLevelOrderedQuery( + IOrderedQueryable query, + SortParameter @param, + Dictionary>> fieldExpressions) + { + if (!fieldExpressions.TryGetValue(param.ParameterName, out var fieldExpression)) + { + throw new Exception($"{param.ParameterName} is not a sortable field"); + } + + return param.Order switch + { + SortOrder.Ascending => query.ThenBy(fieldExpression), + SortOrder.Descending => query.ThenByDescending(fieldExpression), + _ => throw new InvalidOperationException($"{param.Order} is not a supported value") + }; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Application/Extensions/ServiceCollectionExtensions.cs similarity index 73% rename from CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs rename to CleanArchitecture.Application/Extensions/ServiceCollectionExtensions.cs index c8e282c..a8a9fda 100644 --- a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Application/Extensions/ServiceCollectionExtensions.cs @@ -4,15 +4,18 @@ using CleanArchitecture.Application.Queries.Tenants.GetTenantById; using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Application.Services; +using CleanArchitecture.Application.SortProviders; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Entities; using MediatR; using Microsoft.Extensions.DependencyInjection; namespace CleanArchitecture.Application.Extensions; -public static class ServiceCollectionExtension +public static class ServiceCollectionExtensions { public static IServiceCollection AddServices(this IServiceCollection services) { @@ -35,4 +38,12 @@ public static class ServiceCollectionExtension return services; } + + public static IServiceCollection AddSortProviders(this IServiceCollection services) + { + services.AddScoped, TenantViewModelSortProvider>(); + services.AddScoped, UserViewModelSortProvider>(); + + return services; + } } \ No newline at end of file diff --git a/CleanArchitecture.Application/Interfaces/ITenantService.cs b/CleanArchitecture.Application/Interfaces/ITenantService.cs index ff6756b..109e114 100644 --- a/CleanArchitecture.Application/Interfaces/ITenantService.cs +++ b/CleanArchitecture.Application/Interfaces/ITenantService.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; +using CleanArchitecture.Application.SortProviders; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; namespace CleanArchitecture.Application.Interfaces; @@ -11,5 +13,10 @@ public interface ITenantService public Task UpdateTenantAsync(UpdateTenantViewModel tenant); public Task DeleteTenantAsync(Guid tenantId); public Task GetTenantByIdAsync(Guid tenantId); - public Task> GetAllTenantsAsync(PageQuery query, string searchTerm = ""); + + public Task> GetAllTenantsAsync( + PageQuery query, + bool includeDeleted, + string searchTerm = "", + SortQuery? sortQuery = null); } \ No newline at end of file diff --git a/CleanArchitecture.Application/Interfaces/IUserService.cs b/CleanArchitecture.Application/Interfaces/IUserService.cs index 1a77271..7544715 100644 --- a/CleanArchitecture.Application/Interfaces/IUserService.cs +++ b/CleanArchitecture.Application/Interfaces/IUserService.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Users; namespace CleanArchitecture.Application.Interfaces; @@ -9,7 +10,11 @@ public interface IUserService { public Task GetUserByUserIdAsync(Guid userId); public Task GetCurrentUserAsync(); - public Task> GetAllUsersAsync(PageQuery query, string searchTerm = ""); + public Task> GetAllUsersAsync( + PageQuery query, + bool includeDeleted, + string searchTerm = "", + SortQuery? sortQuery = null); public Task CreateUserAsync(CreateUserViewModel user); public Task UpdateUserAsync(UpdateUserViewModel user); public Task DeleteUserAsync(Guid userId); diff --git a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs index 6144730..92dc4d3 100644 --- a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs +++ b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs @@ -1,8 +1,13 @@ using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; using MediatR; namespace CleanArchitecture.Application.Queries.Tenants.GetAll; -public sealed record GetAllTenantsQuery(PageQuery Query, string SearchTerm = "") : +public sealed record GetAllTenantsQuery( + PageQuery Query, + bool IncludeDeleted, + string SearchTerm = "", + SortQuery? SortQuery = null) : IRequest>; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs index 5eb5be0..6e2b558 100644 --- a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs @@ -1,8 +1,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Application.Extensions; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; +using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces.Repositories; using MediatR; using Microsoft.EntityFrameworkCore; @@ -13,10 +16,14 @@ public sealed class GetAllTenantsQueryHandler : IRequestHandler> { private readonly ITenantRepository _tenantRepository; + private readonly ISortingExpressionProvider _sortingExpressionProvider; - public GetAllTenantsQueryHandler(ITenantRepository tenantRepository) + public GetAllTenantsQueryHandler( + ITenantRepository tenantRepository, + ISortingExpressionProvider sortingExpressionProvider) { _tenantRepository = tenantRepository; + _sortingExpressionProvider = sortingExpressionProvider; } public async Task> Handle( @@ -26,7 +33,7 @@ public sealed class GetAllTenantsQueryHandler : var tenantsQuery = _tenantRepository .GetAllNoTracking() .Include(x => x.Users) - .Where(x => !x.Deleted); + .Where(x => request.IncludeDeleted || !x.Deleted); if (!string.IsNullOrWhiteSpace(request.SearchTerm)) { @@ -36,6 +43,8 @@ public sealed class GetAllTenantsQueryHandler : var totalCount = await tenantsQuery.CountAsync(cancellationToken); + tenantsQuery = tenantsQuery.GetOrderedQueryable(request.SortQuery, _sortingExpressionProvider); + var tenants = await tenantsQuery .Skip((request.Query.Page - 1) * request.Query.PageSize) .Take(request.Query.PageSize) diff --git a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs index 5e0361e..fc86e46 100644 --- a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs +++ b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQuery.cs @@ -1,8 +1,13 @@ using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Users; using MediatR; namespace CleanArchitecture.Application.Queries.Users.GetAll; -public sealed record GetAllUsersQuery(PageQuery Query, string SearchTerm = "") : +public sealed record GetAllUsersQuery( + PageQuery Query, + bool IncludeDeleted, + string SearchTerm = "", + SortQuery? SortQuery = null) : IRequest>; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQueryHandler.cs b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQueryHandler.cs index 67d4d5c..4271ae3 100644 --- a/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Users/GetAll/GetAllUsersQueryHandler.cs @@ -1,8 +1,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Application.Extensions; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Interfaces.Repositories; using MediatR; using Microsoft.EntityFrameworkCore; @@ -13,10 +16,14 @@ public sealed class GetAllUsersQueryHandler : IRequestHandler> { private readonly IUserRepository _userRepository; + private readonly ISortingExpressionProvider _sortingExpressionProvider; - public GetAllUsersQueryHandler(IUserRepository userRepository) + public GetAllUsersQueryHandler( + IUserRepository userRepository, + ISortingExpressionProvider sortingExpressionProvider) { _userRepository = userRepository; + _sortingExpressionProvider = sortingExpressionProvider; } public async Task> Handle( @@ -25,7 +32,7 @@ public sealed class GetAllUsersQueryHandler : { var usersQuery = _userRepository .GetAllNoTracking() - .Where(x => !x.Deleted); + .Where(x => request.IncludeDeleted || !x.Deleted); if (!string.IsNullOrWhiteSpace(request.SearchTerm)) { @@ -37,6 +44,8 @@ public sealed class GetAllUsersQueryHandler : var totalCount = await usersQuery.CountAsync(cancellationToken); + usersQuery = usersQuery.GetOrderedQueryable(request.SortQuery, _sortingExpressionProvider); + var users = await usersQuery .Skip((request.Query.Page - 1) * request.Query.PageSize) .Take(request.Query.PageSize) diff --git a/CleanArchitecture.Application/Services/TenantService.cs b/CleanArchitecture.Application/Services/TenantService.cs index 7a99f6a..ca3ae0a 100644 --- a/CleanArchitecture.Application/Services/TenantService.cs +++ b/CleanArchitecture.Application/Services/TenantService.cs @@ -3,7 +3,9 @@ using System.Threading.Tasks; using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.Queries.Tenants.GetAll; using CleanArchitecture.Application.Queries.Tenants.GetTenantById; +using CleanArchitecture.Application.SortProviders; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Tenants; using CleanArchitecture.Domain; using CleanArchitecture.Domain.Commands.Tenants.CreateTenant; @@ -64,8 +66,12 @@ public sealed class TenantService : ITenantService return cachedTenant; } - public async Task> GetAllTenantsAsync(PageQuery query, string searchTerm = "") + public async Task> GetAllTenantsAsync( + PageQuery query, + bool includeDeleted, + string searchTerm = "", + SortQuery? sortQuery = null) { - return await _bus.QueryAsync(new GetAllTenantsQuery(query, searchTerm)); + return await _bus.QueryAsync(new GetAllTenantsQuery(query, includeDeleted, searchTerm, sortQuery)); } } \ No newline at end of file diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs index 847dcdd..e80f625 100644 --- a/CleanArchitecture.Application/Services/UserService.cs +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -4,6 +4,7 @@ using CleanArchitecture.Application.Interfaces; using CleanArchitecture.Application.Queries.Users.GetAll; using CleanArchitecture.Application.Queries.Users.GetUserById; using CleanArchitecture.Application.ViewModels; +using CleanArchitecture.Application.ViewModels.Sorting; using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Domain.Commands.Users.ChangePassword; using CleanArchitecture.Domain.Commands.Users.CreateUser; @@ -35,9 +36,13 @@ public sealed class UserService : IUserService return await _bus.QueryAsync(new GetUserByIdQuery(_user.GetUserId())); } - public async Task> GetAllUsersAsync(PageQuery query, string searchTerm = "") + public async Task> GetAllUsersAsync( + PageQuery query, + bool includeDeleted, + string searchTerm = "", + SortQuery? sortQuery = null) { - return await _bus.QueryAsync(new GetAllUsersQuery(query, searchTerm)); + return await _bus.QueryAsync(new GetAllUsersQuery(query, includeDeleted, searchTerm, sortQuery)); } public async Task CreateUserAsync(CreateUserViewModel user) diff --git a/CleanArchitecture.Application/SortProviders/TenantViewModelSortProvider.cs b/CleanArchitecture.Application/SortProviders/TenantViewModelSortProvider.cs new file mode 100644 index 0000000..e3afb0d --- /dev/null +++ b/CleanArchitecture.Application/SortProviders/TenantViewModelSortProvider.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using CleanArchitecture.Application.ViewModels.Sorting; +using CleanArchitecture.Application.ViewModels.Tenants; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.SortProviders; + +public sealed class TenantViewModelSortProvider : ISortingExpressionProvider +{ + private static readonly Dictionary>> s_expressions = new() + { + { "id", tenant => tenant.Id }, + { "name", tenant => tenant.Name }, + }; + + public Dictionary>> GetSortingExpressions() + { + return s_expressions; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/SortProviders/UserViewModelSortProvider.cs b/CleanArchitecture.Application/SortProviders/UserViewModelSortProvider.cs new file mode 100644 index 0000000..d369a67 --- /dev/null +++ b/CleanArchitecture.Application/SortProviders/UserViewModelSortProvider.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using CleanArchitecture.Application.ViewModels.Sorting; +using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Application.SortProviders; + +public sealed class UserViewModelSortProvider : ISortingExpressionProvider +{ + private static readonly Dictionary>> s_expressions = new() + { + { "email", user => user.Email }, + { "firstName", user => user.FirstName }, + { "lastName", user => user.LastName }, + { "tenantId", user => user.TenantId }, + { "lastloggedindate", user => user.LastLoggedinDate ?? DateTimeOffset.MinValue }, + { "role", user => user.Role }, + { "status", user => user.Status } + }; + + public Dictionary>> GetSortingExpressions() + { + return s_expressions; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Sorting/ISortingExpressionProvider.cs b/CleanArchitecture.Application/ViewModels/Sorting/ISortingExpressionProvider.cs new file mode 100644 index 0000000..489ce8f --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Sorting/ISortingExpressionProvider.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; + +namespace CleanArchitecture.Application.ViewModels.Sorting; + +public interface ISortingExpressionProvider +{ + Dictionary>> GetSortingExpressions(); +} \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Sorting/SortOrder.cs b/CleanArchitecture.Application/ViewModels/Sorting/SortOrder.cs new file mode 100644 index 0000000..16eff6d --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Sorting/SortOrder.cs @@ -0,0 +1,7 @@ +namespace CleanArchitecture.Application.ViewModels.Sorting; + +public enum SortOrder +{ + Ascending = 0, + Descending = 1 +} \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Sorting/SortParameter.cs b/CleanArchitecture.Application/ViewModels/Sorting/SortParameter.cs new file mode 100644 index 0000000..87006b1 --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Sorting/SortParameter.cs @@ -0,0 +1,13 @@ +namespace CleanArchitecture.Application.ViewModels.Sorting; + +public readonly struct SortParameter +{ + public SortOrder Order { get; } + public string ParameterName { get; } + + public SortParameter(string parameterName, SortOrder order) + { + Order = order; + ParameterName = parameterName; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Sorting/SortQuery.cs b/CleanArchitecture.Application/ViewModels/Sorting/SortQuery.cs new file mode 100644 index 0000000..550925e --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Sorting/SortQuery.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.ObjectModel; +using Microsoft.AspNetCore.Mvc; + +namespace CleanArchitecture.Application.ViewModels.Sorting; + +public sealed class SortQuery +{ + private readonly struct QueryInfo + { + public readonly short PlusSignIndex; + public readonly short MinusSignIndex; + public readonly short FirstSpaceIndex; + public readonly short OpeningBracketIndex; + public readonly short ClosingBracketIndex; + + public QueryInfo( + short plusSignIndex, + short minusSignIndex, + short firstSpaceIndex, + short openingBracketIndex, + short closingBracketIndex) + { + PlusSignIndex = plusSignIndex; + MinusSignIndex = minusSignIndex; + FirstSpaceIndex = firstSpaceIndex; + OpeningBracketIndex = openingBracketIndex; + ClosingBracketIndex = closingBracketIndex; + } + } + + private string? _query = string.Empty; + + private ReadOnlyCollection _parameters = new(Array.Empty()); + + public ReadOnlyCollection Parameters => _parameters; + + [FromQuery(Name = "order_by")] + public string? Query + { + get => _query; + set + { + _query = value; + _parameters = ParseQuery(_query); + } + } + + public static ReadOnlyCollection ParseQuery(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new ReadOnlyCollection(Array.Empty()); + } + + if (value.Length > short.MaxValue) + { + throw new ArgumentException($"sort query can not be longer than {short.MaxValue} characters"); + } + + value = value.ToLower(); + + if (!value.Contains(',')) + { + return new ReadOnlyCollection(new[] { GetParam(value) }); + } + + var @params = value.Split(','); + var parsedParams = new SortParameter[@params.Length]; + + for (int i = 0; i < @params.Length; i++) + { + parsedParams[i] = GetParam(@params[i]); + } + + return new ReadOnlyCollection(parsedParams); + } + + private static SortParameter GetParam(string value) + { + value = value.Trim(); + + var queryInfo = FindTokens(value); + + if (queryInfo.OpeningBracketIndex > 0) + { + // asc(name), desc(name), ascending(name), descending(name) + return GetSortParamFromFunctionalStyle(value, queryInfo); + } + + // i.e. "name asc", "name descending" or similar + if (queryInfo.FirstSpaceIndex >= 0) + { + return GetSortParamFromSentence(value, queryInfo); + } + + // name, +name, -name, name+, name- + return GetSortParamFromSingleWord(value, queryInfo); + } + + private static SortParameter GetSortParamFromSentence(string value, QueryInfo info) + { + var secondWordStartIndex = FindNextNonWhitespaceCharacter(value, info.FirstSpaceIndex + 1); + + if (secondWordStartIndex < 0) + { + throw new ArgumentException("Expected query string in form of \"{param} asc/desc\""); + } + + var paramName = value[..info.FirstSpaceIndex]; + var orderName = value[secondWordStartIndex..]; + + if (orderName == "asc" || orderName == "ascending") + { + return new SortParameter(paramName, SortOrder.Ascending); + } + else if (orderName == "desc" || orderName == "descending") + { + return new SortParameter(paramName, SortOrder.Descending); + } + + throw new ArgumentException( + $"Unsupported sort order {orderName}. Valid are 'asc', 'ascending', 'desc' or 'descending'"); + } + + private static SortParameter GetSortParamFromSingleWord(string value, QueryInfo info) + { + if (info.PlusSignIndex < 0 && info.MinusSignIndex < 0) + { + return new SortParameter(value, SortOrder.Ascending); + } + + var order = info.PlusSignIndex >= 0 ? SortOrder.Ascending : SortOrder.Descending; + var indicatorIndex = Math.Max(info.MinusSignIndex, info.PlusSignIndex); + + if (indicatorIndex == 0) + { + return new SortParameter(value[1..], order); + } + else + { + return new SortParameter(value[..indicatorIndex], order); + } + } + + private static SortParameter GetSortParamFromFunctionalStyle(string value, QueryInfo info) + { + var param = value + .Substring(info.OpeningBracketIndex + 1, info.ClosingBracketIndex - info.OpeningBracketIndex - 1) + .Trim(); + + if (string.IsNullOrWhiteSpace(param)) + { + throw new FormatException("Parameter name could not be extracted"); + } + + if (value.StartsWith("asc(") || value.StartsWith("ascending(")) + { + return new SortParameter(param, SortOrder.Ascending); + } + else if (value.StartsWith("desc(") || value.StartsWith("descending(")) + { + return new SortParameter(param, SortOrder.Descending); + } + + throw new FormatException("Unparsable sort query"); + } + + private static QueryInfo FindTokens(string query) + { + short plusSignIndex = -1; + short minusSignIndex = -1; + short firstSpaceIndex = -1; + short openingBracketIndex = -1; + short closingBracketIndex = -1; + + for (short i = 0; i < query.Length; i++) + { + switch (query[i]) + { + case '(': + if (openingBracketIndex >= 0) + { + throw new FormatException("Only one bracket is allowed in functional style queries"); + } + + if (plusSignIndex >= 0 || minusSignIndex >= 0) + { + throw new FormatException( + "Order indicator (\"+\", \"-\") can not be used together with functional style (i.e.\"(name)\")"); + } + + if (firstSpaceIndex >= 0) + { + throw new FormatException($"Unexpected whitespace at position {firstSpaceIndex + 1}"); + } + + openingBracketIndex = i; + break; + + case ')': + if (closingBracketIndex >= 0) + { + throw new FormatException("Only one closing bracket is allowed in functional style queries"); + } + + if (openingBracketIndex < 0) + { + throw new FormatException("Closing brackets can only be places after opening brackets"); + } + + closingBracketIndex = i; + break; + + case '+': + if (plusSignIndex >= 0) + { + throw new FormatException("Only one positive order indicator \"+\" is allowed per query"); + } + + if (minusSignIndex >= 0) + { + throw new FormatException("Only one order indicator (\"+\", \"-\") is allowed per query"); + } + + if (firstSpaceIndex >= 0) + { + throw new FormatException($"Unexpected whitespace at position {firstSpaceIndex + 1}"); + } + + plusSignIndex = i; + break; + + case '-': + if (minusSignIndex >= 0) + { + throw new FormatException("Only one negative order indicator \"-\" is allowed per query"); + } + + if (plusSignIndex >= 0) + { + throw new FormatException("Only one order indicator (\"+\", \"-\") is allowed per query"); + } + + if (firstSpaceIndex >= 0) + { + throw new FormatException($"Unexpected whitespace at position {firstSpaceIndex + 1}"); + } + + minusSignIndex = i; + break; + + case ' ': + if (firstSpaceIndex == -1) + { + firstSpaceIndex = i; + } + + if (minusSignIndex >= 0 || plusSignIndex >= 0) + { + throw new FormatException($"Unexpected whitespace at position {i + 1}"); + } + + break; + + default: + // Check for stuff after query end like "asc(name)blabla + // "+" and "-" can be either at the start or at the end, we are only interested + // in the case where it's at the end. + if (plusSignIndex > 0 || minusSignIndex > 0 || closingBracketIndex >= 0) + { + var endOfQuery = Math.Max(Math.Max(plusSignIndex, minusSignIndex), closingBracketIndex); + + throw new FormatException($"End of query expected at {endOfQuery}"); + } + + break; + } + } + + return new QueryInfo( + plusSignIndex, + minusSignIndex, + firstSpaceIndex, + openingBracketIndex, + closingBracketIndex); + } + + private static int FindNextNonWhitespaceCharacter(string value, int startIndex) + { + for (int i = startIndex; i < value.Length; i++) + { + if (!char.IsWhiteSpace(value[i])) + { + return i; + } + } + + return -1; + } +} \ No newline at end of file