GraphQL in .NET: Pagination, Filtering and Sorting

GraphQL in .NET: Pagination, Filtering and Sorting

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.

Prerequisites

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();

Pagination

Offset-based

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,
    });

Cursor-based

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,
    });

Filtering

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
}

Sorting

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.