1.1.5 • Published 1 month ago

nestjs-graphql-cursor-connections v1.1.5

Weekly downloads
-
License
MIT
Repository
github
Last release
1 month ago

NestJS GraphQL Cursor Connections

GraphQL Cursor Connections specification implementation for NestJS. Provides a handling of cursor, offset, and hybrid (cursor + offset) pagination.

Contents

Installation

npm i nestjs-graphql-cursor-connections

Usage

Assume we have users assigned to groups, and we need to provide a list of users of a specific group ordered by email address using connections pagination pattern.

import { Field, Int, ObjectType } from '@nestjs/graphql'

@ObjectType()
export class User {
  @Field(() => Int)
  public id!: number

  @Field()
  public email!: string

  @Field()
  public name!: string

  @Field()
  public groupId!: string
}
type User {
  id: Int!
  email: String!
  name: String!
  groupId: String!
}

Connection Edge

Create a connection edge type extending the class returned by the ConnectionEdge() function passing the target GraphQL type to it.

import { ObjectType } from '@nestjs/graphql'
import { ConnectionEdge } from 'nestjs-graphql-cursor-connections'

@ObjectType()
export class UserConnectionEdge extends ConnectionEdge(User) {}

This will produce the following GraphQL type:

type UserConnectionEdge {
  cursor: String!
  node: User!
}

Connection

Create a connection type extending the class returned by the Connection() function passing the connection edge type to it.

import { ObjectType } from '@nestjs/graphql'
import { Connection } from 'nestjs-graphql-cursor-connections'

@ObjectType()
export class UserConnection extends Connection(UserConnectionEdge) {}

This will produce the following GraphQL type:

type UserConnection {
  edges: [UserConnectionEdge!]!
  pageInfo: PageInfo!
}

Connection Arguments

Create a connection arguments type extending the ConnectionArgs class and add any extra filtering/sorting arguments if needed. In our case we need a groupId argument to filter users.

import { ArgsType, Field } from '@nestjs/graphql'
import { IsNotEmpty } from 'class-validator'
import { ConnectionArgs } from 'nestjs-graphql-cursor-connections'

@ArgsType()
export class UserConnectionArgs extends ConnectionArgs {
  @Field()
  @IsNotEmpty()
  groupId!: string
}

This will produce the following GraphQL arguments:

after: String
before: String
first: Int
last: Int
edgesPerPage: Int  # The number of edges to display per page (enables pager mode).
page: Int          # The number of a page to display (enables pager mode).
groupId: String!

Connection Page Info

Connection page info is already created and produces the following GraphQL type:

hasPreviousPage: Boolean!
hasNextPage: Boolean!
startCursor: String!
endCursor: String!
totalEdges: Int!   # Total number of edges after applying the cursors.
edgesPerPage: Int  # The number of edges displayed per page (in pager mode).
page: Int          # The number of displayed page (in pager mode).
totalPages: Int    # Total number of pages (in pager mode).

Connection Builder

Create a connection builder extending the ConnectionBuilder class and implement all of its abstract methods.

import { ConnectionBuilder } from 'nestjs-graphql-cursor-connections'

type UserCursorData = string

export class UserConnectionBuilder extends ConnectionBuilder<
  UserConnection, UserConnectionEdge, User, UserCursorData
> {
  /**
   * Cursor data is a node field or a set of node fields that uniquely identify
   * the node position in the list.
   * Cursor data can be a number, a string, an array, or an object.
   */
  protected getCursorData(node: User): UserCursorData {
    // In our case the cursor data is a string representing user's email address.
    return node.email
  }

  /**
   * Since the cursor is an input argument, the unpacked cursor data must be
   * validated to be sure it can be safely used.
   */
  protected isValidCursorData(data: unknown): boolean {
    return 'string' === typeof data && 0 < data.length
  }

  /**
   * When cursor argument can not be unpacked or unpacked cursor data fails
   * validation, the cursor argument can be ignored (return null), or an error
   * can be thrown (return an error).
   */
  protected getCursorDataError(name: 'after' | 'before', value: string): null | Error {
    return new Error(`Cursor argument '${name}' has invalid value '${value}'.`)
  }
}

Connection Resolver

Here is an example implementation of a query resolver. The implementation of a field resolver is similar.

import { Args, Query, Resolver } from '@nestjs/graphql'

@Resolver()
export class UserGraphqlResolver {
  public constructor(private readonly repository: UserRepository) {}

  @Query(() => UserConnection, { nullable: true })
  public async usersByGroup(@Args() args: UserConnectionArgs): Promise<null | UserConnection> {
    const maxEdgesToReturn = 10
    const connectionBuilder = new UserConnectionBuilder(args, maxEdgesToReturn)
    const { groupId } = args
    // Contains the unpacked cursor data (user email) or `undefined`.
    const { after, before } = connectionBuilder
    // The result of the count users query below.
    // Can be cached to improve performance when no after/before arguments are used.
    const totalEdges = await this.repository.countUsersByGroup(groupId, { after, before })
    // Returns the result set bounds: start, end, skip (start), take (end - start).
    const { skip, take } = connectionBuilder.getBounds(totalEdges)
    // The result of the find users query below.
    const users = await this.repository.findUsersByGroup(groupId, { after, before, skip, take })
    return connectionBuilder.build(users, totalEdges)
  }
}

Count users query

SELECT COUNT(*) FROM users
WHERE groupId = :groupId AND email > :after AND email < :before;

Find users query

SELECT * FROM users
WHERE groupId = :groupId AND email > :after AND email < :before
ORDER BY email ASC
LIMIT :take OFFSET :skip;

Arguments Adjusting

If maxEdgesToReturn connection builder parameter is specified, and it is a positive integer, the first, last, and edgesPerPage arguments are being adjusted, so that the number of returned edges never exceeds the value of maxEdgesToReturn.

If maxEdgesToReturn connection builder parameter is omitted, the number of returned edges is not limited.

Pagination Algorithm

To determine what edges to return, the connection evaluates the after and before cursors to filter the edges, (then, if pager mode is enabled, splits the edges into pages and slices them to contain only the selected page), then evaluates first to slice the edges, then last to slice the edges.

Let's say we have a set of all edges:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

If after is set and exists (E), remove all edges before and including after edge:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

If before is set and exists (V), remove all edges after and including before edge:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

If edgesPerPage (10) or page (1) is set, split the edges into pages and slice them to contain only the selected page.

A B C D E <F G H I J K L M N O> <P Q R S T U> V W X Y Z

If first is set (8), and edges length greater than first, slice edges to be of length first by removing edges from the end.

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

If last is set (4), and edges length greater than last, slice edges to be of length last by removing edges from the start.

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

Edges to return:

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

Offset Pagination (Pager Mode)

To enable offset pagination (pager mode) provide the edgesPerPage and/or the page connection argument. If one of these arguments is omitted, the another one will use the default value: edgesPerPage defaults to the value of the maxEdgesToReturn connection builder parameter; page defaults to 1.

When pager mode is enabled, the connection page info will additionally contain the following fields:

  • edgesPerPage: the number of edges displayed per page;
  • page: the number of displayed page;
  • totalPages: total number of pages.

Dynamic Cursor (Sorting)

There are cases where custom connection arguments can affect the cursor data generation. For example, sortBy argument.

Assume we need the ability to sort users by ID and email fields:

export enum UsersSortBy {
  Id = 'id',
  Email = 'email',
}

And we have the following connection arguments type:

import { ArgsType, Field } from '@nestjs/graphql'
import { IsEnum } from 'class-validator'
import { ConnectionArgs } from 'nestjs-graphql-cursor-connections'

@ArgsType()
export class UserConnectionArgs extends ConnectionArgs {
  @IsEnum(UsersSortBy)
  @Field(() => UsersSortBy)
  public readonly sortBy!: UsersSortBy
}

Create a connection builder extending the ConnectionBuilder class and specifying UserConnectionArgs as the 5th generic argument. After this the sortBy argument can be accessed from this.args:

import { ConnectionBuilder } from 'nestjs-graphql-cursor-connections'

type UserCursorData = { id: number } | { email: string }

export class UserConnectionBuilder extends ConnectionBuilder<
  UserConnection,
  UserConnectionEdge,
  User,
  UserCursorData,
  UserConnectionArgs
> {
  protected getCursorData(node: User): UserCursorData {
    switch (this.args.sortBy) {
      case UsersSortBy.Id:
        return { id: node.id }

      case UsersSortBy.Email:
        return { email: node.email }
    }
  }

  protected isValidCursorData(data: unknown): boolean {
    switch (this.args.sortBy) {
      case UsersSortBy.Id:
        // Check that the data is `{ id: number }`.
        return this.isValidIdCursorData(data)

      case UsersSortBy.Email:
        // Check that the data is `{ email: string }`.
        return this.isValidEmailCursorData(data)
    }
  }

  protected getCursorDataError(name: 'after' | 'before', value: string): null | Error {
    return new Error(`Cursor argument '${name}' has invalid value '${value}'.`)
  }
  ...
}

Additional Fields

Connection Edge Additional Fields

Connection edge types may have additional fields related to the edge. There are two ways to provide additional fields:

  • By creating a field resolver for a connection edge
import { Parent, ResolveField, Resolver } from '@nestjs/graphql'

@Resolver(() => UserConnectionEdge)
export class UserConnectionEdgeResolver {
  @ResolveField(() => Boolean)
  public additionalField(@Parent() edge: UserConnectionEdge): Promise<boolean> {
    return this.getAdditionalField(edge)
  }
  ...
}
  • By extending a connection edge type and overriding createConnectionEdge() method of the connection builder
import { Field, ObjectType } from '@nestjs/graphql'
import { ConnectionEdge } from 'nestjs-graphql-cursor-connections'

@ObjectType()
export class UserConnectionEdge extends ConnectionEdge(User) {
  @Field()
  additionalField!: boolean
}
import { ConnectionBuilder, ConnectionEdge } from 'nestjs-graphql-cursor-connections'

export class UserConnectionBuilder extends ConnectionBuilder<...> {
  ...
  protected async createConnectionEdge(edge: ConnectionEdge<User>): Promise<UserConnectionEdge> {
    const additionalField = await this.getAdditionalField(edge)
    return { ...edge, additionalField }
  }
  ...
}

In both cases the following GraphQL type will be produced:

type UserConnectionEdge {
  node: User!
  cursor: String!
  additionalField: Boolean!
}

Connection Additional Fields

Connection types may have additional fields related to the connection. There are two ways to provide additional fields:

  • By creating a field resolver for a connection
import { Parent, ResolveField, Resolver } from '@nestjs/graphql'

@Resolver(() => UserConnection)
export class UserConnectionResolver {
  @ResolveField(() => Boolean)
  public additionalField(@Parent() connection: UserConnection): Promise<boolean> {
    return this.getAdditionalField(connection)
  }
  ...
}
  • By extending a connection type and overriding createConnection() method of the connection builder
import { Field, ObjectType } from '@nestjs/graphql'
import { Connection } from 'nestjs-graphql-cursor-connections'

@ObjectType()
export class UserConnection extends Connection(UserConnectionEdge) {
  @Field()
  additionalField!: boolean
}
import { Connection, ConnectionBuilder } from 'nestjs-graphql-cursor-connections'

export class UserConnectionBuilder extends ConnectionBuilder<...> {
  ...
  protected async createConnection(connection: Connection<UserConnectionEdge>): Promise<UserConnection> {
    const additionalField = await this.getAdditionalField(connection)
    return { ...connection, additionalField }
  }
  ...
}

In both cases the following GraphQL type will be produced:

type UserConnection {
  pageInfo: PageInfo!
  edges: [UserConnectionEdge!]!
  additionalField: Boolean!
}

Fake Cursor Pagination

In cases when node fields can not uniquely identify the node position in the list, there is no way to create a real cursor, and the only way to paginate through the list is to use offset pagination.

It is possible to hide the offset pagination behind the cursor pagination interface, assuming that cursor is a node position in the list – a fake cursor.

Create Connection Edge, Connection, and Connection Arguments.

Fake Cursor Connection Builder

Create a connection builder extending the FakeCursorConnectionBuilder class and implement all of its abstract methods.

import { FakeCursorConnectionBuilder } from 'nestjs-graphql-cursor-connections'

export class UserFakeCursorConnectionBuilder extends FakeCursorConnectionBuilder<
  UserConnection, UserConnectionEdge, User
> {
  /**
   * When cursor can not be unpacked or unpacked cursor data fails validation,
   * the cursor argument can be ignored (return null), or an error can be thrown
   * (return an error).
   */
  protected getCursorDataError(name: string, value: string): null | Error {
    return new Error(`Cursor argument '${name}' has invalid value '${value}'.`)
  }
}

Fake Cursor Connection Resolver

Here is an example implementation of a query resolver. The implementation of a field resolver is similar.

import { Args, Query, Resolver } from '@nestjs/graphql'

@Resolver()
export class UserGraphqlResolver {
  public constructor(private readonly repository: UserRepository) {}

  @Query(() => UserConnection, { nullable: true })
  public async usersByGroup(@Args() args: UserConnectionArgs): Promise<null | UserConnection> {
    const maxEdgesToReturn = 10
    const connectionBuilder = new UserFakeCursorConnectionBuilder(args, maxEdgesToReturn)
    const { groupId } = args
    // The result of the count users query below.
    // Can be cached to improve performance.
    const totalEdges = await this.repository.countUsersByGroup(groupId)
    // Returns the result set bounds: start, end, skip (start), take (end - start).
    const { skip, take } = connectionBuilder.getBounds(totalEdges)
    // The result of the find users query below.
    const users = await this.repository.findUsersByGroup(groupId, { skip, take })
    return connectionBuilder.build(users, totalEdges)
  }
}

Count users query

SELECT COUNT(*) FROM users
WHERE groupId = :groupId

Find users query

SELECT * FROM users
WHERE groupId = :groupId
ORDER BY email ASC
LIMIT :take OFFSET :skip;
1.1.5

1 month ago

1.1.4

1 month ago

1.1.3

1 month ago

1.1.1

1 year ago

1.1.0

1 year ago

1.0.5

1 year ago

1.0.4

1 year ago

1.1.2

1 year ago

1.0.3

2 years ago

1.0.2

2 years ago

1.0.1

2 years ago

1.0.0

2 years ago