-
-
Save tawanorg/3bafe34d8c1b4eba8dde41c14e848424 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); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment