Last active
September 18, 2024 08:18
-
-
Save tumainimosha/6652deb0aea172f7f2c4b2077c72d16c to your computer and use it in GitHub Desktop.
NestJS Graphql Cursor Based pagination
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ObjectType, Field } from "@nestjs/graphql"; | |
@ObjectType() | |
export class PageInfo { | |
@Field({ nullable: true }) | |
startCursor: string; | |
@Field({ nullable: true }) | |
endCursor: string; | |
@Field() | |
hasPreviousPage: boolean; | |
@Field() | |
hasNextPage: boolean; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Logger } from '@nestjs/common'; | |
import { PageInfo } from './page-info'; | |
import { PaginationArgs } from './pagination.args'; | |
import { SelectQueryBuilder, MoreThan, LessThan } from 'typeorm'; | |
/** | |
* Based on https://gist.github.com/VojtaSim/6b03466f1964a6c81a3dbf1f8cec8d5c | |
*/ | |
export async function paginate<T>( | |
query: SelectQueryBuilder<T>, | |
paginationArgs: PaginationArgs, | |
cursorColumn = 'id', | |
defaultLimit = 25, | |
): Promise<any> { | |
const logger = new Logger('Pagination'); | |
// pagination ordering | |
query.orderBy({ [cursorColumn]: 'DESC' }) | |
const totalCountQuery = query.clone(); | |
// FORWARD pagination | |
if (paginationArgs.first) { | |
if (paginationArgs.after) { | |
const offsetId = Number(Buffer.from(paginationArgs.after, 'base64').toString('ascii')); | |
logger.verbose(`Paginate AfterID: ${offsetId}`); | |
query.where({ [cursorColumn]: MoreThan(offsetId) }); | |
} | |
const limit = paginationArgs.first ?? defaultLimit; | |
query.take(limit) | |
} | |
// REVERSE pagination | |
else if (paginationArgs.last && paginationArgs.before) { | |
const offsetId = Number(Buffer.from(paginationArgs.before, 'base64').toString('ascii')); | |
logger.verbose(`Paginate BeforeID: ${offsetId}`); | |
const limit = paginationArgs.last ?? defaultLimit; | |
query | |
.where({ [cursorColumn]: LessThan(offsetId) }) | |
.take(limit); | |
} | |
const result = await query.getMany(); | |
const startCursorId: number = result.length > 0 ? result[0][cursorColumn] : null; | |
const endCursorId: number = result.length > 0 ? result.slice(-1)[0][cursorColumn] : null; | |
const beforeQuery = totalCountQuery.clone(); | |
const afterQuery = beforeQuery.clone(); | |
let countBefore = 0; | |
let countAfter = 0; | |
if (beforeQuery.expressionMap.wheres && beforeQuery.expressionMap.wheres.length) { | |
countBefore = await beforeQuery | |
.andWhere(`${cursorColumn} < :cursor`, { cursor: startCursorId }) | |
.getCount(); | |
countAfter = await afterQuery | |
.andWhere(`${cursorColumn} > :cursor`, { cursor: endCursorId }) | |
.getCount(); | |
} else { | |
countBefore = await beforeQuery | |
.where(`${cursorColumn} < :cursor`, { cursor: startCursorId }) | |
.getCount(); | |
countAfter = await afterQuery | |
.where(`${cursorColumn} > :cursor`, { cursor: endCursorId }) | |
.getCount(); | |
} | |
logger.debug(`CountBefore:${countBefore}`); | |
logger.debug(`CountAfter:${countAfter}`); | |
const edges = result.map((value) => { | |
return { | |
node: value, | |
cursor: Buffer.from(`${value[cursorColumn]}`).toString('base64'), | |
}; | |
}); | |
const pageInfo = new PageInfo(); | |
pageInfo.startCursor = edges.length > 0 ? edges[0].cursor : null; | |
pageInfo.endCursor = edges.length > 0 ? edges.slice(-1)[0].cursor : null; | |
pageInfo.hasNextPage = countAfter > 0; | |
pageInfo.hasPreviousPage = countBefore > 0; | |
// pageInfo.countBefore = countBefore; | |
// pageInfo.countNext = countAfter; | |
// pageInfo.countCurrent = edges.length; | |
// pageInfo.countTotal = countAfter + countBefore + edges.length; | |
return { edges, pageInfo }; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Example of paginated graphql model | |
*/ | |
import { Post } from "../models/post.model"; | |
import { ObjectType } from '@nestjs/graphql'; | |
import { Paginated } from "src/shared/pagination/types/paginated"; | |
@ObjectType() | |
export class PaginatedPost extends Paginated(Post) { } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Field, ObjectType } from '@nestjs/graphql'; | |
import { Type } from '@nestjs/common'; | |
import { PageInfo } from './page-info'; | |
/** | |
* Based on https://docs.nestjs.com/graphql/resolvers#generics | |
* | |
* @param classRef | |
*/ | |
export function Paginated<T>(classRef: Type<T>): any { | |
@ObjectType(`${classRef.name}Edge`, { isAbstract: true }) | |
abstract class EdgeType { | |
@Field(() => String) | |
cursor: string; | |
@Field(() => classRef) | |
node: T; | |
} | |
@ObjectType({ isAbstract: true }) | |
abstract class PaginatedType { | |
@Field(() => [EdgeType], { nullable: true }) | |
edges: EdgeType[]; | |
@Field(() => PageInfo, { nullable: true }) | |
pageInfo: PageInfo; | |
} | |
return PaginatedType; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ArgsType, Int, Field } from '@nestjs/graphql'; | |
@ArgsType() | |
export class PaginationArgs { | |
@Field(() => Int, { nullable: true }) | |
first: number; | |
@Field(() => String, { nullable: true }) | |
after: string; | |
@Field(() => Int, { nullable: true }) | |
last: number; | |
@Field(() => String, { nullable: true }) | |
before: string; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Post } from "../models/post.model"; | |
import { PostService } from '../providers/post.service'; | |
@Resolver(() => Post) | |
export class PostResolver { | |
constructor(private readonly postService: PostService) { } | |
@Query(() => PaginatedPost) | |
getPosts( | |
@Args() pagination: PaginationArgs, | |
@Args() filter: PostFilter, | |
): Promise<PaginatedPost> { | |
return this.postService.getPaginatedPosts(pagination, filter); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { paginate } from './paginate'; | |
@Injectable() | |
export class PostService { | |
private readonly logger = new Logger('PostService'); | |
constructor( | |
@InjectRepository(PostRepository) | |
private postRepository: PostRepository, | |
) { } | |
async getPaginatedPosts(paginationArgs: PaginationArgs, filter: PostFilter): Promise<PaginatedPost> { | |
const query = await this.postRepository | |
.createQueryBuilder() | |
.select(); | |
// todo... you can apply filters here to the query as where clauses | |
return paginate(query, paginationArgs); | |
} | |
} |
Is true, when filter by DESC the displays doesn't work fine.
Reverse order requires the use of LessThan
DESC
.orWhere({ id : LessThan('356731416110829568')}).take(10)
Have a look on this pager module which has taken care of both order cases.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Thanks for the example but this code works fine only when the sorting is ASC, otherwise hasNextPage and hasPreviousPage displays wrong results.