Hot chocolate: GrapghQl sorting, filtering, and pagination for distributed services in .Net core 6
This article is the second part of the series where we will explore how to create a distributed system using the Hot Chocolate framework. In this part, we will see how to implement sorting, filtering, and pagination for the graph API that we built in the previous article. We will also look at how to implement it with stitching, and handle various scenarios with its limitations.
Do check out the link for the previous article: “HotChocolate: Schema stitching for distributed services in .Net core 6”
Let us implement sorting, filtering, and pagination in order API and see how it behaves. we can achieve these by just adding annotation on top of our resolver.
- Add [UseSorting], [UseFiltering], and [UsePaging] annotations for resolver in Query.cs in order API
namespace Order_API
{
public class Query
{
[UsePaging]
[UseFiltering]
[UseSorting]
public List<OrderType> GetOrder([Service] OrderRepository orderRepository)
{
return orderRepository.GetOrder();
}
[UsePaging]
[UseFiltering]
[UseSorting]
public List<OrderType> GetOrderBy([Service] OrderRepository orderRepository,string name)
{
return orderRepository.GetOrderBy(name);
}
}
}
2. Register sorting and filtering (don’t need to register paging) to the schema in progrom.cs file.
builder.Services
.AddSingleton<OrderRepository>()
.AddGraphQLServer()
.AddSorting() // register sorting to schema
.AddFiltering() // register filtering to schema
.AddQueryType<Query>()
.PublishSchemaDefinition(c => c
.SetName("order")
.AddTypeExtensionsFromFile("./Stitching.graphql"));
With this minimal code, we have added sorting, filtering, and pagination capabilities to order graph API. Let us try it out.
Use case for sorting: Get the list of order numbers sorted in descending order of their total cost.
The query and result of the above scenario would look like this.
With the help of the “order” keyword, we can specify the field by which we have to sort and the sort order. By default, we can sort by all fields present in orderType and we can also sort by multiple fields. To use multiple fields, just mention them in the “order” keyword. (Let's try not to get confused by the use of the word order here. One corresponds to the Order service that we have implemented and another corresponds to sorting order.)
{
order(
order: {
customerName:ASC // sort the result by name in asc
totalAmount:DESC // and then by total cost in desc
}){
nodes{
orderNumber
totalAmount
customerName
}
}
}
Use case for filtering: Get the list of order numbers sorted in descending order of their total cost where the total amount is greater or equal to 1000.
The query and result of the above scenario would look like this.
For filtering, we use the “where” to specify the field by which we have to filter. By default, we can filter by all fields present in orderType. we can also filter by multiple fields and use “AND” / “OR” logic on multiple fields. We just need to place the list of filter conditions in an array.
{
order(
where: {
and: [
{totalAmount:{gt:1000}},
{orderNumber:{gt: 3}}
]
}
order: {
customerName:ASC
totalAmount:DESC
}){
nodes{
orderNumber
totalAmount
customerName
}
}
}
Use case for pagination: Get the list of order numbers sorted in descending order of their total cost where the total amount is greater than 100 and fetch the first 2 records from the list.
The query and result of the above scenario would look like this.
But this doesn’t help us much right? We need provision for the client to move towards the next page/previous page and information like the total number of records fetched. Let us get to that part in some time. Before exploring how to write queries for pagination effectively, let us see how we can restrict the client from using sort or filter on certain fields.
This can be done by implementing a custom sort/filter class and using it along with the annotation.
Implementing Custom sort:
- Create CustomOrderSortType.cs file where we will remove the product name field from being used as sorting input.
using HotChocolate.Data.Sorting;
namespace Order_API
{
public class CustomOrderSortType: SortInputType<OrderType>
{
protected override void Configure(ISortInputTypeDescriptor<OrderType> descriptor)
{
descriptor.Ignore(x => x.ProductNames);
descriptor.Description("Removes sorting capabilities by product name");
}
}
}
2. Use CustomOrderSortType along with the use sorting annotation.
namespace Order_API
{
public class Query
{
[UsePaging]
[UseFiltering]
[UseSorting(typeof(CustomOrderSortType))]
public List<OrderType> GetOrder([Service] OrderRepository orderRepository)
{
return orderRepository.GetOrder();
}
[UsePaging]
[UseFiltering]
[UseSorting(typeof(CustomOrderSortType))]
public List<OrderType> GetOrderBy([Service] OrderRepository orderRepository,string name)
{
return orderRepository.GetOrderBy(name);
}
}
}
Implementing Custom filter:
- Create CustomOrderFilterType.cs file where we will remove the order date field from being used as filter Input.
using HotChocolate.Data.Filters;
namespace Order_API
{
public class CustomOrderFilterType: FilterInputType<OrderType>
{
protected override void Configure(IFilterInputTypeDescriptor<OrderType> descriptor)
{
descriptor.Ignore(x => x.OrderDate);
descriptor.Description("Removes filter capabilities by order date");
}
}
}
2. Use CustomOrderFilterType along with the useFiltering annotation.
namespace Order_API
{
public class Query
{
[UsePaging]
[UseFiltering(typeof(CustomOrderFilterType))]
[UseSorting(typeof(CustomOrderSortType))]
public List<OrderType> GetOrder([Service] OrderRepository orderRepository)
{
return orderRepository.GetOrder();
}
[UsePaging]
[UseFiltering(typeof(CustomOrderFilterType))]
[UseSorting(typeof(CustomOrderSortType))]
public List<OrderType> GetOrderBy([Service] OrderRepository orderRepository,string name)
{
return orderRepository.GetOrderBy(name);
}
}
}
After implementing the custom sort/filter, the client will not be able to sort by product name and filter by order date.
Now we will come back to how effectively we can write queries for pagination. Hotchocolate offers two types of pagination cursor-based pagination and offset-based pagination. Each approach has its own benefits. Personally, I prefer cursor-based pagination as it performs well when we are dealing with read-and-write intensive databases. As offset-based pagination has a greater chance to miss records or provide duplicate records.
Diving into cursor-based pagination:
Once we use [UsePaging] annotation in our resolver, the return type would become “connection” and further connection will have edges, nodes, and page info. So query structure will have to be modified accordingly.
Use case: Fetch the first two order records.
We will need to modify usepaging annotation as below to include the total number of records present. This will add “totalcount” field to our query result.
[UsePaging(IncludeTotalCount =true)]
As you can see, the query result has fetched the first 2 records out of 5 total records available.
Let's now go through the new fields that we have used and understand their significance.
1)First: takes int value and represents the number of records fetched per page
2)Edges: These contain “node” and “cursor”. Node has fields that can be queried and the cursor represents a unique identifier that the hot chocolate generates so that we can use it to navigate back and forth through the pages.
3)PageInfo: It has fields that will help the client to navigate through the pages.
“hasNextPage”: Tells us if there is a next page available.
“hasPreviousPage”: Tells us if there is a previous page available.
“startCursor”: Represent the cursor of the first record of the page.
“endCursor”: Represent the cursor of the last record of the page.
4)TotalCount: Represent the total number of records available in DB for the query.
Use case: Fetch the next two records/ move to the next page.
To implement this use case we can take the help of the end cursor and the “after” keyword.
You can see the fields in PageInfo have changed as per the current page and give the client further information to navigate back and forth.
Use case: Fetch the previous records/ move to the previous page.
To implement this use case we can take the help of the start cursor and “before” keyword
Implement sorting, filtering, and pagination along with stitching:
Now we understand how to implement and write queries for sorting, filtering, and pagination. The next step is to implement it along with stitching. For this we have to make changes to the “stitching.grahql” file since the type for order service is now changed from “OrderType” to “OrderByConnection” after implementing pagination.
extend type CustomerType{
orders(first:Int after:String last:Int before:String where:OrderTypeFilterInput order:[OrderTypeSortInput!]): OrderByConnection
@delegate(path: "orderBy(first: $arguments:first last: $arguments:last before: $arguments:before after: $arguments:after where: $arguments:where order: $arguments:order name: $fields:name)")
}
Here we are extending the CustomerType to have another field “orders” whose return type will be “OrderByConnection”. Orders can take arguments such as first, last, after, before, where, and order.
The next line starting with @delegate implies where the value for the field “orders” will come from. Arguments that are entered will be passed to orderBy query.
Let’s test out the stitching.
Inverse Relation between graphql types:
Now we have implemented sorting, filtering, and pagination along with stitching, Let’s explore how to handle certain scenarios.
Use case: Get customer details who have ordered for an amount less than 200.
With our current implementation, we will not be able to fetch data for the above-mentioned use case. Let’s understand why we can’t.
– We have implemented a linear graph (i.e) we can traverse from customer type to order type but we cannot traverse from order type to customer type. For the use case, we should filter orders where the amount is less than 200 and then get the corresponding customer details. So we have to start from order and traverse to customer.
So let’s rectify the above-mentioned problems by establishing an inverse relationship between customer and order (i.e) extend orderType to have customer details.
Extend orderType to have customer details:
extend type OrderType{
customerinfo: CustomerType
@delegate(path: "customerByName(name: $fields:customerName)")
}
Here we have extended OrderType to have another field customerInfo which will have customer details. The value for customerInfo will come from GetCustomerByName query and the customerName in OrderType will be passed as a parameter for the query.
Now we can traverse both ways (i.e) from customer to order and order to customer.
Let’s try out the above-mentioned use case and we should get the below result.
Limitation:
We will not be able to sort/filter a parent node by a field present in the child node. Another way of looking at this is, We cannot sort or filter based on the extended fields.
Let’s define a use case to understand the statement better.
Use case: Get customer details who have ordered for an amount less than 200 and are super members.
The query form for the use case will look like this
{
order (where: {totalAmount:{lt:200}}){
nodes{
customerName
totalAmount
customerinfo(where: {isSuperMember:{eq: true}}) {
name
age
isSuperMember
}
}
}
}
If we look at the GetCustomerByName query we are already passing name as a parameter and whatever we are passing in “Where” condition will be ineffective. Let’s try out this query and see the result.
As we can see there are customer details who are not super members and the query that we have used is ineffective. So for this query order is parent and customer is child, we tried to filter order by its child field: isSupermember. Likewise, customerInfo is the extended field here.
There are a few ways to handle this scenario, one is by creating a type let’s say “LimitationType” which has isSuperMember and totalAmount field in it. And creating a query that will return LimitationType and apply filer/sort upon it. This is not a clean solution but just a way to handle it. We will not be implementing the solution as our motive here is to understand the limitation.
In the next article, we will explore on data loaders in hot chocolate.
Please visit the link for the source code.