Skip to content

Instantly share code, notes, and snippets.

@tumainimosha
Last active August 29, 2023 11:02
Show Gist options
  • Star 40 You must be signed in to star a gist
  • Fork 14 You must be signed in to fork a gist
  • Save tumainimosha/6652deb0aea172f7f2c4b2077c72d16c to your computer and use it in GitHub Desktop.
Save tumainimosha/6652deb0aea172f7f2c4b2077c72d16c to your computer and use it in GitHub Desktop.
NestJS Graphql Cursor Based pagination
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;
}
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 };
}
/**
* 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) { }
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;
}
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;
}
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);
}
}
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);
}
}
@aoxiang78
Copy link

Reverse order requires the use of LessThan
DESC
.orWhere({ id : LessThan('356731416110829568')}).take(10)

@kezoo
Copy link

kezoo commented Aug 29, 2023

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