Skip to content

Commit 59908cd

Browse files
authoredMay 15, 2021
Document Pagination (#3548)
1 parent cddbc4f commit 59908cd

File tree

1 file changed

+567
-5
lines changed

1 file changed

+567
-5
lines changed
 

‎website/src/docs/hotchocolate/fetching-data/pagination.md

+567-5
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,574 @@
22
title: "Pagination"
33
---
44

5-
Hi,
5+
import { ExampleTabs } from "../../../components/mdx/example-tabs";
66

7-
We're currently working on the version 11 documentation. Probably right now at this very moment. However, this is an open-source project, and we need any help we can get! You can jump in at any time and help us improve the documentation for hundreds or even thousands of developers!
7+
Pagination is one of the most common problems that we have to solve when implementing our backend. Often, sets of data are too large to pass them directly to the consumer of our service.
88

9-
In case you might need help, check out our slack channel and get immediate help from the core contributors or the community itself.
9+
Pagination solves this problem by giving the consumer the ability to fetch a set in chunks.
1010

11-
Sorry for any inconvenience, and thank you for being patient!
11+
# Connections
1212

13-
The ChilliCream Team
13+
_Connections_ are a standardized way to expose pagination to clients.
14+
15+
Instead of returning a list of entries, we return a _Connection_.
16+
17+
```sdl
18+
type Query {
19+
users(first: Int after: String last: Int before: String): UserConnection
20+
}
21+
22+
type UserConnection {
23+
pageInfo: PageInfo!
24+
edges: [UserEdge!]
25+
nodes: [User!]
26+
}
27+
28+
type UserEdge {
29+
cursor: String!
30+
node: User!
31+
}
32+
33+
type PageInfo {
34+
hasNextPage: Boolean!
35+
hasPreviousPage: Boolean!
36+
startCursor: String
37+
endCursor: String
38+
}
39+
```
40+
41+
You can learn more about this in the [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm).
42+
43+
> Note: _Connections_ are often associated with _cursor-based_ pagination, due to the use of a _cursor_. Since the specification describes the _cursor_ as opague though, it can be used to faciliate an _offset_ as well.
44+
45+
## Usage
46+
47+
Adding pagination capabilties to our fields is a breeze. All we have to do is add the `UsePaging` middleware.
48+
49+
<ExampleTabs>
50+
<ExampleTabs.Annotation>
51+
52+
```csharp
53+
public class Query
54+
{
55+
[UsePaging]
56+
public IEnumerable<User> GetUsers([Service] IUserRespository repository)
57+
=> repository.GetUsers();
58+
}
59+
```
60+
61+
If we need to specify the concrete node type of our pagination, we can do so by passing a Type as the constructor argument `[UsePaging(typeof(User))]`.
62+
63+
The `UsePaging` attribute also allows us to configure some other properties, like `DefaultPageSize`, `MaxPageSize` and `IncludeTotalCount`.
64+
65+
```csharp
66+
[UsePaging(MaxPageSize = 50)]
67+
```
68+
69+
</ExampleTabs.Annotation>
70+
<ExampleTabs.Code>
71+
72+
```csharp
73+
public class QueryType : ObjectType
74+
{
75+
protected override void Configure(IObjectTypeDescriptor descriptor)
76+
{
77+
descriptor
78+
.Field("users")
79+
.UsePaging()
80+
.Resolve(context =>
81+
{
82+
var repository = context.Service<IUserRespository>();
83+
84+
return repository.GetUsers();
85+
});
86+
}
87+
}
88+
```
89+
90+
If we need to specify the concrete node type of our pagination, we can do so via the generic argument: `UsePaging<UserType>()`.
91+
92+
We can also configure the `UsePaging` middleware further, by specifying `PagingOptions`.
93+
94+
```csharp
95+
descriptor.UsePaging(options: new PagingOptions
96+
{
97+
MaxPageSize = 50
98+
});
99+
```
100+
101+
</ExampleTabs.Code>
102+
<ExampleTabs.Schema>
103+
104+
⚠️ Schema-first does currently not support pagination!
105+
106+
</ExampleTabs.Schema>
107+
</ExampleTabs>
108+
109+
For the `UsePaging` middleware to work, our resolver needs to return an `IEnumerable<T>` or an `IQueryable<T>`. The middleware will then apply the pagination arguments to what we have returned. In the case of an `IQueryable<T>` this means that the pagination operations can be directly translated to native database queries, through database drivers like EntityFramework or the MongoDB client.
110+
111+
## Customization
112+
113+
If we need more control over the pagination process we can do so, by returning a `Connection<T>`.
114+
115+
<ExampleTabs>
116+
<ExampleTabs.Annotation>
117+
118+
```csharp
119+
public class Query
120+
{
121+
[UsePaging]
122+
public Connection<User> GetUsers(string? after, int? first, string sortBy)
123+
{
124+
// get users using the above arguments
125+
IEnumerable<User> users = null;
126+
127+
var edges = users.Select(user => new Edge<User>(user, user.Id))
128+
.ToList();
129+
var pageInfo = new ConnectionPageInfo(false, false, null, null);
130+
131+
var connection = new Connection<User>(edges, pageInfo,
132+
ct => ValueTask.FromResult(0));
133+
134+
return connection;
135+
}
136+
}
137+
```
138+
139+
</ExampleTabs.Annotation>
140+
<ExampleTabs.Code>
141+
142+
```csharp
143+
public class QueryType : ObjectType
144+
{
145+
protected override void Configure(IObjectTypeDescriptor descriptor)
146+
{
147+
descriptor
148+
.Field("users")
149+
.UsePaging()
150+
.Argument("sortBy", a => a.Type<NonNullType<StringType>>())
151+
.Resolve(context =>
152+
{
153+
var after = context.ArgumentValue<string?>("after");
154+
var first = context.ArgumentValue<int?>("first");
155+
var sortBy = context.ArgumentValue<string>("sortBy");
156+
157+
// get users using the above arguments
158+
IEnumerable<User> users = null;
159+
160+
var edges = users.Select(user => new Edge<User>(user, user.Id))
161+
.ToList();
162+
var pageInfo = new ConnectionPageInfo(false, false, null, null);
163+
164+
var connection = new Connection<User>(edges, pageInfo,
165+
ct => ValueTask.FromResult(0));
166+
167+
return connection;
168+
});
169+
}
170+
}
171+
```
172+
173+
If we need to work on an even lower level, we could also use `descriptor.AddPagingArguments()` and `descriptor.Type<ConnectionType<UserType>>()` to get rid of the `UsePaging` middleware.
174+
175+
</ExampleTabs.Code>
176+
<ExampleTabs.Schema>
177+
178+
⚠️ Schema-first does currently not support pagination!
179+
180+
</ExampleTabs.Schema>
181+
</ExampleTabs>
182+
183+
## Total count
184+
185+
Sometimes we might want to return the total number of pageable entries.
186+
187+
For this to work we need to enable the `IncludeTotalCount` flag on the `UsePaging` middleware.
188+
189+
<ExampleTabs>
190+
<ExampleTabs.Annotation>
191+
192+
```csharp
193+
[UsePaging(IncludeTotalCount = true)]
194+
```
195+
196+
</ExampleTabs.Annotation>
197+
<ExampleTabs.Code>
198+
199+
```csharp
200+
descriptor.UsePaging(options: new PagingOptions
201+
{
202+
IncludeTotalCount = true
203+
});
204+
```
205+
206+
</ExampleTabs.Code>
207+
<ExampleTabs.Schema>
208+
209+
⚠️ Schema-first does currently not support pagination!
210+
211+
</ExampleTabs.Schema>
212+
</ExampleTabs>
213+
214+
This will add a new field called `totalCount` to our _Connection_.
215+
216+
```sdl
217+
type UserConnection {
218+
pageInfo: PageInfo!
219+
edges: [UserEdge!]
220+
nodes: [User!]
221+
totalCount: Int!
222+
}
223+
```
224+
225+
If our resolver returns an `IEnumerable<T>` or an `IQueryable<T>` the `totalCount` will be automatically computed, if it has been specified as a subfield in the query.
226+
227+
If we have customized our pagination and our resolver now returns a `Connection<T>`, we have to explicitly declare how the `totalCount` value is computed.
228+
229+
```csharp
230+
var connection = new Connection<User>(
231+
edges,
232+
pageInfo,
233+
getTotalCount: cancellationToken => ValueTask.FromResult(0));
234+
```
235+
236+
<!-- ## Custom Edges
237+
238+
_Edges_ are not only there to hold the _cusor_, they can also be used to include information about the relation between the parent and one of the _nodes_.
239+
240+
```sdl
241+
type User {
242+
id: ID!
243+
name: String!
244+
friends: FriendConnection
245+
}
246+
247+
type FriendConnection {
248+
pageInfo: PageInfo!
249+
edges: [FriendEdge!]
250+
nodes: [User!]
251+
}
252+
253+
type FriendEdge {
254+
cursor: String!
255+
# this is a relation specific property
256+
friendsSince: DateTime!
257+
node: User!
258+
}
259+
260+
type PageInfo {
261+
hasNextPage: Boolean!
262+
hasPreviousPage: Boolean!
263+
startCursor: String
264+
endCursor: String
265+
}
266+
``` -->
267+
268+
# Offset Pagination
269+
270+
> Note: While we support _offset-based_ pagination, we highly encourage the use of [_Connections_](#connections) instead. _Connections_ provide an abstraction which makes it easier to switch to another pagination mechanism later on.
271+
272+
Besides _Connections_ we can also expose a more traditional _offset-based_ pagination.
273+
274+
```sdl
275+
type Query {
276+
users(skip: Int take: Int): UserCollectionSegment
277+
}
278+
279+
type UserCollectionSegment {
280+
items: [User!]
281+
pageInfo: CollectionSegmentInfo!
282+
}
283+
284+
type CollectionSegmentInfo {
285+
hasNextPage: Boolean!
286+
hasPreviousPage: Boolean!
287+
}
288+
```
289+
290+
## Usage
291+
292+
To add _offset-based_ pagination capabilties to our fields we have to add the `UseOffsetPaging` middleware.
293+
294+
<ExampleTabs>
295+
<ExampleTabs.Annotation>
296+
297+
```csharp
298+
public class Query
299+
{
300+
[UseOffsetPaging]
301+
public IEnumerable<User> GetUsers([Service] IUserRespository repository)
302+
=> repository.GetUsers();
303+
}
304+
```
305+
306+
If we need to specify the concrete node type of our pagination, we can do so by passing a Type as the constructor argument `[UseOffsetPaging(typeof(User))]`.
307+
308+
The `UseOffsetPaging` attribute also allows us to configure some other properties, like `DefaultPageSize`, `MaxPageSize` and `IncludeTotalCount`.
309+
310+
```csharp
311+
[UseOffsetPaging(MaxPageSize = 50)]
312+
```
313+
314+
</ExampleTabs.Annotation>
315+
<ExampleTabs.Code>
316+
317+
```csharp
318+
public class QueryType : ObjectType
319+
{
320+
protected override void Configure(IObjectTypeDescriptor descriptor)
321+
{
322+
descriptor
323+
.Field("users")
324+
.UseOffsetPaging()
325+
.Resolve(context =>
326+
{
327+
var repository = context.Service<IUserRespository>();
328+
329+
return repository.GetUsers();
330+
});
331+
}
332+
}
333+
```
334+
335+
If we need to specify the concrete node type of our pagination, we can do so via the generic argument: `UseOffsetPaging<UserType>()`.
336+
337+
We can also configure the `UseOffsetPaging` middleware further, by specifying `PagingOptions`.
338+
339+
```csharp
340+
descriptor.UseOffsetPaging(options: new PagingOptions
341+
{
342+
MaxPageSize = 50
343+
});
344+
```
345+
346+
</ExampleTabs.Code>
347+
<ExampleTabs.Schema>
348+
349+
⚠️ Schema-first does currently not support pagination!
350+
351+
</ExampleTabs.Schema>
352+
</ExampleTabs>
353+
354+
For the `UseOffsetPaging` middleware to work, our resolver needs to return an `IEnumerable<T>` or an `IQueryable<T>`. The middleware will then apply the pagination arguments to what we have returned. In the case of an `IQueryable<T>` this means that the pagination operations can be directly translated to native database queries, through database drivers like EntityFramework or the MongoDB client.
355+
356+
## Customization
357+
358+
If we need more control over the pagination process we can do so, by returning a `CollectionSegment<T>`.
359+
360+
<ExampleTabs>
361+
<ExampleTabs.Annotation>
362+
363+
```csharp
364+
public class Query
365+
{
366+
[UseOffsetPaging]
367+
public CollectionSegment<User> GetUsers(int? skip, int? take, string sortBy)
368+
{
369+
/// get users using the above arguments
370+
IEnumerable<User> users = null;
371+
372+
var pageInfo = new CollectionSegmentInfo(false, false);
373+
374+
var collectionSegment = new CollectionSegment<User>(
375+
users,
376+
pageInfo,
377+
ct => ValueTask.FromResult(0));
378+
379+
return collectionSegment;
380+
}
381+
}
382+
```
383+
384+
</ExampleTabs.Annotation>
385+
<ExampleTabs.Code>
386+
387+
```csharp
388+
public class QueryType : ObjectType
389+
{
390+
protected override void Configure(IObjectTypeDescriptor descriptor)
391+
{
392+
descriptor
393+
.Field("users")
394+
.UseOffsetPaging()
395+
.Argument("sortBy", a => a.Type<NonNullType<StringType>>())
396+
.Resolve(context =>
397+
{
398+
var skip = context.ArgumentValue<int?>("skip");
399+
var take = context.ArgumentValue<int?>("take");
400+
var sortBy = context.ArgumentValue<string>("sortBy");
401+
402+
// get users using the above arguments
403+
IEnumerable<User> users = null;
404+
405+
var pageInfo = new CollectionSegmentInfo(false, false);
406+
407+
var collectionSegment = new CollectionSegment<User>(
408+
users,
409+
pageInfo,
410+
ct => ValueTask.FromResult(0));
411+
412+
return collectionSegment;
413+
});
414+
}
415+
}
416+
```
417+
418+
If we need to work on an even lower level, we could also use `descriptor.AddOffsetPagingArguments()` and `descriptor.Type<CollectionSegmentType<UserType>>()` to get rid of the `UseOffsetPaging` middleware.
419+
420+
</ExampleTabs.Code>
421+
<ExampleTabs.Schema>
422+
423+
⚠️ Schema-first does currently not support pagination!
424+
425+
</ExampleTabs.Schema>
426+
</ExampleTabs>
427+
428+
## Total count
429+
430+
Sometimes we might want to return the total number of pageable entries.
431+
432+
For this to work we need to enable the `IncludeTotalCount` flag on the `UseOffsetPaging` middleware.
433+
434+
<ExampleTabs>
435+
<ExampleTabs.Annotation>
436+
437+
```csharp
438+
[UseOffsetPaging(IncludeTotalCount = true)]
439+
```
440+
441+
</ExampleTabs.Annotation>
442+
<ExampleTabs.Code>
443+
444+
```csharp
445+
descriptor.UseOffsetPaging(options: new PagingOptions
446+
{
447+
IncludeTotalCount = true
448+
});
449+
```
450+
451+
</ExampleTabs.Code>
452+
<ExampleTabs.Schema>
453+
454+
⚠️ Schema-first does currently not support pagination!
455+
456+
</ExampleTabs.Schema>
457+
</ExampleTabs>
458+
459+
This will add a new field called `totalCount` to our _CollectionSegment_.
460+
461+
```sdl
462+
type UserCollectionSegment {
463+
pageInfo: CollectionSegmentInfo!
464+
items: [User!]
465+
totalCount: Int!
466+
}
467+
```
468+
469+
If our resolver returns an `IEnumerable<T>` or an `IQueryable<T>` the `totalCount` will be automatically computed, if it has been specified as a subfield in the query.
470+
471+
If we have customized our pagination and our resolver now returns a `CollectionSegment<T>`, we have to explicitly declare how the `totalCount` value is computed.
472+
473+
```csharp
474+
var collectionSegment = new CollectionSegment<User>(
475+
items,
476+
pageInfo,
477+
getTotalCount: cancellationToken => ValueTask.FromResult(0));
478+
```
479+
480+
# Pagination defaults
481+
482+
If we want to enforce consistent pagination defaults throughout our app, we can do so, by setting the global `PagingOptions`.
483+
484+
```csharp
485+
public class Startup
486+
{
487+
public void ConfigureServices(IServiceCollection services)
488+
{
489+
services
490+
.AddGraphQLServer()
491+
// ...
492+
.SetPagingOptions(new PagingOptions
493+
{
494+
MaxPageSize = 50
495+
});
496+
}
497+
}
498+
```
499+
500+
# Types of pagination
501+
502+
In this section we will look at the most common pagination approaches and their downsides. There are mainly two concepts we find today: _offset-based_ and _cursor-based_ pagination.
503+
504+
> Note: This section is intended as a brief overview and should not be treated as a definitive guide or recommendation.
505+
506+
## Offset Pagination
507+
508+
_Offset-based_ pagination is found in many server implementations whether the backend is implemented in SOAP, REST or GraphQL.
509+
510+
It is so common, since it is the simplest form of pagination we can implement. All it requires is an `offset` (start index) and a `limit` (number of entries) argument.
511+
512+
```sql
513+
SELECT * FROM Users
514+
ORDER BY Id
515+
LIMIT %limit OFFSET %offset
516+
```
517+
518+
### Problems
519+
520+
But whilst _offset-based_ pagination is simple to implement and works relatively well, there are also some problems:
521+
522+
- Using `OFFSET` on the database-side does not scale well for large datasets. Most databases work with an index instead of numbered rows. This means the database always has to count _offset + limit_ rows, before discarding the _offset_ and only returning the requested number of rows.
523+
524+
- If new entries are written to or removed from our database at high frequency, the _offset_ becomes unreliable, potentially skipping or returning duplicate entries.
525+
526+
## Cursor Pagination
527+
528+
Contrary to the _offset-based_ pagination, where we identify the position of an entry using an _offset_, _cursor-based_ pagination works by returning the pointer to the next entry in our pagination.
529+
530+
To understand this concept better, let's look at an example: We want to paginate over the users in our application.
531+
532+
First we execute the following to receive our first page:
533+
534+
```sql
535+
SELECT * FROM Users
536+
ORDER BY Id
537+
LIMIT %limit
538+
```
539+
540+
`%limit` is actually `limit + 1`. We are doing this to know wether there are more entries in our dataset and to receive the _cursor_ of the next entry (in this case its `Id`). This additional entry will not be returned to the consumer of our pagination.
541+
542+
To now receive the second page, we execute:
543+
544+
```sql
545+
SELECT * FROM Users
546+
WHERE Id >= %cursor
547+
ORDER BY Id
548+
LIMIT %limit
549+
```
550+
551+
Using `WHERE` instead of `OFFSET` is great, since now we can leverage the index of the `Id` field and the database does not have to compute an _offset_.
552+
553+
For this to work though, our _cursor_ needs to be **unique** and **sequential**. Most of the time the _Id_ field will be the best fit.
554+
555+
But what if we need to sort by a field that does not have the aforementioned properties? We can simply combine the field with another field, which has the needed properties (like `Id`), to form a _cursor_.
556+
557+
Let's look at another example: We want to paginate over the users sorted by their birthday.
558+
559+
After receiving the first page, we create a combined _cursor_, like `"1435+2020-12-31"` (`Id` + `Birthday`), of the next entry. To receive the second page, we convert the _cursor_ to its original values (`Id` + `Birthday`) and use them in our query:
560+
561+
```sql
562+
SELECT * FROM Users
563+
WHERE (Birthday >= %cursorBirthday
564+
OR (Birthday = %cursorBirthday AND Id >= %cursorId))
565+
ORDER BY Birthday, Id
566+
LIMIT %limit
567+
```
568+
569+
### Problems
570+
571+
Even though _cursor-based_ pagination can be more performant than _offset-based_ pagination, it comes with some downsides as well:
572+
573+
- When using `WHERE` and `ORDER BY` on a field without an index, it can be slower than using `ORDER BY` with `OFFSET`.
574+
575+
- Since we now only know of the next entry, there is no more concept of pages. If we have a feed or only _Next_ and _Previous_ buttons, this works great, but if we depend on page numbers, we are in a tight spot.

0 commit comments

Comments
 (0)
Please sign in to comment.