diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj
index 2a4b57b..345bdf5 100644
--- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj
+++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj
@@ -7,29 +7,29 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
diff --git a/CleanArchitecture.Api/Controllers/TenantController.cs b/CleanArchitecture.Api/Controllers/TenantController.cs
index 0f8a78a..4a312eb 100644
--- a/CleanArchitecture.Api/Controllers/TenantController.cs
+++ b/CleanArchitecture.Api/Controllers/TenantController.cs
@@ -1,9 +1,13 @@
using System;
using System.Threading.Tasks;
using CleanArchitecture.Api.Models;
+using CleanArchitecture.Api.Swagger;
using CleanArchitecture.Application.Interfaces;
+using CleanArchitecture.Application.SortProviders;
using CleanArchitecture.Application.ViewModels;
+using CleanArchitecture.Application.ViewModels.Sorting;
using CleanArchitecture.Application.ViewModels.Tenants;
+using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Notifications;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -31,11 +35,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, SortableFieldsAttribute] 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..6e12fea 100644
--- a/CleanArchitecture.Api/Controllers/UserController.cs
+++ b/CleanArchitecture.Api/Controllers/UserController.cs
@@ -1,9 +1,13 @@
using System;
using System.Threading.Tasks;
using CleanArchitecture.Api.Models;
+using CleanArchitecture.Api.Swagger;
using CleanArchitecture.Application.Interfaces;
+using CleanArchitecture.Application.SortProviders;
using CleanArchitecture.Application.ViewModels;
+using CleanArchitecture.Application.ViewModels.Sorting;
using CleanArchitecture.Application.ViewModels.Users;
+using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Notifications;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -31,11 +35,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, SortableFieldsAttribute] SortQuery? sortQuery = null)
{
var users = await _userService.GetAllUsersAsync(
query,
- searchTerm);
+ includeDeleted,
+ searchTerm,
+ sortQuery);
return Response(users);
}
diff --git a/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs
index 7224bcc..793d22f 100644
--- a/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs
+++ b/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Text;
+using CleanArchitecture.Api.Swagger;
using CleanArchitecture.Domain.Settings;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
@@ -34,6 +35,10 @@ public static class ServiceCollectionExtension
Scheme = "bearer"
});
+ c.ParameterFilter();
+
+ c.SupportNonNullableReferenceTypes();
+
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs
index 5448b13..8bb5488 100644
--- a/CleanArchitecture.Api/Program.cs
+++ b/CleanArchitecture.Api/Program.cs
@@ -38,7 +38,7 @@ if (builder.Environment.IsProduction())
.AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!)
.AddRedis(builder.Configuration["RedisHostName"]!, "Redis")
.AddRabbitMQ(
- rabbitConnectionString: $"amqp://{rabbitUser}:{rabbitPass}@{rabbitHost}",
+ $"amqp://{rabbitUser}:{rabbitPass}@{rabbitHost}",
name: "RabbitMQ");
}
@@ -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.Api/Swagger/SortableFieldsAttribute.cs b/CleanArchitecture.Api/Swagger/SortableFieldsAttribute.cs
new file mode 100644
index 0000000..83cca2d
--- /dev/null
+++ b/CleanArchitecture.Api/Swagger/SortableFieldsAttribute.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using CleanArchitecture.Application.ViewModels.Sorting;
+
+namespace CleanArchitecture.Api.Swagger;
+
+[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
+public sealed class SortableFieldsAttribute
+ : SwaggerSortableFieldsAttribute
+ where TSortingProvider : ISortingExpressionProvider, new()
+{
+ public override IEnumerable GetFields()
+ {
+ return new TSortingProvider().GetSortingExpressions().Keys;
+ }
+}
diff --git a/CleanArchitecture.Api/Swagger/SortableFieldsAttributeFilter.cs b/CleanArchitecture.Api/Swagger/SortableFieldsAttributeFilter.cs
new file mode 100644
index 0000000..d628229
--- /dev/null
+++ b/CleanArchitecture.Api/Swagger/SortableFieldsAttributeFilter.cs
@@ -0,0 +1,32 @@
+using System.Linq;
+using System.Reflection;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace CleanArchitecture.Api.Swagger;
+
+public sealed class SortableFieldsAttributeFilter : IParameterFilter
+{
+ public void Apply(OpenApiParameter parameter, ParameterFilterContext context)
+ {
+ if (context.ParameterInfo is null)
+ {
+ return;
+ }
+
+ var attribute = context.ParameterInfo
+ .GetCustomAttributes()
+ .SingleOrDefault();
+
+ if (attribute is null)
+ {
+ return;
+ }
+
+ var description = string.Join("
", attribute.GetFields().Order());
+
+ parameter.Description = $"{parameter.Description}
" +
+ $"**Allowed values:**
{description}";
+ }
+}
+
diff --git a/CleanArchitecture.Api/Swagger/SwaggerSortableFieldsAttribute.cs b/CleanArchitecture.Api/Swagger/SwaggerSortableFieldsAttribute.cs
new file mode 100644
index 0000000..1dff29d
--- /dev/null
+++ b/CleanArchitecture.Api/Swagger/SwaggerSortableFieldsAttribute.cs
@@ -0,0 +1,9 @@
+using System;
+using System.Collections.Generic;
+
+namespace CleanArchitecture.Api.Swagger;
+
+public abstract class SwaggerSortableFieldsAttribute : Attribute
+{
+ public abstract IEnumerable GetFields();
+}
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..35e5a55
--- /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 (var 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..c7d053c 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..47d7e9d 100644
--- a/CleanArchitecture.Application/Interfaces/ITenantService.cs
+++ b/CleanArchitecture.Application/Interfaces/ITenantService.cs
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels;
+using CleanArchitecture.Application.ViewModels.Sorting;
using CleanArchitecture.Application.ViewModels.Tenants;
namespace CleanArchitecture.Application.Interfaces;
@@ -11,5 +12,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..3d1ecaa 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,13 @@ 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..a097c13 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;
@@ -12,11 +15,15 @@ namespace CleanArchitecture.Application.Queries.Tenants.GetAll;
public sealed class GetAllTenantsQueryHandler :
IRequestHandler>
{
+ private readonly ISortingExpressionProvider _sortingExpressionProvider;
private readonly ITenantRepository _tenantRepository;
- 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..18f2df3 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;
@@ -12,11 +15,15 @@ namespace CleanArchitecture.Application.Queries.Users.GetAll;
public sealed class GetAllUsersQueryHandler :
IRequestHandler>
{
+ private readonly ISortingExpressionProvider _sortingExpressionProvider;
private readonly IUserRepository _userRepository;
- 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..e9bd707 100644
--- a/CleanArchitecture.Application/Services/TenantService.cs
+++ b/CleanArchitecture.Application/Services/TenantService.cs
@@ -4,6 +4,7 @@ using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
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 +65,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..e8a5b53
--- /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..cfff203
--- /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..bbdc55b
--- /dev/null
+++ b/CleanArchitecture.Application/ViewModels/Sorting/SortQuery.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.ObjectModel;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CleanArchitecture.Application.ViewModels.Sorting;
+
+public sealed class SortQuery
+{
+ private string? _query = string.Empty;
+
+ public ReadOnlyCollection Parameters { get; private set; } = new(Array.Empty());
+
+
+ [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 (var 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);
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+
+ 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 (var i = startIndex; i < value.Length; i++)
+ {
+ if (!char.IsWhiteSpace(value[i]))
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ 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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/CleanArchitecture.Domain/Rabbitmq/RabbitMqHandler.cs b/CleanArchitecture.Domain/Rabbitmq/RabbitMqHandler.cs
index 09c20b0..81cb7bc 100644
--- a/CleanArchitecture.Domain/Rabbitmq/RabbitMqHandler.cs
+++ b/CleanArchitecture.Domain/Rabbitmq/RabbitMqHandler.cs
@@ -52,7 +52,7 @@ public sealed class RabbitMqHandler : BackgroundService
{
if (!_configuration.Enabled)
{
- _logger.LogInformation($"RabbitMQ is disabled. Skipping the creation of exchange {exchangeName}.");
+ _logger.LogInformation("RabbitMQ is disabled. Skipping the creation of exchange {exchangeName}.", exchangeName);
return;
}
@@ -159,7 +159,7 @@ public sealed class RabbitMqHandler : BackgroundService
}
catch (Exception ex)
{
- _logger.LogError(ex, $"Error while handling event in queue {ea.RoutingKey}");
+ _logger.LogError(ex, "Error while handling event in queue {RoutingKey}", ea.RoutingKey);
}
}