Skip to content

Commit

Permalink
cursor pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
t.kosminov committed Oct 3, 2023
1 parent af740da commit 6445d88
Show file tree
Hide file tree
Showing 20 changed files with 1,153 additions and 905 deletions.
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1 @@
nodejs 20.2.0
nodejs 20.5.0
118 changes: 118 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [Filtering](#filtering)
- [Ordering](#ordering)
- [Pagination](#pagination)
- [Cursor pagination](#cursor-pagination)
- [Permanent filters](#permanent-filters)

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

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

## [Cursor pagination](https://the-guild.dev/blog/graphql-cursor-pagination-with-postgresql)

Pagination works in tandem with a data loader, filters, and sorting and allows you to limit the number of records received from the database

```ts
@Resolver(() => Author)
export class AuthorResolver {
...
@Query(() => [Author])
public async authors(
@Loader({
loader_type: ELoaderType.MANY,
field_name: 'authors',
entity: () => Author,
entity_fk_key: 'id',
}) field_alias: string,
@Filter(() => Author) _filter: unknown, // <-- ADD
@Order(() => Author) _order: unknown, // <-- ADD
@Pagination() _pagination: unknown, // <-- ADD
@Context() ctx: GraphQLExecutionContext
) {
return await ctx[field_alias];
}
...
}
```

Then you can get the first page using the query:

```gql
query firstPage {
authors(
ORDER: { id: { SORT: ASC } }
PAGINATION: { per_page: 10 }
) {
id
}
}
```

Then you can get the next page using the query:

```gql
query nextPage($ID_of_the_last_element_from_the_previous_page: ID!) {
authors(
WHERE: { id: { GT: $ID_of_the_last_element_from_the_previous_page }}
ORDER: { id: { SORT: ASC } }
PAGINATION: { per_page: 10 }
) {
id
}
}
```

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.

With such pagination, it is important to take into account the order in which the fields specified in the sorting are listed.

You can also use several fields as cursors. The main thing is to maintain order.

Then you can get the first page using the query:

```gql
query firstPage{
authors(
ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } }
PAGINATION: { per_page: 10 }
) {
id
}
}

```

Then you can get the next page using the query:

```gql
query nextPage(
$UPDATED_AT_of_the_last_element_from_the_previous_page: DateTime!
$ID_of_the_last_element_from_the_previous_page: ID!
) {
authors(
WHERE: {
updated_at: { LT: $UPDATED_AT_of_the_last_element_from_the_previous_page }
OR: {
updated_at: {
EQ: $UPDATED_AT_of_the_last_element_from_the_previous_page
}
id: { GT: $ID_of_the_last_element_from_the_previous_page }
}
}
ORDER: { updated_at: { SORT: DESC }, id: { SORT: ASC } }
PAGINATION: { per_page: 10 }
) {
id
}
}
```

However, it is recommended to limit the time columns to milliseconds:

```ts
@ObjectType()
@Entity()
export class Author {
...
@Field(() => Date, { filterable: true, sortable: true })
@UpdateDateColumn({
type: 'timestamp without time zone',
precision: 3, // <-- ADD
default: () => 'CURRENT_TIMESTAMP',
})
public updated_at: Date;
...
}
```

## Permanent filters

You can also specify permanent filters that will always be applied regardless of the query
Expand Down
4 changes: 2 additions & 2 deletions lib/filter/builder.filter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { InputType, ReturnTypeFunc, Int, Float, GqlTypeReference } from '@nestjs/graphql';
import { InputType, ReturnTypeFunc, Int, Float, GqlTypeReference, ID } from '@nestjs/graphql';

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

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

const string_types: GqlTypeReference[] = [String];
const precision_types: GqlTypeReference[] = [Int, Float, Number, Date];
const precision_types: GqlTypeReference[] = [ID, Int, Float, Number, Date];

function findEnumName(col_type: GqlTypeReference) {
let col_type_name: string = null;
Expand Down
17 changes: 10 additions & 7 deletions lib/helper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ export function reduceToObject<T>(array: T[], key: string): { [K: string]: T } {
}

export function groupBy<T>(array: T[], key: string): { [key: string]: T[] } {
return array.reduce((acc, curr) => {
if (!acc.hasOwnProperty(curr[key])) {
acc[curr[key]] = [];
}
return array.reduce(
(acc, curr) => {
if (!acc.hasOwnProperty(curr[key])) {
acc[curr[key]] = [];
}

acc[curr[key]].push(curr);
acc[curr[key]].push(curr);

return acc;
}, {} as { [key: string]: T[] });
return acc;
},
{} as { [key: string]: T[] }
);
}

export function validateDTO(type: ClassConstructor<unknown>, value: unknown) {
Expand Down
12 changes: 6 additions & 6 deletions lib/loader/decorator.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,25 +72,25 @@ export const Loader = createParamDecorator((data: ILoaderData, ctx: ExecutionCon
const entity_table_name = underscore(entity_class_name);
const field_alias = entity_table_name;

const filters = gargs['WHERE'];
const filters: IFilterValue | undefined = gargs['WHERE'];
let parsed_filters: IParsedFilter = null;

if (filters) {
parsed_filters = parseFilter(entity_table_name, filters as IFilterValue);
parsed_filters = parseFilter(entity_table_name, filters);
}

const orders = gargs['ORDER'];
const orders: IOrderValue | undefined = gargs['ORDER'];
let parsed_orders: IParsedOrder[] = null;

if (orders) {
parsed_orders = parseOrder(entity_table_name, orders as IOrderValue);
parsed_orders = parseOrder(entity_table_name, orders);
}

const paginations = gargs['PAGINATION'];
const paginations: IPaginationValue | undefined = gargs['PAGINATION'];
let parsed_paginations: IParsedPagination = null;

if (paginations) {
parsed_paginations = parsePagination(paginations as IPaginationValue);
parsed_paginations = parsePagination(paginations);
}

const selected_fields = recursiveSelectedFields(_data, info.fieldNodes, info.fragments);
Expand Down
5 changes: 4 additions & 1 deletion lib/loader/many.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export const manyLoader = (

if (paginations) {
qb.limit(paginations.limit);
qb.offset(paginations.offset);

if (paginations.offset) {
qb.offset(paginations.offset);
}
}

return qb.getMany();
Expand Down
5 changes: 3 additions & 2 deletions lib/pagination/decorator.pagination.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Args, Field, InputType, Int } from '@nestjs/graphql';

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

@InputType()
export class PaginationInputType {
@Field(() => Int, { nullable: false })
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
page: number;
Expand Down
13 changes: 9 additions & 4 deletions lib/pagination/parser.pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,19 @@ export interface IPaginationValue {

export interface IParsedPagination {
limit: number;
offset: number;
offset?: number;
}

export function parsePagination(data: IPaginationValue): IParsedPagination {
export function parsePagination(data: IPaginationValue) {
validateDTO(PaginationInputType, data);

return {
const pagination: IParsedPagination = {
limit: data.per_page,
offset: data.per_page * data.page,
};

if (data.page != null) {
pagination.offset = data.page * data.per_page;
}

return pagination;
}
Loading

0 comments on commit 6445d88

Please sign in to comment.