From 4e451dd8881f9898b654fea3aab89902bb30680a Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Tue, 24 Oct 2017 22:00:35 -0500 Subject: [PATCH 1/2] feature(paging): support negative paging --- .../Data/DefaultEntityRepository.cs | 56 +++++--- .../JsonApiDotNetCore.csproj | 2 +- .../Serialization/IJsonApiDeSerializer.cs | 1 + .../Serialization/JsonApiDeSerializer.cs | 5 +- .../Services/JsonApiContext.cs | 2 +- .../Acceptance/Spec/PagingTests.cs | 134 ++++++++++++++++++ .../Acceptance/TestFixture.cs | 37 +++++ .../Acceptance/TodoItemsControllerTests.cs | 37 +---- .../xunit.runner.json | 5 + 9 files changed, 220 insertions(+), 59 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/xunit.runner.json diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index bc2d9b0661..1199389c60 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -13,9 +13,9 @@ namespace JsonApiDotNetCore.Data { - public class DefaultEntityRepository + public class DefaultEntityRepository : DefaultEntityRepository, - IEntityRepository + IEntityRepository where TEntity : class, IIdentifiable { public DefaultEntityRepository( @@ -25,8 +25,8 @@ public DefaultEntityRepository( { } } - public class DefaultEntityRepository - : IEntityRepository + public class DefaultEntityRepository + : IEntityRepository where TEntity : class, IIdentifiable { private readonly DbContext _context; @@ -62,33 +62,33 @@ public DefaultEntityRepository( public virtual IQueryable Get() { - if(_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Any()) + if (_jsonApiContext.QuerySet?.Fields != null && _jsonApiContext.QuerySet.Fields.Any()) return _dbSet.Select(_jsonApiContext.QuerySet?.Fields); - + return _dbSet; } - public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) + public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { - if(filterQuery == null) + if (filterQuery == null) return entities; - if(filterQuery.IsAttributeOfRelationship) + if (filterQuery.IsAttributeOfRelationship) return entities.Filter(new RelatedAttrFilterQuery(_jsonApiContext, filterQuery)); - + return entities.Filter(new AttrFilterQuery(_jsonApiContext, filterQuery)); } public virtual IQueryable Sort(IQueryable entities, List sortQueries) { - if(sortQueries == null || sortQueries.Count == 0) + if (sortQueries == null || sortQueries.Count == 0) return entities; var orderedEntities = entities.Sort(sortQueries[0]); if (sortQueries.Count <= 1) return orderedEntities; - for(var i=1; i < sortQueries.Count; i++) + for (var i = 1; i < sortQueries.Count; i++) orderedEntities = orderedEntities.Sort(sortQueries[i]); return orderedEntities; @@ -124,10 +124,10 @@ public virtual async Task UpdateAsync(TId id, TEntity entity) if (oldEntity == null) return null; - foreach(var attr in _jsonApiContext.AttributesToUpdate) + foreach (var attr in _jsonApiContext.AttributesToUpdate) attr.Key.SetValue(oldEntity, attr.Value); - foreach(var relationship in _jsonApiContext.RelationshipsToUpdate) + foreach (var relationship in _jsonApiContext.RelationshipsToUpdate) relationship.Key.SetValue(oldEntity, relationship.Value); await _context.SaveChangesAsync(); @@ -159,20 +159,34 @@ public virtual IQueryable Include(IQueryable entities, string { var entity = _jsonApiContext.RequestEntity; var relationship = entity.Relationships.FirstOrDefault(r => r.PublicRelationshipName == relationshipName); - if(relationship != null) + if (relationship != null) return entities.Include(relationship.InternalRelationshipName); - + throw new JsonApiException(400, $"Invalid relationship {relationshipName} on {entity.EntityName}", $"{entity.EntityName} does not have a relationship named {relationshipName}"); } public virtual async Task> PageAsync(IQueryable entities, int pageSize, int pageNumber) { - if(pageSize > 0) - return await entities - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(); + if (pageSize > 0) + { + if (pageNumber == 0) + pageNumber = 1; + + if (pageNumber > 0) + return await entities + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + else // page from the end of the set + return (await entities + .OrderByDescending(t => t.Id) + .Skip((Math.Abs(pageNumber) - 1) * pageSize) + .Take(pageSize) + .ToListAsync()) + .OrderBy(t => t.Id) + .ToList(); + } return await entities.ToListAsync(); } diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 9177566e00..5f114c875c 100755 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,6 +1,6 @@  - 2.1.6 + 2.1.7 netstandard1.6 JsonApiDotNetCore JsonApiDotNetCore diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs index 02f84a747a..5fb91dae36 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeSerializer.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCore.Serialization public interface IJsonApiDeSerializer { object Deserialize(string requestBody); + TEntity Deserialize(string requestBody); object DeserializeRelationship(string requestBody); List DeserializeList(string requestBody); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs index dc7e515561..608a23b66e 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiDeSerializer.cs @@ -38,8 +38,7 @@ public object Deserialize(string requestBody) } } - public TEntity Deserialize(string requestBody) - => (TEntity)Deserialize(requestBody); + public TEntity Deserialize(string requestBody) => (TEntity)Deserialize(requestBody); public object DeserializeRelationship(string requestBody) { @@ -117,7 +116,7 @@ private object SetEntityAttributes( var convertedValue = ConvertAttrValue(newValue, entityProperty.PropertyType); entityProperty.SetValue(entity, convertedValue); - if(attr.IsImmutable == false) + if (attr.IsImmutable == false) _jsonApiContext.AttributesToUpdate[attr] = convertedValue; } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index 47702f68f7..e634938087 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -86,7 +86,7 @@ private PageManager GetPageManager() return new PageManager { DefaultPageSize = Options.DefaultPageSize, - CurrentPage = query.PageOffset > 0 ? query.PageOffset : 1, + CurrentPage = query.PageOffset, PageSize = query.PageSize > 0 ? query.PageSize : Options.DefaultPageSize }; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs new file mode 100644 index 0000000000..ea99cb7d59 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/PagingTests.cs @@ -0,0 +1,134 @@ +using System.Collections.Generic; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Bogus; +using DotNetCoreDocs; +using DotNetCoreDocs.Models; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec +{ + public class PagingTests : TestFixture + { + private readonly Faker _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()) + .RuleFor(t => t.CreatedDate, f => f.Date.Past()); + + [Fact] + public async Task Can_Paginate_TodoItems() + { + // Arrange + const int expectedEntitiesPerPage = 2; + var totalCount = expectedEntitiesPerPage * 2; + var person = new Person(); + var todoItems = _todoItemFaker.Generate(totalCount); + + foreach (var todoItem in todoItems) + todoItem.Owner = person; + + Context.TodoItems.AddRange(todoItems); + Context.SaveChanges(); + + var route = $"/api/v1/todo-items?page[size]={expectedEntitiesPerPage}"; + + // Act + var response = await Client.GetAsync(route); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = GetService().DeserializeList(body); + + Assert.NotEmpty(deserializedBody); + Assert.Equal(expectedEntitiesPerPage, deserializedBody.Count); + } + + [Fact] + public async Task Can_Paginate_TodoItems_From_Start() + { + // Arrange + const int expectedEntitiesPerPage = 2; + var totalCount = expectedEntitiesPerPage * 2; + var person = new Person(); + var todoItems = _todoItemFaker.Generate(totalCount); + + foreach (var todoItem in todoItems) + todoItem.Owner = person; + + Context.TodoItems.RemoveRange(Context.TodoItems); + Context.TodoItems.AddRange(todoItems); + Context.SaveChanges(); + + var route = $"/api/v1/todo-items?page[size]={expectedEntitiesPerPage}&page[number]=1"; + + // Act + var response = await Client.GetAsync(route); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = GetService().DeserializeList(body); + + Assert.NotEmpty(deserializedBody); + Assert.Equal(expectedEntitiesPerPage, deserializedBody.Count); + + var expectedTodoItems = Context.TodoItems.Take(2); + foreach (var todoItem in expectedTodoItems) + Assert.NotNull(deserializedBody.SingleOrDefault(t => t.Id == todoItem.Id)); + } + + [Fact] + public async Task Can_Paginate_TodoItems_From_End() + { + // Arrange + const int expectedEntitiesPerPage = 2; + var totalCount = expectedEntitiesPerPage * 2; + var person = new Person(); + var todoItems = _todoItemFaker.Generate(totalCount); + + foreach (var todoItem in todoItems) + todoItem.Owner = person; + + Context.TodoItems.RemoveRange(Context.TodoItems); + Context.TodoItems.AddRange(todoItems); + Context.SaveChanges(); + + var route = $"/api/v1/todo-items?page[size]={expectedEntitiesPerPage}&page[number]=-1"; + + // Act + var response = await Client.GetAsync(route); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + var deserializedBody = GetService().DeserializeList(body); + + Assert.NotEmpty(deserializedBody); + Assert.Equal(expectedEntitiesPerPage, deserializedBody.Count); + + var expectedTodoItems = Context.TodoItems + .OrderByDescending(t => t.Id) + .Take(2) + .ToList() + .OrderBy(t => t.Id) + .ToList(); + + for (int i = 0; i < expectedEntitiesPerPage; i++) + Assert.Equal(expectedTodoItems[i].Id, deserializedBody[i].Id); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs new file mode 100644 index 0000000000..0379eae91d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -0,0 +1,37 @@ +using System; +using System.Net.Http; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using JsonApiDotNetCore.Services; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests.Acceptance +{ + public class TestFixture where TStartup : class + { + private readonly TestServer _server; + private IServiceProvider _services; + + public TestFixture() + { + var builder = new WebHostBuilder() + .UseStartup(); + + _server = new TestServer(builder); + _services = _server.Host.Services; + + Client = _server.CreateClient(); + Context = GetService(); + DeSerializer = GetService(); + JsonApiContext = GetService(); + } + + public HttpClient Client { get; set; } + public AppDbContext Context { get; private set; } + public IJsonApiDeSerializer DeSerializer { get; private set; } + public IJsonApiContext JsonApiContext { get; private set; } + public T GetService() => (T)_services.GetService(typeof(T)); + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs index 13c6d8e78c..4bf4ce68ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TodoItemsControllerTests.cs @@ -65,35 +65,6 @@ public async Task Can_Get_TodoItems() Assert.True(deserializedBody.Count <= expectedEntitiesPerPage); } - [Fact] - public async Task Can_Paginate_TodoItems() - { - // Arrange - const int expectedEntitiesPerPage = 2; - var person = new Person(); - var todoItem = _todoItemFaker.Generate(); - todoItem.Owner = person; - _context.TodoItems.Add(todoItem); - _context.SaveChanges(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items?page[size]={expectedEntitiesPerPage}"; - - var description = new RequestProperties("Paginate TodoItems", new Dictionary { - { "?page[size]=", "Number of entities per page" } - }); - - // Act - var response = await _fixture.MakeRequest(description, httpMethod, route); - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = _fixture.GetService().DeserializeList(body); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.NotEmpty(deserializedBody); - Assert.True(deserializedBody.Count <= expectedEntitiesPerPage); - } - [Fact] public async Task Can_Filter_TodoItems() { @@ -194,7 +165,7 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Ascending() { Assert.True(todoItemResult.Ordinal > priorOrdinal); priorOrdinal = todoItemResult.Ordinal; - } + } } [Fact] @@ -236,7 +207,7 @@ public async Task Can_Sort_TodoItems_By_Ordinal_Descending() { Assert.True(todoItemResult.Ordinal < priorOrdinal); priorOrdinal = todoItemResult.Ordinal; - } + } } [Fact] @@ -362,7 +333,7 @@ public async Task Can_Post_TodoItem() public async Task Can_Patch_TodoItem() { // Arrange - var person = new Person(); + var person = new Person(); _context.People.Add(person); _context.SaveChanges(); @@ -520,7 +491,7 @@ public async Task Can_Patch_TodoItemWithNullValue() public async Task Can_Delete_TodoItem() { // Arrange - var person = new Person(); + var person = new Person(); _context.People.Add(person); _context.SaveChanges(); diff --git a/test/JsonApiDotNetCoreExampleTests/xunit.runner.json b/test/JsonApiDotNetCoreExampleTests/xunit.runner.json new file mode 100644 index 0000000000..8f5f10571b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} From b45e9ff46a86bbe9f019b340ba05348075b64fd6 Mon Sep 17 00:00:00 2001 From: jaredcnance Date: Tue, 24 Oct 2017 22:17:54 -0500 Subject: [PATCH 2/2] fix(pageManager): handle case when current page = 0 --- src/JsonApiDotNetCore/Internal/PageManager.cs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Internal/PageManager.cs b/src/JsonApiDotNetCore/Internal/PageManager.cs index 124aaad6dd..17da00333a 100644 --- a/src/JsonApiDotNetCore/Internal/PageManager.cs +++ b/src/JsonApiDotNetCore/Internal/PageManager.cs @@ -11,28 +11,30 @@ public class PageManager public int DefaultPageSize { get; set; } public int CurrentPage { get; set; } public bool IsPaginated => PageSize > 0; - public int TotalPages => (TotalRecords == 0) ? -1: (int)Math.Ceiling(decimal.Divide(TotalRecords, PageSize)); + public int TotalPages => (TotalRecords == 0) ? -1 : (int)Math.Ceiling(decimal.Divide(TotalRecords, PageSize)); public RootLinks GetPageLinks(LinkBuilder linkBuilder) - { - if(!IsPaginated || (CurrentPage == 1 && TotalPages <= 0)) + { + if (ShouldIncludeLinksObject()) return null; - + var rootLinks = new RootLinks(); - if(CurrentPage > 1) + if (CurrentPage > 1) rootLinks.First = linkBuilder.GetPageLink(1, PageSize); - if(CurrentPage > 1) + if (CurrentPage > 1) rootLinks.Prev = linkBuilder.GetPageLink(CurrentPage - 1, PageSize); - - if(CurrentPage < TotalPages) + + if (CurrentPage < TotalPages) rootLinks.Next = linkBuilder.GetPageLink(CurrentPage + 1, PageSize); - - if(TotalPages > 0) + + if (TotalPages > 0) rootLinks.Last = linkBuilder.GetPageLink(TotalPages, PageSize); return rootLinks; } + + private bool ShouldIncludeLinksObject() => (!IsPaginated || ((CurrentPage == 1 || CurrentPage == 0) && TotalPages <= 0)); } }