DEV Community

Cover image for Cursor Pagination for NestJS GraphQL with Mikro-ORM and SQL
Afonso Barracha
Afonso Barracha

Posted on • Updated on

Cursor Pagination for NestJS GraphQL with Mikro-ORM and SQL

Introduction

There are two common cursor pagination methods for GraphQL, the "Pagination and Edges" pagination as seen here and the "Relay Style" pagination described here.

Overview

To be able to paginate objects, we need to create:

  1. Both interfaces and generic (abstract objects) for pagination;
  2. The base for the pagination logic;
  3. Enums for the unique cursor, and SQL order by;
  4. A generic implementation for Mikro-ORM query builder.

I like to organize all these steps in the same global module which I normally call "common".

Architecture

Common Module

The common module itself will be very simple, with just its service as a dependency:

import { Global, Module } from '@nestjs/common';
import { CommonService } from './common.service';

@Global()
@Module({
  providers: [CommonService],
  exports: [CommonService],
})
export class CommonModule {}
Enter fullscreen mode Exit fullscreen mode

Interfaces

Firstly inside the common module folder, create a interfaces folder were you will add a file called "paginated.interface.ts", which will contain five interfaces:

  • Edge Interface;
  • Basic Page Info Interface;
  • Relay Page Info Interface;
  • Basic Paginated Interface;
  • Relay Paginated Interface.

Edge Interface

It represents the edge of both cursor pagination methods:

export interface IEdge<T> {
  cursor: string;
  node: T;
}
Enter fullscreen mode Exit fullscreen mode

Page Info Interface

Since the basic is a one way and the relay a two way pagination the "Relay Style" extends the "Pagination and Edges" page info.

export interface IBasicPageInfo {
  endCursor: string;
  hasNextPage: boolean; 
}

export interface IRelayPageInfo extends IBasicPageInfo {
  startCursor: string;
  hasPreviousPage: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Paginated Interface

The total count in the basic paginated is the current distinct count of the cursor parameter. While we have two count for relay, previousCount, the count of the previous page, and currentCount, the same as total count.

export interface IBasicPaginated<T> {
  totalCount: number;
  edges: IEdge<T>[];
  pageInfo: IBasicPageInfo;
}

export interface IRelayPaginated<T> {
  previousCount: number;
  currentCount: number;
  edges: IEdge<T>[];
  pageInfo: IRelayPageInfo;
}
Enter fullscreen mode Exit fullscreen mode

Putting it all together, your "paginated.interface.ts" should look like this:

export interface IEdge<T> {
  cursor: string;
  node: T;
}

export interface IBasicPageInfo {
  endCursor: string;
  hasNextPage: boolean; 
}

export interface IRelayPageInfo extends IBasicPageInfo {
  startCursor: string;
  hasPreviousPage: boolean;
}

export interface IBasicPaginated<T> {
  totalCount: number;
  edges: IEdge<T>[];
  pageInfo: IBasicPageInfo;
}

export interface IRelayPaginated<T> {
  previousCount: number;
  currentCount: number;
  edges: IEdge<T>[];
  pageInfo: IRelayPageInfo;
}
Enter fullscreen mode Exit fullscreen mode

Generics

After setting up the interfaces we need generics to be able to create Paginated Objects, on your common module folder create a directory called "gql-types", where all common GraphQL Object Types will be stored.

Edge Generic

In a file called "edge.type.ts" create the following generic:

import { Type } from '@nestjs/common';
import { Field, ObjectType } from '@nestjs/graphql';
import { IEdge } from '../interfaces/paginated.interface';

export function Edge<T>(classRef: Type<T>): Type<IEdge<T>> {
  @ObjectType({ isAbstract: true })
  abstract class EdgeType implements IEdge<T> {
    @Field(() => String)
    public cursor: string;

    @Field(() => classRef)
    public node: T;
  }

  return EdgeType as Type<IEdge<T>>;
}
Enter fullscreen mode Exit fullscreen mode

Basic Paginated Generic

In a file called "basic-paginated.type.ts" create the following generic:

import { Type } from '@nestjs/common';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Edge } from './edge.type';
import { IBasicPageInfo, IBasicPaginated } from '../interfaces/paginated.interface';

@ObjectType('BasicPageInfo')
abstract class PageInfoType implements IBasicPageInfo {
  @Field(() => String)
  public endCursor: string;

  @Field(() => Boolean)
  public hasNextPage: boolean;
}

export function BasicPaginated<T>(classRef: Type<T>): Type<IBasicPaginated<T>> {
  @ObjectType(`${classRef.name}BasicEdge`)
  abstract class EdgeType extends Edge(classRef) {}

  @ObjectType({ isAbstract: true })
  abstract class PaginatedType implements IBasicPaginated<T> {
    @Field(() => Int)
    public totalCount: number;

    @Field(() => [EdgeType])
    public edges: EdgeType[];

    @Field(() => PageInfoType)
    public pageInfo: PageInfoType;
  }
  return PaginatedType as Type<IBasicPaginated<T>>;
}
Enter fullscreen mode Exit fullscreen mode

Relay Paginated Generic

In a file called "relay-paginated.type.ts" create the following generic:

import { Type } from '@nestjs/common';
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Edge } from './edge.type';
import { IRelayPageInfo, IRelayPaginated } from '../interfaces/paginated.interface';

@ObjectType('RelayPageInfo')
abstract class PageInfoType implements IRelayPageInfo {
  @Field(() => String)
  public startCursor: string;

  @Field(() => String)
  public endCursor: string;

  @Field(() => Boolean)
  public hasNextPage: boolean;

  @Field(() => Boolean)
  public hasPreviousPage: boolean;
}

export function RelayPaginated<T>(classRef: Type<T>): Type<IRelayPaginated<T>> {
  @ObjectType(`${classRef.name}RelayEdge`)
  abstract class EdgeType extends Edge(classRef) {}

  @ObjectType({ isAbstract: true })
  abstract class RelayPaginatedType implements IRelayPaginated<T> {
    @Field(() => Int)
    public previousCount: number;

    @Field(() => Int)
    public currentCount: number;

    @Field(() => [EdgeType])
    public edges: EdgeType[];

    @Field(() => PageInfoType)
    public pageInfo: PageInfoType;
  }

  return PaginatedType as Type<IRelayPaginated<T>>;
}
Enter fullscreen mode Exit fullscreen mode

Enums

There are are two enums that are necessary for filtering cursor paginated objects:

  • The Query Cursor Enum that represents the type of cursor, normally a alphabetical or chronological unique cursor;
  • The Query Order Enum that represents the order, which can be either ascending or descending.

Start by creating the "enum" directory on the common module folder.

Query Cursor Enum

The base interface represents the base entity that all your main entities will extend from. In this particular example the ID will be an auto-incremented integer that will represent the chronological cursor, while the slug is a unique varchar index that will represent the alphabetical cursor.

import { registerEnumType } from '@nestjs/graphql';
import { IBase } from '../interfaces/base.interface';
import { IUser } from '../../users/interfaces/user.interface';

export enum QueryCursorEnum {
  DATE = 'DATE',
  ALPHA = 'ALPHA',
}

registerEnumType(QueryCursorEnum, {
  name: 'QueryCursor',
});

export const getQueryCursor = (cursor: QueryCursorEnum): keyof IBase =>
  cursor === QueryCursorEnum.ALPHA ? 'id' : 'slug';
Enter fullscreen mode Exit fullscreen mode

Query Order Enum

Is a smaller version of the Mikro-ORM Order Enum. I normally save the helper functions inside the enums, but you are free to move them into their own file.

import { registerEnumType } from '@nestjs/graphql';

export type tOrderEnum = '$gt' | '$lt';
export type tOppositeOrder = '$gte' | '$lte';

export enum QueryOrderEnum {
  ASC = 'ASC',
  DESC = 'DESC',
}

export const getQueryOrder = (order: QueryOrderEnum): tOrderEnum =>
  order === QueryOrderEnum.ASC ? '$gt' : '$lt';

export const getOppositeOrder = (order: QueryOrderEnum): tOppositeOrder =>
  order === QueryOrderEnum.ASC ? '$lte' : '$gte';

registerEnumType(QueryOrderEnum, {
  name: 'QueryOrder',
});
Enter fullscreen mode Exit fullscreen mode

The get opposite order is necessary to get the previous count in the "Relay Style" pagination.

Common Service

In the common service we will have all the logic necessary for paginating objects. This logic will be divided in various methods:

  • Encoding and Decoding the Cursor to base 64;
  • Edge creation;
  • Raw implementation of the basic and relay cursor pagination;
  • Query Builder implementation of the basic and relay cursor pagination.

Encoding and Decoding the Cursor

For these methods we can use the NodeJS buffer object.

Since the enconding function is private I will make it static:

import { Injectable } from '@nestjs/common';

@Injectable()
export class CommonService {
  /**
   * Encode Cursor
   *
   * Takes a date, string or integer and returns the base 64
   * representation of it
   */
  private static encodeCursor(val: Date | string | number): string {
    let str: string;

    if (val instanceof Date) {
      str = val.getTime().toString();
    } else if (typeof val === 'number' || typeof val === 'bigint') {
      str = val.toString();
    } else {
      str = val;
    }

    return Buffer.from(str, 'utf-8').toString('base64');
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Even though in the encoding method we didn't need to specify the type of cursor, in the decoding method we will have to:

@Injectable()
export class CommonService {
  // ...

  /**
   * Decode Cursor
   *
   * Takes a base64 cursor and returns the string or number value
   */
  public decodeCursor(cursor: string, isNum = false): string | number {
    const str = Buffer.from(cursor, 'base64').toString('utf-8');

    if (isNum) {
      const num = parseInt(str, 10);

      if (isNaN(num))
        throw new BadRequestException(
          'Cursor does not reference a valid number',
        );

      return num;
    }

    return str;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Edge Creation

The inner cursor is for entities paginated by a relation. As the encoder since it's a private method I'll make it static:

// ...
import { IEdge } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  // ...

   /**
   * Create Edge
   *
   * Takes an instance, the cursor key and a innerCursor,
   * and generates a GraphQL edge
   */
  private static createEdge<T>(
    instance: T,
    cursor: keyof T,
    innerCursor?: string,
  ): IEdge<T> {
    try {
      return {
        node: instance,
        cursor: CommonService.encodeCursor(
          innerCursor ? instance[cursor][innerCursor] : instance[cursor],
        ),
      };
    } catch (_) {
      throw new InternalServerErrorException('The given cursor is invalid');
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Raw Implementation

The basic and relay versions are not that different, and they take about the same parameters:

  • The instances;
  • The count values;
  • The cursor;
  • The amount of fetched instances;
  • And optional inner cursor, for instances paginated by relations.

The basic version:

// ...
import { IEdge, IBasicPaginated } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  // ...

  /**
   * Basic Paginate
   *
   * Takes an entity array and returns the paginated type of that entity array
   * It uses cursor pagination as recommended in https://graphql.org/learn/pagination/
   */
  public basicPaginate<T>(
    instances: T[],
    totalCount: number,
    cursor: keyof T,
    first: number,
    innerCursor?: string,
  ): IBasicPaginated<T> {
    const pages: IBasicPaginated<T> = {
      totalCount,
      edges: [],
      pageInfo: {
        endCursor: '',
        hasNextPage: false,
      },
    };
    const len = instances.length;

    if (len > 0) {
      for (let i = 0; i < len; i++) {
        pages.edges.push(this.createEdge(instances[i], cursor, innerCursor));
      }
      pages.pageInfo.endCursor = pages.edges[len - 1].cursor;
      pages.pageInfo.hasNextPage = totalCount > first;
    }

    return pages;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The relay version:

// ...
import { IEdge, IRelayPaginated } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  // ...

  /**
   * Relay Paginate
   *
   * Takes an entity array and returns the paginated type of that entity array
   * It uses cursor pagination as recommended in https://relay.dev/graphql/connections.htm
   */
  public relayPaginate<T>(
    instances: T[],
    currentCount: number,
    previousCount: number,
    cursor: keyof T,
    first: number,
    innerCursor?: string,
  ): IRelayPaginated<T> {
    const pages: IRelayPaginated<T> = {
      currentCount,
      previousCount,
      edges: [],
      pageInfo: {
        endCursor: '',
        startCursor: '',
        hasPreviousPage: false,
        hasNextPage: false,
      },
    };
    const len = instances.length;

    if (len > 0) {
      for (let i = 0; i < len; i++) {
        pages.edges.push(
          CommonService.createEdge(instances[i], cursor, innerCursor),
        );
      }
      pages.pageInfo.startCursor = pages.edges[0].cursor;
      pages.pageInfo.endCursor = pages.edges[len - 1].cursor;
      pages.pageInfo.hasNextPage = currentCount > first;
      pages.pageInfo.hasPreviousPage = previousCount > 0;
    }

    return pages;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

QueryBuilder Implementation

Before implementing the methods themselves, we need some helper methods:

  • Get Order By method to get the order input for the query builder;
  • Get Filters method to get the where input for the query builder;
  • Throw Internal Error method, a promise wrapper to throw internal errors.

Since both the first two are private methods with no common service dependencies I'll make them static.

Get Order By

//...
import { Dictionary, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, QueryBuilder } from '@mikro-orm/postgresql';
import { QueryOrderEnum } from './enums/query-order.enum';

@Injectable()
export class CommonService {
  // ...

  /**
   * Get Order By
   *
   * Makes the order by query for MikroORM orderBy method.
   */
  private static getOrderBy<T>(
    cursor: keyof T,
    order: QueryOrderEnum,
    innerCursor?: string,
  ): Record<string, QueryOrderEnum | Record<string, QueryOrderEnum>> {
    return innerCursor
      ? {
          [cursor]: {
            [innerCursor]: order,
          },
        }
      : {
          [cursor]: order,
        };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Get Filters

//...
import { Dictionary, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, QueryBuilder } from '@mikro-orm/postgresql';
import { 
  QueryOrderEnum, 
  tOppositeOrder, 
  tOrderEnum,
} from './enums/query-order.enum';

@Injectable()
export class CommonService {
  // ...

  /**
   * Get Filters
   *
   * Gets the where clause filter logic for the query builder pagination
   */
  private static getFilters<T>(
    cursor: keyof T,
    decoded: string | number,
    order: tOrderEnum | tOppositeOrder,
    innerCursor?: string,
  ): FilterQuery<Dictionary<T>> {
    return innerCursor
      ? {
          [cursor]: {
            [innerCursor]: {
              [order]: decoded,
            },
          },
        }
      : {
          [cursor]: {
            [order]: decoded,
          },
        };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Throw Internal Error

import { Injectable, InternalServerErrorException } from '@nestjs/common';

@Injectable()
export class CommonService {
  // ...

  /**
   * Throw Internal Error
   *
   * Function to abstract throwing internal server exception
   */
  public async throwInternalError<T>(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

In terms of parameters both version of the pagination methods will have the same ones.

  • Alias: the alias of the query builder;
  • Cursor: the unique cursor;
  • First: the amount of instances to be fetched;
  • Order: the query order enum;
  • QB: the query builder;
  • After: the optional parameter for where the query "should start" after;
  • After Is Number: since the cursor can be a numeric value;
  • Inner Cursor: for relations.

Basic Implementation

//...
import { Dictionary, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, QueryBuilder } from '@mikro-orm/postgresql';
import {
  getQueryOrder,
  QueryOrderEnum, 
  tOppositeOrder, 
  tOrderEnum,
} from './enums/query-order.enum';
import { IEdge, IBasicPaginated } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  // ...

  /**
   * Basic Query Builder Pagination
   *
   * Takes a query builder and returns the entities paginated
   */
  public async basicQueryBuilderPagination<T extends Object>(
    alias: string,
    cursor: keyof T,
    first: number,
    order: QueryOrderEnum,
    qb: QueryBuilder<T>,
    after?: string,
    afterIsNum = false,
    innerCursor?: string,
  ): Promise<IBasicPaginated<T>> {    
    if (after) {
      const decoded = this.decodeCursor(after, afterIsNum);
      const qbOrder = getQueryOrder(order);

      qb.andWhere(
        CommonService.getFilters(cursor, decoded, qbOrder, innerCursor),
      );
    }

    const cqb = qb.clone()
    const [count, entities]: [number, T[]] =
      await this.throwInternalError(
        Promise.all([
          cqb.count(`${alias}.${String(cursor)}`, true),
          qb
            .select(`${alias}.*`)
            .orderBy(this.getOrderBy(cursor, order, innerCursor))
            .limit(first)
            .getResult(),
        ]),
      );

    return this.basicPaginate(
      entities,
      count,
      cursor,
      first,
      innerCursor,
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Relay Implementation

//...
import { Dictionary, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, QueryBuilder } from '@mikro-orm/postgresql';
import {
  getOppositeOrder,
  getQueryOrder,
  QueryOrderEnum, 
  tOppositeOrder, 
  tOrderEnum,
} from './enums/query-order.enum';
import { IEdge, IBasicPaginated, IRelayPaginated } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  // ...

  /**
   * Relay Query Builder Pagination
   *
   * Takes a query builder and returns the entities paginated
   */
  public async relayQueryBuilderPagination<T extends Object>(
    alias: string,
    cursor: keyof T,
    first: number,
    order: QueryOrderEnum,
    qb: QueryBuilder<T>,
    after?: string,
    afterIsNum = false,
    innerCursor?: string,
  ): Promise<IRelayPaginated<T>> {
    const strCursor = String(cursor);
    const aliasCursor = `${alias}.${strCursor}`;
    let prevCount = 0;

    if (after) {
      const decoded = this.decodeCursor(after, afterIsNum);
      const oppositeOd = getOppositeOrder(order);
      const tempQb = qb.clone();
      tempQb.andWhere(
        CommonService.getFilters(cursor, decoded, oppositeOd, innerCursor),
      );
      prevCount = await tempQb.count(aliasCursor, true);

      const normalOd = getQueryOrder(order);
      qb.andWhere(
        CommonService.getFilters(cursor, decoded, normalOd, innerCursor),
      );
    }

    const cqb = qb.clone();
    const [count, entities]: [number, T[]] = await this.throwInternalError(
      Promise.all([
        cqb.count(aliasCursor, true),
        qb
          .select(`${alias}.*`)
          .orderBy(CommonService.getOrderBy(cursor, order, innerCursor))
          .limit(first)
          .getResult(),
      ]),
    );

    return this.relayPaginate(
      entities,
      count,
      prevCount,
      cursor,
      first,
      innerCursor,
    );
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Puting it All Together

Finally your common service should look something like this:

import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { Dictionary, FilterQuery } from '@mikro-orm/core';
import { EntityRepository, QueryBuilder } from '@mikro-orm/postgresql';
import {
  getOppositeOrder,
  getQueryOrder,
  QueryOrderEnum, 
  tOppositeOrder, 
  tOrderEnum,
} from './enums/query-order.enum';
import { IEdge, IBasicPaginated, IRelayPaginated } from './interfaces/paginated.interface';

@Injectable()
export class CommonService {
  /**
   * Encode Cursor
   *
   * Takes a date, string or integer and returns the base 64
   * representation of it
   */
  private static encodeCursor(val: Date | string | number): string {
    let str: string;

    if (val instanceof Date) {
      str = val.getTime().toString();
    } else if (typeof val === 'number' || typeof val === 'bigint') {
      str = val.toString();
    } else {
      str = val;
    }

    return Buffer.from(str, 'utf-8').toString('base64');
  }


  /**
   * Create Edge
   *
   * Takes an instance, the cursor key and a innerCursor,
   * and generates a GraphQL edge
   */
  private static createEdge<T>(
    instance: T,
    cursor: keyof T,
    innerCursor?: string,
  ): IEdge<T> {
    try {
      return {
        node: instance,
        cursor: CommonService.encodeCursor(
          innerCursor ? instance[cursor][innerCursor] : instance[cursor],
        ),
      };
    } catch (_) {
      throw new InternalServerErrorException('The given cursor is invalid');
    }
  }

  /**
   * Get Order By
   *
   * Makes the order by query for MikroORM orderBy method.
   */
  private static getOrderBy<T>(
    cursor: keyof T,
    order: QueryOrderEnum,
    innerCursor?: string,
  ): Record<string, QueryOrderEnum | Record<string, QueryOrderEnum>> {
    return innerCursor
      ? {
          [cursor]: {
            [innerCursor]: order,
          },
        }
      : {
          [cursor]: order,
        };
  }

  /**
   * Get Filters
   *
   * Gets the where clause filter logic for the query builder pagination
   */
  private static getFilters<T>(
    cursor: keyof T,
    decoded: string | number,
    order: tOrderEnum | tOppositeOrder,
    innerCursor?: string,
  ): FilterQuery<Dictionary<T>> {
    return innerCursor
      ? {
          [cursor]: {
            [innerCursor]: {
              [order]: decoded,
            },
          },
        }
      : {
          [cursor]: {
            [order]: decoded,
          },
        };
  }

  /**
   * Throw Internal Error
   *
   * Function to abstract throwing internal server exception
   */
  public async throwInternalError<T>(promise: Promise<T>): Promise<T> {
    try {
      return await promise;
    } catch (error) {
      throw new InternalServerErrorException(error);
    }
  }

  /**
   * Decode Cursor
   *
   * Takes a base64 cursor and returns the string or number value
   */
  public decodeCursor(cursor: string, isNum = false): string | number {
    const str = Buffer.from(cursor, 'base64').toString('utf-8');

    if (isNum) {
      const num = parseInt(str, 10);

      if (isNaN(num))
        throw new BadRequestException(
          'Cursor does not reference a valid number',
        );

      return num;
    }

    return str;
  }


  /**
   * Basic Paginate
   *
   * Takes an entity array and returns the paginated type of that entity array
   * It uses cursor pagination as recommended in https://graphql.org/learn/pagination/
   */
  public basicPaginate<T>(
    instances: T[],
    totalCount: number,
    cursor: keyof T,
    first: number,
    innerCursor?: string,
  ): IBasicPaginated<T> {
    const pages: IBasicPaginated<T> = {
      totalCount,
      edges: [],
      pageInfo: {
        endCursor: '',
        hasNextPage: false,
      },
    };
    const len = instances.length;

    if (len > 0) {
      for (let i = 0; i < len; i++) {
        pages.edges.push(
          CommonService.createEdge(instances[i], cursor, innerCursor),
        );
      }
      pages.pageInfo.endCursor = pages.edges[len - 1].cursor;
      pages.pageInfo.hasNextPage = totalCount > first;
    }

    return pages;
  }

  /**
   * Relay Paginate
   *
   * Takes an entity array and returns the paginated type of that entity array
   * It uses cursor pagination as recommended in https://relay.dev/graphql/connections.htm
   */
  public relayPaginate<T>(
    instances: T[],
    currentCount: number,
    previousCount: number,
    cursor: keyof T,
    first: number,
    innerCursor?: string,
  ): IRelayPaginated<T> {
    const pages: IRelayPaginated<T> = {
      currentCount,
      previousCount,
      edges: [],
      pageInfo: {
        endCursor: '',
        startCursor: '',
        hasPreviousPage: false,
        hasNextPage: false,
      },
    };
    const len = instances.length;

    if (len > 0) {
      for (let i = 0; i < len; i++) {
        pages.edges.push(
          CommonService.createEdge(instances[i], cursor, innerCursor),
        );
      }
      pages.pageInfo.startCursor = pages.edges[0].cursor;
      pages.pageInfo.endCursor = pages.edges[len - 1].cursor;
      pages.pageInfo.hasNextPage = currentCount > first;
      pages.pageInfo.hasPreviousPage = previousCount > 0;
    }

    return pages;
  }

  /**
   * Basic Query Builder Pagination
   *
   * Takes a query builder and returns the entities paginated
   */
  public async basicQueryBuilderPagination<T extends Object>(
    alias: string,
    cursor: keyof T,
    first: number,
    order: QueryOrderEnum,
    qb: QueryBuilder<T>,
    after?: string,
    afterIsNum = false,
    innerCursor?: string,
  ): Promise<IBasicPaginated<T>> {    
    if (after) {
      const decoded = this.decodeCursor(after, afterIsNum);
      const qbOrder = getQueryOrder(order);

      qb.andWhere(
        CommonService.getFilters(cursor, decoded, qbOrder, innerCursor),
      );
    }

    const cqb = qb.clone()
    const [count, entities]: [number, T[]] =
      await this.throwInternalError(
        Promise.all([
          cqb.count(`${alias}.${String(cursor)}`, true),
          qb
            .select(`${alias}.*`)
            .orderBy(this.getOrderBy(cursor, order, innerCursor))
            .limit(first)
            .getResult(),
        ]),
      );

    return this.basicPaginate(
      entities,
      count,
      cursor,
      first,
      innerCursor,
    );
  }

  /**
   * Relay Query Builder Pagination
   *
   * Takes a query builder and returns the entities paginated
   */
  public async relayQueryBuilderPagination<T extends Object>(
    alias: string,
    cursor: keyof T,
    first: number,
    order: QueryOrderEnum,
    qb: QueryBuilder<T>,
    after?: string,
    afterIsNum = false,
    innerCursor?: string,
  ): Promise<IRelayPaginated<T>> {
    const strCursor = String(cursor);
    const aliasCursor = `${alias}.${strCursor}`;
    let prevCount = 0;

    if (after) {
      const decoded = this.decodeCursor(after, afterIsNum);
      const oppositeOd = getOppositeOrder(order);
      const tempQb = qb.clone();
      tempQb.andWhere(
        CommonService.getFilters(cursor, decoded, oppositeOd, innerCursor),
      );
      prevCount = await tempQb.count(aliasCursor, true);

      const normalOd = getQueryOrder(order);
      qb.andWhere(
        CommonService.getFilters(cursor, decoded, normalOd, innerCursor),
      );
    }

    const cqb = qb.clone();
    const [count, entities]: [number, T[]] = await this.throwInternalError(
      Promise.all([
        cqb.count(aliasCursor, true),
        qb
          .select(`${alias}.*`)
          .orderBy(CommonService.getOrderBy(cursor, order, innerCursor))
          .limit(first)
          .getResult(),
      ]),
    );

    return this.relayPaginate(
      entities,
      count,
      prevCount,
      cursor,
      first,
      innerCursor,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

With this implementation you'll be able to create paginated objects of your main object types and filter them on your resolvers.

Top comments (4)

Collapse
 
simplenotezy profile image
Mattias Fjellvang

It's an really interesting and reusable approach you're taking @tugascript but your article should include a repo. I believe you missed something along writing it, as I am getting:

Cannot determine a GraphQL output type for the "campaignsPaginated"

It would also be nice if you shared an example of how you actually used the designed implementation.

Collapse
 
tugascript profile image
Afonso Barracha

I haven't maintain it, but I created this for a hackathon years ago:
github.com/tugascript/flutter-slid...

Collapse
 
muratas profile image
murat

Thanks for this. Is there a working repo?

Collapse
 
tugascript profile image
Afonso Barracha

Its archive and a bit broken, but you have this template that covers all my tutorials: github.com/tugascript/nestjs-graph...