Skip to content

Commit 6445d88

Browse files
author
t.kosminov
committed
cursor pagination
1 parent af740da commit 6445d88

File tree

20 files changed

+1153
-905
lines changed

20 files changed

+1153
-905
lines changed

.tool-versions

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
nodejs 20.2.0
1+
nodejs 20.5.0

README.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- [Filtering](#filtering)
2020
- [Ordering](#ordering)
2121
- [Pagination](#pagination)
22+
- [Cursor pagination](#cursor-pagination)
2223
- [Permanent filters](#permanent-filters)
2324

2425
## Description
@@ -500,6 +501,123 @@ This will add arguments to the query for pagination:
500501

501502
When working with pagination, it is important to remember [point 5 of the important section](#important).
502503

504+
## [Cursor pagination](https://the-guild.dev/blog/graphql-cursor-pagination-with-postgresql)
505+
506+
Pagination works in tandem with a data loader, filters, and sorting and allows you to limit the number of records received from the database
507+
508+
```ts
509+
@Resolver(() => Author)
510+
export class AuthorResolver {
511+
...
512+
@Query(() => [Author])
513+
public async authors(
514+
@Loader({
515+
loader_type: ELoaderType.MANY,
516+
field_name: 'authors',
517+
entity: () => Author,
518+
entity_fk_key: 'id',
519+
}) field_alias: string,
520+
@Filter(() => Author) _filter: unknown, // <-- ADD
521+
@Order(() => Author) _order: unknown, // <-- ADD
522+
@Pagination() _pagination: unknown, // <-- ADD
523+
@Context() ctx: GraphQLExecutionContext
524+
) {
525+
return await ctx[field_alias];
526+
}
527+
...
528+
}
529+
```
530+
531+
Then you can get the first page using the query:
532+
533+
```gql
534+
query firstPage {
535+
authors(
536+
ORDER: { id: { SORT: ASC } }
537+
PAGINATION: { per_page: 10 }
538+
) {
539+
id
540+
}
541+
}
542+
```
543+
544+
Then you can get the next page using the query:
545+
546+
```gql
547+
query nextPage($ID_of_the_last_element_from_the_previous_page: ID!) {
548+
authors(
549+
WHERE: { id: { GT: $ID_of_the_last_element_from_the_previous_page }}
550+
ORDER: { id: { SORT: ASC } }
551+
PAGINATION: { per_page: 10 }
552+
) {
553+
id
554+
}
555+
}
556+
```
557+
558+
Fields that are planned to be used as a cursor must be allowed for filtering and sorting in the `@Field` decorator, and it is also recommended to index them indicating the sort order.
559+
560+
With such pagination, it is important to take into account the order in which the fields specified in the sorting are listed.
561+
562+
You can also use several fields as cursors. The main thing is to maintain order.
563+
564+
Then you can get the first page using the query:
565+
566+
```gql
567+
query firstPage{
568+
authors(
569+
ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } }
570+
PAGINATION: { per_page: 10 }
571+
) {
572+
id
573+
}
574+
}
575+
576+
```
577+
578+
Then you can get the next page using the query:
579+
580+
```gql
581+
query nextPage(
582+
$UPDATED_AT_of_the_last_element_from_the_previous_page: DateTime!
583+
$ID_of_the_last_element_from_the_previous_page: ID!
584+
) {
585+
authors(
586+
WHERE: {
587+
updated_at: { LT: $UPDATED_AT_of_the_last_element_from_the_previous_page }
588+
OR: {
589+
updated_at: {
590+
EQ: $UPDATED_AT_of_the_last_element_from_the_previous_page
591+
}
592+
id: { GT: $ID_of_the_last_element_from_the_previous_page }
593+
}
594+
}
595+
ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } }
596+
PAGINATION: { per_page: 10 }
597+
) {
598+
id
599+
}
600+
}
601+
```
602+
603+
However, it is recommended to limit the time columns to milliseconds:
604+
605+
```ts
606+
@ObjectType()
607+
@Entity()
608+
export class Author {
609+
...
610+
@Field(() => Date, { filterable: true, sortable: true })
611+
@UpdateDateColumn({
612+
type: 'timestamp without time zone',
613+
precision: 3, // <-- ADD
614+
default: () => 'CURRENT_TIMESTAMP',
615+
})
616+
public updated_at: Date;
617+
...
618+
}
619+
```
620+
503621
## Permanent filters
504622

505623
You can also specify permanent filters that will always be applied regardless of the query

lib/filter/builder.filter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-empty-function */
2-
import { InputType, ReturnTypeFunc, Int, Float, GqlTypeReference } from '@nestjs/graphql';
2+
import { InputType, ReturnTypeFunc, Int, Float, GqlTypeReference, ID } from '@nestjs/graphql';
33

44
import { decorateField, where_field_input_types, where_input_types, gql_fields, gql_enums, IField } from '../store/graphql';
55

@@ -28,7 +28,7 @@ const string_operations = ['ILIKE', 'NOT_ILIKE'];
2828
const precision_operations = ['GT', 'GTE', 'LT', 'LTE'];
2929

3030
const string_types: GqlTypeReference[] = [String];
31-
const precision_types: GqlTypeReference[] = [Int, Float, Number, Date];
31+
const precision_types: GqlTypeReference[] = [ID, Int, Float, Number, Date];
3232

3333
function findEnumName(col_type: GqlTypeReference) {
3434
let col_type_name: string = null;

lib/helper/index.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,18 @@ export function reduceToObject<T>(array: T[], key: string): { [K: string]: T } {
3939
}
4040

4141
export function groupBy<T>(array: T[], key: string): { [key: string]: T[] } {
42-
return array.reduce((acc, curr) => {
43-
if (!acc.hasOwnProperty(curr[key])) {
44-
acc[curr[key]] = [];
45-
}
42+
return array.reduce(
43+
(acc, curr) => {
44+
if (!acc.hasOwnProperty(curr[key])) {
45+
acc[curr[key]] = [];
46+
}
4647

47-
acc[curr[key]].push(curr);
48+
acc[curr[key]].push(curr);
4849

49-
return acc;
50-
}, {} as { [key: string]: T[] });
50+
return acc;
51+
},
52+
{} as { [key: string]: T[] }
53+
);
5154
}
5255

5356
export function validateDTO(type: ClassConstructor<unknown>, value: unknown) {

lib/loader/decorator.loader.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,25 +72,25 @@ export const Loader = createParamDecorator((data: ILoaderData, ctx: ExecutionCon
7272
const entity_table_name = underscore(entity_class_name);
7373
const field_alias = entity_table_name;
7474

75-
const filters = gargs['WHERE'];
75+
const filters: IFilterValue | undefined = gargs['WHERE'];
7676
let parsed_filters: IParsedFilter = null;
7777

7878
if (filters) {
79-
parsed_filters = parseFilter(entity_table_name, filters as IFilterValue);
79+
parsed_filters = parseFilter(entity_table_name, filters);
8080
}
8181

82-
const orders = gargs['ORDER'];
82+
const orders: IOrderValue | undefined = gargs['ORDER'];
8383
let parsed_orders: IParsedOrder[] = null;
8484

8585
if (orders) {
86-
parsed_orders = parseOrder(entity_table_name, orders as IOrderValue);
86+
parsed_orders = parseOrder(entity_table_name, orders);
8787
}
8888

89-
const paginations = gargs['PAGINATION'];
89+
const paginations: IPaginationValue | undefined = gargs['PAGINATION'];
9090
let parsed_paginations: IParsedPagination = null;
9191

9292
if (paginations) {
93-
parsed_paginations = parsePagination(paginations as IPaginationValue);
93+
parsed_paginations = parsePagination(paginations);
9494
}
9595

9696
const selected_fields = recursiveSelectedFields(_data, info.fieldNodes, info.fragments);

lib/loader/many.loader.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,10 @@ export const manyLoader = (
4949

5050
if (paginations) {
5151
qb.limit(paginations.limit);
52-
qb.offset(paginations.offset);
52+
53+
if (paginations.offset) {
54+
qb.offset(paginations.offset);
55+
}
5356
}
5457

5558
return qb.getMany();

lib/pagination/decorator.pagination.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { Args, Field, InputType, Int } from '@nestjs/graphql';
22

3-
import { IsInt, Min } from 'class-validator';
3+
import { IsInt, IsOptional, Min } from 'class-validator';
44

55
@InputType()
66
export class PaginationInputType {
7-
@Field(() => Int, { nullable: false })
7+
@Field(() => Int, { nullable: true })
8+
@IsOptional()
89
@IsInt()
910
@Min(0)
1011
page: number;

lib/pagination/parser.pagination.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,19 @@ export interface IPaginationValue {
99

1010
export interface IParsedPagination {
1111
limit: number;
12-
offset: number;
12+
offset?: number;
1313
}
1414

15-
export function parsePagination(data: IPaginationValue): IParsedPagination {
15+
export function parsePagination(data: IPaginationValue) {
1616
validateDTO(PaginationInputType, data);
1717

18-
return {
18+
const pagination: IParsedPagination = {
1919
limit: data.per_page,
20-
offset: data.per_page * data.page,
2120
};
21+
22+
if (data.page != null) {
23+
pagination.offset = data.page * data.per_page;
24+
}
25+
26+
return pagination;
2227
}

0 commit comments

Comments
 (0)