Skip to content

Commit 4798144

Browse files
author
Bart Koelman
authored
Secondary paging (#1100)
* JSON:API spec compliance: do not unescape brackets in response From https://jsonapi.org/format/1.1/#appendix-query-details-square-brackets: > According to the query parameter serialization rules above, a compliant implementation will percent-encode these square brackets. * Updated existing IResourceDefinition tests to capture all relevant callbacks * Retrieve total resource count on secondary/relationship endpoints using inverse relationship Bugfix: links.next was not set on full page at relationship endpoint * Rename flags enum to plural * Clarified documentation; fixed broken link * Check off roadmap entry
1 parent 1c2313a commit 4798144

File tree

61 files changed

+1069
-453
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+1069
-453
lines changed

ROADMAP.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ The need for breaking changes has blocked several efforts in the v4.x release, s
2222
- [x] Support System.Text.Json [#664](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/664) [#999](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/999) [1077](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1077) [1078](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1078)
2323
- [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233)
2424
- [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029)
25+
- [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010)
2526

2627
Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version.
2728

28-
- Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010)
2929
- Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365)
3030
- Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170)
3131
- Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004)

benchmarks/Serialization/ResourceSerializationBenchmarks.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG
129129
RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4));
130130
RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5));
131131

132-
ImmutableArray<ResourceFieldAttribute> chain = ArrayFactory.Create<ResourceFieldAttribute>(single2, single3, multi4, multi5).ToImmutableArray();
132+
ImmutableArray<ResourceFieldAttribute> chain = ImmutableArray.Create<ResourceFieldAttribute>(single2, single3, multi4, multi5);
133133
IEnumerable<ResourceFieldChainExpression> chains = new ResourceFieldChainExpression(chain).AsEnumerable();
134134

135135
var converter = new IncludeChainConverter();

docs/usage/options.md

+3
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ options.MaximumPageNumber = new PageNumber(50);
3939
options.IncludeTotalResourceCount = true;
4040
```
4141

42+
To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined.
43+
If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full.
44+
4245
## Relative Links
4346

4447
All links are absolute by default. However, you can configure relative links.

docs/usage/resources/nullability.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Properties on a resource class can be declared as nullable or non-nullable. This affects both ASP.NET ModelState validation and the way Entity Framework Core generates database columns.
44

5-
ModelState validation is enabled by default since v5.0. In earlier versions, it can be enabled in [options](~/usage/options.md#enable-modelstate-validation).
5+
ModelState validation is enabled by default since v5.0. In earlier versions, it can be enabled in [options](~/usage/options.md#modelstate-validation).
66

77
# Value types
88

src/JsonApiDotNetCore/CollectionExtensions.cs

+7
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ public static IEnumerable<T> EmptyIfNull<T>(this IEnumerable<T>? source)
7676
return source ?? Enumerable.Empty<T>();
7777
}
7878

79+
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
80+
{
81+
#pragma warning disable AV1250 // Evaluate LINQ query before returning it
82+
return source.Where(element => element is not null)!;
83+
#pragma warning restore AV1250 // Evaluate LINQ query before returning it
84+
}
85+
7986
public static void AddRange<T>(this ICollection<T> source, IEnumerable<T> itemsToAdd)
8087
{
8188
ArgumentGuard.NotNull(source, nameof(source));

src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs

+9
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ public LogicalExpression(LogicalOperator @operator, IImmutableList<FilterExpress
3434
Terms = terms;
3535
}
3636

37+
public static FilterExpression? Compose(LogicalOperator @operator, params FilterExpression?[] filters)
38+
{
39+
ArgumentGuard.NotNull(filters, nameof(filters));
40+
41+
ImmutableArray<FilterExpression> terms = filters.WhereNotNull().ToImmutableArray();
42+
43+
return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault();
44+
}
45+
3746
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
3847
{
3948
return visitor.VisitLogical(this, argument);

src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs

+7-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@ namespace JsonApiDotNetCore.Queries
1212
public interface IQueryLayerComposer
1313
{
1414
/// <summary>
15-
/// Builds a top-level filter from constraints, used to determine total resource count.
15+
/// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint.
1616
/// </summary>
17-
FilterExpression? GetTopFilterFromConstraints(ResourceType primaryResourceType);
17+
FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType);
18+
19+
/// <summary>
20+
/// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint.
21+
/// </summary>
22+
FilterExpression? GetSecondaryFilterFromConstraints<TId>(TId primaryId, HasManyAttribute hasManyRelationship);
1823

1924
/// <summary>
2025
/// Collects constraints and builds a <see cref="QueryLayer" /> out of them, used to retrieve the actual resources.

src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs

+73-4
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public QueryLayerComposer(IEnumerable<IQueryConstraintProvider> constraintProvid
4646
}
4747

4848
/// <inheritdoc />
49-
public FilterExpression? GetTopFilterFromConstraints(ResourceType primaryResourceType)
49+
public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType)
5050
{
5151
ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray();
5252

@@ -65,6 +65,75 @@ public QueryLayerComposer(IEnumerable<IQueryConstraintProvider> constraintProvid
6565
return GetFilter(filtersInTopScope, primaryResourceType);
6666
}
6767

68+
/// <inheritdoc />
69+
public FilterExpression? GetSecondaryFilterFromConstraints<TId>(TId primaryId, HasManyAttribute hasManyRelationship)
70+
{
71+
ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship));
72+
73+
if (hasManyRelationship.InverseNavigationProperty == null)
74+
{
75+
return null;
76+
}
77+
78+
RelationshipAttribute? inverseRelationship =
79+
hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name);
80+
81+
if (inverseRelationship == null)
82+
{
83+
return null;
84+
}
85+
86+
ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray();
87+
88+
var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship);
89+
90+
// @formatter:wrap_chained_method_calls chop_always
91+
// @formatter:keep_existing_linebreaks true
92+
93+
FilterExpression[] filtersInSecondaryScope = constraints
94+
.Where(constraint => secondaryScope.Equals(constraint.Scope))
95+
.Select(constraint => constraint.Expression)
96+
.OfType<FilterExpression>()
97+
.ToArray();
98+
99+
// @formatter:keep_existing_linebreaks restore
100+
// @formatter:wrap_chained_method_calls restore
101+
102+
FilterExpression? primaryFilter = GetFilter(Array.Empty<QueryExpression>(), hasManyRelationship.LeftType);
103+
FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType);
104+
105+
FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship);
106+
107+
return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter);
108+
}
109+
110+
private static FilterExpression GetInverseRelationshipFilter<TId>(TId primaryId, HasManyAttribute relationship,
111+
RelationshipAttribute inverseRelationship)
112+
{
113+
return inverseRelationship is HasManyAttribute hasManyInverseRelationship
114+
? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship)
115+
: GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship);
116+
}
117+
118+
private static FilterExpression GetInverseHasOneRelationshipFilter<TId>(TId primaryId, HasManyAttribute relationship,
119+
HasOneAttribute inverseRelationship)
120+
{
121+
AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType);
122+
var idChain = new ResourceFieldChainExpression(ImmutableArray.Create<ResourceFieldAttribute>(inverseRelationship, idAttribute));
123+
124+
return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!));
125+
}
126+
127+
private static FilterExpression GetInverseHasManyRelationshipFilter<TId>(TId primaryId, HasManyAttribute relationship,
128+
HasManyAttribute inverseRelationship)
129+
{
130+
AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType);
131+
var idChain = new ResourceFieldChainExpression(ImmutableArray.Create<ResourceFieldAttribute>(idAttribute));
132+
var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!));
133+
134+
return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison);
135+
}
136+
68137
/// <inheritdoc />
69138
public QueryLayer ComposeFromConstraints(ResourceType requestResourceType)
70139
{
@@ -309,7 +378,7 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression?
309378
filter = new AnyExpression(idChain, constants);
310379
}
311380

312-
return filter == null ? existingFilter : existingFilter == null ? filter : new LogicalExpression(LogicalOperator.And, filter, existingFilter);
381+
return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter);
313382
}
314383

315384
/// <inheritdoc />
@@ -419,8 +488,8 @@ protected virtual IImmutableSet<IncludeElementExpression> GetIncludeElements(IIm
419488
ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope));
420489
ArgumentGuard.NotNull(resourceType, nameof(resourceType));
421490

422-
ImmutableArray<FilterExpression> filters = expressionsInScope.OfType<FilterExpression>().ToImmutableArray();
423-
FilterExpression? filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault();
491+
FilterExpression[] filters = expressionsInScope.OfType<FilterExpression>().ToArray();
492+
FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters);
424493

425494
return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter);
426495
}

src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,11 @@ public virtual async Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer qu
8585
}
8686

8787
/// <inheritdoc />
88-
public virtual async Task<int> CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken)
88+
public virtual async Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken)
8989
{
9090
_traceWriter.LogMethodStart(new
9191
{
92-
topFilter
92+
filter
9393
});
9494

9595
using (CodeTimingSessionManager.Current.Measure("Repository - Count resources"))
@@ -98,7 +98,7 @@ public virtual async Task<int> CountAsync(FilterExpression? topFilter, Cancellat
9898

9999
var layer = new QueryLayer(resourceType)
100100
{
101-
Filter = topFilter
101+
Filter = filter
102102
};
103103

104104
IQueryable<TResource> query = ApplyQueryLayer(layer);

src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ public interface IResourceReadRepository<TResource, in TId>
2727
Task<IReadOnlyCollection<TResource>> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken);
2828

2929
/// <summary>
30-
/// Executes a read query using the specified top-level filter and returns the top-level count of matching resources.
30+
/// Executes a read query using the specified filter and returns the count of matching resources.
3131
/// </summary>
32-
Task<int> CountAsync(FilterExpression? topFilter, CancellationToken cancellationToken);
32+
Task<int> CountAsync(FilterExpression? filter, CancellationToken cancellationToken);
3333
}
3434
}

src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@ Task<IReadOnlyCollection<TResource>> GetAsync<TResource>(QueryLayer queryLayer,
2727
/// <summary>
2828
/// Invokes <see cref="IResourceReadRepository{TResource,TId}.CountAsync" /> for the specified resource type.
2929
/// </summary>
30-
Task<int> CountAsync<TResource>(FilterExpression? topFilter, CancellationToken cancellationToken)
31-
where TResource : class, IIdentifiable;
30+
Task<int> CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken);
3231

3332
/// <summary>
3433
/// Invokes <see cref="IResourceWriteRepository{TResource,TId}.GetForCreateAsync" />.

src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs

+3-4
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,10 @@ public async Task<IReadOnlyCollection<IIdentifiable>> GetAsync(ResourceType reso
5050
}
5151

5252
/// <inheritdoc />
53-
public async Task<int> CountAsync<TResource>(FilterExpression? topFilter, CancellationToken cancellationToken)
54-
where TResource : class, IIdentifiable
53+
public async Task<int> CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken)
5554
{
56-
dynamic repository = ResolveReadRepository(typeof(TResource));
57-
return (int)await repository.CountAsync(topFilter, cancellationToken);
55+
dynamic repository = ResolveReadRepository(resourceType);
56+
return (int)await repository.CountAsync(filter, cancellationToken);
5857
}
5958

6059
/// <inheritdoc />

src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs

+2-7
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourc
114114

115115
private string GetLinkForTopLevelSelf()
116116
{
117+
// Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting.
117118
return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl();
118119
}
119120

@@ -223,13 +224,7 @@ private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeVa
223224
parameters[PageNumberParameterName] = pageOffset.ToString();
224225
}
225226

226-
string queryStringValue = QueryString.Create(parameters).Value ?? string.Empty;
227-
return DecodeSpecialCharacters(queryStringValue);
228-
}
229-
230-
private static string DecodeSpecialCharacters(string uri)
231-
{
232-
return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":");
227+
return QueryString.Create(parameters).Value ?? string.Empty;
233228
}
234229

235230
/// <inheritdoc />

0 commit comments

Comments
 (0)