diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index 348233253e..0a2f5eec28 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -18,6 +18,7 @@ function EnsureHttpServerIsInstalled { throw "Unable to find npm in your PATH. please install Node.js first." } + # If this command fails with ENOENT after installing Node.js on Windows, manually create the directory %APPDATA%\npm. npm list --depth 1 --global httpserver >$null if ($LastExitCode -eq 1) { diff --git a/docs/usage/options.md b/docs/usage/options.md index 7e89ff0090..c78e9584e1 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -59,6 +59,9 @@ options.IncludeTotalResourceCount = true; 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. If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. +> [!TIP] +> Since v5.8, pagination can be [turned off per relationship](~/usage/resources/relationships.md#disable-pagination). + ## Relative Links All links are absolute by default. However, you can configure relative links: diff --git a/docs/usage/reading/pagination.md b/docs/usage/reading/pagination.md index e53ae1c3a5..dd02662c67 100644 --- a/docs/usage/reading/pagination.md +++ b/docs/usage/reading/pagination.md @@ -25,3 +25,6 @@ GET /api/blogs/1/articles?include=revisions&page[size]=10,revisions:5&page[numbe ## Configuring Default Behavior You can configure the global default behavior as described [here](~/usage/options.md#pagination). + +> [!TIP] +> Since v5.8, pagination can be [turned off per relationship](~/usage/resources/relationships.md#disable-pagination). diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index f318b2ddcd..b8c563e94e 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -213,6 +213,17 @@ public class Person : Identifiable The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems"). +### Disable pagination + +_since v5.8_ + +Pagination can be turned off per to-many relationship by setting `DisablePagination` to `true`. +When doing so, it overrules the global pagination settings in options, and any pagination used in the query string +for the relationship. + +This feature exists for cases where the number of *related* resources is typically small. +For example, while the number of products is usually high, the number of products *in a shopping basket* is not. + ## HasManyThrough _removed since v5.0_ diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index a906f4a667..7eb521aad8 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -50,6 +50,18 @@ public HasManyCapabilities Capabilities set => _capabilities = value; } + /// + /// When set to true, overrules the default page size, the page size from a resource definition, and the + /// + /// page[size] + /// + /// query string parameter by forcibly turning off pagination on the related resources for this relationship. + /// + /// + /// Caution: only use this when the number of related resources (along with their nested includes) is known to always be small. + /// + public bool DisablePagination { get; set; } + public HasManyAttribute() { _lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs index cf83f0ce17..9defe8d1d8 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs @@ -11,4 +11,7 @@ public sealed class HasManyAttribute : RelationshipAttribute { /// public HasManyCapabilities Capabilities { get; set; } + + /// + public bool DisablePagination { get; set; } } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 7141125e40..b8ec3ab43e 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -105,7 +105,8 @@ public interface IJsonApiOptions bool IncludeTotalResourceCount { get; } /// - /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default. + /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default. This setting can + /// be overruled per relationship by setting to true. /// PageSize? DefaultPageSize { get; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 173c77503c..48613d6f60 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -124,7 +124,7 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume if (newElements.Count != 0) { - var newExpression = new SortExpression(newElements); + var newExpression = new SortExpression(newElements, expression.IsAutoGenerated); return newExpression.Equals(expression) ? expression : newExpression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 9c63e46013..d51ab6dff0 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -13,16 +13,27 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class SortExpression : QueryExpression { + /// + /// Indicates whether this expression was generated by JsonApiDotNetCore to ensure a deterministic order. + /// + internal bool IsAutoGenerated { get; } + /// /// One or more elements to sort on. /// public IImmutableList Elements { get; } public SortExpression(IImmutableList elements) + : this(elements, false) + { + } + + internal SortExpression(IImmutableList elements, bool isAutoGenerated) { ArgumentGuard.NotNullNorEmpty(elements); Elements = elements; + IsAutoGenerated = isAutoGenerated; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -37,7 +48,7 @@ public override string ToString() public override string ToFullString() { - return string.Join(",", Elements.Select(child => child.ToFullString())); + return $"{string.Join(",", Elements.Select(child => child.ToFullString()))}{(IsAutoGenerated ? " (auto-generated)" : "")}"; } public override bool Equals(object? obj) @@ -54,12 +65,13 @@ public override bool Equals(object? obj) var other = (SortExpression)obj; - return Elements.SequenceEqual(other.Elements); + return IsAutoGenerated == other.IsAutoGenerated && Elements.SequenceEqual(other.Elements); } public override int GetHashCode() { var hashCode = new HashCode(); + hashCode.Add(IsAutoGenerated); foreach (SortElementExpression element in Elements) { diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs index 7954aa0e76..020040e928 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -243,12 +243,13 @@ private IImmutableSet ProcessIncludeSet(IImmutableSet< ResourceType resourceType = includeElement.Relationship.RightType; bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; + bool allowPagination = includeElement.Relationship is HasManyAttribute { DisablePagination: false }; var subLayer = new QueryLayer(resourceType) { Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, - Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null, + Pagination = allowPagination ? GetPagination(expressionsInCurrentScope, resourceType) : null, Selection = GetSelectionForSparseAttributeSet(resourceType) }; @@ -384,12 +385,26 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - return new QueryLayer(primaryResourceType) + var primaryLayer = new QueryLayer(primaryResourceType) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter), Selection = primarySelection }; + + if (relationship is HasManyAttribute { DisablePagination: true } && secondaryLayer.Pagination != null) + { + // Undo pagination/sort. At the time secondaryLayer was being built, we were not yet aware that it needed to be turned off. + secondaryLayer.Pagination = null; + _paginationContext.PageSize = null; + + if (secondaryLayer.Sort is { IsAutoGenerated: true }) + { + secondaryLayer.Sort = null; + } + } + + return primaryLayer; } private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) @@ -554,7 +569,7 @@ private SortExpression CreateSortById(ResourceType resourceType) { AttrAttribute idAttribute = GetIdAttribute(resourceType); var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); - return new SortExpression(ImmutableArray.Create(idAscendingSort)); + return new SortExpression(ImmutableArray.Create(idAscendingSort), true); } protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs index 269758fef6..6afdb35994 100644 --- a/test/AnnotationTests/Models/TreeNode.cs +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -17,6 +17,7 @@ public sealed class TreeNode : Identifiable [HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)] public TreeNode? Parent { get; set; } - [HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All)] + [HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All, + DisablePagination = true)] public ISet Children { get; set; } = new HashSet(); } diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs index 9783672e63..961f4e9f9c 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -50,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Type.Should().Be("people"); responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); store.SqlCommands.Should().HaveCount(1); @@ -95,7 +95,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.Value.Should().BeNull(); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); store.SqlCommands.Should().HaveCount(1); diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs index 52bb378b2a..40a3345a96 100644 --- a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -128,7 +128,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(todoItem.LastModifiedAt); responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); store.SqlCommands.Should().HaveCount(1); @@ -285,7 +285,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(todoItem.Owner.DisplayName); responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); store.SqlCommands.Should().HaveCount(1); @@ -329,7 +329,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.Should().BeNull(); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); store.SqlCommands.Should().HaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index e13a922941..5619615c0c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track!) + .WithOne(lyric => lyric.Track) .HasForeignKey("LyricId"); builder.Entity() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index c4f8e0f26a..6095238d31 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -30,7 +30,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(dealership => dealership.Inventory) - .WithOne(car => car.Dealership!); + .WithOne(car => car.Dealership); builder.Entity() .HasMany(car => car.PreviousDealerships) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 9a480988cc..c3adeca216 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent!); + .WithOne(systemDirectory => systemDirectory.Parent); builder.Entity() .HasOne(systemDirectory => systemDirectory.Self) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index aa4eb06598..f07911d324 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -122,7 +122,7 @@ public async Task Hides_resource_count_in_create_resource_response() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); } [Fact] @@ -160,6 +160,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 36a93f9ee3..41a05b13f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -20,6 +20,9 @@ public sealed class Appointment : Identifiable [Attr] public DateTimeOffset EndTime { get; set; } - [HasMany] + [HasOne] + public Calendar? Calendar { get; set; } + + [HasMany(DisablePagination = true)] public IList Reminders { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/DisablePaginationOnRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/DisablePaginationOnRelationshipTests.cs new file mode 100644 index 0000000000..219d1a58eb --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/DisablePaginationOnRelationshipTests.cs @@ -0,0 +1,238 @@ +using System.Net; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Includes; + +public sealed class DisablePaginationOnRelationshipTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public DisablePaginationOnRelationshipTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddResourceDefinition(); + services.AddSingleton(); + }); + + var paginationToggle = testContext.Factory.Services.GetRequiredService(); + paginationToggle.IsEnabled = false; + paginationToggle.IsCalled = false; + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.DefaultPageSize = new PageSize(5); + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Can_include_in_primary_resources() + { + // Arrange + Appointment appointment = _fakers.Appointment.GenerateOne(); + appointment.Reminders = _fakers.Reminder.GenerateList(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Appointments.Add(appointment); + await dbContext.SaveChangesAsync(); + }); + + const string route = "appointments?include=reminders"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("appointments"); + responseDocument.Data.ManyValue[0].Id.Should().Be(appointment.StringId); + + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("reminders").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.ManyValue.Should().HaveCount(7); + + value.Links.Should().NotBeNull(); + value.Links.Self.Should().Be($"/appointments/{appointment.StringId}/relationships/reminders"); + value.Links.Related.Should().Be($"/appointments/{appointment.StringId}/reminders"); + }); + + responseDocument.Included.Should().HaveCount(7); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders")); + + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Can_get_all_secondary_resources() + { + // Arrange + Appointment appointment = _fakers.Appointment.GenerateOne(); + appointment.Reminders = _fakers.Reminder.GenerateList(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Appointments.Add(appointment); + await dbContext.SaveChangesAsync(); + }); + + string route = $"appointments/{appointment.StringId}/reminders"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(7); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders")); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + + responseDocument.Meta.Should().ContainTotal(7); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + Appointment appointment = _fakers.Appointment.GenerateOne(); + appointment.Reminders = _fakers.Reminder.GenerateList(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Appointments.Add(appointment); + await dbContext.SaveChangesAsync(); + }); + + string route = $"appointments/{appointment.StringId}/relationships/reminders"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(7); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders")); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + + responseDocument.Meta.Should().ContainTotal(7); + } + + [Fact] + public async Task Ignores_pagination_from_query_string() + { + // Arrange + Calendar calendar = _fakers.Calendar.GenerateOne(); + calendar.Appointments = _fakers.Appointment.GenerateSet(3); + calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.GenerateList(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"calendars/{calendar.StringId}/appointments?include=reminders&page[size]=2,reminders:4"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("appointments")); + + ResourceObject firstAppointment = responseDocument.Data.ManyValue.Single(resource => resource.Id == calendar.Appointments.ElementAt(0).StringId); + + firstAppointment.Relationships.Should().ContainKey("reminders").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.ManyValue.Should().HaveCount(7); + }); + + responseDocument.Included.Should().HaveCount(7); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders")); + + responseDocument.Meta.Should().ContainTotal(3); + } + + [Fact] + public async Task Ignores_pagination_from_resource_definition() + { + // Arrange + var paginationToggle = _testContext.Factory.Services.GetRequiredService(); + paginationToggle.IsEnabled = true; + + Appointment appointment = _fakers.Appointment.GenerateOne(); + appointment.Reminders = _fakers.Reminder.GenerateList(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Appointments.Add(appointment); + await dbContext.SaveChangesAsync(); + }); + + string route = $"appointments/{appointment.StringId}/reminders"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(7); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("reminders")); + + responseDocument.Meta.Should().ContainTotal(7); + + paginationToggle.IsCalled.Should().BeTrue(); + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class ReminderDefinition(PaginationToggle paginationToggle, IResourceGraph resourceGraph) + : JsonApiResourceDefinition(resourceGraph) + { + private readonly PaginationToggle _paginationToggle = paginationToggle; + + public override PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + _paginationToggle.IsCalled = true; + return _paginationToggle.IsEnabled ? new PaginationExpression(PageNumber.ValueOne, new PageSize(4)) : existingPagination; + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class PaginationToggle + { + public bool IsEnabled { get; set; } + public bool IsCalled { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 0a7102c3d2..4ea8f13b51 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -28,13 +28,17 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasMany(webAccount => webAccount.Posts) - .WithOne(blogPost => blogPost.Author!); + .WithOne(blogPost => blogPost.Author); builder.Entity() .HasOne(man => man.Wife) .WithOne(woman => woman.Husband) .HasForeignKey(); + builder.Entity() + .HasMany(calendar => calendar.Appointments) + .WithOne(appointment => appointment.Calendar); + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Reminder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Reminder.cs index 6143df11b0..34759f50de 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Reminder.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Reminder.cs @@ -10,4 +10,7 @@ public sealed class Reminder : Identifiable { [Attr] public DateTime RemindsAt { get; set; } + + [HasOne] + public Appointment? Appointment { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs index b76f018448..c1296f1c93 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/ReadWriteDbContext.cs @@ -29,7 +29,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(workItemGroup => workItemGroup.Color) - .WithOne(color => color.Group!) + .WithOne(color => color.Group) .HasForeignKey("GroupId"); builder.Entity() diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs index c22619a825..c6cc458ce0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionReadTests.cs @@ -438,7 +438,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(0).StringId); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -534,7 +534,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(star.Planets.ElementAt(0).StringId); - responseDocument.Meta.Should().BeNull(); + responseDocument.Meta.Should().NotContainTotal(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs index cbbf8142c5..9282998c63 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs @@ -17,7 +17,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasMany(scholarship => scholarship.Participants) - .WithOne(student => student.Scholarship!); + .WithOne(student => student.Scholarship); base.OnModelCreating(builder); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs index 836a4637dc..a3fb6045e9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/ZeroKeyDbContext.cs @@ -18,7 +18,7 @@ protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasMany(game => game.Maps) - .WithOne(map => map.Game!); + .WithOne(map => map.Game); builder.Entity() .HasOne(player => player.ActiveGame) diff --git a/test/OpenApiNSwagClientTests/LegacyOpenApi/ResponseTests.cs b/test/OpenApiNSwagClientTests/LegacyOpenApi/ResponseTests.cs index 0da98c30af..c8c88f6d4d 100644 --- a/test/OpenApiNSwagClientTests/LegacyOpenApi/ResponseTests.cs +++ b/test/OpenApiNSwagClientTests/LegacyOpenApi/ResponseTests.cs @@ -102,7 +102,7 @@ public async Task Getting_resource_collection_translates_response() // Assert response.Jsonapi.Should().BeNull(); response.Meta.Should().HaveCount(1); - response.Meta["total-resources"].Should().Be(documentMetaValue); + response.Meta.Should().ContainKey("total-resources").WhoseValue.Should().Be(documentMetaValue); response.Links.Self.Should().Be(topLevelLink); response.Links.First.Should().Be(topLevelLink); response.Links.Last.Should().Be(topLevelLink); @@ -112,7 +112,7 @@ public async Task Getting_resource_collection_translates_response() flight.Id.Should().Be(flightId); flight.Links.Self.Should().Be(flightResourceLink); flight.Meta.Should().HaveCount(1); - flight.Meta["docs"].Should().Be(flightMetaValue); + flight.Meta.Should().ContainKey("docs").WhoseValue.Should().Be(flightMetaValue); flight.Attributes.FinalDestination.Should().Be(flightDestination); flight.Attributes.StopOverDestination.Should().BeNull(); @@ -128,19 +128,19 @@ public async Task Getting_resource_collection_translates_response() flight.Relationships.Purser.Links.Self.Should().Be($"{flightResourceLink}/relationships/purser"); flight.Relationships.Purser.Links.Related.Should().Be($"{flightResourceLink}/purser"); flight.Relationships.Purser.Meta.Should().HaveCount(1); - flight.Relationships.Purser.Meta["docs"].Should().Be(purserMetaValue); + flight.Relationships.Purser.Meta.Should().ContainKey("docs").WhoseValue.Should().Be(purserMetaValue); flight.Relationships.CabinCrewMembers.Data.Should().BeNull(); flight.Relationships.CabinCrewMembers.Links.Self.Should().Be($"{flightResourceLink}/relationships/cabin-crew-members"); flight.Relationships.CabinCrewMembers.Links.Related.Should().Be($"{flightResourceLink}/cabin-crew-members"); flight.Relationships.CabinCrewMembers.Meta.Should().HaveCount(1); - flight.Relationships.CabinCrewMembers.Meta["docs"].Should().Be(cabinCrewMembersMetaValue); + flight.Relationships.CabinCrewMembers.Meta.Should().ContainKey("docs").WhoseValue.Should().Be(cabinCrewMembersMetaValue); flight.Relationships.Passengers.Data.Should().BeNull(); flight.Relationships.Passengers.Links.Self.Should().Be($"{flightResourceLink}/relationships/passengers"); flight.Relationships.Passengers.Links.Related.Should().Be($"{flightResourceLink}/passengers"); flight.Relationships.Passengers.Meta.Should().HaveCount(1); - flight.Relationships.Passengers.Meta["docs"].Should().Be(passengersMetaValue); + flight.Relationships.Passengers.Meta.Should().ContainKey("docs").WhoseValue.Should().Be(passengersMetaValue); } [Fact] diff --git a/test/TestBuildingBlocks/FluentMetaExtensions.cs b/test/TestBuildingBlocks/FluentMetaExtensions.cs index ff81bb47f0..72a5a8bb2c 100644 --- a/test/TestBuildingBlocks/FluentMetaExtensions.cs +++ b/test/TestBuildingBlocks/FluentMetaExtensions.cs @@ -19,6 +19,17 @@ public static void ContainTotal(this GenericDictionaryAssertions + /// Asserts that a "meta" dictionary does not contain a single element named "total" when not null. + /// + [CustomAssertion] +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + public static void NotContainTotal(this GenericDictionaryAssertions, string, object?> source, string? keyName = null) +#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + { + source.Subject?.Should().NotContainKey(keyName ?? "total"); + } + /// /// Asserts that a "meta" dictionary contains a single element named "requestBody" that isn't empty. ///