Relay-style pagination for NestJS GraphQL server.
Installation
npm i nestjs-graphql-connection
TypeScript type definitions are included in the box.
You must also install @nestjs/graphql as a peer dependency (you should have this
already).
Usage
Create an Edge type
Extend a class from createEdgeType
function, passing it the class of objects to be represented by the edge's node
.
import { ObjectType } from '@nestjs/graphql';
import { createEdgeType } from 'nestjs-graphql-connection';
import { Person } from './entities';
@ObjectType()
export class PersonEdge extends createEdgeType(Person) {}
Create a Connection type
Extend a class from createConnectionType
function, passing it the class of objects to be represented by the
connection's edges
:
import { ObjectType } from '@nestjs/graphql';
import { createConnectionType } from 'nestjs-graphql-connection';
@ObjectType()
export class PersonConnection extends createConnectionType(PersonEdge) {}
Create a Connection Arguments type
Extend a class from ConnectionArgs
class to have pagination arguments pre-defined for you. You can additionally
define your own arguments for filtering, etc.
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { ConnectionArgs } from 'nestjs-graphql-connection';
@ArgsType()
export class PersonConnectionArgs extends ConnectionArgs {
@Field(type => ID, { nullable: true })
public personId?: string;
}
Create a Connection Builder
Now define a ConnectionBuilder
class for your Connection
object. The builder is responsible for interpreting
pagination arguments for the connection, and creating the cursors and Edge
objects that make up the connection.
import { ConnectionBuilder, Cursor, PageInfo, validateParamsUsingSchema } from 'nestjs-graphql-connection';
export type PersonCursorParams = { id: string };
export type PersonCursor = Cursor<PersonCursorParams>;
export class PersonConnectionBuilder extends ConnectionBuilder<
PersonConnection,
PersonConnectionArgs,
PersonEdge,
Person,
PersonCursor
> {
public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection {
return new PersonConnection(fields);
}
public createEdge(fields: { node: TestNode; cursor: string }): TestEdge {
return new PersonEdge(fields);
}
public createCursor(node: Person): PersonCursor {
return new Cursor({ id: node.id });
}
public decodeCursor(encodedString: string): PersonCursor {
const schema: Joi.ObjectSchema<PersonCursorParams> = Joi.object({
id: Joi.string().empty('').required(),
}).unknown(false);
return Cursor.fromString(encodedString, params => validateParamsUsingSchema(params, schema));
}
}
Resolve a Connection
Your resolvers can now return your Connection
as an object type. Use your ConnectionBuilder
class to determine which
page of results to fetch and to create the PageInfo
, cursors, and edges in the result.
import { Args, Query, Resolver } from '@nestjs/graphql';
@Resolver()
export class PersonQueryResolver {
@Query(returns => PersonConnection)
public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise<PersonConnection> {
const { personId } = connectionArgs;
const connectionBuilder = new PersonConnectionBuilder(connectionArgs);
const totalEdges = await countPersons({ where: { personId } });
const persons = await fetchPersons({
where: { personId },
take: connectionBuilder.edgesPerPage,
});
return connectionBuilder.build({
totalEdges,
nodes: persons,
});
}
}
With offset pagination, cursor values are an encoded representation of the row offset. It is possible for clients to
paginate by specifying either an after
argument with the cursor of the last row on the previous page, or to pass a
page
argument with an explicit page number (based on the rows per page set by the first
argument). Offset paginated
connections do not support the last
or before
connection arguments, results must be fetched in forward order.
Offset pagination is useful when you want to be able to retrieve a page of edges at an arbitrary position in the result
set, without knowing anything about the intermediate entries. For example, to link to "page 10" without first
determining what the last result was on page 9.
To use offset cursors, extend your builder class from OffsetPaginatedConnectionBuilder
instead of ConnectionBuilder
:
import { OffsetPaginatedConnectionBuilder, PageInfo, validateParamsUsingSchema } from 'nestjs-graphql-connection';
export class PersonConnectionBuilder extends OffsetPaginatedConnectionBuilder<
PersonConnection,
PersonConnectionArgs,
PersonEdge,
Person
> {
public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection {
return new PersonConnection(fields);
}
public createEdge(fields: { node: TestNode; cursor: string }): TestEdge {
return new PersonEdge(fields);
}
}
In your resolver, you can use the startOffset
property of the builder to determine the zero-indexed offset from which
to begin the result set. For example, this works with SQL databases that accept a SKIP
or OFFSET
parameter in
queries.
import { Args, Query, Resolver } from '@nestjs/graphql';
@Resolver()
export class PersonQueryResolver {
@Query(returns => PersonConnection)
public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise<PersonConnection> {
const { personId } = connectionArgs;
const connectionBuilder = new PersonConnectionBuilder(connectionArgs);
const totalEdges = await countPersons({ where: { personId } });
const persons = await fetchPersons({
where: { personId },
take: connectionBuilder.edgesPerPage,
skip: connectionBuilder.startOffset,
});
return connectionBuilder.build({
totalEdges,
nodes: persons,
});
}
}
Advanced Topics
Enriching Edges with additional metadata
The previous examples are sufficient for resolving connections that represent simple lists of objects with pagination.
However, sometimes you need to model connections and edges that contain additional metadata. For example, you might
relate Person
objects together into networks of friends using a PersonFriendConnection
containing PersonFriendEdge
edges. In this case the node
on each edge is still a Person
object, but the relationship itself may have
properties -- such as the date that the friend was added, or the type of relationship. (In relational database terms
this is analogous to having a Many-to-Many relation where the intermediate join table contains additional data columns
beyond just the keys of the two joined tables.)
In this case your edge type would look like the following example. Notice that we pass a { createdAt: Date }
type
argument to createEdgeType
; this specifies typings for the fields that are allowed to be passed to your edge class's
constructor for initialization when doing new PersonFriendEdge({ ...fields })
.
import { Field, GraphQLISODateTime, ObjectType } from '@nestjs/graphql';
import { createEdgeType } from 'nestjs-graphql-connection';
import { Person } from './entities';
@ObjectType()
export class PersonFriendEdge extends createEdgeType<{ createdAt: Date }>(Person) {
@Field(type => GraphQLISODateTime)
public createdAt: Date;
}
ConnectionBuilder
supports overriding the createConnection()
and createEdge()
methods when calling build()
. This
enables you to enrich the connection and edges with additional metadata at resolve time.
The following example assumes you have a GraphQL schema that defines a friends
field on your Person
object, which
resolves to a PersonFriendConnection
containing the person's friends. In your database you would have a friend
table
that relates a person
to an otherPerson
, and that relationship has a createdAt
date.
import { Args, ResolveField, Resolver, Root } from '@nestjs/graphql';
@Resolver(of => Person)
export class PersonResolver {
@ResolveField(returns => PersonFriendConnection)
public async friends(
@Root() person: Person,
@Args() connectionArgs: PersonFriendConnectionArgs,
): Promise<PersonFriendConnection> {
const connectionBuilder = new PersonFriendConnectionBuilder(connectionArgs);
const totalEdges = await countFriends({ where: { personId: person.id } });
const friends = await fetchFriends({
where: { personId: person.id },
take: connectionBuilder.edgesPerPage,
});
return connectionBuilder.build({
totalEdges,
nodes: friends.map(friend => friend.otherPerson),
createEdge: ({ node, cursor }) => {
const friend = friends.find(friend => friend.otherPerson.id === node.id);
return new PersonFriendEdge({ node, cursor, createdAt: friend.createdAt });
},
});
}
}
Alternatively, you could build the connection result yourself by replacing the connectionBuilder.build(...)
statement
with something like the following:
const edges = friends.map(
(friend, index) =>
new PersonFriendEdge({
cursor: connectionBuilder.createCursor(friend.otherPerson, index),
node: friend.otherPerson,
createdAt: friend.createdAt,
}),
);
return new PersonFriendConnection({
pageInfo: connectionBuilder.createPageInfo({
edges,
totalEdges,
hasNextPage: true,
hasPreviousPage: false,
}),
edges,
});
Customising Cursors
When using cursors for pagination of connections that allow the client to choose from different sorting options, you may
need to customise your cursor to reflect the chosen sort order. For example, if the client can sort PersonConnection
by either name or creation date, the cursors you create on each edge will need to be different. It is no use knowing the
creation date of the last node if you are trying to fetch the next page of edges after the name "Smith", and vice versa.
You could set the node ID as the cursor in all cases and simply look up the relevant data (name or creation date) from
the node when given such a cursor. However, if you have a dataset that could change between requests then this approach
introduces the potential for odd behavior and/or missing results.
Imagine you have a sortOption
field on your PersonConnectionArgs
that determines the requested sort order:
@ArgsType()
export class PersonConnectionArgs extends ConnectionArgs {
@Field(type => String, { nullable: true })
public sortOption?: string;
}
You can customise your cursor based on the sortOption
from the ConnectionArgs
by changing your definition of
createCursor
and decodeCursor
in your builder class like the following example:
export class PersonConnectionBuilder extends ConnectionBuilder<
PersonConnection,
PersonConnectionArgs,
PersonEdge,
Person,
PersonCursor
> {
public createCursor(node: Person): PersonCursor {
if (this.connectionArgs.sortOption === 'name') {
return new Cursor({ name: node.name });
}
return new Cursor({ createdAt: node.createdAt.toISOString() });
}
public decodeCursor(encodedString: string): PersonCursor {
if (this.connectionArgs.sortOption === 'name') {
return Cursor.fromString(encodedString, params =>
validateParamsUsingSchema(
params,
Joi.object({
name: Joi.string().empty('').required(),
}).unknown(false),
),
);
}
return Cursor.fromString(encodedString, params =>
validateParamsUsingSchema(
params,
Joi.object({
id: Joi.string().empty('').required(),
}).unknown(false),
),
);
}
}
Alternatively, ConnectionBuilder
supports overriding the createCursor()
method when calling build()
. So you could
also do it like this:
import { Args, ResolveField, Resolver, Root } from '@nestjs/graphql';
@Resolver()
export class PersonQueryResolver {
@Query(returns => PersonConnection)
public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise<PersonConnection> {
const { sortOption } = connectionArgs;
const connectionBuilder = new PersonConnectionBuilder(connectionArgs);
const persons = await fetchPersons({
where: { personId },
order: sortOption === 'name' ? { name: 'ASC' } : { createdAt: 'ASC' },
take: connectionBuilder.edgesPerPage,
});
return connectionBuilder.build({
totalEdges: await countPersons(),
nodes: persons,
createCursor(node) {
return new Cursor(sortOption === 'name' ? { name: node.name } : { createdAt: node.createdAt.toISOString() });
},
});
}
}
License
MIT