diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj index 14aeb37..ee6a8bb 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/Cnblogs.Architecture.Ddd.Cqrs.Abstractions.csproj @@ -17,8 +17,8 @@ - - + + diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs index 9a9ee57..83fc8b5 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.Abstractions/PageableQueryHandlerBase.cs @@ -1,5 +1,4 @@ using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; - using Mapster; namespace Cnblogs.Architecture.Ddd.Cqrs.Abstractions; @@ -21,15 +20,21 @@ public async Task> Handle(TQuery request, CancellationToken can request.OrderByString, out var orderBySegments); + var isNegativeIndexing = request.PagingParams?.PageIndex < 0; + if (isNegativeIndexing && orderBySegments is { Count: > 0 }) + { + orderBySegments = orderBySegments.Select(o => o with { IsDesc = !o.IsDesc }).ToList(); + } + var ordered = hasOrderBy && orderBySegments is { Count: > 0 } ? queryable.OrderBy(orderBySegments) - : DefaultOrderBy(request, queryable); + : DefaultOrderBy(request, queryable, isNegativeIndexing); var totalCount = 0; if (request.PagingParams != null) { totalCount = await CountAsync(request, queryable); - if (request.PagingParams.PageSize == 0 || totalCount == 0) + if (request.PagingParams.PageSize <= 0 || totalCount == 0) { // need count only or no available item, short circuit here. return new PagedList([], request.PagingParams, totalCount); @@ -60,6 +65,17 @@ public async Task> Handle(TQuery request, CancellationToken can /// Ordered . protected abstract IQueryable DefaultOrderBy(TQuery query, IQueryable queryable); + /// + /// The default reverse order by field, used when is not present and pageIndex is negative. + /// + /// The query parameters. + /// returned by . + /// Ordered . + protected virtual IQueryable DefaultReverseOrderBy(TQuery query, IQueryable queryable) + { + return DefaultOrderBy(query, queryable); + } + /// /// Create queryable and apply filter, return filtered . /// @@ -95,4 +111,12 @@ protected virtual IQueryable ProjectToView(TQuery query, IQueryableProjected . /// The query result. protected abstract Task> ToListAsync(TQuery query, IQueryable queryable); + + private IQueryable DefaultOrderBy( + TQuery query, + IQueryable queryable, + bool isNegativeIndexing) + { + return isNegativeIndexing ? DefaultReverseOrderBy(query, queryable) : DefaultOrderBy(query, queryable); + } } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs index df11d82..de79dc7 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/PagingParamsModelBinder.cs @@ -29,9 +29,9 @@ public Task BindModelAsync(ModelBindingContext bindingContext) } var pageIndexSuccess = int.TryParse(pageIndexString, out var pageIndexNumber); - if (pageIndexSuccess == false || pageIndexNumber <= 0) + if (pageIndexSuccess == false) { - bindingContext.ModelState.TryAddModelError(modelName, "PageIndex must be a positive number"); + bindingContext.ModelState.TryAddModelError(modelName, "PageIndex must be a number"); bindingContext.Result = ModelBindingResult.Failed(); return Task.CompletedTask; } @@ -39,7 +39,7 @@ public Task BindModelAsync(ModelBindingContext bindingContext) var pageSizeSuccess = int.TryParse(pageSizeString, out var pageSizeNumber); if (pageSizeSuccess == false || pageSizeNumber < 0) { - bindingContext.ModelState.TryAddModelError(modelName, "PageIndex must be a positive number or 0"); + bindingContext.ModelState.TryAddModelError(modelName, "PageSize must be a positive number or 0"); bindingContext.Result = ModelBindingResult.Failed(); return Task.CompletedTask; } diff --git a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj index 8168f94..99e1d98 100644 --- a/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj +++ b/src/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent/Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent.csproj @@ -12,11 +12,11 @@ - + - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagingParams.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagingParams.cs index e505c45..f2c8a6f 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagingParams.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/PagingParams.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; @@ -10,7 +10,7 @@ namespace Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; /// /// 页码。 /// 每页元素数。 -public record PagingParams([Range(1, int.MaxValue)] int PageIndex, [Range(0, int.MaxValue)] int PageSize) +public record PagingParams(int PageIndex, [Range(0, int.MaxValue)] int PageSize) { /// public override string ToString() diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs index d1949aa..00b5ba7 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryPager.cs @@ -18,7 +18,7 @@ public static class QueryPager public static IQueryable Paging(this IQueryable queryable, PagingParams pagingParams) { var (pageIndex, pageSize) = pagingParams; - return queryable.Paging(pageIndex, pageSize); + return queryable.Paging(pageIndex < 0 ? -pageIndex : pageIndex, pageSize < 0 ? -pageSize : pageSize); } /// @@ -53,4 +53,4 @@ public static IQueryable Paging(this IQueryable queryable, int pageInde ? queryable.Take(pageSize) : queryable.Skip((pageIndex - 1) * pageSize).Take(pageSize); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryStringBuilder.cs b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryStringBuilder.cs index c5b2c80..c5b0908 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryStringBuilder.cs +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Abstractions/QueryStringBuilder.cs @@ -45,15 +45,15 @@ public QueryStringBuilder( /// /// 分页参数。 /// - public QueryStringBuilder AddPaging(PagingParams pagingParams) - => AddPaging(pagingParams.PageIndex, pagingParams.PageSize); + public QueryStringBuilder AddPaging(PagingParams? pagingParams) + => AddPaging(pagingParams?.PageIndex, pagingParams?.PageSize); /// /// 添加分页参数。 /// /// 页码。 /// 分页大小。 - public QueryStringBuilder AddPaging(int pageIndex, int pageSize) + public QueryStringBuilder AddPaging(int? pageIndex, int? pageSize) => Add(nameof(pageIndex), pageIndex).Add(nameof(pageSize), pageSize); /// @@ -170,4 +170,4 @@ public override string ToString() { return Build(); } -} \ No newline at end of file +} diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj index 67631a4..ef7c884 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.InMemory.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj index 756e73b..cde4f08 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis/Cnblogs.Architecture.Ddd.Infrastructure.CacheProviders.Redis.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj index 33baa71..6ac3d2d 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.Dapper/Cnblogs.Architecture.Ddd.Infrastructure.Dapper.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj index 941b4b6..c3fe132 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework/Cnblogs.Architecture.Ddd.Infrastructure.EntityFramework.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj index 18fdb2f..0a8dd80 100644 --- a/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj +++ b/src/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb/Cnblogs.Architecture.Ddd.Infrastructure.MongoDb.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj b/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj index 535e7de..abe9d12 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj +++ b/test/Cnblogs.Architecture.IntegrationTests/Cnblogs.Architecture.IntegrationTests.csproj @@ -1,7 +1,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -12,7 +12,7 @@ - + diff --git a/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs b/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs index b647e8d..f00288d 100644 --- a/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs +++ b/test/Cnblogs.Architecture.IntegrationTests/CustomModelBinderTests.cs @@ -41,7 +41,6 @@ public async Task PagingParamsModelBinder_NoPageIndexOrPageSize_NullAsync(string [Theory] [InlineData("hello")] - [InlineData("-1")] public async Task PagingParamsModelBinder_PageIndexInvalid_FailAsync(string pageIndex) { // Arrange diff --git a/test/Cnblogs.Architecture.TestShared/EntityGenerator.Varies.cs b/test/Cnblogs.Architecture.TestShared/EntityGenerator.Varies.cs index 84daed5..96d357f 100644 --- a/test/Cnblogs.Architecture.TestShared/EntityGenerator.Varies.cs +++ b/test/Cnblogs.Architecture.TestShared/EntityGenerator.Varies.cs @@ -44,6 +44,15 @@ public EntityGenerator VaryByDateTimeDay( days, DateTime.Now); + public EntityGenerator VaryByDateTimeDay( + Expression>? datetimeAccess, + int days) + => VaryByDateTime( + datetimeAccess, + (start, day) => start.AddDays(-day), + days, + DateTime.Now); + public EntityGenerator VaryByDateTime( Expression>? datetimeAccess, Func timeDiffer, @@ -59,6 +68,21 @@ public EntityGenerator VaryByDateTime( return VaryBy(datetimeAccess, dates); } + public EntityGenerator VaryByDateTime( + Expression>? datetimeAccess, + Func timeDiffer, + int diffs, + DateTime startDate) + { + var dates = new DateTimeOffset[diffs]; + for (var i = 0; i < diffs; i++) + { + dates[i] = timeDiffer(startDate, i); + } + + return VaryBy(datetimeAccess, dates); + } + public EntityGenerator VaryByBoolean(Expression>? booleanAccess) => VaryBy(booleanAccess, true, false); @@ -67,4 +91,4 @@ public EntityGenerator VaryByInt(Expression>? intAcc public EntityGenerator MultiplyBy(int times) => VaryBy(null, Enumerable.Range(0, times)); -} \ No newline at end of file +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj b/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj index 50d1a3c..b9b3ec6 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj +++ b/test/Cnblogs.Architecture.UnitTests/Cnblogs.Architecture.UnitTests.csproj @@ -2,8 +2,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakePostDto.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakePostDto.cs new file mode 100644 index 0000000..10e1a96 --- /dev/null +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/FakePostDto.cs @@ -0,0 +1,3 @@ +namespace Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; + +public record FakePostDto(int Id, DateTimeOffset DateAdded, DateTimeOffset DateUpdated); diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQuery.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQuery.cs new file mode 100644 index 0000000..4b81b78 --- /dev/null +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQuery.cs @@ -0,0 +1,7 @@ +using Cnblogs.Architecture.Ddd.Cqrs.Abstractions; +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; + +namespace Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; + +public record TestPageableQuery(bool? Deleted, PagingParams? PagingParams, string? OrderByString) + : IPageableQuery; diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQueryHandler.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQueryHandler.cs new file mode 100644 index 0000000..8ac099d --- /dev/null +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/FakeObjects/TestPageableQueryHandler.cs @@ -0,0 +1,33 @@ +using Cnblogs.Architecture.Ddd.Cqrs.EntityFramework; +using Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; +using Microsoft.EntityFrameworkCore; + +namespace Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; + +public class TestPageableQueryHandler(DbContext context) + : EfPageableQueryHandler +{ + /// + protected override IQueryable DefaultOrderBy(TestPageableQuery query, IQueryable queryable) + { + return queryable.OrderByDescending(x => x.DateAdded).ThenByDescending(x => x.DateUpdated); + } + + /// + protected override IQueryable DefaultReverseOrderBy(TestPageableQuery query, IQueryable queryable) + { + return queryable.OrderBy(x => x.DateAdded).ThenBy(x => x.DateUpdated); + } + + /// + protected override IQueryable Filter(TestPageableQuery query) + { + var queryable = context.Set().AsNoTracking(); + if (query.Deleted.HasValue) + { + queryable = queryable.Where(x => x.Deleted == query.Deleted.Value); + } + + return queryable; + } +} diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/PageableQueryHandlerTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/PageableQueryHandlerTests.cs new file mode 100644 index 0000000..bcadae2 --- /dev/null +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/Handlers/PageableQueryHandlerTests.cs @@ -0,0 +1,253 @@ +using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions; +using Cnblogs.Architecture.TestShared; +using Cnblogs.Architecture.UnitTests.Cqrs.FakeObjects; +using Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; +using Microsoft.EntityFrameworkCore; + +namespace Cnblogs.Architecture.UnitTests.Cqrs.Handlers; + +public class PageableQueryHandlerTests +{ + [Fact] + public async Task Handle_NoPaging_AllItemsAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + var response = await handler.Handle(new TestPageableQuery(null, null, null), CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, response.TotalCount); + Assert.Equal(1, response.PageIndex); + Assert.Equal(posts.Count, response.PageSize); + Assert.Equal(posts.Count, response.Items.Count); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task Handle_ZeroOrNegativePageSize_EmptyListWithTotalCountAsync(int pageSize) + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = 1; + var response = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), null), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, response.TotalCount); + Assert.Equal(pageIndex, response.PageIndex); + Assert.Equal(pageSize, response.PageSize); + Assert.Empty(response.Items); + } + + [Fact] + public async Task Handle_Paging_PagedItemsWithTotalCountAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + var newestPostId = posts.OrderByDescending(x => x.DateAdded).ThenByDescending(x => x.DateUpdated).First().Id; + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = 1; + const int pageSize = 3; + var response = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), null), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, response.TotalCount); + Assert.Equal(pageIndex, response.PageIndex); + Assert.Equal(pageSize, response.PageSize); + Assert.Equal(pageSize, response.Items.Count); + Assert.Equal(newestPostId, response.Items.First().Id); + } + + [Fact] + public async Task Handle_NegativeIndexing_PagedItemsWithTotalCountAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + var oldestPostId = posts.OrderBy(x => x.DateAdded).ThenBy(x => x.DateUpdated).First().Id; + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = -1; + const int pageSize = 3; + var response = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), null), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, response.TotalCount); + Assert.Equal(pageIndex, response.PageIndex); + Assert.Equal(pageSize, response.PageSize); + Assert.Equal(pageSize, response.Items.Count); + Assert.Equal(oldestPostId, response.Items.First().Id); + } + + [Fact] + public async Task Handle_OrderByString_OverrideDefaultOrderByAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + OrderBySegmentConfig.RegisterSortableProperty("id", x => x.Id); + var minId = posts.Min(x => x.Id); + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = 1; + const int pageSize = 3; + var response = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), "id"), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, response.TotalCount); + Assert.Equal(pageIndex, response.PageIndex); + Assert.Equal(pageSize, response.PageSize); + Assert.Equal(pageSize, response.Items.Count); + Assert.Equal(minId, response.Items.First().Id); + } + + [Fact] + public async Task Handle_OrderByStringWithNegativeIndexing_OverrideDefaultOrderByAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + OrderBySegmentConfig.RegisterSortableProperty("id", x => x.Id); + var maxId = posts.Max(x => x.Id); + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = -1; + const int pageSize = 3; + var negativeIndexing = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), "id"), + CancellationToken.None); + var descendingOrderBy = await handler.Handle( + new TestPageableQuery(null, new PagingParams(-pageIndex, pageSize), "-id"), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, negativeIndexing.TotalCount); + Assert.Equal(pageIndex, negativeIndexing.PageIndex); + Assert.Equal(pageSize, negativeIndexing.PageSize); + Assert.Equal(pageSize, negativeIndexing.Items.Count); + Assert.Equal(maxId, negativeIndexing.Items.First().Id); + Assert.Equivalent(negativeIndexing.Items, descendingOrderBy.Items); + } + + [Fact] + public async Task Handle_MultipleOrderBySegment_OverrideDefaultOrderByAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + OrderBySegmentConfig.RegisterSortableProperty("dateAdded", x => x.DateAdded); + OrderBySegmentConfig.RegisterSortableProperty("dateUpdated", x => x.DateUpdated); + var newestPostId = posts.OrderByDescending(x => x.DateAdded).ThenByDescending(x => x.DateUpdated).First().Id; + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = 1; + const int pageSize = 3; + var withOrderByString = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), "-dateAdded,-dateUpdated"), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, withOrderByString.TotalCount); + Assert.Equal(pageIndex, withOrderByString.PageIndex); + Assert.Equal(pageSize, withOrderByString.PageSize); + Assert.Equal(pageSize, withOrderByString.Items.Count); + Assert.Equal(newestPostId, withOrderByString.Items.First().Id); + } + + [Fact] + public async Task Handle_MultipleOrderBySegmentWithNegativeIndexing_EquivlentToNelegateOrderByStringAsync() + { + // Arrange + var posts = new EntityGenerator(new FakePost()) + .VaryByDateTimeDay(x => x.DateAdded, 5) + .VaryByDateTimeDay(x => x.DateUpdated, 2) + .FillWithInt(x => x.Id, 1, 100) + .Generate(); + OrderBySegmentConfig.RegisterSortableProperty("dateAdded", x => x.DateAdded); + OrderBySegmentConfig.RegisterSortableProperty("dateUpdated", x => x.DateUpdated); + var oldestPostId = posts.OrderBy(x => x.DateAdded).ThenBy(x => x.DateUpdated).First().Id; + var dbContext = await GetDbContextAsync(posts); + var handler = new TestPageableQueryHandler(dbContext); + + // Act + const int pageIndex = -1; + const int pageSize = 3; + var negativeIndexing = await handler.Handle( + new TestPageableQuery(null, new PagingParams(pageIndex, pageSize), "-dateAdded,-dateUpdated"), + CancellationToken.None); + var negativeOrdering = await handler.Handle( + new TestPageableQuery(null, new PagingParams(-pageIndex, pageSize), "dateAdded,dateUpdated"), + CancellationToken.None); + + // Assert + Assert.Equal(posts.Count, negativeIndexing.TotalCount); + Assert.Equal(pageIndex, negativeIndexing.PageIndex); + Assert.Equal(pageSize, negativeIndexing.PageSize); + Assert.Equal(pageSize, negativeIndexing.Items.Count); + Assert.Equal(oldestPostId, negativeIndexing.Items.First().Id); + Assert.Equivalent(negativeIndexing.Items, negativeOrdering.Items); + } + + private static async Task GetDbContextAsync(ICollection entities) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(PageableQueryHandlerTests)).Options; + var context = new FakeDbContext(options); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + await context.AddRangeAsync(entities); + await context.SaveChangesAsync(); + return context; + } +} diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs index 703dc68..6b506df 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/EntityFramework/BaseRepositoryTests.cs @@ -22,8 +22,7 @@ public async Task GetEntityAsync_Include_GetEntityAsync() new EntityGenerator(new FakePost()) .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var repository = new TestRepository(Substitute.For(), db); @@ -36,6 +35,16 @@ public async Task GetEntityAsync_Include_GetEntityAsync() Assert.Equivalent(entity.Posts, got.Posts); } + private static FakeDbContext GetFakeDbContext() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(nameof(BaseRepositoryTests)) + .Options; + var context = new FakeDbContext(options); + context.Database.EnsureDeleted(); + context.Database.EnsureCreated(); + return context; + } + [Fact] public async Task GetEntityAsync_StringBasedInclude_NotNullAsync() { @@ -48,8 +57,7 @@ public async Task GetEntityAsync_StringBasedInclude_NotNullAsync() new EntityGenerator(new FakePost()) .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var repository = new TestRepository(Substitute.For(), db); @@ -75,8 +83,7 @@ public async Task GetEntityAsync_ThenInclude_NotNullAsync() .HasManyForEachEntity(x => x.Tags, new EntityGenerator(new FakeTag())) .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var repository = new TestRepository(Substitute.For(), db); @@ -101,8 +108,7 @@ public async Task SaveEntitiesAsync_CallBeforeUpdateForRelatedEntity_UpdateDateU new EntityGenerator(new FakePost()) .Setup(x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var repository = new TestRepository(Substitute.For(), db); @@ -126,11 +132,10 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEvents_DispatchAllAsync( .HasManyForEachEntity( x => x.Posts, x => x.Blog, - new EntityGenerator(new FakePost()).Setup( - x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + new EntityGenerator(new FakePost()).Setup(x + => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var mediator = Substitute.For(); @@ -160,11 +165,10 @@ public async Task SaveEntitiesAsync_DispatchRelatedEntityDomainEvents_DispatchAl .HasManyForEachEntity( x => x.Posts, x => x.Blog, - new EntityGenerator(new FakePost()).Setup( - x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + new EntityGenerator(new FakePost()).Setup(x + => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); db.Add(entity); await db.SaveChangesAsync(); var mediator = Substitute.For(); @@ -195,11 +199,10 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithGeneratedId_Di .HasManyForEachEntity( x => x.Posts, x => x.Blog, - new EntityGenerator(new FakePost()).Setup( - x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + new EntityGenerator(new FakePost()).Setup(x + => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); var mediator = Substitute.For(); var repository = new TestRepository(mediator, db); @@ -226,11 +229,10 @@ public async Task SaveEntitiesAsync_DispatchEntityDomainEventsWithMultipleExcept .HasManyForEachEntity( x => x.Posts, x => x.Blog, - new EntityGenerator(new FakePost()).Setup( - x => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) + new EntityGenerator(new FakePost()).Setup(x + => x.DateUpdated = DateTimeOffset.Now.AddDays(-1))) .GenerateSingle(); - var db = new FakeDbContext( - new DbContextOptionsBuilder().UseInMemoryDatabase("inmemory").Options); + var db = GetFakeDbContext(); var mediator = Substitute.For(); mediator.Publish(Arg.Any(), Arg.Any()) .ThrowsAsync(); diff --git a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs index 95f6495..13dfd37 100644 --- a/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs +++ b/test/Cnblogs.Architecture.UnitTests/Infrastructure/FakeObjects/FakeDbContext.cs @@ -2,13 +2,8 @@ namespace Cnblogs.Architecture.UnitTests.Infrastructure.FakeObjects; -public class FakeDbContext : DbContext +public class FakeDbContext(DbContextOptions options) : DbContext(options) { - public FakeDbContext(DbContextOptions options) - : base(options) - { - } - /// protected override void OnModelCreating(ModelBuilder modelBuilder) {