Skip to content

Enable to force-disable pagination per relationship #1750

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/build-dev.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/reading/pagination.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
11 changes: 11 additions & 0 deletions docs/usage/resources/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ public class Person : Identifiable<int>

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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ public HasManyCapabilities Capabilities
set => _capabilities = value;
}

/// <summary>
/// When set to <c>true</c>, overrules the default page size, the page size from a resource definition, and the
/// <c>
/// page[size]
/// </c>
/// query string parameter by forcibly turning off pagination on the related resources for this relationship.
/// </summary>
/// <remarks>
/// Caution: only use this when the number of related resources (along with their nested includes) is known to always be small.
/// </remarks>
public bool DisablePagination { get; set; }

public HasManyAttribute()
{
_lazyIsManyToMany = new Lazy<bool>(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ public sealed class HasManyAttribute : RelationshipAttribute
{
/// <summary />
public HasManyCapabilities Capabilities { get; set; }

/// <summary />
public bool DisablePagination { get; set; }
}
3 changes: 2 additions & 1 deletion src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ public interface IJsonApiOptions
bool IncludeTotalResourceCount { get; }

/// <summary>
/// The page size (10 by default) that is used when not specified in query string. Set to <c>null</c> to not use pagination by default.
/// The page size (10 by default) that is used when not specified in query string. Set to <c>null</c> to not use pagination by default. This setting can
/// be overruled per relationship by setting <see cref="HasManyAttribute.DisablePagination" /> to <c>true</c>.
/// </summary>
PageSize? DefaultPageSize { get; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
16 changes: 14 additions & 2 deletions src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,27 @@ namespace JsonApiDotNetCore.Queries.Expressions;
[PublicAPI]
public class SortExpression : QueryExpression
{
/// <summary>
/// Indicates whether this expression was generated by JsonApiDotNetCore to ensure a deterministic order.
/// </summary>
internal bool IsAutoGenerated { get; }

/// <summary>
/// One or more elements to sort on.
/// </summary>
public IImmutableList<SortElementExpression> Elements { get; }

public SortExpression(IImmutableList<SortElementExpression> elements)
: this(elements, false)
{
}

internal SortExpression(IImmutableList<SortElementExpression> elements, bool isAutoGenerated)
{
ArgumentGuard.NotNullNorEmpty(elements);

Elements = elements;
IsAutoGenerated = isAutoGenerated;
}

public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgument, TResult> visitor, TArgument argument)
Expand All @@ -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)
Expand All @@ -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)
{
Expand Down
21 changes: 18 additions & 3 deletions src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -243,12 +243,13 @@ private IImmutableSet<IncludeElementExpression> 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)
};

Expand Down Expand Up @@ -384,12 +385,26 @@ public QueryLayer WrapLayerForSecondaryEndpoint<TId>(QueryLayer secondaryLayer,
FilterExpression? primaryFilter = GetFilter(Array.Empty<QueryExpression>(), 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)
Expand Down Expand Up @@ -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<QueryExpression> expressionsInScope, ResourceType resourceType)
Expand Down
3 changes: 2 additions & 1 deletion test/AnnotationTests/Models/TreeNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class TreeNode : Identifiable<long>
[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<TreeNode> Children { get; set; } = new HashSet<TreeNode>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<MusicTrack>()
.HasOne(musicTrack => musicTrack.Lyric)
.WithOne(lyric => lyric.Track!)
.WithOne(lyric => lyric.Track)
.HasForeignKey<MusicTrack>("LyricId");

builder.Entity<MusicTrack>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ protected override void OnModelCreating(ModelBuilder builder)

builder.Entity<Dealership>()
.HasMany(dealership => dealership.Inventory)
.WithOne(car => car.Dealership!);
.WithOne(car => car.Dealership);

builder.Entity<Car>()
.HasMany(car => car.PreviousDealerships)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ protected override void OnModelCreating(ModelBuilder builder)

builder.Entity<SystemDirectory>()
.HasMany(systemDirectory => systemDirectory.Subdirectories)
.WithOne(systemDirectory => systemDirectory.Parent!);
.WithOne(systemDirectory => systemDirectory.Parent);

builder.Entity<SystemDirectory>()
.HasOne(systemDirectory => systemDirectory.Self)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -160,6 +160,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

responseDocument.Meta.Should().BeNull();
responseDocument.Meta.Should().NotContainTotal();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ public sealed class Appointment : Identifiable<int>
[Attr]
public DateTimeOffset EndTime { get; set; }

[HasMany]
[HasOne]
public Calendar? Calendar { get; set; }

[HasMany(DisablePagination = true)]
public IList<Reminder> Reminders { get; set; } = new List<Reminder>();
}
Loading
Loading