GraphQL in .NET: Pagination, Filtering and Sorting

Somebody who likes to code
Search for a command to run...

Somebody who likes to code
No comments yet. Be the first to comment.
In our journey with GraphQL, sooner or later, we are going to need to pick a strategy to handle errors during mutations. There is a great post "A Guide to GraphQL Errors" where the author shows several options (its pros and cons). Based on this, we ...
Google processes tens of thousands of code changes every day across a codebase of hundreds of millions of lines. The secret isn't magic tooling — it's a documented, principled approach to code review

Amazon Simple Notification Service (SNS) looks straightforward on the surface — publish a message, deliver it to subscribers — but the gap between a topic that "works in dev" and one that is productio

Amazon Simple Queue Service (SQS) is deceptively simple to get started with, but surprisingly easy to misconfigure in ways that cause silent message loss, runaway costs, duplicate processing storms, o

HTTP API is the right default choice for the majority of .NET serverless workloads. At 70% less cost than REST API, lower latency, and native JWT authorization, it covers most production requirements

AWS Lambda has become a cornerstone of modern serverless architectures, but writing a function that "just works" is very different from writing one that is performant, cost-effective, secure, and read

In our post GraphQL in .NET with Hot Chocolate, we gave an introduction about how to use the library with a simple example. Today we want to extend that exercise with a set of common features such as pagination, filtering, and sorting, showing how easy it is to implement them with Hot Chocolate.
We will use as a starting code the solution located here, clone or download the code. Let's start by adding a new NuGet package to the api project:
dotnet add api package HotChocolate.Data --version 12.14.0
All HotChocolate.* packages need to have the same version.
Open the Program.cs file and replace the content with:
using api;
var builder = WebApplication.CreateBuilder(args);
var posts = Enumerable.Range(0, 20).Select(index => new Post() { Body = $"Body {index}", Id = Guid.NewGuid(), Title = $"Title {index}" });
builder.Services.AddSingleton(new Storage() { Posts = new List<Post>(posts) });
builder.Services
.AddGraphQLServer()
.AddQueryType<PostQueriesType>()
.AddMutationType<PostMutationsType>() ;
var app = builder.Build();
app.MapGraphQL();
app.Run();
Offset-based pagination is a technique wherein the client requests two parameters to paginate through items in a collection:
limit: the number of records.offset: number of records that need to be skipped.Go to the PostQueriesType.cs file to add UseOffsetPaging() to the descriptor as follow:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UseOffsetPaging();
}
}
And that's it. Hot Chocolate will add a middleware in charge of applying the pagination arguments to what we have returned. Run the application with the following command:
dotnet run --project api\api.csproj --urls="http://localhost:7121"
Open the URL http://localhost:7121/graphql/ in your browser and run the following query in Banana Cake Pops:
query {
posts(skip: 2, take: 5) {
items {
title
body
id
}
pageInfo {
hasNextPage
hasPreviousPage
}
}
}
PagingOptions is available as an argument of the UseOffsetPaging method:
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UseOffsetPaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount =true,
});
A cursor is a unique identifier for a specific record, which acts as a pointer to the next record we want to start querying from to get the next page of results. We will update the PostQueriesType.cs file to use UsePaging() as follow:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging();
}
}
Run the following query:
query {
posts(first: 2) {
edges {
cursor
node {
title
body
id
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
To get something like:
{
"data": {
"posts": {
"edges": [
{
"cursor": "MA==",
"node": {
"title": "Title 0",
"body": "Body 0",
"id": "f8641118a70c455c954f067a58b9c02e"
}
},
{
"cursor": "MQ==",
"node": {
"title": "Title 1",
"body": "Body 1",
"id": "1c631d9cb12b4d68960ff075ae3ab095"
}
}
],
"pageInfo": {
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "MA==",
"endCursor": "MQ=="
}
}
}
}
Let's see the structure of the result:
Connections: Instead of returning a list of entries, we return a Connection.Edges: We return an array of Edges in a Connection. An Edge has two properties, a cursor(a unique identifier for the entry) and a node(the entry itself).PageInfo: Information to aid in pagination.The list of arguments that we can use are:
first: Returns the first N elements from the list.after: Returns the elements in the list that come after the specified cursor.last: Returns the last N elements from the list.before: Returns the elements in the list that come before the specified cursor.PagingOptions is available as an argument of the UsePaging method:
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount =true,
});
To use the Hot Chocolate default filtering implementation, we need to modify the Program.cs file with:
builder.Services
.AddGraphQLServer()
.AddQueryType<PostQueriesType>()
.AddMutationType<PostMutationsType>()
.AddFiltering();
Then go to the PostQueriesType.cs file and update as follow:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount = true,
})
.UseFiltering();
}
}
Run the application and go to Schema Definition to see the PostFilterInput type:
input PostFilterInput {
and: [PostFilterInput!]
or: [PostFilterInput!]
id: ComparableGuidOperationFilterInput
title: StringOperationFilterInput
body: StringOperationFilterInput
}
input StringOperationFilterInput {
and: [StringOperationFilterInput!]
or: [StringOperationFilterInput!]
eq: String
neq: String
contains: String
ncontains: String
in: [String]
nin: [String]
startsWith: String
nstartsWith: String
endsWith: String
nendsWith: String
}
input ComparableGuidOperationFilterInput {
eq: UUID
neq: UUID
in: [UUID!]
nin: [UUID!]
gt: UUID
ngt: UUID
gte: UUID
ngte: UUID
lt: UUID
nlt: UUID
lte: UUID
nlte: UUID
}
Let's run a query based on that definition:
query {
posts(where: { title:{ contains: "5" } }) {
edges {
cursor
node {
title
body
id
}
}
}
}
If we don't want to expose all this filtering surface to our clients, we can customize what we offer and how. Let's rename the title argument to topic limit the search to only use contains, and disallow the use of the and operator. Create a PostFilterType.cs file with the following content:
public class PostFilterType : FilterInputType<Post>
{
protected override void Configure(
IFilterInputTypeDescriptor<Post> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(f => f.Title).Name("topic").Type<TitleOperationFilterInput>();
}
}
public class TitleOperationFilterInput : StringOperationFilterInputType
{
protected override void Configure(IFilterInputTypeDescriptor descriptor)
{
descriptor.Operation(DefaultFilterOperations.Contains).Type<StringType>();
descriptor.AllowOr(false);
}
}
Update the PostQueriesType.cs file with:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount = true,
})
.UseFiltering<PostFilterType>();
}
}
Run the application, and let's see, again, the Schema Definition:
input PostFilterInput {
and: [PostFilterInput!]
or: [PostFilterInput!]
topic: TitleOperationFilterInput
}
input TitleOperationFilterInput {
and: [TitleOperationFilterInput!]
contains: String
}
To use the Hot Chocolate default sorting implementation, we need to modify the Program.cs file with:
builder.Services
.AddGraphQLServer()
.AddQueryType<PostQueriesType>()
.AddMutationType<PostMutationsType>()
.AddFiltering()
.AddSorting();
Then go to the PostQueriesType.cs file and update as follow:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount = true,
})
.UseFiltering<PostFilterType>()
.UseSorting();
}
}
Note: If you use more than one middleware, keep in mind that ORDER MATTERS. The correct order is UsePaging > UseProjections > UseFiltering > UseSorting
Run the application and go to Schema Definition to see the PostSortInput type:
input PostSortInput {
id: SortEnumType
title: SortEnumType
body: SortEnumType
}
enum SortEnumType {
ASC
DESC
}
As we did with the filtering, we can customize the sorting. Let's limit the sorting to the title property. Create a PostSortType.cs file with the following content:
public class PostSortType : SortInputType<Post>
{
protected override void Configure(ISortInputTypeDescriptor<Post> descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(f => f.Title).Name("topic");
}
}
Modify the PostQueriesType.cs file with:
public class PostQueriesType : ObjectType<PostQueries>
{
protected override void Configure(IObjectTypeDescriptor<PostQueries> descriptor)
{
descriptor
.Field(f => f.GetPost(default!, default!))
.Type<PostType>();
descriptor
.Field(f => f.GetPosts(default!))
.Type<ListType<PostType>>()
.UsePaging(options: new PagingOptions()
{
MaxPageSize = 10,
DefaultPageSize = 5,
IncludeTotalCount = true,
})
.UseFiltering<PostFilterType>()
.UseSorting<PostSortType>();
}
}
Start up the application and run the query:
posts( order: { topic: DESC } ) {
edges {
cursor
node {
title
body
id
}
}
}
}
All these topics, and more, can be found in the official documentation here. The code of this post is available here. Thanks, and happy coding.