Skip to content

Commit b4835af

Browse files
committed
Enable to enforce pagination disabled per relationship
1 parent db8ee0a commit b4835af

File tree

10 files changed

+292
-8
lines changed

10 files changed

+292
-8
lines changed

src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ public HasManyCapabilities Capabilities
5050
set => _capabilities = value;
5151
}
5252

53+
/// <summary>
54+
/// When set to <c>true</c>, disables pagination for this relationship, which overrules global options, resource definitions, and the <c>page</c> query
55+
/// string parameter. Only use this when the number of related resources is known to always be small.
56+
/// </summary>
57+
public bool DisablePagination { get; set; }
58+
5359
public HasManyAttribute()
5460
{
5561
_lazyIsManyToMany = new Lazy<bool>(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly);

src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ public sealed class HasManyAttribute : RelationshipAttribute
1111
{
1212
/// <summary />
1313
public HasManyCapabilities Capabilities { get; set; }
14+
15+
/// <summary />
16+
public bool DisablePagination { get; set; }
1417
}

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume
124124

125125
if (newElements.Count != 0)
126126
{
127-
var newExpression = new SortExpression(newElements);
127+
var newExpression = new SortExpression(newElements, expression.IsAutoGenerated);
128128
return newExpression.Equals(expression) ? expression : newExpression;
129129
}
130130

src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,27 @@ namespace JsonApiDotNetCore.Queries.Expressions;
1313
[PublicAPI]
1414
public class SortExpression : QueryExpression
1515
{
16+
/// <summary>
17+
/// Indicates whether this expression was generated by JsonApiDotNetCore to ensure a deterministic order.
18+
/// </summary>
19+
internal bool IsAutoGenerated { get; }
20+
1621
/// <summary>
1722
/// One or more elements to sort on.
1823
/// </summary>
1924
public IImmutableList<SortElementExpression> Elements { get; }
2025

2126
public SortExpression(IImmutableList<SortElementExpression> elements)
27+
: this(elements, false)
28+
{
29+
}
30+
31+
internal SortExpression(IImmutableList<SortElementExpression> elements, bool isAutoGenerated)
2232
{
2333
ArgumentGuard.NotNullNorEmpty(elements);
2434

2535
Elements = elements;
36+
IsAutoGenerated = isAutoGenerated;
2637
}
2738

2839
public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
@@ -37,7 +48,7 @@ public override string ToString()
3748

3849
public override string ToFullString()
3950
{
40-
return string.Join(",", Elements.Select(child => child.ToFullString()));
51+
return $"{string.Join(",", Elements.Select(child => child.ToFullString()))}{(IsAutoGenerated ? " (auto-generated)" : "")}";
4152
}
4253

4354
public override bool Equals(object? obj)
@@ -54,12 +65,13 @@ public override bool Equals(object? obj)
5465

5566
var other = (SortExpression)obj;
5667

57-
return Elements.SequenceEqual(other.Elements);
68+
return IsAutoGenerated == other.IsAutoGenerated && Elements.SequenceEqual(other.Elements);
5869
}
5970

6071
public override int GetHashCode()
6172
{
6273
var hashCode = new HashCode();
74+
hashCode.Add(IsAutoGenerated);
6375

6476
foreach (SortElementExpression element in Elements)
6577
{

src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,13 @@ private IImmutableSet<IncludeElementExpression> ProcessIncludeSet(IImmutableSet<
243243

244244
ResourceType resourceType = includeElement.Relationship.RightType;
245245
bool isToManyRelationship = includeElement.Relationship is HasManyAttribute;
246+
bool allowPagination = includeElement.Relationship is HasManyAttribute { DisablePagination: false };
246247

247248
var subLayer = new QueryLayer(resourceType)
248249
{
249250
Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null,
250251
Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null,
251-
Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null,
252+
Pagination = isToManyRelationship && allowPagination ? GetPagination(expressionsInCurrentScope, resourceType) : null,
252253
Selection = GetSelectionForSparseAttributeSet(resourceType)
253254
};
254255

@@ -384,12 +385,25 @@ public QueryLayer WrapLayerForSecondaryEndpoint<TId>(QueryLayer secondaryLayer,
384385
FilterExpression? primaryFilter = GetFilter(Array.Empty<QueryExpression>(), primaryResourceType);
385386
AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType);
386387

387-
return new QueryLayer(primaryResourceType)
388+
var primaryLayer = new QueryLayer(primaryResourceType)
388389
{
389390
Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship),
390391
Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter),
391392
Selection = primarySelection
392393
};
394+
395+
if (relationship is HasManyAttribute { DisablePagination: true } && secondaryLayer.Pagination != null)
396+
{
397+
secondaryLayer.Pagination = null;
398+
_paginationContext.PageSize = null;
399+
400+
if (secondaryLayer.Sort is { IsAutoGenerated: true })
401+
{
402+
secondaryLayer.Sort = null;
403+
}
404+
}
405+
406+
return primaryLayer;
393407
}
394408

395409
private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship)
@@ -554,7 +568,7 @@ private SortExpression CreateSortById(ResourceType resourceType)
554568
{
555569
AttrAttribute idAttribute = GetIdAttribute(resourceType);
556570
var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true);
557-
return new SortExpression(ImmutableArray.Create(idAscendingSort));
571+
return new SortExpression(ImmutableArray.Create(idAscendingSort), true);
558572
}
559573

560574
protected virtual PaginationExpression GetPagination(IReadOnlyCollection<QueryExpression> expressionsInScope, ResourceType resourceType)

test/AnnotationTests/Models/TreeNode.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public sealed class TreeNode : Identifiable<long>
1717
[HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)]
1818
public TreeNode? Parent { get; set; }
1919

20-
[HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All)]
20+
[HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All,
21+
DisablePagination = true)]
2122
public ISet<TreeNode> Children { get; set; } = new HashSet<TreeNode>();
2223
}

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ public sealed class Appointment : Identifiable<int>
2020
[Attr]
2121
public DateTimeOffset EndTime { get; set; }
2222

23-
[HasMany]
23+
[HasOne]
24+
public Calendar? Calendar { get; set; }
25+
26+
[HasMany(DisablePagination = true)]
2427
public IList<Reminder> Reminders { get; set; } = new List<Reminder>();
2528
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
using System.Net;
2+
using FluentAssertions;
3+
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Configuration;
5+
using JsonApiDotNetCore.Queries.Expressions;
6+
using JsonApiDotNetCore.Resources;
7+
using JsonApiDotNetCore.Serialization.Objects;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using TestBuildingBlocks;
10+
using Xunit;
11+
12+
namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Includes;
13+
14+
public sealed class DisablePaginationOnRelationshipTests : IClassFixture<IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext>>
15+
{
16+
private readonly IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> _testContext;
17+
private readonly QueryStringFakers _fakers = new();
18+
19+
public DisablePaginationOnRelationshipTests(IntegrationTestContext<TestableStartup<QueryStringDbContext>, QueryStringDbContext> testContext)
20+
{
21+
_testContext = testContext;
22+
23+
testContext.UseController<AppointmentsController>();
24+
testContext.UseController<CalendarsController>();
25+
26+
testContext.ConfigureServices(services =>
27+
{
28+
services.AddResourceDefinition<ReminderDefinition>();
29+
services.AddSingleton<PaginationToggle>();
30+
});
31+
32+
var paginationToggle = testContext.Factory.Services.GetRequiredService<PaginationToggle>();
33+
paginationToggle.IsEnabled = false;
34+
paginationToggle.IsCalled = false;
35+
36+
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>();
37+
options.DefaultPageSize = new PageSize(5);
38+
options.UseRelativeLinks = true;
39+
options.IncludeTotalResourceCount = true;
40+
}
41+
42+
[Fact]
43+
public async Task Can_include_in_primary_resources()
44+
{
45+
// Arrange
46+
Appointment appointment = _fakers.Appointment.GenerateOne();
47+
appointment.Reminders = _fakers.Reminder.GenerateList(7);
48+
49+
await _testContext.RunOnDatabaseAsync(async dbContext =>
50+
{
51+
await dbContext.ClearTableAsync<Appointment>();
52+
dbContext.Appointments.Add(appointment);
53+
await dbContext.SaveChangesAsync();
54+
});
55+
56+
const string route = "appointments?include=reminders";
57+
58+
// Act
59+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
60+
61+
// Assert
62+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
63+
64+
responseDocument.Data.ManyValue.Should().HaveCount(1);
65+
responseDocument.Data.ManyValue[0].Type.Should().Be("appointments");
66+
responseDocument.Data.ManyValue[0].Id.Should().Be(appointment.StringId);
67+
68+
responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("reminders").WhoseValue.With(value =>
69+
{
70+
value.Should().NotBeNull();
71+
value.Data.ManyValue.Should().HaveCount(7);
72+
73+
value.Links.Should().NotBeNull();
74+
value.Links.Self.Should().Be($"/appointments/{appointment.StringId}/relationships/reminders");
75+
value.Links.Related.Should().Be($"/appointments/{appointment.StringId}/reminders");
76+
});
77+
78+
responseDocument.Included.Should().HaveCount(7);
79+
responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders"));
80+
81+
responseDocument.Meta.Should().ContainTotal(1);
82+
}
83+
84+
[Fact]
85+
public async Task Can_get_all_secondary_resources()
86+
{
87+
// Arrange
88+
Appointment appointment = _fakers.Appointment.GenerateOne();
89+
appointment.Reminders = _fakers.Reminder.GenerateList(7);
90+
91+
await _testContext.RunOnDatabaseAsync(async dbContext =>
92+
{
93+
dbContext.Appointments.Add(appointment);
94+
await dbContext.SaveChangesAsync();
95+
});
96+
97+
string route = $"appointments/{appointment.StringId}/reminders";
98+
99+
// Act
100+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
101+
102+
// Assert
103+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
104+
105+
responseDocument.Data.ManyValue.Should().HaveCount(7);
106+
responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders"));
107+
108+
responseDocument.Links.Should().NotBeNull();
109+
responseDocument.Links.First.Should().BeNull();
110+
responseDocument.Links.Next.Should().BeNull();
111+
responseDocument.Links.Last.Should().BeNull();
112+
113+
responseDocument.Meta.Should().ContainTotal(7);
114+
}
115+
116+
[Fact]
117+
public async Task Can_get_ToMany_relationship()
118+
{
119+
// Arrange
120+
Appointment appointment = _fakers.Appointment.GenerateOne();
121+
appointment.Reminders = _fakers.Reminder.GenerateList(7);
122+
123+
await _testContext.RunOnDatabaseAsync(async dbContext =>
124+
{
125+
dbContext.Appointments.Add(appointment);
126+
await dbContext.SaveChangesAsync();
127+
});
128+
129+
string route = $"appointments/{appointment.StringId}/relationships/reminders";
130+
131+
// Act
132+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
133+
134+
// Assert
135+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
136+
137+
responseDocument.Data.ManyValue.Should().HaveCount(7);
138+
responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders"));
139+
140+
responseDocument.Links.Should().NotBeNull();
141+
responseDocument.Links.First.Should().BeNull();
142+
responseDocument.Links.Next.Should().BeNull();
143+
responseDocument.Links.Last.Should().BeNull();
144+
145+
responseDocument.Meta.Should().ContainTotal(7);
146+
}
147+
148+
[Fact]
149+
public async Task Ignores_pagination_from_query_string()
150+
{
151+
// Arrange
152+
Calendar calendar = _fakers.Calendar.GenerateOne();
153+
calendar.Appointments = _fakers.Appointment.GenerateSet(3);
154+
calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.GenerateList(7);
155+
156+
await _testContext.RunOnDatabaseAsync(async dbContext =>
157+
{
158+
dbContext.Calendars.Add(calendar);
159+
await dbContext.SaveChangesAsync();
160+
});
161+
162+
string route = $"calendars/{calendar.StringId}/appointments?include=reminders&page[size]=2,reminders:4";
163+
164+
// Act
165+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
166+
167+
// Assert
168+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
169+
170+
responseDocument.Data.ManyValue.Should().HaveCount(2);
171+
responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("appointments"));
172+
173+
ResourceObject firstAppointment = responseDocument.Data.ManyValue.Single(resource => resource.Id == calendar.Appointments.ElementAt(0).StringId);
174+
175+
firstAppointment.Relationships.Should().ContainKey("reminders").WhoseValue.With(value =>
176+
{
177+
value.Should().NotBeNull();
178+
value.Data.ManyValue.Should().HaveCount(7);
179+
});
180+
181+
responseDocument.Included.Should().HaveCount(7);
182+
responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders"));
183+
184+
responseDocument.Meta.Should().ContainTotal(3);
185+
}
186+
187+
[Fact]
188+
public async Task Ignores_pagination_from_resource_definition()
189+
{
190+
// Arrange
191+
var paginationToggle = _testContext.Factory.Services.GetRequiredService<PaginationToggle>();
192+
paginationToggle.IsEnabled = true;
193+
194+
Appointment appointment = _fakers.Appointment.GenerateOne();
195+
appointment.Reminders = _fakers.Reminder.GenerateList(7);
196+
197+
await _testContext.RunOnDatabaseAsync(async dbContext =>
198+
{
199+
dbContext.Appointments.Add(appointment);
200+
await dbContext.SaveChangesAsync();
201+
});
202+
203+
string route = $"appointments/{appointment.StringId}/reminders";
204+
205+
// Act
206+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
207+
208+
// Assert
209+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
210+
211+
responseDocument.Data.ManyValue.Should().HaveCount(7);
212+
responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders"));
213+
214+
responseDocument.Meta.Should().ContainTotal(7);
215+
216+
paginationToggle.IsCalled.Should().BeTrue();
217+
}
218+
219+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
220+
private sealed class ReminderDefinition(PaginationToggle paginationToggle, IResourceGraph resourceGraph)
221+
: JsonApiResourceDefinition<Reminder, int>(resourceGraph)
222+
{
223+
private readonly PaginationToggle _paginationToggle = paginationToggle;
224+
225+
public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination)
226+
{
227+
_paginationToggle.IsCalled = true;
228+
return _paginationToggle.IsEnabled ? new PaginationExpression(PageNumber.ValueOne, new PageSize(4)) : existingPagination;
229+
}
230+
}
231+
232+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
233+
private sealed class PaginationToggle
234+
{
235+
public bool IsEnabled { get; set; }
236+
public bool IsCalled { get; set; }
237+
}
238+
}

0 commit comments

Comments
 (0)