Skip to content

Commit 4ebc9ce

Browse files
author
Bart Koelman
authored
Best effort paging (#840)
* Adds support for best-effort paging links (in case the total number of resources is unknown). This strategy is also applied on all secondary resources, as it is quite complex to determine total resource count. Fixed: exception on pass-through query string parameter in secondary request Optimization: skip query execution for primary resource if total count is zero Fixed: self link was calculated twice (in different ways) when paging active Fixed: nested page size was lost when rendering paging links Fixed: invalid paging links on secondary endpoints Fixed: cases where self link was different from the actual URL being requested (we now leave page number/size in, even when they match the default) * Migrated PaginationLinkTests
1 parent bf2df54 commit 4ebc9ce

File tree

11 files changed

+502
-168
lines changed

11 files changed

+502
-168
lines changed

src/JsonApiDotNetCore/Queries/IPaginationContext.cs

+6
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ public interface IPaginationContext
1919
/// </summary>
2020
PageSize PageSize { get; set; }
2121

22+
/// <summary>
23+
/// Indicates whether the number of resources on the current page equals the page size.
24+
/// When <c>true</c>, a subsequent page might exist (assuming <see cref="TotalResourceCount"/> is unknown).
25+
/// </summary>
26+
bool IsPageFull { get; set; }
27+
2228
/// <summary>
2329
/// The total number of resources.
2430
/// <c>null</c> when <see cref="IJsonApiOptions.IncludeTotalResourceCount"/> is set to <c>false</c>.

src/JsonApiDotNetCore/Queries/PaginationContext.cs

+3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ internal sealed class PaginationContext : IPaginationContext
1212
/// <inheritdoc />
1313
public PageSize PageSize { get; set; }
1414

15+
/// <inheritdoc />
16+
public bool IsPageFull { get; set; }
17+
1518
/// <inheritdoc />
1619
public int? TotalResourceCount { get; set; }
1720

src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs

+5-3
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,17 @@ public virtual void Read(string parameterName, StringValues parameterValue)
4646

4747
private object GetQueryableHandler(string parameterName)
4848
{
49-
if (_request.Kind != EndpointKind.Primary)
49+
var resourceType = _request.PrimaryResource.ResourceType;
50+
var handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceType, parameterName);
51+
52+
if (handler != null && _request.Kind != EndpointKind.Primary)
5053
{
5154
throw new InvalidQueryStringParameterException(parameterName,
5255
"Custom query string parameters cannot be used on nested resource endpoints.",
5356
$"Query string parameter '{parameterName}' cannot be used on a nested resource endpoint.");
5457
}
5558

56-
var resourceType = _request.PrimaryResource.ResourceType;
57-
return _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceType, parameterName);
59+
return handler;
5860
}
5961

6062
/// <inheritdoc />

src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs

+90-30
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
using JsonApiDotNetCore.Configuration;
66
using JsonApiDotNetCore.Middleware;
77
using JsonApiDotNetCore.Queries;
8+
using JsonApiDotNetCore.Queries.Expressions;
9+
using JsonApiDotNetCore.Queries.Internal.Parsing;
810
using JsonApiDotNetCore.QueryStrings;
911
using JsonApiDotNetCore.Resources;
1012
using JsonApiDotNetCore.Resources.Annotations;
@@ -15,6 +17,9 @@ namespace JsonApiDotNetCore.Serialization.Building
1517
{
1618
public class LinkBuilder : ILinkBuilder
1719
{
20+
private const string _pageSizeParameterName = "page[size]";
21+
private const string _pageNumberParameterName = "page[number]";
22+
1823
private readonly IResourceContextProvider _provider;
1924
private readonly IRequestQueryStringAccessor _queryStringAccessor;
2025
private readonly IJsonApiOptions _options;
@@ -42,10 +47,10 @@ public TopLevelLinks GetTopLevelLinks()
4247
TopLevelLinks topLevelLinks = null;
4348
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Self))
4449
{
45-
topLevelLinks = new TopLevelLinks { Self = GetSelfTopLevelLink(resourceContext) };
50+
topLevelLinks = new TopLevelLinks {Self = GetSelfTopLevelLink(resourceContext, null)};
4651
}
4752

48-
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Paging) && _paginationContext.PageSize != null)
53+
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Paging) && _paginationContext.PageSize != null && _request.IsCollection)
4954
{
5055
SetPageLinks(resourceContext, topLevelLinks ??= new TopLevelLinks());
5156
}
@@ -70,36 +75,38 @@ private bool ShouldAddTopLevelLink(ResourceContext resourceContext, LinkTypes li
7075

7176
private void SetPageLinks(ResourceContext resourceContext, TopLevelLinks links)
7277
{
73-
if (_paginationContext.PageNumber.OneBasedValue > 1)
78+
links.First = GetPageLink(resourceContext, 1, _paginationContext.PageSize);
79+
80+
if (_paginationContext.TotalPageCount > 0)
7481
{
75-
links.Prev = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue - 1, _paginationContext.PageSize);
82+
links.Last = GetPageLink(resourceContext, _paginationContext.TotalPageCount.Value, _paginationContext.PageSize);
7683
}
7784

78-
if (_paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount)
85+
if (_paginationContext.PageNumber.OneBasedValue > 1)
7986
{
80-
links.Next = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue + 1, _paginationContext.PageSize);
87+
links.Prev = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue - 1, _paginationContext.PageSize);
8188
}
8289

83-
if (_paginationContext.TotalPageCount > 0)
90+
bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount;
91+
bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull;
92+
93+
if (hasNextPage || possiblyHasNextPage)
8494
{
85-
links.Self = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue, _paginationContext.PageSize);
86-
links.First = GetPageLink(resourceContext, 1, _paginationContext.PageSize);
87-
links.Last = GetPageLink(resourceContext, _paginationContext.TotalPageCount.Value, _paginationContext.PageSize);
95+
links.Next = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue + 1, _paginationContext.PageSize);
8896
}
8997
}
9098

91-
private string GetSelfTopLevelLink(ResourceContext resourceContext)
99+
private string GetSelfTopLevelLink(ResourceContext resourceContext, Action<Dictionary<string, string>> queryStringUpdateAction)
92100
{
93101
var builder = new StringBuilder();
94102
builder.Append(_request.BasePath);
95103
builder.Append("/");
96104
builder.Append(resourceContext.PublicName);
97105

98-
string resourceId = _request.PrimaryId;
99-
if (resourceId != null)
106+
if (_request.PrimaryId != null)
100107
{
101108
builder.Append("/");
102-
builder.Append(resourceId);
109+
builder.Append(_request.PrimaryId);
103110
}
104111

105112
if (_request.Relationship != null)
@@ -108,49 +115,102 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext)
108115
builder.Append(_request.Relationship.PublicName);
109116
}
110117

111-
builder.Append(DecodeSpecialCharacters(_queryStringAccessor.QueryString.Value));
118+
string queryString = BuildQueryString(queryStringUpdateAction);
119+
builder.Append(queryString);
112120

113121
return builder.ToString();
114122
}
115123

124+
private string BuildQueryString(Action<Dictionary<string, string>> updateAction)
125+
{
126+
var parameters = _queryStringAccessor.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
127+
updateAction?.Invoke(parameters);
128+
string queryString = QueryString.Create(parameters).Value;
129+
130+
return DecodeSpecialCharacters(queryString);
131+
}
132+
133+
private static string DecodeSpecialCharacters(string uri)
134+
{
135+
return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":");
136+
}
137+
116138
private string GetPageLink(ResourceContext resourceContext, int pageOffset, PageSize pageSize)
117139
{
118-
string queryString = BuildQueryString(parameters =>
140+
return GetSelfTopLevelLink(resourceContext, parameters =>
119141
{
120-
if (pageSize == null || pageSize.Equals(_options.DefaultPageSize))
142+
var existingPageSizeParameterValue = parameters.ContainsKey(_pageSizeParameterName)
143+
? parameters[_pageSizeParameterName]
144+
: null;
145+
146+
PageSize newTopPageSize = Equals(pageSize, _options.DefaultPageSize) ? null : pageSize;
147+
148+
string newPageSizeParameterValue = ChangeTopPageSize(existingPageSizeParameterValue, newTopPageSize);
149+
if (newPageSizeParameterValue == null)
121150
{
122-
parameters.Remove("page[size]");
151+
parameters.Remove(_pageSizeParameterName);
123152
}
124153
else
125154
{
126-
parameters["page[size]"] = pageSize.ToString();
155+
parameters[_pageSizeParameterName] = newPageSizeParameterValue;
127156
}
128157

129158
if (pageOffset == 1)
130159
{
131-
parameters.Remove("page[number]");
160+
parameters.Remove(_pageNumberParameterName);
132161
}
133162
else
134163
{
135-
parameters["page[number]"] = pageOffset.ToString();
164+
parameters[_pageNumberParameterName] = pageOffset.ToString();
136165
}
137166
});
138-
139-
return $"{_request.BasePath}/{resourceContext.PublicName}" + queryString;
140167
}
141168

142-
private string BuildQueryString(Action<Dictionary<string, string>> updateAction)
169+
private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize)
143170
{
144-
var parameters = _queryStringAccessor.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString());
145-
updateAction(parameters);
146-
string queryString = QueryString.Create(parameters).Value;
171+
var elements = ParsePageSizeExpression(pageSizeParameterValue);
172+
var elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null);
147173

148-
return DecodeSpecialCharacters(queryString);
174+
if (topPageSize != null)
175+
{
176+
var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value);
177+
178+
if (elementInTopScopeIndex != -1)
179+
{
180+
elements[elementInTopScopeIndex] = topPageSizeElement;
181+
}
182+
else
183+
{
184+
elements.Insert(0, topPageSizeElement);
185+
}
186+
}
187+
else
188+
{
189+
if (elementInTopScopeIndex != -1)
190+
{
191+
elements.RemoveAt(elementInTopScopeIndex);
192+
}
193+
}
194+
195+
var parameterValue = string.Join(',',
196+
elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}"));
197+
198+
return parameterValue == string.Empty ? null : parameterValue;
149199
}
150200

151-
private static string DecodeSpecialCharacters(string uri)
201+
private List<PaginationElementQueryStringValueExpression> ParsePageSizeExpression(string pageSizeParameterValue)
152202
{
153-
return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'");
203+
if (pageSizeParameterValue == null)
204+
{
205+
return new List<PaginationElementQueryStringValueExpression>();
206+
}
207+
208+
var requestResource = _request.SecondaryResource ?? _request.PrimaryResource;
209+
210+
var parser = new PaginationParser(_provider);
211+
var paginationExpression = parser.Parse(pageSizeParameterValue, requestResource);
212+
213+
return new List<PaginationElementQueryStringValueExpression>(paginationExpression.Elements);
154214
}
155215

156216
/// <inheritdoc />

src/JsonApiDotNetCore/Services/JsonApiResourceService.cs

+28-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections;
23
using System.Collections.Generic;
34
using System.Linq;
45
using System.Threading.Tasks;
@@ -119,6 +120,11 @@ public virtual async Task<IReadOnlyCollection<TResource>> GetAsync()
119120
{
120121
var topFilter = _queryLayerComposer.GetTopFilter();
121122
_paginationContext.TotalResourceCount = await _repository.CountAsync(topFilter);
123+
124+
if (_paginationContext.TotalResourceCount == 0)
125+
{
126+
return Array.Empty<TResource>();
127+
}
122128
}
123129

124130
var queryLayer = _queryLayerComposer.Compose(_request.PrimaryResource);
@@ -130,6 +136,11 @@ public virtual async Task<IReadOnlyCollection<TResource>> GetAsync()
130136
return _hookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray();
131137
}
132138

139+
if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count)
140+
{
141+
_paginationContext.IsPageFull = true;
142+
}
143+
133144
return resources;
134145
}
135146

@@ -233,6 +244,14 @@ public virtual async Task<object> GetSecondaryAsync(TId id, string relationshipN
233244
var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource);
234245
var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship);
235246

247+
if (_request.IsCollection && _options.IncludeTotalResourceCount)
248+
{
249+
// TODO: Consider support for pagination links on secondary resource collection. This requires to call Count() on the inverse relationship (which may not exist).
250+
// For /blogs/1/articles we need to execute Count(Articles.Where(article => article.Blog.Id == 1 && article.Blog.existingFilter))) to determine TotalResourceCount.
251+
// This also means we need to invoke ResourceRepository<Article>.CountAsync() from ResourceService<Blog>.
252+
// And we should call BlogResourceDefinition.OnApplyFilter to filter out soft-deleted blogs and translate from equals('IsDeleted','false') to equals('Blog.IsDeleted','false')
253+
}
254+
236255
var primaryResources = await _repository.GetAsync(primaryLayer);
237256

238257
var primaryResource = primaryResources.SingleOrDefault();
@@ -244,7 +263,15 @@ public virtual async Task<object> GetSecondaryAsync(TId id, string relationshipN
244263
primaryResource = _hookExecutor.OnReturn(AsList(primaryResource), ResourcePipeline.GetRelationship).Single();
245264
}
246265

247-
return _request.Relationship.GetValue(primaryResource);
266+
var secondaryResource = _request.Relationship.GetValue(primaryResource);
267+
268+
if (secondaryResource is ICollection secondaryResources &&
269+
secondaryLayer.Pagination?.PageSize != null && secondaryLayer.Pagination.PageSize.Value == secondaryResources.Count)
270+
{
271+
_paginationContext.IsPageFull = true;
272+
}
273+
274+
return secondaryResource;
248275
}
249276

250277
/// <inheritdoc />

0 commit comments

Comments
 (0)