From 3220905417609c15505e58d5d3ecebb41b888bd8 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:39:56 +1200 Subject: [PATCH 01/41] Abstract CursorInterface --- src/cursor/Cursor.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cursor/Cursor.ts b/src/cursor/Cursor.ts index 4d15dc2f..92b9b50f 100644 --- a/src/cursor/Cursor.ts +++ b/src/cursor/Cursor.ts @@ -3,8 +3,16 @@ import queryString, { StringifiableRecord } from 'query-string'; export type CursorParameters = StringifiableRecord; -export class Cursor

{ - constructor(public readonly parameters: P) {} +export interface CursorInterface { + parameters: TParams; + + toString(): string; + + encode(): string; +} + +export class Cursor implements CursorInterface { + constructor(public readonly parameters: TParams) {} public toString(): string { return queryString.stringify(this.parameters); From 6879899b84ff6e675e1c1157c3bffe7d2e4223d2 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:40:15 +1200 Subject: [PATCH 02/41] Abstract decodeCursorString function --- src/cursor/Cursor.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cursor/Cursor.ts b/src/cursor/Cursor.ts index 92b9b50f..f9546876 100644 --- a/src/cursor/Cursor.ts +++ b/src/cursor/Cursor.ts @@ -1,6 +1,13 @@ -import Joi from 'joi'; import queryString, { StringifiableRecord } from 'query-string'; +export function decodeCursorString(encodedString: string): queryString.ParsedQuery { + // opaque cursors are base64 encoded, decode it first + const decodedString = Buffer.from(encodedString, 'base64').toString(); + + // cursor string is URL encoded, parse it into a map of parameters + return queryString.parse(decodedString); +} + export type CursorParameters = StringifiableRecord; export interface CursorInterface { @@ -23,11 +30,7 @@ export class Cursor impleme } public static decode(encodedString: string): queryString.ParsedQuery { - // opaque cursors are base64 encoded, decode it first - const decodedString = Buffer.from(encodedString, 'base64').toString(); - - // cursor string is URL encoded, parse it into a map of parameters - return queryString.parse(decodedString); + return decodeCursorString(encodedString); } public static create(encodedString: string, schema: Joi.ObjectSchema): Cursor { From 822bc3228ec02c5e8ffbf716de45c2a5141b6ed8 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:45:15 +1200 Subject: [PATCH 03/41] Rename Cursor.create() -> fromString(), refactor validation --- src/cursor/Cursor.ts | 15 ++++--- src/cursor/OffsetCursorPaginator.spec.ts | 16 ++++---- src/cursor/OffsetCursorPaginator.ts | 52 +++++++++++++++--------- src/cursor/index.ts | 1 + src/cursor/validateCursorParameters.ts | 21 ++++++++++ 5 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 src/cursor/validateCursorParameters.ts diff --git a/src/cursor/Cursor.ts b/src/cursor/Cursor.ts index f9546876..e4ea909b 100644 --- a/src/cursor/Cursor.ts +++ b/src/cursor/Cursor.ts @@ -33,16 +33,15 @@ export class Cursor impleme return decodeCursorString(encodedString); } - public static create(encodedString: string, schema: Joi.ObjectSchema): Cursor { + public static fromString( + encodedString: string, + validateParams?: (params: unknown) => TParams, + ): Cursor { const parameters = Cursor.decode(encodedString); - // validate the cursor parameters match the schema we expect, this also converts data types - const { error, value: validatedParameters } = schema.validate(parameters); + // run the cursor parameters through the validation function, if we have one + const validatedParameters = validateParams != null ? validateParams(parameters) : (parameters as TParams); - if (error != null) { - throw error; - } - - return new Cursor(validatedParameters); + return new Cursor(validatedParameters); } } diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index 36e7886e..6b545396 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -13,9 +13,9 @@ describe('OffsetCursorPaginator', () => { expect(pageInfo.hasPreviousPage).toBe(false); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(0); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(0); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(19); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(19); }); test('PageInfo is correct for second page', () => { @@ -30,9 +30,9 @@ describe('OffsetCursorPaginator', () => { expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(20); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(20); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(39); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(39); }); test('PageInfo is correct for last page', () => { @@ -47,9 +47,9 @@ describe('OffsetCursorPaginator', () => { expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(false); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(40); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(40); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(49); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(49); }); test('PageInfo is correct for fixed offset pagination', () => { @@ -66,8 +66,8 @@ describe('OffsetCursorPaginator', () => { expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(60); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(60); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(79); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(79); }); }); diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index 052118cc..968d2c12 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,32 +1,44 @@ import Joi from 'joi'; -import { ConnectionArgs, PageInfo } from '../type'; -import { Cursor } from './Cursor'; +import queryString from 'query-string'; import { ConnectionArgsValidationError, CursorValidationError } from '../error'; +import { ConnectionArgs, PageInfo } from '../type'; +import { Cursor, CursorInterface, decodeCursorString } from './Cursor'; +import { validateCursorParameters } from './validateCursorParameters'; export type OffsetCursorParameters = { offset: number; }; -export class OffsetCursor extends Cursor { - public static create(encodedString: string): OffsetCursor { - const parameters = Cursor.decode(encodedString); +const offsetCursorSchema = Joi.object({ + offset: Joi.number().integer().min(0).empty('').required(), +}).unknown(false); - // validate the cursor parameters match the schema we expect, this also converts data types - const schema = Joi.object({ - offset: Joi.number().integer().min(0).empty('').required(), - }).unknown(false); - const { error, value: validatedParameters } = schema.validate(parameters); +export class OffsetCursor implements CursorInterface { + constructor(public readonly parameters: OffsetCursorParameters) {} - if (error != null) { - const errorMessages = - error.details != null ? error.details.map(detail => `- ${detail.message}`).join('\n') : `- ${error.message}`; + public toString(): string { + return queryString.stringify(this.parameters); + } - throw new CursorValidationError( - `A provided cursor value is not valid. The following problems were found:\n\n${errorMessages}`, - ); - } + public encode(): string { + return Buffer.from(this.toString()).toString('base64'); + } - return new OffsetCursor(validatedParameters); + public static decode(encodedString: string): queryString.ParsedQuery { + return decodeCursorString(encodedString); + } + + public static fromString(encodedString: string): OffsetCursor { + const parameters = OffsetCursor.decode(encodedString); + + return new OffsetCursor(validateCursorParameters(parameters, offsetCursorSchema)); + } + + /** + * @deprecated + */ + public static create(encodedString: string): OffsetCursor { + return OffsetCursor.fromString(encodedString); } } @@ -103,7 +115,7 @@ export class OffsetCursorPaginator { ); } - if (last > 100 || last < 1) { + if (last > maxEdgesPerPage || last < 1) { throw new ConnectionArgsValidationError( `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, ); @@ -120,7 +132,7 @@ export class OffsetCursorPaginator { ); } - skip = OffsetCursor.create(after).parameters.offset + 1; + skip = OffsetCursor.fromString(after).parameters.offset + 1; } if (before != null) { diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 8fa4c090..d5eaaa4c 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -4,3 +4,4 @@ export * from './Cursor'; export * from './OffsetCursorPaginator'; +export * from './validateCursorParameters'; diff --git a/src/cursor/validateCursorParameters.ts b/src/cursor/validateCursorParameters.ts new file mode 100644 index 00000000..bbf43de7 --- /dev/null +++ b/src/cursor/validateCursorParameters.ts @@ -0,0 +1,21 @@ +import Joi from 'joi'; +import { CursorValidationError } from '../error'; +import { CursorParameters } from './Cursor'; + +export function validateCursorParameters( + parameters: unknown, + schema: Joi.ObjectSchema, +): TParams { + const { error, value: validatedParameters } = schema.validate(parameters); + + if (error != null) { + const errorMessages = + error.details != null ? error.details.map(detail => `- ${detail.message}`).join('\n') : `- ${error.message}`; + + throw new CursorValidationError( + `A provided cursor value is not valid. The following problems were found:\n\n${errorMessages}`, + ); + } + + return validatedParameters!; +} From 3f43a303f85ccb3a233ec7450479e544512172e5 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:47:13 +1200 Subject: [PATCH 04/41] Add EdgeFactoryInterface --- src/type/Edge.ts | 2 +- src/type/EdgeFactory.ts | 15 +++++++++++++++ src/type/index.ts | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/type/EdgeFactory.ts diff --git a/src/type/Edge.ts b/src/type/Edge.ts index b17a3b33..448a6fe3 100644 --- a/src/type/Edge.ts +++ b/src/type/Edge.ts @@ -1,5 +1,5 @@ -import * as Relay from 'graphql-relay'; import * as GQL from '@nestjs/graphql'; +import * as Relay from 'graphql-relay'; export interface EdgeInterface extends Relay.Edge { node: TNode; diff --git a/src/type/EdgeFactory.ts b/src/type/EdgeFactory.ts new file mode 100644 index 00000000..93aa1bed --- /dev/null +++ b/src/type/EdgeFactory.ts @@ -0,0 +1,15 @@ +import { Cursor, CursorParameters } from '../cursor'; +import { EdgeInterface } from './Edge'; + +export interface EdgeFactoryInterface< + TNode, + TEdge extends EdgeInterface, + TCursorParams extends CursorParameters = CursorParameters, + TCursor extends Cursor = Cursor, +> { + createEdge(node: TNode): TEdge; + + createCursor(node: TNode): TCursor; + + decodeCursor?(encodedString: string): TCursor; +} diff --git a/src/type/index.ts b/src/type/index.ts index a3e3b7c5..edf4b181 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -5,4 +5,5 @@ export * from './Connection'; export * from './ConnectionArgs'; export * from './Edge'; +export * from './EdgeFactory'; export * from './PageInfo'; From cdc98f3ea9d06c62bd249815e91de47b06f398d7 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:48:00 +1200 Subject: [PATCH 05/41] Add CursorPaginator --- src/cursor/CursorPaginator.spec.ts | 108 +++++++++++++++++++++++++ src/cursor/CursorPaginator.ts | 125 +++++++++++++++++++++++++++++ src/cursor/index.ts | 1 + 3 files changed, 234 insertions(+) create mode 100644 src/cursor/CursorPaginator.spec.ts create mode 100644 src/cursor/CursorPaginator.ts diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts new file mode 100644 index 00000000..b18ca21b --- /dev/null +++ b/src/cursor/CursorPaginator.spec.ts @@ -0,0 +1,108 @@ +import Joi from 'joi'; +import { createEdgeType, EdgeFactoryInterface } from '../type'; +import { Cursor } from './Cursor'; +import { CursorPaginator } from './CursorPaginator'; +import { validateCursorParameters } from './validateCursorParameters'; + +class TestNode { + id: string; +} + +class TestEdge extends createEdgeType(TestNode) {} + +type TestCursorParams = { id: string }; + +const testCursorSchema = Joi.object({ + id: Joi.string().empty('').required(), +}).unknown(false); + +const testEdgeFactory: EdgeFactoryInterface = { + createEdge(node) { + return new TestEdge({ + node, + cursor: this.createCursor(node).encode(), + }); + }, + createCursor(node) { + return new Cursor({ id: node.id }); + }, + decodeCursor(encodedString: string): Cursor { + return Cursor.fromString(encodedString, params => validateCursorParameters(params, testCursorSchema)); + }, +}; + +describe('CursorPaginator', () => { + test('PageInfo is correct for first page', () => { + const paginator = new CursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([ + { id: 'node1' }, + { id: 'node2' }, + { id: 'node3' }, + { id: 'node4' }, + { id: 'node5' }, + ]), + hasMore: true, + }); + + expect(pageInfo.totalEdges).toBe(12); + expect(pageInfo.hasPreviousPage).toBe(false); + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.startCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node1'); + expect(pageInfo.endCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node5'); + }); + + test('PageInfo is correct for second page', () => { + const paginator = new CursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + afterCursor: new Cursor({ id: 'node5' }), + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([ + { id: 'node6' }, + { id: 'node7' }, + { id: 'node8' }, + { id: 'node9' }, + { id: 'node10' }, + ]), + hasMore: true, + }); + + expect(pageInfo.totalEdges).toBe(12); + expect(pageInfo.hasPreviousPage).toBe(true); + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.startCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node6'); + expect(pageInfo.endCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node10'); + }); + + test('PageInfo is correct for last page', () => { + const paginator = new CursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + afterCursor: new Cursor({ id: 'node10' }), + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([{ id: 'node11' }, { id: 'node12' }]), + hasMore: false, + }); + + expect(pageInfo.totalEdges).toBe(12); + expect(pageInfo.hasPreviousPage).toBe(true); + expect(pageInfo.hasNextPage).toBe(false); + expect(pageInfo.startCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node11'); + expect(pageInfo.endCursor).toBeDefined(); + expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node12'); + }); +}); diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts new file mode 100644 index 00000000..11a71092 --- /dev/null +++ b/src/cursor/CursorPaginator.ts @@ -0,0 +1,125 @@ +import { ConnectionArgs, EdgeFactoryInterface, EdgeInterface, PageInfo } from '../type'; +import { Cursor, CursorParameters } from './Cursor'; +import { ConnectionArgsValidationError } from '../error'; + +interface CreateFromConnectionArgsOptions { + defaultEdgesPerPage?: number; + maxEdgesPerPage?: number; + allowReverseOrder?: boolean; +} + +export class CursorPaginator< + TEdge extends EdgeInterface, + TParams extends CursorParameters = CursorParameters, + TNode = any, +> { + public edgeFactory: EdgeFactoryInterface>; + public edgesPerPage: number = 20; + public totalEdges?: number; + public afterCursor?: Cursor; + public beforeCursor?: Cursor; + + constructor({ + edgeFactory, + edgesPerPage, + totalEdges, + afterCursor, + beforeCursor, + }: Pick< + CursorPaginator, + 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'afterCursor' | 'beforeCursor' + >) { + this.edgeFactory = edgeFactory; + this.edgesPerPage = edgesPerPage; + this.totalEdges = totalEdges; + this.afterCursor = afterCursor; + this.beforeCursor = beforeCursor; + } + + public createEdges(nodes: TNode[]): TEdge[] { + return nodes.map(node => this.edgeFactory.createEdge(node)); + } + + public createPageInfo({ edges, hasMore }: { edges: EdgeInterface[]; hasMore?: boolean }): PageInfo { + return { + startCursor: edges[0].cursor, + endCursor: edges[edges.length - 1].cursor, + hasNextPage: hasMore ?? (this.totalEdges != null && this.totalEdges > edges.length), + hasPreviousPage: this.afterCursor != null || this.beforeCursor != null, + totalEdges: this.totalEdges, + }; + } + + public static createFromConnectionArgs< + TEdge extends EdgeInterface, + TParams extends CursorParameters = CursorParameters, + TNode = any, + >({ + edgeFactory, + totalEdges, + page, + first, + last, + before, + after, + defaultEdgesPerPage = 20, + maxEdgesPerPage = 100, + allowReverseOrder = true, + }: Pick, 'edgeFactory' | 'totalEdges'> & + ConnectionArgs & + CreateFromConnectionArgsOptions): CursorPaginator { + let edgesPerPage: number = defaultEdgesPerPage; + + if (page != null) { + throw new ConnectionArgsValidationError('This connection does not support the "page" argument for pagination.'); + } + + if (first != null) { + if (first > maxEdgesPerPage || first < 1) { + throw new ConnectionArgsValidationError( + `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + edgesPerPage = first; + } + + if (last != null) { + if (first != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "first" and "last" arguments simultaneously.', + ); + } + + if (!allowReverseOrder) { + throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); + } + + if (last > maxEdgesPerPage || last < 1) { + throw new ConnectionArgsValidationError( + `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + edgesPerPage = last; + } + + if (after != null) { + if (before != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "after" and "before" arguments simultaneously.', + ); + } + } + + const decodeCursor = edgeFactory.decodeCursor ?? (params => Cursor.fromString(params)); + + return new CursorPaginator({ + edgeFactory, + edgesPerPage, + totalEdges, + beforeCursor: before != null ? decodeCursor(before) : undefined, + afterCursor: after != null ? decodeCursor(after) : undefined, + }); + } +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index d5eaaa4c..29787a6d 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -3,5 +3,6 @@ */ export * from './Cursor'; +export * from './CursorPaginator'; export * from './OffsetCursorPaginator'; export * from './validateCursorParameters'; From 348612c75eaa78396779280778bafe4fff18dc95 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:48:46 +1200 Subject: [PATCH 06/41] Move OffsetCursor to its own file --- src/cursor/OffsetCursor.ts | 40 +++++++++++++++++++++ src/cursor/OffsetCursorPaginator.spec.ts | 3 +- src/cursor/OffsetCursorPaginator.ts | 44 ++---------------------- src/cursor/index.ts | 1 + 4 files changed, 45 insertions(+), 43 deletions(-) create mode 100644 src/cursor/OffsetCursor.ts diff --git a/src/cursor/OffsetCursor.ts b/src/cursor/OffsetCursor.ts new file mode 100644 index 00000000..25bca2d2 --- /dev/null +++ b/src/cursor/OffsetCursor.ts @@ -0,0 +1,40 @@ +import Joi from 'joi'; +import queryString from 'query-string'; +import { CursorInterface, decodeCursorString } from './Cursor'; +import { validateCursorParameters } from './validateCursorParameters'; + +export type OffsetCursorParameters = { + offset: number; +}; +const offsetCursorSchema = Joi.object({ + offset: Joi.number().integer().min(0).empty('').required(), +}).unknown(false); + +export class OffsetCursor implements CursorInterface { + constructor(public readonly parameters: OffsetCursorParameters) {} + + public toString(): string { + return queryString.stringify(this.parameters); + } + + public encode(): string { + return Buffer.from(this.toString()).toString('base64'); + } + + public static decode(encodedString: string): queryString.ParsedQuery { + return decodeCursorString(encodedString); + } + + public static fromString(encodedString: string): OffsetCursor { + const parameters = OffsetCursor.decode(encodedString); + + return new OffsetCursor(validateCursorParameters(parameters, offsetCursorSchema)); + } + + /** + * @deprecated + */ + public static create(encodedString: string): OffsetCursor { + return OffsetCursor.fromString(encodedString); + } +} diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index 6b545396..cac3acce 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,4 +1,5 @@ -import { OffsetCursor, OffsetCursorPaginator } from './OffsetCursorPaginator'; +import { OffsetCursor } from './OffsetCursor'; +import { OffsetCursorPaginator } from './OffsetCursorPaginator'; describe('OffsetCursorPaginator', () => { test('PageInfo is correct for first page', () => { diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index 968d2c12..42324ceb 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,46 +1,6 @@ -import Joi from 'joi'; -import queryString from 'query-string'; -import { ConnectionArgsValidationError, CursorValidationError } from '../error'; +import { ConnectionArgsValidationError } from '../error'; import { ConnectionArgs, PageInfo } from '../type'; -import { Cursor, CursorInterface, decodeCursorString } from './Cursor'; -import { validateCursorParameters } from './validateCursorParameters'; - -export type OffsetCursorParameters = { - offset: number; -}; - -const offsetCursorSchema = Joi.object({ - offset: Joi.number().integer().min(0).empty('').required(), -}).unknown(false); - -export class OffsetCursor implements CursorInterface { - constructor(public readonly parameters: OffsetCursorParameters) {} - - public toString(): string { - return queryString.stringify(this.parameters); - } - - public encode(): string { - return Buffer.from(this.toString()).toString('base64'); - } - - public static decode(encodedString: string): queryString.ParsedQuery { - return decodeCursorString(encodedString); - } - - public static fromString(encodedString: string): OffsetCursor { - const parameters = OffsetCursor.decode(encodedString); - - return new OffsetCursor(validateCursorParameters(parameters, offsetCursorSchema)); - } - - /** - * @deprecated - */ - public static create(encodedString: string): OffsetCursor { - return OffsetCursor.fromString(encodedString); - } -} +import { OffsetCursor } from './OffsetCursor'; interface CreateFromConnectionArgsOptions { defaultEdgesPerPage?: number; diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 29787a6d..d34a95c5 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -4,5 +4,6 @@ export * from './Cursor'; export * from './CursorPaginator'; +export * from './OffsetCursor'; export * from './OffsetCursorPaginator'; export * from './validateCursorParameters'; From df407e79549fc77c07f40552788a23b6b9cac82a Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 16:53:57 +1200 Subject: [PATCH 07/41] Move EdgeFactory to src/factory --- src/cursor/CursorPaginator.spec.ts | 3 ++- src/cursor/CursorPaginator.ts | 3 ++- src/{type => factory}/EdgeFactory.ts | 2 +- src/factory/index.ts | 5 +++++ src/index.ts | 1 + src/type/index.ts | 1 - 6 files changed, 11 insertions(+), 4 deletions(-) rename src/{type => factory}/EdgeFactory.ts (89%) create mode 100644 src/factory/index.ts diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index b18ca21b..71e5b1ea 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -1,5 +1,6 @@ import Joi from 'joi'; -import { createEdgeType, EdgeFactoryInterface } from '../type'; +import { EdgeFactoryInterface } from '../factory'; +import { createEdgeType } from '../type'; import { Cursor } from './Cursor'; import { CursorPaginator } from './CursorPaginator'; import { validateCursorParameters } from './validateCursorParameters'; diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index 11a71092..d1061162 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -1,4 +1,5 @@ -import { ConnectionArgs, EdgeFactoryInterface, EdgeInterface, PageInfo } from '../type'; +import { EdgeFactoryInterface } from '../factory'; +import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; import { Cursor, CursorParameters } from './Cursor'; import { ConnectionArgsValidationError } from '../error'; diff --git a/src/type/EdgeFactory.ts b/src/factory/EdgeFactory.ts similarity index 89% rename from src/type/EdgeFactory.ts rename to src/factory/EdgeFactory.ts index 93aa1bed..55aa1a31 100644 --- a/src/type/EdgeFactory.ts +++ b/src/factory/EdgeFactory.ts @@ -1,5 +1,5 @@ import { Cursor, CursorParameters } from '../cursor'; -import { EdgeInterface } from './Edge'; +import { EdgeInterface } from '../type/Edge'; export interface EdgeFactoryInterface< TNode, diff --git a/src/factory/index.ts b/src/factory/index.ts new file mode 100644 index 00000000..b6a3a57b --- /dev/null +++ b/src/factory/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './EdgeFactory'; diff --git a/src/index.ts b/src/index.ts index 81da3baa..c3be26ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export * from './cursor/index'; export * from './error/index'; +export * from './factory/index'; export * from './type/index'; diff --git a/src/type/index.ts b/src/type/index.ts index edf4b181..a3e3b7c5 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -5,5 +5,4 @@ export * from './Connection'; export * from './ConnectionArgs'; export * from './Edge'; -export * from './EdgeFactory'; export * from './PageInfo'; From ea6ffa2c616c506ee39bd1457cd44d7db7f779d9 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Wed, 8 Jun 2022 17:06:11 +1200 Subject: [PATCH 08/41] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20Rename=20OffsetCurso?= =?UTF-8?q?rPaginator.take=20->=20edgesPerPage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/cursor/OffsetCursorPaginator.spec.ts | 6 +++--- src/cursor/OffsetCursorPaginator.ts | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d50d7b34..dad4a0a4 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ export class PersonQueryResolver { // Example: Do whatever you need to do to fetch the current page of persons const persons = await fetchPersons({ where: { personId }, - take: paginator.take, // how many rows to fetch + take: paginator.edgesPerPage, // how many rows to fetch skip: paginator.skip, // row offset to fetch from }); diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index cac3acce..e5184bd8 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -4,7 +4,7 @@ import { OffsetCursorPaginator } from './OffsetCursorPaginator'; describe('OffsetCursorPaginator', () => { test('PageInfo is correct for first page', () => { const paginator = new OffsetCursorPaginator({ - take: 20, + edgesPerPage: 20, skip: 0, totalEdges: 50, }); @@ -21,7 +21,7 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for second page', () => { const paginator = new OffsetCursorPaginator({ - take: 20, + edgesPerPage: 20, skip: 20, totalEdges: 50, }); @@ -38,7 +38,7 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for last page', () => { const paginator = new OffsetCursorPaginator({ - take: 20, + edgesPerPage: 20, skip: 40, totalEdges: 50, }); diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index 42324ceb..be36daac 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -8,14 +8,14 @@ interface CreateFromConnectionArgsOptions { } export class OffsetCursorPaginator { - public take: number = 20; - public skip: number = 0; + public edgesPerPage: number = 20; public totalEdges: number = 0; + public skip: number = 0; - constructor({ take, skip, totalEdges }: Pick) { - this.take = take; - this.skip = skip; + constructor({ edgesPerPage, totalEdges, skip }: Pick) { + this.edgesPerPage = edgesPerPage; this.totalEdges = totalEdges; + this.skip = skip; } public createPageInfo(edgesInPage: number): PageInfo { @@ -38,7 +38,7 @@ export class OffsetCursorPaginator { options: CreateFromConnectionArgsOptions = {}, ): OffsetCursorPaginator { const { defaultEdgesPerPage = 20, maxEdgesPerPage = 100 } = options; - let take: number = defaultEdgesPerPage; + let edgesPerPage: number = defaultEdgesPerPage; let skip: number = 0; if (first != null) { @@ -48,7 +48,7 @@ export class OffsetCursorPaginator { ); } - take = first; + edgesPerPage = first; skip = 0; } @@ -65,7 +65,7 @@ export class OffsetCursorPaginator { ); } - skip = take * (page - 1); + skip = edgesPerPage * (page - 1); } if (last != null) { @@ -81,7 +81,7 @@ export class OffsetCursorPaginator { ); } - take = last; + edgesPerPage = last; skip = totalEdges > last ? totalEdges - last : 0; } @@ -100,7 +100,7 @@ export class OffsetCursorPaginator { } return new OffsetCursorPaginator({ - take, + edgesPerPage, skip, totalEdges, }); From 62ca134c9447f05735e779fcff2848f2e88b860f Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 07:22:39 +1200 Subject: [PATCH 09/41] Add offset argument to EdgeFactory methods --- src/cursor/CursorPaginator.ts | 2 +- src/factory/EdgeFactory.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index d1061162..d0ed3fd9 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -38,7 +38,7 @@ export class CursorPaginator< } public createEdges(nodes: TNode[]): TEdge[] { - return nodes.map(node => this.edgeFactory.createEdge(node)); + return nodes.map((node, index) => this.edgeFactory.createEdge(node, index)); } public createPageInfo({ edges, hasMore }: { edges: EdgeInterface[]; hasMore?: boolean }): PageInfo { diff --git a/src/factory/EdgeFactory.ts b/src/factory/EdgeFactory.ts index 55aa1a31..34d335e4 100644 --- a/src/factory/EdgeFactory.ts +++ b/src/factory/EdgeFactory.ts @@ -1,5 +1,5 @@ import { Cursor, CursorParameters } from '../cursor'; -import { EdgeInterface } from '../type/Edge'; +import { EdgeInterface } from '../type'; export interface EdgeFactoryInterface< TNode, @@ -7,9 +7,9 @@ export interface EdgeFactoryInterface< TCursorParams extends CursorParameters = CursorParameters, TCursor extends Cursor = Cursor, > { - createEdge(node: TNode): TEdge; + createEdge(node: TNode, offset: number): TEdge; - createCursor(node: TNode): TCursor; + createCursor(node: TNode, offset: number): TCursor; decodeCursor?(encodedString: string): TCursor; } From cb878d76cd4f63ff86c2719d7125a4fe8e94c209 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 08:56:45 +1200 Subject: [PATCH 10/41] Use correct Edge type in createPageInfo --- src/cursor/CursorPaginator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index d0ed3fd9..65455cf5 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -41,7 +41,7 @@ export class CursorPaginator< return nodes.map((node, index) => this.edgeFactory.createEdge(node, index)); } - public createPageInfo({ edges, hasMore }: { edges: EdgeInterface[]; hasMore?: boolean }): PageInfo { + public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { return { startCursor: edges[0].cursor, endCursor: edges[edges.length - 1].cursor, From be238c3e2411baae26ebd8a7f71adc55ea135a11 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 10:07:28 +1200 Subject: [PATCH 11/41] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=20Re-work=20OffsetCurs?= =?UTF-8?q?orPaginator=20to=20match=20CursorPaginator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes breaking changes to method signatures --- README.md | 29 +++-- src/cursor/CursorPaginator.ts | 4 +- src/cursor/OffsetCursorPaginator.spec.ts | 136 +++++++++++++++++------ src/cursor/OffsetCursorPaginator.ts | 77 ++++++++----- 4 files changed, 171 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index dad4a0a4..f0a5c074 100644 --- a/README.md +++ b/README.md @@ -83,24 +83,35 @@ export class PersonQueryResolver { const totalPersons = await countPersons({ where: { personId } }); // Create paginator instance - const paginator = OffsetCursorPaginator.createFromConnectionArgs(connectionArgs, totalPersons); + const paginator = OffsetCursorPaginator.createFromConnectionArgs({ + ...connectionArgs, + totalEdges: totalPersons, + edgeFactory: { + createEdge(node, offset) { + return new PersonEdge({ + node, + cursor: this.createCursor(node, offset).encode(), + }); + }, + createCursor(node, offset) { + return new OffsetCursor({ offset }); + }, + } + }); // Example: Do whatever you need to do to fetch the current page of persons const persons = await fetchPersons({ where: { personId }, take: paginator.edgesPerPage, // how many rows to fetch - skip: paginator.skip, // row offset to fetch from + skip: paginator.startOffset, // row offset to fetch from }); + + const edges = paginator.createEdges(persons); // Return resolved PersonConnection with edges and pageInfo return new PersonConnection({ - pageInfo: paginator.createPageInfo(persons.length), - edges: persons.map((node, index) => { - return new PersonEdge({ - node, - cursor: paginator.createCursor(index).encode(), - }); - }), + pageInfo: paginator.createPageInfo({ edges }), + edges, }); } } diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index 65455cf5..9886a409 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -69,6 +69,8 @@ export class CursorPaginator< }: Pick, 'edgeFactory' | 'totalEdges'> & ConnectionArgs & CreateFromConnectionArgsOptions): CursorPaginator { + const decodeCursor = edgeFactory.decodeCursor ?? (params => Cursor.fromString(params)); + let edgesPerPage: number = defaultEdgesPerPage; if (page != null) { @@ -113,8 +115,6 @@ export class CursorPaginator< } } - const decodeCursor = edgeFactory.decodeCursor ?? (params => Cursor.fromString(params)); - return new CursorPaginator({ edgeFactory, edgesPerPage, diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index e5184bd8..b179c834 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,74 +1,140 @@ -import { OffsetCursor } from './OffsetCursor'; +import { EdgeFactoryInterface } from '../factory'; +import { createEdgeType } from '../type'; +import { OffsetCursor, OffsetCursorParameters } from './OffsetCursor'; import { OffsetCursorPaginator } from './OffsetCursorPaginator'; +class TestNode { + id: string; +} + +class TestEdge extends createEdgeType(TestNode) {} + +const testEdgeFactory: EdgeFactoryInterface = { + createEdge(node, offset) { + return new TestEdge({ + node, + cursor: this.createCursor(node, offset).encode(), + }); + }, + createCursor(node, offset) { + return new OffsetCursor({ offset }); + }, +}; + describe('OffsetCursorPaginator', () => { test('PageInfo is correct for first page', () => { - const paginator = new OffsetCursorPaginator({ - edgesPerPage: 20, - skip: 0, - totalEdges: 50, + const paginator = new OffsetCursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + startOffset: 0, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([ + { id: 'node1' }, + { id: 'node2' }, + { id: 'node3' }, + { id: 'node4' }, + { id: 'node5' }, + ]), }); - const pageInfo = paginator.createPageInfo(20); - expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.totalEdges).toBe(12); expect(pageInfo.hasPreviousPage).toBe(false); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(0); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(19); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(4); }); test('PageInfo is correct for second page', () => { - const paginator = new OffsetCursorPaginator({ - edgesPerPage: 20, - skip: 20, - totalEdges: 50, + const paginator = new OffsetCursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + startOffset: 5, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([ + { id: 'node6' }, + { id: 'node7' }, + { id: 'node8' }, + { id: 'node9' }, + { id: 'node10' }, + ]), }); - const pageInfo = paginator.createPageInfo(20); - expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.totalEdges).toBe(12); expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(20); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(5); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(39); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(9); }); test('PageInfo is correct for last page', () => { - const paginator = new OffsetCursorPaginator({ - edgesPerPage: 20, - skip: 40, - totalEdges: 50, + const paginator = new OffsetCursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 12, + startOffset: 10, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([{ id: 'node11' }, { id: 'node12' }]), }); - const pageInfo = paginator.createPageInfo(10); - expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.totalEdges).toBe(12); expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(false); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(40); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(10); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(49); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(11); }); test('PageInfo is correct for fixed offset pagination', () => { - const paginator = OffsetCursorPaginator.createFromConnectionArgs( - { - first: 20, - page: 4, - }, - 100, - ); - const pageInfo = paginator.createPageInfo(20); + const paginator = OffsetCursorPaginator.createFromConnectionArgs({ + edgeFactory: testEdgeFactory, + first: 5, + page: 2, + totalEdges: 12, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([ + { id: 'node6' }, + { id: 'node7' }, + { id: 'node8' }, + { id: 'node9' }, + { id: 'node10' }, + ]), + }); - expect(pageInfo.totalEdges).toBe(100); + expect(pageInfo.totalEdges).toBe(12); expect(pageInfo.hasPreviousPage).toBe(true); expect(pageInfo.hasNextPage).toBe(true); expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(60); + expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(5); expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(79); + expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(9); + }); + + test('PageInfo is correct for empty result', () => { + const paginator = new OffsetCursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 0, + startOffset: 0, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([]), + }); + + expect(pageInfo.totalEdges).toBe(0); + expect(pageInfo.hasPreviousPage).toBe(false); + expect(pageInfo.hasNextPage).toBe(false); + expect(pageInfo.startCursor).toBeNull(); + expect(pageInfo.endCursor).toBeNull(); }); }); diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index be36daac..a723eb0b 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,45 +1,63 @@ import { ConnectionArgsValidationError } from '../error'; -import { ConnectionArgs, PageInfo } from '../type'; -import { OffsetCursor } from './OffsetCursor'; +import { EdgeFactoryInterface } from '../factory'; +import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; +import { Cursor } from './Cursor'; +import { OffsetCursor, OffsetCursorParameters } from './OffsetCursor'; interface CreateFromConnectionArgsOptions { defaultEdgesPerPage?: number; maxEdgesPerPage?: number; } -export class OffsetCursorPaginator { +export class OffsetCursorPaginator, TNode = any> { + public edgeFactory: EdgeFactoryInterface; public edgesPerPage: number = 20; - public totalEdges: number = 0; - public skip: number = 0; - - constructor({ edgesPerPage, totalEdges, skip }: Pick) { + public totalEdges?: number; + public startOffset: number = 0; + + constructor({ + edgeFactory, + edgesPerPage, + totalEdges, + startOffset, + }: Pick, 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'startOffset'>) { + this.edgeFactory = edgeFactory; this.edgesPerPage = edgesPerPage; this.totalEdges = totalEdges; - this.skip = skip; + this.startOffset = startOffset; + } + + public createEdges(nodes: TNode[]): TEdge[] { + return nodes.map((node, index) => this.edgeFactory.createEdge(node, this.startOffset + index)); } - public createPageInfo(edgesInPage: number): PageInfo { + public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { return { - startCursor: edgesInPage > 0 ? this.createCursor(0).encode() : null, - endCursor: edgesInPage > 0 ? this.createCursor(edgesInPage - 1).encode() : null, - hasNextPage: this.skip + edgesInPage < this.totalEdges, - hasPreviousPage: this.skip > 0, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + hasNextPage: hasMore ?? (this.totalEdges != null && this.startOffset + edges.length < this.totalEdges), + hasPreviousPage: this.startOffset > 0, totalEdges: this.totalEdges, }; } - public createCursor(index: number): OffsetCursor { - return new OffsetCursor({ offset: this.skip + index }); - } + public static createFromConnectionArgs, TNode = any>({ + edgeFactory, + totalEdges, + page, + first, + last, + before, + after, + defaultEdgesPerPage = 20, + maxEdgesPerPage = 100, + }: Pick, 'edgeFactory' | 'totalEdges'> & + ConnectionArgs & + CreateFromConnectionArgsOptions): OffsetCursorPaginator { + const decodeCursor = edgeFactory.decodeCursor ?? (params => OffsetCursor.fromString(params)); - public static createFromConnectionArgs( - { page, first, last, before, after }: ConnectionArgs, - totalEdges: number, - options: CreateFromConnectionArgsOptions = {}, - ): OffsetCursorPaginator { - const { defaultEdgesPerPage = 20, maxEdgesPerPage = 100 } = options; let edgesPerPage: number = defaultEdgesPerPage; - let skip: number = 0; + let startOffset: number = 0; if (first != null) { if (first > maxEdgesPerPage || first < 1) { @@ -49,7 +67,7 @@ export class OffsetCursorPaginator { } edgesPerPage = first; - skip = 0; + startOffset = 0; } if (page != null) { @@ -65,7 +83,7 @@ export class OffsetCursorPaginator { ); } - skip = edgesPerPage * (page - 1); + startOffset = edgesPerPage * (page - 1); } if (last != null) { @@ -82,7 +100,7 @@ export class OffsetCursorPaginator { } edgesPerPage = last; - skip = totalEdges > last ? totalEdges - last : 0; + startOffset = totalEdges != null && totalEdges > last ? totalEdges - last : 0; } if (after != null) { @@ -92,17 +110,18 @@ export class OffsetCursorPaginator { ); } - skip = OffsetCursor.fromString(after).parameters.offset + 1; + startOffset = decodeCursor(after).parameters.offset + 1; } if (before != null) { throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.'); } - return new OffsetCursorPaginator({ + return new OffsetCursorPaginator({ + edgeFactory, edgesPerPage, - skip, totalEdges, + startOffset, }); } } From 72bd04ad3758e1af4e36ff93dd25eeba288d85fc Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 10:15:34 +1200 Subject: [PATCH 12/41] Handle empty results in CursorPaginator --- src/cursor/CursorPaginator.spec.ts | 18 ++++++++++++++++++ src/cursor/CursorPaginator.ts | 4 ++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index 71e5b1ea..4c0133ce 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -106,4 +106,22 @@ describe('CursorPaginator', () => { expect(pageInfo.endCursor).toBeDefined(); expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node12'); }); + + test('PageInfo is correct for empty result', () => { + const paginator = new CursorPaginator({ + edgeFactory: testEdgeFactory, + edgesPerPage: 5, + totalEdges: 0, + }); + const pageInfo = paginator.createPageInfo({ + edges: paginator.createEdges([]), + hasMore: false, + }); + + expect(pageInfo.totalEdges).toBe(0); + expect(pageInfo.hasPreviousPage).toBe(false); + expect(pageInfo.hasNextPage).toBe(false); + expect(pageInfo.startCursor).toBeNull(); + expect(pageInfo.endCursor).toBeNull(); + }); }); diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index 9886a409..7a00c318 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -43,8 +43,8 @@ export class CursorPaginator< public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { return { - startCursor: edges[0].cursor, - endCursor: edges[edges.length - 1].cursor, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, hasNextPage: hasMore ?? (this.totalEdges != null && this.totalEdges > edges.length), hasPreviousPage: this.afterCursor != null || this.beforeCursor != null, totalEdges: this.totalEdges, From 6e040abcaf4c9ff0d79bbc4ac93fa2f8103f0f54 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 10:51:50 +1200 Subject: [PATCH 13/41] Modify EdgeFactoryInterface type parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TEdge before TNode. And we don’t need TCursorParams. --- src/cursor/CursorPaginator.spec.ts | 4 ++-- src/cursor/CursorPaginator.ts | 2 +- src/cursor/OffsetCursorPaginator.spec.ts | 4 ++-- src/cursor/OffsetCursorPaginator.ts | 5 ++--- src/factory/EdgeFactory.ts | 7 +------ 5 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index 4c0133ce..ff91aa93 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -17,7 +17,7 @@ const testCursorSchema = Joi.object({ id: Joi.string().empty('').required(), }).unknown(false); -const testEdgeFactory: EdgeFactoryInterface = { +const testEdgeFactory: EdgeFactoryInterface> = { createEdge(node) { return new TestEdge({ node, @@ -27,7 +27,7 @@ const testEdgeFactory: EdgeFactoryInterface { + decodeCursor(encodedString: string) { return Cursor.fromString(encodedString, params => validateCursorParameters(params, testCursorSchema)); }, }; diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index 7a00c318..b5cc9afd 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -14,7 +14,7 @@ export class CursorPaginator< TParams extends CursorParameters = CursorParameters, TNode = any, > { - public edgeFactory: EdgeFactoryInterface>; + public edgeFactory: EdgeFactoryInterface>; public edgesPerPage: number = 20; public totalEdges?: number; public afterCursor?: Cursor; diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index b179c834..f2954263 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,6 +1,6 @@ import { EdgeFactoryInterface } from '../factory'; import { createEdgeType } from '../type'; -import { OffsetCursor, OffsetCursorParameters } from './OffsetCursor'; +import { OffsetCursor } from './OffsetCursor'; import { OffsetCursorPaginator } from './OffsetCursorPaginator'; class TestNode { @@ -9,7 +9,7 @@ class TestNode { class TestEdge extends createEdgeType(TestNode) {} -const testEdgeFactory: EdgeFactoryInterface = { +const testEdgeFactory: EdgeFactoryInterface = { createEdge(node, offset) { return new TestEdge({ node, diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index a723eb0b..c6280a98 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,8 +1,7 @@ import { ConnectionArgsValidationError } from '../error'; import { EdgeFactoryInterface } from '../factory'; import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; -import { Cursor } from './Cursor'; -import { OffsetCursor, OffsetCursorParameters } from './OffsetCursor'; +import { OffsetCursor } from './OffsetCursor'; interface CreateFromConnectionArgsOptions { defaultEdgesPerPage?: number; @@ -10,7 +9,7 @@ interface CreateFromConnectionArgsOptions { } export class OffsetCursorPaginator, TNode = any> { - public edgeFactory: EdgeFactoryInterface; + public edgeFactory: EdgeFactoryInterface; public edgesPerPage: number = 20; public totalEdges?: number; public startOffset: number = 0; diff --git a/src/factory/EdgeFactory.ts b/src/factory/EdgeFactory.ts index 34d335e4..c3ea2f07 100644 --- a/src/factory/EdgeFactory.ts +++ b/src/factory/EdgeFactory.ts @@ -1,12 +1,7 @@ import { Cursor, CursorParameters } from '../cursor'; import { EdgeInterface } from '../type'; -export interface EdgeFactoryInterface< - TNode, - TEdge extends EdgeInterface, - TCursorParams extends CursorParameters = CursorParameters, - TCursor extends Cursor = Cursor, -> { +export interface EdgeFactoryInterface, TNode, TCursor extends Cursor = Cursor> { createEdge(node: TNode, offset: number): TEdge; createCursor(node: TNode, offset: number): TCursor; From e0e75d6a9ebff1d59ca60649aafb155b21014094 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 10:52:42 +1200 Subject: [PATCH 14/41] Rename EdgeFactoryInterface -> EdgeFactory --- src/cursor/CursorPaginator.spec.ts | 4 ++-- src/cursor/CursorPaginator.ts | 4 ++-- src/cursor/OffsetCursorPaginator.spec.ts | 4 ++-- src/cursor/OffsetCursorPaginator.ts | 4 ++-- src/factory/EdgeFactory.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index ff91aa93..66380640 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -1,5 +1,5 @@ import Joi from 'joi'; -import { EdgeFactoryInterface } from '../factory'; +import { EdgeFactory } from '../factory'; import { createEdgeType } from '../type'; import { Cursor } from './Cursor'; import { CursorPaginator } from './CursorPaginator'; @@ -17,7 +17,7 @@ const testCursorSchema = Joi.object({ id: Joi.string().empty('').required(), }).unknown(false); -const testEdgeFactory: EdgeFactoryInterface> = { +const testEdgeFactory: EdgeFactory> = { createEdge(node) { return new TestEdge({ node, diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index b5cc9afd..56f05a43 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -1,4 +1,4 @@ -import { EdgeFactoryInterface } from '../factory'; +import { EdgeFactory } from '../factory'; import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; import { Cursor, CursorParameters } from './Cursor'; import { ConnectionArgsValidationError } from '../error'; @@ -14,7 +14,7 @@ export class CursorPaginator< TParams extends CursorParameters = CursorParameters, TNode = any, > { - public edgeFactory: EdgeFactoryInterface>; + public edgeFactory: EdgeFactory>; public edgesPerPage: number = 20; public totalEdges?: number; public afterCursor?: Cursor; diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index f2954263..2383ad8f 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,4 +1,4 @@ -import { EdgeFactoryInterface } from '../factory'; +import { EdgeFactory } from '../factory'; import { createEdgeType } from '../type'; import { OffsetCursor } from './OffsetCursor'; import { OffsetCursorPaginator } from './OffsetCursorPaginator'; @@ -9,7 +9,7 @@ class TestNode { class TestEdge extends createEdgeType(TestNode) {} -const testEdgeFactory: EdgeFactoryInterface = { +const testEdgeFactory: EdgeFactory = { createEdge(node, offset) { return new TestEdge({ node, diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index c6280a98..4d16aeee 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,5 +1,5 @@ import { ConnectionArgsValidationError } from '../error'; -import { EdgeFactoryInterface } from '../factory'; +import { EdgeFactory } from '../factory'; import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; import { OffsetCursor } from './OffsetCursor'; @@ -9,7 +9,7 @@ interface CreateFromConnectionArgsOptions { } export class OffsetCursorPaginator, TNode = any> { - public edgeFactory: EdgeFactoryInterface; + public edgeFactory: EdgeFactory; public edgesPerPage: number = 20; public totalEdges?: number; public startOffset: number = 0; diff --git a/src/factory/EdgeFactory.ts b/src/factory/EdgeFactory.ts index c3ea2f07..8489d204 100644 --- a/src/factory/EdgeFactory.ts +++ b/src/factory/EdgeFactory.ts @@ -1,7 +1,7 @@ import { Cursor, CursorParameters } from '../cursor'; import { EdgeInterface } from '../type'; -export interface EdgeFactoryInterface, TNode, TCursor extends Cursor = Cursor> { +export interface EdgeFactory, TNode, TCursor extends Cursor = Cursor> { createEdge(node: TNode, offset: number): TEdge; createCursor(node: TNode, offset: number): TCursor; From 282ebd33480cbf07630cf5f7ced256e802315cfd Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Thu, 9 Jun 2022 11:02:40 +1200 Subject: [PATCH 15/41] Add TConnection type parameter to Paginator classes --- src/cursor/CursorPaginator.spec.ts | 12 +++++++----- src/cursor/CursorPaginator.ts | 12 +++++++----- src/cursor/OffsetCursorPaginator.spec.ts | 14 +++++++------ src/cursor/OffsetCursorPaginator.ts | 25 +++++++++++++++++------- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index 66380640..5abab329 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; import { EdgeFactory } from '../factory'; -import { createEdgeType } from '../type'; +import { createConnectionType, createEdgeType } from '../type'; import { Cursor } from './Cursor'; import { CursorPaginator } from './CursorPaginator'; import { validateCursorParameters } from './validateCursorParameters'; @@ -11,6 +11,8 @@ class TestNode { class TestEdge extends createEdgeType(TestNode) {} +class TestConnection extends createConnectionType(TestEdge) {} + type TestCursorParams = { id: string }; const testCursorSchema = Joi.object({ @@ -34,7 +36,7 @@ const testEdgeFactory: EdgeFactory> describe('CursorPaginator', () => { test('PageInfo is correct for first page', () => { - const paginator = new CursorPaginator({ + const paginator = new CursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -60,7 +62,7 @@ describe('CursorPaginator', () => { }); test('PageInfo is correct for second page', () => { - const paginator = new CursorPaginator({ + const paginator = new CursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -87,7 +89,7 @@ describe('CursorPaginator', () => { }); test('PageInfo is correct for last page', () => { - const paginator = new CursorPaginator({ + const paginator = new CursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -108,7 +110,7 @@ describe('CursorPaginator', () => { }); test('PageInfo is correct for empty result', () => { - const paginator = new CursorPaginator({ + const paginator = new CursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 0, diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index 56f05a43..df4720a8 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -1,5 +1,5 @@ import { EdgeFactory } from '../factory'; -import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; +import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; import { Cursor, CursorParameters } from './Cursor'; import { ConnectionArgsValidationError } from '../error'; @@ -10,6 +10,7 @@ interface CreateFromConnectionArgsOptions { } export class CursorPaginator< + TConnection extends ConnectionInterface, TEdge extends EdgeInterface, TParams extends CursorParameters = CursorParameters, TNode = any, @@ -27,7 +28,7 @@ export class CursorPaginator< afterCursor, beforeCursor, }: Pick< - CursorPaginator, + CursorPaginator, 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'afterCursor' | 'beforeCursor' >) { this.edgeFactory = edgeFactory; @@ -52,6 +53,7 @@ export class CursorPaginator< } public static createFromConnectionArgs< + TConnection extends ConnectionInterface, TEdge extends EdgeInterface, TParams extends CursorParameters = CursorParameters, TNode = any, @@ -66,9 +68,9 @@ export class CursorPaginator< defaultEdgesPerPage = 20, maxEdgesPerPage = 100, allowReverseOrder = true, - }: Pick, 'edgeFactory' | 'totalEdges'> & + }: Pick, 'edgeFactory' | 'totalEdges'> & ConnectionArgs & - CreateFromConnectionArgsOptions): CursorPaginator { + CreateFromConnectionArgsOptions): CursorPaginator { const decodeCursor = edgeFactory.decodeCursor ?? (params => Cursor.fromString(params)); let edgesPerPage: number = defaultEdgesPerPage; @@ -115,7 +117,7 @@ export class CursorPaginator< } } - return new CursorPaginator({ + return new CursorPaginator({ edgeFactory, edgesPerPage, totalEdges, diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index 2383ad8f..17aee50d 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,5 +1,5 @@ import { EdgeFactory } from '../factory'; -import { createEdgeType } from '../type'; +import { createConnectionType, createEdgeType } from '../type'; import { OffsetCursor } from './OffsetCursor'; import { OffsetCursorPaginator } from './OffsetCursorPaginator'; @@ -9,6 +9,8 @@ class TestNode { class TestEdge extends createEdgeType(TestNode) {} +class TestConnection extends createConnectionType(TestEdge) {} + const testEdgeFactory: EdgeFactory = { createEdge(node, offset) { return new TestEdge({ @@ -23,7 +25,7 @@ const testEdgeFactory: EdgeFactory = { describe('OffsetCursorPaginator', () => { test('PageInfo is correct for first page', () => { - const paginator = new OffsetCursorPaginator({ + const paginator = new OffsetCursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -49,7 +51,7 @@ describe('OffsetCursorPaginator', () => { }); test('PageInfo is correct for second page', () => { - const paginator = new OffsetCursorPaginator({ + const paginator = new OffsetCursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -75,7 +77,7 @@ describe('OffsetCursorPaginator', () => { }); test('PageInfo is correct for last page', () => { - const paginator = new OffsetCursorPaginator({ + const paginator = new OffsetCursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 12, @@ -95,7 +97,7 @@ describe('OffsetCursorPaginator', () => { }); test('PageInfo is correct for fixed offset pagination', () => { - const paginator = OffsetCursorPaginator.createFromConnectionArgs({ + const paginator = OffsetCursorPaginator.createFromConnectionArgs({ edgeFactory: testEdgeFactory, first: 5, page: 2, @@ -121,7 +123,7 @@ describe('OffsetCursorPaginator', () => { }); test('PageInfo is correct for empty result', () => { - const paginator = new OffsetCursorPaginator({ + const paginator = new OffsetCursorPaginator({ edgeFactory: testEdgeFactory, edgesPerPage: 5, totalEdges: 0, diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index 4d16aeee..30eecf09 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,6 +1,6 @@ import { ConnectionArgsValidationError } from '../error'; import { EdgeFactory } from '../factory'; -import { ConnectionArgs, EdgeInterface, PageInfo } from '../type'; +import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; import { OffsetCursor } from './OffsetCursor'; interface CreateFromConnectionArgsOptions { @@ -8,7 +8,11 @@ interface CreateFromConnectionArgsOptions { maxEdgesPerPage?: number; } -export class OffsetCursorPaginator, TNode = any> { +export class OffsetCursorPaginator< + TConnection extends ConnectionInterface, + TEdge extends EdgeInterface, + TNode = any, +> { public edgeFactory: EdgeFactory; public edgesPerPage: number = 20; public totalEdges?: number; @@ -19,7 +23,10 @@ export class OffsetCursorPaginator, TNode = a edgesPerPage, totalEdges, startOffset, - }: Pick, 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'startOffset'>) { + }: Pick< + OffsetCursorPaginator, + 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'startOffset' + >) { this.edgeFactory = edgeFactory; this.edgesPerPage = edgesPerPage; this.totalEdges = totalEdges; @@ -40,7 +47,11 @@ export class OffsetCursorPaginator, TNode = a }; } - public static createFromConnectionArgs, TNode = any>({ + public static createFromConnectionArgs< + TConnection extends ConnectionInterface, + TEdge extends EdgeInterface, + TNode = any, + >({ edgeFactory, totalEdges, page, @@ -50,9 +61,9 @@ export class OffsetCursorPaginator, TNode = a after, defaultEdgesPerPage = 20, maxEdgesPerPage = 100, - }: Pick, 'edgeFactory' | 'totalEdges'> & + }: Pick, 'edgeFactory' | 'totalEdges'> & ConnectionArgs & - CreateFromConnectionArgsOptions): OffsetCursorPaginator { + CreateFromConnectionArgsOptions): OffsetCursorPaginator { const decodeCursor = edgeFactory.decodeCursor ?? (params => OffsetCursor.fromString(params)); let edgesPerPage: number = defaultEdgesPerPage; @@ -116,7 +127,7 @@ export class OffsetCursorPaginator, TNode = a throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.'); } - return new OffsetCursorPaginator({ + return new OffsetCursorPaginator({ edgeFactory, edgesPerPage, totalEdges, From 89871880fd5bd8ee1c2390dbc8b5b76f6d4beeb7 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 10 Jun 2022 08:00:23 +1200 Subject: [PATCH 16/41] Split factories out to separate functions --- README.md | 28 +++++----- src/cursor/CursorPaginator.spec.ts | 58 ++++++++++++++------- src/cursor/CursorPaginator.ts | 59 ++++++++++++++++----- src/cursor/OffsetCursorPaginator.spec.ts | 51 +++++++++++------- src/cursor/OffsetCursorPaginator.ts | 66 ++++++++++++++++++------ src/factory/EdgeFactory.ts | 10 ---- src/factory/index.ts | 5 -- src/index.ts | 1 - src/type/factories.ts | 18 +++++++ src/type/index.ts | 1 + 10 files changed, 199 insertions(+), 98 deletions(-) delete mode 100644 src/factory/EdgeFactory.ts delete mode 100644 src/factory/index.ts create mode 100644 src/type/factories.ts diff --git a/README.md b/README.md index f0a5c074..226f7272 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ import { createEdgeType, EdgeInterface } from 'nestjs-graphql-connection'; import { Person } from './entities'; @ObjectType() -export class PersonEdge extends createEdgeType(Person) implements EdgeInterface {} +export class PersonEdge extends createEdgeType(Person) implements EdgeInterface { +} ``` ### Create a Connection type @@ -34,7 +35,8 @@ import { ObjectType } from '@nestjs/graphql'; import { createConnectionType } from 'nestjs-graphql-connection'; @ObjectType() -export class PersonConnection extends createConnectionType(PersonEdge) {} +export class PersonConnection extends createConnectionType(PersonEdge) { +} ``` ### Create a Connection Arguments type @@ -86,17 +88,15 @@ export class PersonQueryResolver { const paginator = OffsetCursorPaginator.createFromConnectionArgs({ ...connectionArgs, totalEdges: totalPersons, - edgeFactory: { - createEdge(node, offset) { - return new PersonEdge({ - node, - cursor: this.createCursor(node, offset).encode(), - }); - }, - createCursor(node, offset) { - return new OffsetCursor({ offset }); - }, - } + createEdge({ node, cursor }) { + return new PersonEdge({ + node, + cursor, + }); + }, + createCursor(node, offset) { + return new OffsetCursor({ offset }); + }, }); // Example: Do whatever you need to do to fetch the current page of persons @@ -105,7 +105,7 @@ export class PersonQueryResolver { take: paginator.edgesPerPage, // how many rows to fetch skip: paginator.startOffset, // row offset to fetch from }); - + const edges = paginator.createEdges(persons); // Return resolved PersonConnection with edges and pageInfo diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts index 5abab329..b7d851de 100644 --- a/src/cursor/CursorPaginator.spec.ts +++ b/src/cursor/CursorPaginator.spec.ts @@ -1,6 +1,12 @@ import Joi from 'joi'; -import { EdgeFactory } from '../factory'; -import { createConnectionType, createEdgeType } from '../type'; +import { + ConnectionFactoryFunction, + createConnectionType, + createEdgeType, + CursorDecoderFunction, + CursorFactoryFunction, + EdgeFactoryFunction, +} from '../type'; import { Cursor } from './Cursor'; import { CursorPaginator } from './CursorPaginator'; import { validateCursorParameters } from './validateCursorParameters'; @@ -19,25 +25,28 @@ const testCursorSchema = Joi.object({ id: Joi.string().empty('').required(), }).unknown(false); -const testEdgeFactory: EdgeFactory> = { - createEdge(node) { - return new TestEdge({ - node, - cursor: this.createCursor(node).encode(), - }); - }, - createCursor(node) { - return new Cursor({ id: node.id }); - }, - decodeCursor(encodedString: string) { - return Cursor.fromString(encodedString, params => validateCursorParameters(params, testCursorSchema)); - }, -}; +const testConnectionFactory: ConnectionFactoryFunction = ({ edges, pageInfo }) => + new TestConnection({ edges, pageInfo }); + +const testEdgeFactory: EdgeFactoryFunction = ({ node, cursor }) => + new TestEdge({ + node, + cursor, + }); + +const testCursorFactory: CursorFactoryFunction> = node => + new Cursor({ id: node.id }); + +const testCursorDecoder: CursorDecoderFunction> = encodedString => + Cursor.fromString(encodedString, params => validateCursorParameters(params, testCursorSchema)); describe('CursorPaginator', () => { test('PageInfo is correct for first page', () => { const paginator = new CursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, + decodeCursor: testCursorDecoder, edgesPerPage: 5, totalEdges: 12, }); @@ -63,7 +72,10 @@ describe('CursorPaginator', () => { test('PageInfo is correct for second page', () => { const paginator = new CursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, + decodeCursor: testCursorDecoder, edgesPerPage: 5, totalEdges: 12, afterCursor: new Cursor({ id: 'node5' }), @@ -90,7 +102,10 @@ describe('CursorPaginator', () => { test('PageInfo is correct for last page', () => { const paginator = new CursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, + decodeCursor: testCursorDecoder, edgesPerPage: 5, totalEdges: 12, afterCursor: new Cursor({ id: 'node10' }), @@ -111,7 +126,10 @@ describe('CursorPaginator', () => { test('PageInfo is correct for empty result', () => { const paginator = new CursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, + decodeCursor: testCursorDecoder, edgesPerPage: 5, totalEdges: 0, }); diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts index df4720a8..79b16a9f 100644 --- a/src/cursor/CursorPaginator.ts +++ b/src/cursor/CursorPaginator.ts @@ -1,7 +1,15 @@ -import { EdgeFactory } from '../factory'; -import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; -import { Cursor, CursorParameters } from './Cursor'; import { ConnectionArgsValidationError } from '../error'; +import { + ConnectionArgs, + ConnectionFactoryFunction, + ConnectionInterface, + CursorDecoderFunction, + CursorFactoryFunction, + EdgeFactoryFunction, + EdgeInterface, + PageInfo, +} from '../type'; +import { Cursor, CursorParameters } from './Cursor'; interface CreateFromConnectionArgsOptions { defaultEdgesPerPage?: number; @@ -15,23 +23,37 @@ export class CursorPaginator< TParams extends CursorParameters = CursorParameters, TNode = any, > { - public edgeFactory: EdgeFactory>; + public connectionFactory: ConnectionFactoryFunction; + public edgeFactory: EdgeFactoryFunction; + public cursorFactory: CursorFactoryFunction>; + public cursorDecoder: CursorDecoderFunction>; public edgesPerPage: number = 20; public totalEdges?: number; public afterCursor?: Cursor; public beforeCursor?: Cursor; constructor({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor, edgesPerPage, totalEdges, afterCursor, beforeCursor, - }: Pick< + }: { + createConnection: ConnectionFactoryFunction; + createEdge: EdgeFactoryFunction; + createCursor: CursorFactoryFunction>; + decodeCursor?: CursorDecoderFunction>; + } & Pick< CursorPaginator, - 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'afterCursor' | 'beforeCursor' + 'edgesPerPage' | 'totalEdges' | 'afterCursor' | 'beforeCursor' >) { - this.edgeFactory = edgeFactory; + this.connectionFactory = createConnection; + this.edgeFactory = createEdge; + this.cursorFactory = createCursor; + this.cursorDecoder = decodeCursor ?? (params => Cursor.fromString(params)); this.edgesPerPage = edgesPerPage; this.totalEdges = totalEdges; this.afterCursor = afterCursor; @@ -39,7 +61,7 @@ export class CursorPaginator< } public createEdges(nodes: TNode[]): TEdge[] { - return nodes.map((node, index) => this.edgeFactory.createEdge(node, index)); + return nodes.map((node, index) => this.edgeFactory({ node, cursor: this.cursorFactory(node, index).encode() })); } public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { @@ -58,7 +80,10 @@ export class CursorPaginator< TParams extends CursorParameters = CursorParameters, TNode = any, >({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor = params => Cursor.fromString(params), totalEdges, page, first, @@ -68,11 +93,14 @@ export class CursorPaginator< defaultEdgesPerPage = 20, maxEdgesPerPage = 100, allowReverseOrder = true, - }: Pick, 'edgeFactory' | 'totalEdges'> & + }: { + createConnection: ConnectionFactoryFunction; + createEdge: EdgeFactoryFunction; + createCursor: CursorFactoryFunction>; + decodeCursor?: CursorDecoderFunction>; + } & Pick, 'totalEdges'> & ConnectionArgs & CreateFromConnectionArgsOptions): CursorPaginator { - const decodeCursor = edgeFactory.decodeCursor ?? (params => Cursor.fromString(params)); - let edgesPerPage: number = defaultEdgesPerPage; if (page != null) { @@ -118,7 +146,10 @@ export class CursorPaginator< } return new CursorPaginator({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor, edgesPerPage, totalEdges, beforeCursor: before != null ? decodeCursor(before) : undefined, diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts index 17aee50d..580f4682 100644 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -1,5 +1,11 @@ -import { EdgeFactory } from '../factory'; -import { createConnectionType, createEdgeType } from '../type'; +import { + ConnectionFactoryFunction, + createConnectionType, + createEdgeType, + CursorFactoryFunction, + EdgeFactoryFunction, +} from '../type'; +import { Cursor } from './Cursor'; import { OffsetCursor } from './OffsetCursor'; import { OffsetCursorPaginator } from './OffsetCursorPaginator'; @@ -11,22 +17,23 @@ class TestEdge extends createEdgeType(TestNode) {} class TestConnection extends createConnectionType(TestEdge) {} -const testEdgeFactory: EdgeFactory = { - createEdge(node, offset) { - return new TestEdge({ - node, - cursor: this.createCursor(node, offset).encode(), - }); - }, - createCursor(node, offset) { - return new OffsetCursor({ offset }); - }, -}; +const testConnectionFactory: ConnectionFactoryFunction = ({ edges, pageInfo }) => + new TestConnection({ edges, pageInfo }); + +const testEdgeFactory: EdgeFactoryFunction = ({ node, cursor }) => + new TestEdge({ + node, + cursor, + }); + +const testCursorFactory: CursorFactoryFunction = (node, offset) => new Cursor({ offset }); describe('OffsetCursorPaginator', () => { test('PageInfo is correct for first page', () => { const paginator = new OffsetCursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, edgesPerPage: 5, totalEdges: 12, startOffset: 0, @@ -52,7 +59,9 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for second page', () => { const paginator = new OffsetCursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, edgesPerPage: 5, totalEdges: 12, startOffset: 5, @@ -78,7 +87,9 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for last page', () => { const paginator = new OffsetCursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, edgesPerPage: 5, totalEdges: 12, startOffset: 10, @@ -98,7 +109,9 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for fixed offset pagination', () => { const paginator = OffsetCursorPaginator.createFromConnectionArgs({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, first: 5, page: 2, totalEdges: 12, @@ -124,7 +137,9 @@ describe('OffsetCursorPaginator', () => { test('PageInfo is correct for empty result', () => { const paginator = new OffsetCursorPaginator({ - edgeFactory: testEdgeFactory, + createConnection: testConnectionFactory, + createEdge: testEdgeFactory, + createCursor: testCursorFactory, edgesPerPage: 5, totalEdges: 0, startOffset: 0, diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts index 30eecf09..09312e2b 100644 --- a/src/cursor/OffsetCursorPaginator.ts +++ b/src/cursor/OffsetCursorPaginator.ts @@ -1,6 +1,14 @@ import { ConnectionArgsValidationError } from '../error'; -import { EdgeFactory } from '../factory'; -import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; +import { + ConnectionArgs, + ConnectionFactoryFunction, + ConnectionInterface, + CursorDecoderFunction, + CursorFactoryFunction, + EdgeFactoryFunction, + EdgeInterface, + PageInfo, +} from '../type'; import { OffsetCursor } from './OffsetCursor'; interface CreateFromConnectionArgsOptions { @@ -8,33 +16,51 @@ interface CreateFromConnectionArgsOptions { maxEdgesPerPage?: number; } +const defaultOffsetCursorDecoder: CursorDecoderFunction = params => OffsetCursor.fromString(params); + export class OffsetCursorPaginator< TConnection extends ConnectionInterface, TEdge extends EdgeInterface, TNode = any, > { - public edgeFactory: EdgeFactory; + public connectionFactory: ConnectionFactoryFunction; + public edgeFactory: EdgeFactoryFunction; + public cursorFactory: CursorFactoryFunction; + public cursorDecoder: CursorDecoderFunction; public edgesPerPage: number = 20; public totalEdges?: number; public startOffset: number = 0; constructor({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor, edgesPerPage, totalEdges, startOffset, - }: Pick< - OffsetCursorPaginator, - 'edgeFactory' | 'edgesPerPage' | 'totalEdges' | 'startOffset' - >) { - this.edgeFactory = edgeFactory; + }: { + createConnection: ConnectionFactoryFunction; + createEdge: EdgeFactoryFunction; + createCursor?: CursorFactoryFunction; + decodeCursor?: CursorDecoderFunction; + } & Pick, 'edgesPerPage' | 'totalEdges' | 'startOffset'>) { + this.connectionFactory = createConnection; + this.edgeFactory = createEdge; + this.cursorFactory = createCursor ?? ((node, offset) => new OffsetCursor({ offset })); + this.cursorDecoder = decodeCursor ?? defaultOffsetCursorDecoder; this.edgesPerPage = edgesPerPage; this.totalEdges = totalEdges; this.startOffset = startOffset; } public createEdges(nodes: TNode[]): TEdge[] { - return nodes.map((node, index) => this.edgeFactory.createEdge(node, this.startOffset + index)); + return nodes.map((node, index) => + this.edgeFactory({ + node, + cursor: this.cursorFactory(node, this.startOffset + index).encode(), + }), + ); } public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { @@ -52,7 +78,10 @@ export class OffsetCursorPaginator< TEdge extends EdgeInterface, TNode = any, >({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor = defaultOffsetCursorDecoder, totalEdges, page, first, @@ -61,11 +90,13 @@ export class OffsetCursorPaginator< after, defaultEdgesPerPage = 20, maxEdgesPerPage = 100, - }: Pick, 'edgeFactory' | 'totalEdges'> & - ConnectionArgs & + }: Pick, 'totalEdges'> & { + createConnection: ConnectionFactoryFunction; + createEdge: EdgeFactoryFunction; + createCursor?: CursorFactoryFunction; + decodeCursor?: CursorDecoderFunction; + } & ConnectionArgs & CreateFromConnectionArgsOptions): OffsetCursorPaginator { - const decodeCursor = edgeFactory.decodeCursor ?? (params => OffsetCursor.fromString(params)); - let edgesPerPage: number = defaultEdgesPerPage; let startOffset: number = 0; @@ -128,7 +159,10 @@ export class OffsetCursorPaginator< } return new OffsetCursorPaginator({ - edgeFactory, + createConnection, + createEdge, + createCursor, + decodeCursor, edgesPerPage, totalEdges, startOffset, diff --git a/src/factory/EdgeFactory.ts b/src/factory/EdgeFactory.ts deleted file mode 100644 index 8489d204..00000000 --- a/src/factory/EdgeFactory.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Cursor, CursorParameters } from '../cursor'; -import { EdgeInterface } from '../type'; - -export interface EdgeFactory, TNode, TCursor extends Cursor = Cursor> { - createEdge(node: TNode, offset: number): TEdge; - - createCursor(node: TNode, offset: number): TCursor; - - decodeCursor?(encodedString: string): TCursor; -} diff --git a/src/factory/index.ts b/src/factory/index.ts deleted file mode 100644 index b6a3a57b..00000000 --- a/src/factory/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @file Automatically generated by barrelsby. - */ - -export * from './EdgeFactory'; diff --git a/src/index.ts b/src/index.ts index c3be26ee..81da3baa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,4 @@ export * from './cursor/index'; export * from './error/index'; -export * from './factory/index'; export * from './type/index'; diff --git a/src/type/factories.ts b/src/type/factories.ts new file mode 100644 index 00000000..a4d7b96f --- /dev/null +++ b/src/type/factories.ts @@ -0,0 +1,18 @@ +import { Cursor } from '../cursor'; +import { ConnectionInterface } from './Connection'; +import { EdgeInterface } from './Edge'; +import { PageInfo } from './PageInfo'; + +export type ConnectionFactoryFunction, TNode> = (fields: { + edges: EdgeInterface[]; + pageInfo: PageInfo; +}) => TConnection; + +export type EdgeFactoryFunction, TNode> = (fields: { + node: TNode; + cursor: string; +}) => TEdge; + +export type CursorFactoryFunction = (node: TNode, offset: number) => TCursor; + +export type CursorDecoderFunction = (encodedString: string) => TCursor; diff --git a/src/type/index.ts b/src/type/index.ts index a3e3b7c5..7ea38d25 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -6,3 +6,4 @@ export * from './Connection'; export * from './ConnectionArgs'; export * from './Edge'; export * from './PageInfo'; +export * from './factories'; From 112b2ff631c3f5ca7ac84e19dc1d2eaa94eb847e Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 12:45:16 +1200 Subject: [PATCH 17/41] Add ConnectionBuilder, tests --- src/builder/ConnectionBuilder.spec.ts | 163 ++++++++++++++++++++++++++ src/builder/ConnectionBuilder.ts | 137 ++++++++++++++++++++++ src/builder/index.ts | 5 + src/index.ts | 1 + 4 files changed, 306 insertions(+) create mode 100644 src/builder/ConnectionBuilder.spec.ts create mode 100644 src/builder/ConnectionBuilder.ts create mode 100644 src/builder/index.ts diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts new file mode 100644 index 00000000..d1acb633 --- /dev/null +++ b/src/builder/ConnectionBuilder.spec.ts @@ -0,0 +1,163 @@ +import Joi from 'joi'; +import { Cursor } from '../cursor/Cursor'; +import { validateCursorParameters } from '../cursor/validateCursorParameters'; +import { createConnectionType, createEdgeType, PageInfo } from '../type'; +import { ConnectionBuilder } from './ConnectionBuilder'; + +class TestNode { + id: string; +} + +class TestEdge extends createEdgeType(TestNode) {} + +class TestConnection extends createConnectionType(TestEdge) {} + +type TestCursorParams = { id: string }; + +type TestCursor = Cursor; + +class TestConnectionBuilder extends ConnectionBuilder { + public createConnection(fields: { edges: TestEdge[]; pageInfo: PageInfo }): TestConnection { + return new TestConnection(fields); + } + + public createEdge(fields: { node: TestNode; cursor: string }): TestEdge { + return new TestEdge(fields); + } + + public createCursor(node: TestNode): TestCursor { + return new Cursor({ id: node.id }); + } + + public decodeCursor(encodedString: string): TestCursor { + const schema: Joi.ObjectSchema = Joi.object({ + id: Joi.string().empty('').required(), + }).unknown(false); + + return Cursor.fromString(encodedString, params => validateCursorParameters(params, schema)); + } +} + +describe('ConnectionBuilder', () => { + test('First page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.afterCursor).toBeUndefined(); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: false, + startCursor: new Cursor({ id: 'node1' }).encode(), + endCursor: new Cursor({ id: 'node5' }).encode(), + }, + edges: [ + { node: { id: 'node1' }, cursor: new Cursor({ id: 'node1' }).encode() }, + { node: { id: 'node2' }, cursor: new Cursor({ id: 'node2' }).encode() }, + { node: { id: 'node3' }, cursor: new Cursor({ id: 'node3' }).encode() }, + { node: { id: 'node4' }, cursor: new Cursor({ id: 'node4' }).encode() }, + { node: { id: 'node5' }, cursor: new Cursor({ id: 'node5' }).encode() }, + ], + }); + }); + + test('Second page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + after: new Cursor({ id: 'node5' }).encode(), + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.afterCursor).toMatchObject(new Cursor({ id: 'node5' })); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node6' }, { id: 'node7' }, { id: 'node8' }, { id: 'node9' }, { id: 'node10' }], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: true, + startCursor: new Cursor({ id: 'node6' }).encode(), + endCursor: new Cursor({ id: 'node10' }).encode(), + }, + edges: [ + { node: { id: 'node6' }, cursor: new Cursor({ id: 'node6' }).encode() }, + { node: { id: 'node7' }, cursor: new Cursor({ id: 'node7' }).encode() }, + { node: { id: 'node8' }, cursor: new Cursor({ id: 'node8' }).encode() }, + { node: { id: 'node9' }, cursor: new Cursor({ id: 'node9' }).encode() }, + { node: { id: 'node10' }, cursor: new Cursor({ id: 'node10' }).encode() }, + ], + }); + }); + + test('Last page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + after: new Cursor({ id: 'node10' }).encode(), + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.afterCursor).toMatchObject(new Cursor({ id: 'node10' })); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node11' }, { id: 'node12' }], + hasNextPage: false, // must be set explicitly when using Cursor pagination + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: false, + hasPreviousPage: true, + startCursor: new Cursor({ id: 'node11' }).encode(), + endCursor: new Cursor({ id: 'node12' }).encode(), + }, + edges: [ + { node: { id: 'node11' }, cursor: new Cursor({ id: 'node11' }).encode() }, + { node: { id: 'node12' }, cursor: new Cursor({ id: 'node12' }).encode() }, + ], + }); + }); + + test('Empty result is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.afterCursor).toBeUndefined(); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 0, + nodes: [], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 0, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + edges: [], + }); + }); +}); diff --git a/src/builder/ConnectionBuilder.ts b/src/builder/ConnectionBuilder.ts new file mode 100644 index 00000000..1dce7022 --- /dev/null +++ b/src/builder/ConnectionBuilder.ts @@ -0,0 +1,137 @@ +import { Cursor } from '../cursor'; +import { ConnectionArgsValidationError } from '../error'; +import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; + +export interface ConnectionBuilderOptions { + defaultEdgesPerPage?: number; + maxEdgesPerPage?: number; + allowReverseOrder?: boolean; +} + +export abstract class ConnectionBuilder< + TConnection extends ConnectionInterface, + TEdge extends EdgeInterface, + TNode, + TCursor extends Cursor = Cursor, +> { + public edgesPerPage: number; + public beforeCursor?: TCursor; + public afterCursor?: TCursor; + + public constructor( + { page, first, last, before, after }: ConnectionArgs, + { defaultEdgesPerPage = 20, maxEdgesPerPage = 100, allowReverseOrder = true }: ConnectionBuilderOptions = {}, + ) { + this.edgesPerPage = defaultEdgesPerPage; + + if (page != null) { + throw new ConnectionArgsValidationError('This connection does not support the "page" argument for pagination.'); + } + + if (first != null) { + if (first > maxEdgesPerPage || first < 1) { + throw new ConnectionArgsValidationError( + `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + this.edgesPerPage = first; + } + + if (last != null) { + if (first != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "first" and "last" arguments simultaneously.', + ); + } + + if (!allowReverseOrder) { + throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); + } + + if (last > maxEdgesPerPage || last < 1) { + throw new ConnectionArgsValidationError( + `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + this.edgesPerPage = last; + } + + if (after != null) { + if (before != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "after" and "before" arguments simultaneously.', + ); + } + } + + this.beforeCursor = before != null ? this.decodeCursor(before) : undefined; + this.afterCursor = after != null ? this.decodeCursor(after) : undefined; + } + + public abstract createConnection(fields: { edges: TEdge[]; pageInfo: PageInfo }): TConnection; + + public abstract createEdge(fields: { node: TNode; cursor: string }): TEdge; + + public abstract createCursor(node: TNode, offset: number): TCursor; + + public decodeCursor(encodedString: string): TCursor { + return Cursor.fromString(encodedString) as TCursor; + } + + public createPageInfo({ + edges, + hasNextPage, + hasPreviousPage, + totalEdges, + }: { + edges: TEdge[]; + hasNextPage: boolean; + hasPreviousPage: boolean; + totalEdges?: number; + }): PageInfo { + return { + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + hasNextPage, + hasPreviousPage, + totalEdges, + }; + } + + public build({ + nodes, + totalEdges, + hasNextPage, + hasPreviousPage, + createConnection = this.createConnection, + createEdge = this.createEdge, + createCursor = this.createCursor, + }: { + nodes: TNode[]; + totalEdges?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; + createConnection?: (fields: { edges: TEdge[]; pageInfo: PageInfo }) => TConnection; + createEdge?: (fields: { node: TNode; cursor: string }) => TEdge; + createCursor?: (node: TNode, offset: number) => TCursor; + }): TConnection { + const edges = nodes.map((node, index) => + createEdge({ + node, + cursor: createCursor(node, index).encode(), + }), + ); + + return createConnection({ + edges, + pageInfo: this.createPageInfo({ + edges, + totalEdges, + hasNextPage: hasNextPage ?? (totalEdges != null && totalEdges > edges.length), + hasPreviousPage: hasPreviousPage ?? (this.afterCursor != null || this.beforeCursor != null), + }), + }); + } +} diff --git a/src/builder/index.ts b/src/builder/index.ts new file mode 100644 index 00000000..7f43bbbc --- /dev/null +++ b/src/builder/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './ConnectionBuilder'; diff --git a/src/index.ts b/src/index.ts index 81da3baa..0de1bcfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ * @file Automatically generated by barrelsby. */ +export * from './builder/index'; export * from './cursor/index'; export * from './error/index'; export * from './type/index'; From 7f9119037068423d8034c5a2d86ae5aac2b8d4d5 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 12:45:25 +1200 Subject: [PATCH 18/41] Use ConnectionBuilder in docs --- README.md | 91 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 226f7272..f6b36ec8 100644 --- a/README.md +++ b/README.md @@ -54,16 +54,50 @@ export class PersonConnectionArgs extends ConnectionArgs { * PersonConnectionArgs will inherit `first`, `last`, `before`, `after`, and `page` fields from ConnectionArgs */ - // Optional: example of a custom argument for filtering + // EXAMPLE: Defining a custom argument for filtering @Field(type => ID, { nullable: true }) public personId?: string; } ``` -### Return a Connection from a Query Resolver +### 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. + +```ts +import { ConnectionBuilder, Cursor, PageInfo, validateCursorParameters } from 'nestjs-graphql-connection'; + +export type PersonCursorParams = { id: string }; +export type PersonCursor = Cursor; + +export class PersonConnectionBuilder extends ConnectionBuilder { + public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection { + return new PersonConnection(fields); + } + + public createEdge(fields: { node: TestNode; cursor: string }): TestEdge { + return new PersonEdge(fields); + } -Your resolvers can return a `Connection` as an object type. Use a `Paginator` class to help you determine which page -of results to fetch and to create `PageInfo` and cursors in the result. + public createCursor(node: Person): PersonCursor { + return new Cursor({ id: node.id }); + } + + public decodeCursor(encodedString: string): PersonCursor { + // A cursor sent to or received from a client is represented as a base64-encoded, URL-style query string containing + // one or more key/value pairs describing the referenced node's position in the result set (its ID, a date, etc.) + // Validation is optional, but recommended to enforce that cursor values supplied by clients must be well-formed. + // See documentation for Joi at https://joi.dev/api/?v=17#object + // The following schema accepts only an object matching the type { id: string }: + const schema: Joi.ObjectSchema = Joi.object({ + id: Joi.string().empty('').required(), + }).unknown(false); + + return Cursor.fromString(encodedString, params => validateCursorParameters(params, schema)); + } +} +``` #### Using Offset Pagination @@ -71,9 +105,15 @@ With offset pagination, cursor values are an encoded representation of the row o 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). +(TODO) + +### Return a Connection from a Query Resolver + +Your resolvers can return a `Connection` as an object type. Use your `ConnectionBuilder` class to determine which page +of results to fetch and to create `PageInfo`, cursors, and edges in the result. + ```ts import { Query, Resolver } from '@nestjs/graphql'; -import { OffsetCursorPaginator } from 'nestjs-graphql-connection'; @Resolver() export class PersonQueryResolver { @@ -81,44 +121,23 @@ export class PersonQueryResolver { public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise { const { personId } = connectionArgs; - // Example: Count the total number of matching persons (ignoring pagination) - const totalPersons = await countPersons({ where: { personId } }); - - // Create paginator instance - const paginator = OffsetCursorPaginator.createFromConnectionArgs({ - ...connectionArgs, - totalEdges: totalPersons, - createEdge({ node, cursor }) { - return new PersonEdge({ - node, - cursor, - }); - }, - createCursor(node, offset) { - return new OffsetCursor({ offset }); - }, - }); + // Create builder instance + const connectionBuilder = new PersonConnectionBuilder(connectionArgs); + + // EXAMPLE: Count the total number of matching persons (without pagination) + const totalEdges = await countPersons({ where: { personId } }); - // Example: Do whatever you need to do to fetch the current page of persons + // EXAMPLE: Do whatever you need to do to fetch the current page of persons const persons = await fetchPersons({ where: { personId }, - take: paginator.edgesPerPage, // how many rows to fetch - skip: paginator.startOffset, // row offset to fetch from + take: connectionBuilder.edgesPerPage, // how many rows to fetch }); - const edges = paginator.createEdges(persons); - // Return resolved PersonConnection with edges and pageInfo - return new PersonConnection({ - pageInfo: paginator.createPageInfo({ edges }), - edges, + return connectionBuilder.build({ + totalEdges, + nodes: persons, }); } } ``` - -#### Using Cursor Pagination - -🚧 **WIP** 🚧 - -_Cursors are ready but there is no Paginator class implementation yet._ From a8c7ae124ddc293ade5de385dde8e7f0b8065856 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 12:46:23 +1200 Subject: [PATCH 19/41] Delete CursorPaginator --- src/cursor/CursorPaginator.spec.ts | 147 -------------------------- src/cursor/CursorPaginator.ts | 159 ----------------------------- src/cursor/index.ts | 1 - 3 files changed, 307 deletions(-) delete mode 100644 src/cursor/CursorPaginator.spec.ts delete mode 100644 src/cursor/CursorPaginator.ts diff --git a/src/cursor/CursorPaginator.spec.ts b/src/cursor/CursorPaginator.spec.ts deleted file mode 100644 index b7d851de..00000000 --- a/src/cursor/CursorPaginator.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import Joi from 'joi'; -import { - ConnectionFactoryFunction, - createConnectionType, - createEdgeType, - CursorDecoderFunction, - CursorFactoryFunction, - EdgeFactoryFunction, -} from '../type'; -import { Cursor } from './Cursor'; -import { CursorPaginator } from './CursorPaginator'; -import { validateCursorParameters } from './validateCursorParameters'; - -class TestNode { - id: string; -} - -class TestEdge extends createEdgeType(TestNode) {} - -class TestConnection extends createConnectionType(TestEdge) {} - -type TestCursorParams = { id: string }; - -const testCursorSchema = Joi.object({ - id: Joi.string().empty('').required(), -}).unknown(false); - -const testConnectionFactory: ConnectionFactoryFunction = ({ edges, pageInfo }) => - new TestConnection({ edges, pageInfo }); - -const testEdgeFactory: EdgeFactoryFunction = ({ node, cursor }) => - new TestEdge({ - node, - cursor, - }); - -const testCursorFactory: CursorFactoryFunction> = node => - new Cursor({ id: node.id }); - -const testCursorDecoder: CursorDecoderFunction> = encodedString => - Cursor.fromString(encodedString, params => validateCursorParameters(params, testCursorSchema)); - -describe('CursorPaginator', () => { - test('PageInfo is correct for first page', () => { - const paginator = new CursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - decodeCursor: testCursorDecoder, - edgesPerPage: 5, - totalEdges: 12, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([ - { id: 'node1' }, - { id: 'node2' }, - { id: 'node3' }, - { id: 'node4' }, - { id: 'node5' }, - ]), - hasMore: true, - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(false); - expect(pageInfo.hasNextPage).toBe(true); - expect(pageInfo.startCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node1'); - expect(pageInfo.endCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node5'); - }); - - test('PageInfo is correct for second page', () => { - const paginator = new CursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - decodeCursor: testCursorDecoder, - edgesPerPage: 5, - totalEdges: 12, - afterCursor: new Cursor({ id: 'node5' }), - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([ - { id: 'node6' }, - { id: 'node7' }, - { id: 'node8' }, - { id: 'node9' }, - { id: 'node10' }, - ]), - hasMore: true, - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(true); - expect(pageInfo.hasNextPage).toBe(true); - expect(pageInfo.startCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node6'); - expect(pageInfo.endCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node10'); - }); - - test('PageInfo is correct for last page', () => { - const paginator = new CursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - decodeCursor: testCursorDecoder, - edgesPerPage: 5, - totalEdges: 12, - afterCursor: new Cursor({ id: 'node10' }), - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([{ id: 'node11' }, { id: 'node12' }]), - hasMore: false, - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(true); - expect(pageInfo.hasNextPage).toBe(false); - expect(pageInfo.startCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.startCursor!).parameters.id).toStrictEqual('node11'); - expect(pageInfo.endCursor).toBeDefined(); - expect(Cursor.fromString(pageInfo.endCursor!).parameters.id).toStrictEqual('node12'); - }); - - test('PageInfo is correct for empty result', () => { - const paginator = new CursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - decodeCursor: testCursorDecoder, - edgesPerPage: 5, - totalEdges: 0, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([]), - hasMore: false, - }); - - expect(pageInfo.totalEdges).toBe(0); - expect(pageInfo.hasPreviousPage).toBe(false); - expect(pageInfo.hasNextPage).toBe(false); - expect(pageInfo.startCursor).toBeNull(); - expect(pageInfo.endCursor).toBeNull(); - }); -}); diff --git a/src/cursor/CursorPaginator.ts b/src/cursor/CursorPaginator.ts deleted file mode 100644 index 79b16a9f..00000000 --- a/src/cursor/CursorPaginator.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { ConnectionArgsValidationError } from '../error'; -import { - ConnectionArgs, - ConnectionFactoryFunction, - ConnectionInterface, - CursorDecoderFunction, - CursorFactoryFunction, - EdgeFactoryFunction, - EdgeInterface, - PageInfo, -} from '../type'; -import { Cursor, CursorParameters } from './Cursor'; - -interface CreateFromConnectionArgsOptions { - defaultEdgesPerPage?: number; - maxEdgesPerPage?: number; - allowReverseOrder?: boolean; -} - -export class CursorPaginator< - TConnection extends ConnectionInterface, - TEdge extends EdgeInterface, - TParams extends CursorParameters = CursorParameters, - TNode = any, -> { - public connectionFactory: ConnectionFactoryFunction; - public edgeFactory: EdgeFactoryFunction; - public cursorFactory: CursorFactoryFunction>; - public cursorDecoder: CursorDecoderFunction>; - public edgesPerPage: number = 20; - public totalEdges?: number; - public afterCursor?: Cursor; - public beforeCursor?: Cursor; - - constructor({ - createConnection, - createEdge, - createCursor, - decodeCursor, - edgesPerPage, - totalEdges, - afterCursor, - beforeCursor, - }: { - createConnection: ConnectionFactoryFunction; - createEdge: EdgeFactoryFunction; - createCursor: CursorFactoryFunction>; - decodeCursor?: CursorDecoderFunction>; - } & Pick< - CursorPaginator, - 'edgesPerPage' | 'totalEdges' | 'afterCursor' | 'beforeCursor' - >) { - this.connectionFactory = createConnection; - this.edgeFactory = createEdge; - this.cursorFactory = createCursor; - this.cursorDecoder = decodeCursor ?? (params => Cursor.fromString(params)); - this.edgesPerPage = edgesPerPage; - this.totalEdges = totalEdges; - this.afterCursor = afterCursor; - this.beforeCursor = beforeCursor; - } - - public createEdges(nodes: TNode[]): TEdge[] { - return nodes.map((node, index) => this.edgeFactory({ node, cursor: this.cursorFactory(node, index).encode() })); - } - - public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { - return { - startCursor: edges.length > 0 ? edges[0].cursor : null, - endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, - hasNextPage: hasMore ?? (this.totalEdges != null && this.totalEdges > edges.length), - hasPreviousPage: this.afterCursor != null || this.beforeCursor != null, - totalEdges: this.totalEdges, - }; - } - - public static createFromConnectionArgs< - TConnection extends ConnectionInterface, - TEdge extends EdgeInterface, - TParams extends CursorParameters = CursorParameters, - TNode = any, - >({ - createConnection, - createEdge, - createCursor, - decodeCursor = params => Cursor.fromString(params), - totalEdges, - page, - first, - last, - before, - after, - defaultEdgesPerPage = 20, - maxEdgesPerPage = 100, - allowReverseOrder = true, - }: { - createConnection: ConnectionFactoryFunction; - createEdge: EdgeFactoryFunction; - createCursor: CursorFactoryFunction>; - decodeCursor?: CursorDecoderFunction>; - } & Pick, 'totalEdges'> & - ConnectionArgs & - CreateFromConnectionArgsOptions): CursorPaginator { - let edgesPerPage: number = defaultEdgesPerPage; - - if (page != null) { - throw new ConnectionArgsValidationError('This connection does not support the "page" argument for pagination.'); - } - - if (first != null) { - if (first > maxEdgesPerPage || first < 1) { - throw new ConnectionArgsValidationError( - `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - edgesPerPage = first; - } - - if (last != null) { - if (first != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "first" and "last" arguments simultaneously.', - ); - } - - if (!allowReverseOrder) { - throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); - } - - if (last > maxEdgesPerPage || last < 1) { - throw new ConnectionArgsValidationError( - `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - edgesPerPage = last; - } - - if (after != null) { - if (before != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "after" and "before" arguments simultaneously.', - ); - } - } - - return new CursorPaginator({ - createConnection, - createEdge, - createCursor, - decodeCursor, - edgesPerPage, - totalEdges, - beforeCursor: before != null ? decodeCursor(before) : undefined, - afterCursor: after != null ? decodeCursor(after) : undefined, - }); - } -} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index d34a95c5..74fdc09c 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -3,7 +3,6 @@ */ export * from './Cursor'; -export * from './CursorPaginator'; export * from './OffsetCursor'; export * from './OffsetCursorPaginator'; export * from './validateCursorParameters'; From 3438b9a67749f35ae894e704de8a08938ae5efa1 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:23:06 +1200 Subject: [PATCH 20/41] Add OffsetPaginatedConnectionBuilder --- src/builder/ConnectionBuilder.ts | 116 +++++++------- .../OffsetPaginatedConnectionBuilder.spec.ts | 148 ++++++++++++++++++ .../OffsetPaginatedConnectionBuilder.ts | 101 ++++++++++++ 3 files changed, 309 insertions(+), 56 deletions(-) create mode 100644 src/builder/OffsetPaginatedConnectionBuilder.spec.ts create mode 100644 src/builder/OffsetPaginatedConnectionBuilder.ts diff --git a/src/builder/ConnectionBuilder.ts b/src/builder/ConnectionBuilder.ts index 1dce7022..5f10baf3 100644 --- a/src/builder/ConnectionBuilder.ts +++ b/src/builder/ConnectionBuilder.ts @@ -14,67 +14,19 @@ export abstract class ConnectionBuilder< TNode, TCursor extends Cursor = Cursor, > { - public edgesPerPage: number; + public edgesPerPage: number = 20; public beforeCursor?: TCursor; public afterCursor?: TCursor; - public constructor( - { page, first, last, before, after }: ConnectionArgs, - { defaultEdgesPerPage = 20, maxEdgesPerPage = 100, allowReverseOrder = true }: ConnectionBuilderOptions = {}, - ) { - this.edgesPerPage = defaultEdgesPerPage; - - if (page != null) { - throw new ConnectionArgsValidationError('This connection does not support the "page" argument for pagination.'); - } - - if (first != null) { - if (first > maxEdgesPerPage || first < 1) { - throw new ConnectionArgsValidationError( - `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - this.edgesPerPage = first; - } - - if (last != null) { - if (first != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "first" and "last" arguments simultaneously.', - ); - } - - if (!allowReverseOrder) { - throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); - } - - if (last > maxEdgesPerPage || last < 1) { - throw new ConnectionArgsValidationError( - `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - this.edgesPerPage = last; - } - - if (after != null) { - if (before != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "after" and "before" arguments simultaneously.', - ); - } - } - - this.beforeCursor = before != null ? this.decodeCursor(before) : undefined; - this.afterCursor = after != null ? this.decodeCursor(after) : undefined; + public constructor(connectionArgs: ConnectionArgs, options: ConnectionBuilderOptions = {}) { + this.applyConnectionArgs(connectionArgs, options); } public abstract createConnection(fields: { edges: TEdge[]; pageInfo: PageInfo }): TConnection; public abstract createEdge(fields: { node: TNode; cursor: string }): TEdge; - public abstract createCursor(node: TNode, offset: number): TCursor; + public abstract createCursor(node: TNode, index: number): TCursor; public decodeCursor(encodedString: string): TCursor { return Cursor.fromString(encodedString) as TCursor; @@ -105,9 +57,9 @@ export abstract class ConnectionBuilder< totalEdges, hasNextPage, hasPreviousPage, - createConnection = this.createConnection, - createEdge = this.createEdge, - createCursor = this.createCursor, + createConnection = this.createConnection.bind(this), + createEdge = this.createEdge.bind(this), + createCursor = this.createCursor.bind(this), }: { nodes: TNode[]; totalEdges?: number; @@ -115,7 +67,7 @@ export abstract class ConnectionBuilder< hasPreviousPage?: boolean; createConnection?: (fields: { edges: TEdge[]; pageInfo: PageInfo }) => TConnection; createEdge?: (fields: { node: TNode; cursor: string }) => TEdge; - createCursor?: (node: TNode, offset: number) => TCursor; + createCursor?: (node: TNode, index: number) => TCursor; }): TConnection { const edges = nodes.map((node, index) => createEdge({ @@ -134,4 +86,56 @@ export abstract class ConnectionBuilder< }), }); } + + protected applyConnectionArgs( + { page, first, last, before, after }: ConnectionArgs, + { defaultEdgesPerPage, maxEdgesPerPage = 100, allowReverseOrder = true }: ConnectionBuilderOptions = {}, + ): void { + this.edgesPerPage = defaultEdgesPerPage ?? this.edgesPerPage; + + if (page != null) { + throw new ConnectionArgsValidationError('This connection does not support the "page" argument for pagination.'); + } + + if (first != null) { + if (first > maxEdgesPerPage || first < 1) { + throw new ConnectionArgsValidationError( + `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + this.edgesPerPage = first; + } + + if (last != null) { + if (first != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "first" and "last" arguments simultaneously.', + ); + } + + if (!allowReverseOrder) { + throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); + } + + if (last > maxEdgesPerPage || last < 1) { + throw new ConnectionArgsValidationError( + `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + this.edgesPerPage = last; + } + + if (after != null) { + if (before != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "after" and "before" arguments simultaneously.', + ); + } + } + + this.beforeCursor = before != null ? this.decodeCursor(before) : undefined; + this.afterCursor = after != null ? this.decodeCursor(after) : undefined; + } } diff --git a/src/builder/OffsetPaginatedConnectionBuilder.spec.ts b/src/builder/OffsetPaginatedConnectionBuilder.spec.ts new file mode 100644 index 00000000..59a1f60d --- /dev/null +++ b/src/builder/OffsetPaginatedConnectionBuilder.spec.ts @@ -0,0 +1,148 @@ +import { Cursor } from '../cursor/Cursor'; +import { createConnectionType, createEdgeType, PageInfo } from '../type'; +import { OffsetPaginatedConnectionBuilder } from './OffsetPaginatedConnectionBuilder'; + +class TestNode { + id: string; +} + +class TestEdge extends createEdgeType(TestNode) {} + +class TestConnection extends createConnectionType(TestEdge) {} + +class TestConnectionBuilder extends OffsetPaginatedConnectionBuilder { + public createConnection(fields: { edges: TestEdge[]; pageInfo: PageInfo }): TestConnection { + return new TestConnection(fields); + } + + public createEdge(fields: { node: TestNode; cursor: string }): TestEdge { + return new TestEdge(fields); + } +} + +describe('OffsetPaginatedConnectionBuilder', () => { + test('First page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.startOffset).toBe(0); + expect(builder.afterCursor).toBeUndefined(); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: false, + startCursor: new Cursor({ offset: 0 }).encode(), + endCursor: new Cursor({ offset: 4 }).encode(), + }, + edges: [ + { node: { id: 'node1' }, cursor: new Cursor({ offset: 0 }).encode() }, + { node: { id: 'node2' }, cursor: new Cursor({ offset: 1 }).encode() }, + { node: { id: 'node3' }, cursor: new Cursor({ offset: 2 }).encode() }, + { node: { id: 'node4' }, cursor: new Cursor({ offset: 3 }).encode() }, + { node: { id: 'node5' }, cursor: new Cursor({ offset: 4 }).encode() }, + ], + }); + }); + + test('Second page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + after: new Cursor({ offset: 4 }).encode(), + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.startOffset).toBe(5); + expect(builder.afterCursor).toMatchObject(new Cursor({ offset: 4 })); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node6' }, { id: 'node7' }, { id: 'node8' }, { id: 'node9' }, { id: 'node10' }], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: true, + startCursor: new Cursor({ offset: 5 }).encode(), + endCursor: new Cursor({ offset: 9 }).encode(), + }, + edges: [ + { node: { id: 'node6' }, cursor: new Cursor({ offset: 5 }).encode() }, + { node: { id: 'node7' }, cursor: new Cursor({ offset: 6 }).encode() }, + { node: { id: 'node8' }, cursor: new Cursor({ offset: 7 }).encode() }, + { node: { id: 'node9' }, cursor: new Cursor({ offset: 8 }).encode() }, + { node: { id: 'node10' }, cursor: new Cursor({ offset: 9 }).encode() }, + ], + }); + }); + + test('Last page is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + after: new Cursor({ offset: 9 }).encode(), + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.startOffset).toBe(10); + expect(builder.afterCursor).toMatchObject(new Cursor({ offset: 9 })); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node11' }, { id: 'node12' }], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: false, + hasPreviousPage: true, + startCursor: new Cursor({ offset: 10 }).encode(), + endCursor: new Cursor({ offset: 11 }).encode(), + }, + edges: [ + { node: { id: 'node11' }, cursor: new Cursor({ offset: 10 }).encode() }, + { node: { id: 'node12' }, cursor: new Cursor({ offset: 11 }).encode() }, + ], + }); + }); + + test('Empty result is built correctly', () => { + const builder = new TestConnectionBuilder({ + first: 5, + }); + + expect(builder.edgesPerPage).toBe(5); + expect(builder.startOffset).toBe(0); + expect(builder.afterCursor).toBeUndefined(); + expect(builder.beforeCursor).toBeUndefined(); + + const connection = builder.build({ + totalEdges: 0, + nodes: [], + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 0, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + }, + edges: [], + }); + }); +}); diff --git a/src/builder/OffsetPaginatedConnectionBuilder.ts b/src/builder/OffsetPaginatedConnectionBuilder.ts new file mode 100644 index 00000000..0bf98886 --- /dev/null +++ b/src/builder/OffsetPaginatedConnectionBuilder.ts @@ -0,0 +1,101 @@ +import { OffsetCursor } from '../cursor'; +import { ConnectionArgsValidationError } from '../error'; +import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '../type'; +import { ConnectionBuilder, ConnectionBuilderOptions } from './ConnectionBuilder'; + +export abstract class OffsetPaginatedConnectionBuilder< + TConnection extends ConnectionInterface, + TEdge extends EdgeInterface, + TNode, +> extends ConnectionBuilder { + public startOffset: number; + + public createCursor(node: TNode, index: number): OffsetCursor { + return new OffsetCursor({ offset: this.startOffset + index }); + } + + public decodeCursor(encodedString: string): OffsetCursor { + return OffsetCursor.fromString(encodedString); + } + + public build({ + nodes, + totalEdges, + hasNextPage, + hasPreviousPage, + createConnection = this.createConnection.bind(this), + createEdge = this.createEdge.bind(this), + createCursor = this.createCursor.bind(this), + }: { + nodes: TNode[]; + totalEdges?: number; + hasNextPage?: boolean; + hasPreviousPage?: boolean; + createConnection?: (fields: { edges: TEdge[]; pageInfo: PageInfo }) => TConnection; + createEdge?: (fields: { node: TNode; cursor: string }) => TEdge; + createCursor?: (node: TNode, index: number) => OffsetCursor; + }): TConnection { + const edges = nodes.map((node, index) => + createEdge({ + node, + cursor: createCursor(node, index).encode(), + }), + ); + + return createConnection({ + edges, + pageInfo: this.createPageInfo({ + edges, + totalEdges, + hasNextPage: hasNextPage ?? (totalEdges != null && totalEdges > this.startOffset + edges.length), + hasPreviousPage: hasPreviousPage ?? this.startOffset > 0, + }), + }); + } + + protected applyConnectionArgs( + { page, first, last, before, after }: ConnectionArgs, + { defaultEdgesPerPage, maxEdgesPerPage = 100 }: ConnectionBuilderOptions = {}, + ): void { + this.edgesPerPage = defaultEdgesPerPage ?? this.edgesPerPage; + this.startOffset = 0; + + if (first != null) { + if (first > maxEdgesPerPage || first < 1) { + throw new ConnectionArgsValidationError( + `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, + ); + } + + this.edgesPerPage = first; + this.startOffset = 0; + } + + if (page != null) { + if (after != null) { + throw new ConnectionArgsValidationError(`The "page" argument cannot be used together with "after".`); + } + + if (page < 1) { + throw new ConnectionArgsValidationError( + `The "page" argument accepts only a positive integer greater than zero.`, + ); + } + + this.startOffset = this.edgesPerPage * (page - 1); + } + + if (after != null) { + this.afterCursor = this.decodeCursor(after); + this.startOffset = this.afterCursor.parameters.offset + 1; + } + + if (last != null) { + throw new ConnectionArgsValidationError('This connection does not support the "last" argument for pagination.'); + } + + if (before != null) { + throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.'); + } + } +} From 935202263ef345ff77b29c2bd7fd89af599fa3e6 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:23:39 +1200 Subject: [PATCH 21/41] Delete OffsetCursorPaginator --- src/cursor/OffsetCursorPaginator.spec.ts | 157 --------------------- src/cursor/OffsetCursorPaginator.ts | 171 ----------------------- src/cursor/index.ts | 1 - 3 files changed, 329 deletions(-) delete mode 100644 src/cursor/OffsetCursorPaginator.spec.ts delete mode 100644 src/cursor/OffsetCursorPaginator.ts diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts deleted file mode 100644 index 580f4682..00000000 --- a/src/cursor/OffsetCursorPaginator.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - ConnectionFactoryFunction, - createConnectionType, - createEdgeType, - CursorFactoryFunction, - EdgeFactoryFunction, -} from '../type'; -import { Cursor } from './Cursor'; -import { OffsetCursor } from './OffsetCursor'; -import { OffsetCursorPaginator } from './OffsetCursorPaginator'; - -class TestNode { - id: string; -} - -class TestEdge extends createEdgeType(TestNode) {} - -class TestConnection extends createConnectionType(TestEdge) {} - -const testConnectionFactory: ConnectionFactoryFunction = ({ edges, pageInfo }) => - new TestConnection({ edges, pageInfo }); - -const testEdgeFactory: EdgeFactoryFunction = ({ node, cursor }) => - new TestEdge({ - node, - cursor, - }); - -const testCursorFactory: CursorFactoryFunction = (node, offset) => new Cursor({ offset }); - -describe('OffsetCursorPaginator', () => { - test('PageInfo is correct for first page', () => { - const paginator = new OffsetCursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - edgesPerPage: 5, - totalEdges: 12, - startOffset: 0, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([ - { id: 'node1' }, - { id: 'node2' }, - { id: 'node3' }, - { id: 'node4' }, - { id: 'node5' }, - ]), - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(false); - expect(pageInfo.hasNextPage).toBe(true); - expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(0); - expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(4); - }); - - test('PageInfo is correct for second page', () => { - const paginator = new OffsetCursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - edgesPerPage: 5, - totalEdges: 12, - startOffset: 5, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([ - { id: 'node6' }, - { id: 'node7' }, - { id: 'node8' }, - { id: 'node9' }, - { id: 'node10' }, - ]), - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(true); - expect(pageInfo.hasNextPage).toBe(true); - expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(5); - expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(9); - }); - - test('PageInfo is correct for last page', () => { - const paginator = new OffsetCursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - edgesPerPage: 5, - totalEdges: 12, - startOffset: 10, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([{ id: 'node11' }, { id: 'node12' }]), - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(true); - expect(pageInfo.hasNextPage).toBe(false); - expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(10); - expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(11); - }); - - test('PageInfo is correct for fixed offset pagination', () => { - const paginator = OffsetCursorPaginator.createFromConnectionArgs({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - first: 5, - page: 2, - totalEdges: 12, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([ - { id: 'node6' }, - { id: 'node7' }, - { id: 'node8' }, - { id: 'node9' }, - { id: 'node10' }, - ]), - }); - - expect(pageInfo.totalEdges).toBe(12); - expect(pageInfo.hasPreviousPage).toBe(true); - expect(pageInfo.hasNextPage).toBe(true); - expect(pageInfo.startCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.startCursor!).parameters.offset).toStrictEqual(5); - expect(pageInfo.endCursor).toBeDefined(); - expect(OffsetCursor.fromString(pageInfo.endCursor!).parameters.offset).toStrictEqual(9); - }); - - test('PageInfo is correct for empty result', () => { - const paginator = new OffsetCursorPaginator({ - createConnection: testConnectionFactory, - createEdge: testEdgeFactory, - createCursor: testCursorFactory, - edgesPerPage: 5, - totalEdges: 0, - startOffset: 0, - }); - const pageInfo = paginator.createPageInfo({ - edges: paginator.createEdges([]), - }); - - expect(pageInfo.totalEdges).toBe(0); - expect(pageInfo.hasPreviousPage).toBe(false); - expect(pageInfo.hasNextPage).toBe(false); - expect(pageInfo.startCursor).toBeNull(); - expect(pageInfo.endCursor).toBeNull(); - }); -}); diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts deleted file mode 100644 index 09312e2b..00000000 --- a/src/cursor/OffsetCursorPaginator.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { ConnectionArgsValidationError } from '../error'; -import { - ConnectionArgs, - ConnectionFactoryFunction, - ConnectionInterface, - CursorDecoderFunction, - CursorFactoryFunction, - EdgeFactoryFunction, - EdgeInterface, - PageInfo, -} from '../type'; -import { OffsetCursor } from './OffsetCursor'; - -interface CreateFromConnectionArgsOptions { - defaultEdgesPerPage?: number; - maxEdgesPerPage?: number; -} - -const defaultOffsetCursorDecoder: CursorDecoderFunction = params => OffsetCursor.fromString(params); - -export class OffsetCursorPaginator< - TConnection extends ConnectionInterface, - TEdge extends EdgeInterface, - TNode = any, -> { - public connectionFactory: ConnectionFactoryFunction; - public edgeFactory: EdgeFactoryFunction; - public cursorFactory: CursorFactoryFunction; - public cursorDecoder: CursorDecoderFunction; - public edgesPerPage: number = 20; - public totalEdges?: number; - public startOffset: number = 0; - - constructor({ - createConnection, - createEdge, - createCursor, - decodeCursor, - edgesPerPage, - totalEdges, - startOffset, - }: { - createConnection: ConnectionFactoryFunction; - createEdge: EdgeFactoryFunction; - createCursor?: CursorFactoryFunction; - decodeCursor?: CursorDecoderFunction; - } & Pick, 'edgesPerPage' | 'totalEdges' | 'startOffset'>) { - this.connectionFactory = createConnection; - this.edgeFactory = createEdge; - this.cursorFactory = createCursor ?? ((node, offset) => new OffsetCursor({ offset })); - this.cursorDecoder = decodeCursor ?? defaultOffsetCursorDecoder; - this.edgesPerPage = edgesPerPage; - this.totalEdges = totalEdges; - this.startOffset = startOffset; - } - - public createEdges(nodes: TNode[]): TEdge[] { - return nodes.map((node, index) => - this.edgeFactory({ - node, - cursor: this.cursorFactory(node, this.startOffset + index).encode(), - }), - ); - } - - public createPageInfo({ edges, hasMore }: { edges: TEdge[]; hasMore?: boolean }): PageInfo { - return { - startCursor: edges.length > 0 ? edges[0].cursor : null, - endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, - hasNextPage: hasMore ?? (this.totalEdges != null && this.startOffset + edges.length < this.totalEdges), - hasPreviousPage: this.startOffset > 0, - totalEdges: this.totalEdges, - }; - } - - public static createFromConnectionArgs< - TConnection extends ConnectionInterface, - TEdge extends EdgeInterface, - TNode = any, - >({ - createConnection, - createEdge, - createCursor, - decodeCursor = defaultOffsetCursorDecoder, - totalEdges, - page, - first, - last, - before, - after, - defaultEdgesPerPage = 20, - maxEdgesPerPage = 100, - }: Pick, 'totalEdges'> & { - createConnection: ConnectionFactoryFunction; - createEdge: EdgeFactoryFunction; - createCursor?: CursorFactoryFunction; - decodeCursor?: CursorDecoderFunction; - } & ConnectionArgs & - CreateFromConnectionArgsOptions): OffsetCursorPaginator { - let edgesPerPage: number = defaultEdgesPerPage; - let startOffset: number = 0; - - if (first != null) { - if (first > maxEdgesPerPage || first < 1) { - throw new ConnectionArgsValidationError( - `The "first" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - edgesPerPage = first; - startOffset = 0; - } - - if (page != null) { - if (last != null || after != null || before != null) { - throw new ConnectionArgsValidationError( - `The "page" argument cannot be used together with "last", "after" or "before".`, - ); - } - - if (page < 1) { - throw new ConnectionArgsValidationError( - `The "page" argument accepts only a positive integer greater than zero.`, - ); - } - - startOffset = edgesPerPage * (page - 1); - } - - if (last != null) { - if (first != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "first" and "last" arguments simultaneously.', - ); - } - - if (last > maxEdgesPerPage || last < 1) { - throw new ConnectionArgsValidationError( - `The "last" argument accepts a value between 1 and ${maxEdgesPerPage}, inclusive.`, - ); - } - - edgesPerPage = last; - startOffset = totalEdges != null && totalEdges > last ? totalEdges - last : 0; - } - - if (after != null) { - if (last != null) { - throw new ConnectionArgsValidationError( - 'It is not permitted to specify both "last" and "after" arguments simultaneously.', - ); - } - - startOffset = decodeCursor(after).parameters.offset + 1; - } - - if (before != null) { - throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.'); - } - - return new OffsetCursorPaginator({ - createConnection, - createEdge, - createCursor, - decodeCursor, - edgesPerPage, - totalEdges, - startOffset, - }); - } -} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 74fdc09c..a3c6919e 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -4,5 +4,4 @@ export * from './Cursor'; export * from './OffsetCursor'; -export * from './OffsetCursorPaginator'; export * from './validateCursorParameters'; From 31bc4afa2df4a1bff2d052f27579e17d1ded8395 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:26:16 +1200 Subject: [PATCH 22/41] Rename validateCursorParameters -> validateParamsUsingSchema --- README.md | 4 ++-- src/builder/ConnectionBuilder.spec.ts | 4 ++-- src/cursor/OffsetCursor.ts | 4 ++-- src/cursor/index.ts | 2 +- ...lidateCursorParameters.ts => validateParamsUsingSchema.ts} | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) rename src/cursor/{validateCursorParameters.ts => validateParamsUsingSchema.ts} (89%) diff --git a/README.md b/README.md index f6b36ec8..9b00a109 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Now define a `ConnectionBuilder` class for your `Connection` object. The builder pagination arguments for the connection, and creating the cursors and `Edge` objects that make up the connection. ```ts -import { ConnectionBuilder, Cursor, PageInfo, validateCursorParameters } from 'nestjs-graphql-connection'; +import { ConnectionBuilder, Cursor, PageInfo, validateParamsUsingSchema } from 'nestjs-graphql-connection'; export type PersonCursorParams = { id: string }; export type PersonCursor = Cursor; @@ -94,7 +94,7 @@ export class PersonConnectionBuilder extends ConnectionBuilder validateCursorParameters(params, schema)); + return Cursor.fromString(encodedString, params => validateParamsUsingSchema(params, schema)); } } ``` diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index d1acb633..2c00b05c 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; import { Cursor } from '../cursor/Cursor'; -import { validateCursorParameters } from '../cursor/validateCursorParameters'; +import { validateParamsUsingSchema } from '../cursor/validateParamsUsingSchema'; import { createConnectionType, createEdgeType, PageInfo } from '../type'; import { ConnectionBuilder } from './ConnectionBuilder'; @@ -34,7 +34,7 @@ class TestConnectionBuilder extends ConnectionBuilder validateCursorParameters(params, schema)); + return Cursor.fromString(encodedString, params => validateParamsUsingSchema(params, schema)); } } diff --git a/src/cursor/OffsetCursor.ts b/src/cursor/OffsetCursor.ts index 25bca2d2..ab8cfa8f 100644 --- a/src/cursor/OffsetCursor.ts +++ b/src/cursor/OffsetCursor.ts @@ -1,7 +1,7 @@ import Joi from 'joi'; import queryString from 'query-string'; import { CursorInterface, decodeCursorString } from './Cursor'; -import { validateCursorParameters } from './validateCursorParameters'; +import { validateParamsUsingSchema } from './validateParamsUsingSchema'; export type OffsetCursorParameters = { offset: number; @@ -28,7 +28,7 @@ export class OffsetCursor implements CursorInterface { public static fromString(encodedString: string): OffsetCursor { const parameters = OffsetCursor.decode(encodedString); - return new OffsetCursor(validateCursorParameters(parameters, offsetCursorSchema)); + return new OffsetCursor(validateParamsUsingSchema(parameters, offsetCursorSchema)); } /** diff --git a/src/cursor/index.ts b/src/cursor/index.ts index a3c6919e..ce703a46 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -4,4 +4,4 @@ export * from './Cursor'; export * from './OffsetCursor'; -export * from './validateCursorParameters'; +export * from './validateParamsUsingSchema'; diff --git a/src/cursor/validateCursorParameters.ts b/src/cursor/validateParamsUsingSchema.ts similarity index 89% rename from src/cursor/validateCursorParameters.ts rename to src/cursor/validateParamsUsingSchema.ts index bbf43de7..980e4fed 100644 --- a/src/cursor/validateCursorParameters.ts +++ b/src/cursor/validateParamsUsingSchema.ts @@ -2,7 +2,7 @@ import Joi from 'joi'; import { CursorValidationError } from '../error'; import { CursorParameters } from './Cursor'; -export function validateCursorParameters( +export function validateParamsUsingSchema( parameters: unknown, schema: Joi.ObjectSchema, ): TParams { From fefb311a1ad17784fdfd080c64c7f7eeb3f5b846 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:26:54 +1200 Subject: [PATCH 23/41] Delete factory function types --- src/type/factories.ts | 18 ------------------ src/type/index.ts | 1 - 2 files changed, 19 deletions(-) delete mode 100644 src/type/factories.ts diff --git a/src/type/factories.ts b/src/type/factories.ts deleted file mode 100644 index a4d7b96f..00000000 --- a/src/type/factories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Cursor } from '../cursor'; -import { ConnectionInterface } from './Connection'; -import { EdgeInterface } from './Edge'; -import { PageInfo } from './PageInfo'; - -export type ConnectionFactoryFunction, TNode> = (fields: { - edges: EdgeInterface[]; - pageInfo: PageInfo; -}) => TConnection; - -export type EdgeFactoryFunction, TNode> = (fields: { - node: TNode; - cursor: string; -}) => TEdge; - -export type CursorFactoryFunction = (node: TNode, offset: number) => TCursor; - -export type CursorDecoderFunction = (encodedString: string) => TCursor; diff --git a/src/type/index.ts b/src/type/index.ts index 7ea38d25..a3e3b7c5 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -6,4 +6,3 @@ export * from './Connection'; export * from './ConnectionArgs'; export * from './Edge'; export * from './PageInfo'; -export * from './factories'; From dd6fa1fce18955f49cc7345aabc9d1ffb2a618f5 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:48:33 +1200 Subject: [PATCH 24/41] Update docs --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9b00a109..3c6b5ec7 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ export class PersonConnectionArgs extends ConnectionArgs { } ``` -### Create a Connection Builder +### Create a Connection Builder and resolve a Connection 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. @@ -99,18 +99,76 @@ export class PersonConnectionBuilder extends ConnectionBuilder PersonConnection) + public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise { + const { personId } = connectionArgs; + + // Create builder instance + const connectionBuilder = new PersonConnectionBuilder(connectionArgs); + + // EXAMPLE: Count the total number of matching persons (without pagination) + const totalEdges = await countPersons({ where: { personId } }); + + // EXAMPLE: Do whatever you need to do to fetch the current page of persons + const persons = await fetchPersons({ + where: { personId }, + take: connectionBuilder.edgesPerPage, // how many rows to fetch + }); + + // Return resolved PersonConnection with edges and pageInfo + return connectionBuilder.build({ + totalEdges, + nodes: persons, + }); + } +} +``` + +### Using Offset Pagination 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). +`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`: -(TODO) +```ts +import { + OffsetPaginatedConnectionBuilder, + PageInfo, + validateParamsUsingSchema +} from 'nestjs-graphql-connection'; + +export class PersonConnectionBuilder extends OffsetPaginatedConnectionBuilder { + public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection { + return new PersonConnection(fields); + } -### Return a Connection from a Query Resolver + public createEdge(fields: { node: TestNode; cursor: string }): TestEdge { + return new PersonEdge(fields); + } + + // When extending from OffsetPaginatedConnectionBuilder, cursor encoding/decoding always uses the OffsetCursor type. + // So it's not necessary to implement the createCursor() or decodeCursor() methods here. +} +``` -Your resolvers can return a `Connection` as an object type. Use your `ConnectionBuilder` class to determine which page -of results to fetch and to create `PageInfo`, cursors, and edges in the result. +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. ```ts import { Query, Resolver } from '@nestjs/graphql'; @@ -131,6 +189,7 @@ export class PersonQueryResolver { const persons = await fetchPersons({ where: { personId }, take: connectionBuilder.edgesPerPage, // how many rows to fetch + skip: connectionBuilder.startOffset, // row offset to start at }); // Return resolved PersonConnection with edges and pageInfo From ff38f12bbc63488e602f749d79c668457ac3d33f Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sat, 11 Jun 2022 13:56:09 +1200 Subject: [PATCH 25/41] Add npm badge to README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3c6b5ec7..75f1c1f1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ npm i nestjs-graphql-connection ``` +[![npm version](https://badge.fury.io/js/nestjs-graphql-connection.svg)](https://badge.fury.io/js/nestjs-graphql-connection) + ## Usage ### Create an Edge type From 894d18c4eba6d5fc24304c10ebec94a06ee631c1 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sun, 12 Jun 2022 11:26:53 +1200 Subject: [PATCH 26/41] Add test for overriding createConnection & createEdge --- src/builder/ConnectionBuilder.spec.ts | 48 +++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index 2c00b05c..ffd75c61 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -8,9 +8,13 @@ class TestNode { id: string; } -class TestEdge extends createEdgeType(TestNode) {} +class TestEdge extends createEdgeType(TestNode) { + customEdgeField?: number; +} -class TestConnection extends createConnectionType(TestEdge) {} +class TestConnection extends createConnectionType(TestEdge) { + customConnectionField?: number; +} type TestCursorParams = { id: string }; @@ -160,4 +164,44 @@ describe('ConnectionBuilder', () => { edges: [], }); }); + + test('Can override createConnection and createEdge when building Connection', () => { + const builder = new TestConnectionBuilder({ + first: 5, + }); + const connection = builder.build({ + totalEdges: 12, + nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], + createConnection: ({ edges, pageInfo }) => { + const connection = new TestConnection({ edges, pageInfo }); + connection.customConnectionField = 99; + + return connection; + }, + createEdge: ({ node, cursor }) => { + const edge = new TestEdge({ node, cursor }); + edge.customEdgeField = 99; + + return edge; + }, + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: false, + startCursor: new Cursor({ id: 'node1' }).encode(), + endCursor: new Cursor({ id: 'node5' }).encode(), + }, + edges: [ + { node: { id: 'node1' }, cursor: new Cursor({ id: 'node1' }).encode(), customEdgeField: 99 }, + { node: { id: 'node2' }, cursor: new Cursor({ id: 'node2' }).encode(), customEdgeField: 99 }, + { node: { id: 'node3' }, cursor: new Cursor({ id: 'node3' }).encode(), customEdgeField: 99 }, + { node: { id: 'node4' }, cursor: new Cursor({ id: 'node4' }).encode(), customEdgeField: 99 }, + { node: { id: 'node5' }, cursor: new Cursor({ id: 'node5' }).encode(), customEdgeField: 99 }, + ], + customConnectionField: 99, + }); + }); }); From b24f4998129e6e6209c6bc32f567e521c87f2f7d Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sun, 12 Jun 2022 11:27:08 +1200 Subject: [PATCH 27/41] Add documentation for enriching edges with metadata --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 75f1c1f1..15369854 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Your resolvers can now return your `Connection` as an object type. Use your `Con page of results to fetch and to create the `PageInfo`, cursors, and edges in the result. ```ts -import { Query, Resolver } from '@nestjs/graphql'; +import { Args, Query, Resolver } from '@nestjs/graphql'; @Resolver() export class PersonQueryResolver { @@ -173,7 +173,7 @@ to begin the result set. For example, this works with SQL databases that accept queries. ```ts -import { Query, Resolver } from '@nestjs/graphql'; +import { Args, Query, Resolver } from '@nestjs/graphql'; @Resolver() export class PersonQueryResolver { @@ -202,3 +202,87 @@ export class PersonQueryResolver { } } ``` + +### 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.) + +`ConnectionBuilder` supports this use case by enabling the `createConnection()` and `createEdge()` methods to be +overridden when calling `build()`. This allows you to enrich the connection and edges with data that is only available +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. + +```ts +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 { + // Create builder instance + const connectionBuilder = new PersonFriendConnectionBuilder(connectionArgs); + + // EXAMPLE: Count the total number of this person's friends (without pagination) + const totalEdges = await countFriends({ where: { personId: person.id } }); + + // EXAMPLE: Do whatever you need to do to fetch the current page of this person's friends + const friends = await fetchFriends({ + where: { personId: person.id }, + take: connectionBuilder.edgesPerPage, // how many rows to fetch + }); + + // Return resolved PersonFriendConnection with edges and pageInfo + return connectionBuilder.build({ + totalEdges, + nodes: friends.map(friend => friend.otherPerson), + createEdge: ({ node, cursor }) => { + const friend = friends.find(friend => friend.otherPerson.id === node.id); + + const edge = new PersonFriendEdge({ node, cursor }); + edge.createdAt = friend.createdAt; + + return edge; + } + }); + } +} +``` + +Alternatively, you could build the connection result yourself by replacing the `connectionBuilder.build(...)` statement +with something like the following: + +```ts +// Resolve edges with cursor, node, and additional metadata +const edges = friends.map((friend, index) => { + const edge = new PersonFriendEdge({ + cursor: connectionBuilder.createCursor(friend.otherPerson, index), + node: friend.otherPerson, + }); + edge.createdAt = friend.createdAt; + + return edge; +}); + +// Return resolved PersonFriendConnection +return new PersonFriendConnection({ + pageInfo: connectionBuilder.createPageInfo({ + edges, + totalEdges, + hasNextPage: true, + hasPreviousPage: false, + }), + edges, +}); +``` From c739cad70b9a2cf45500a8689388136ee9ad95ed Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Sun, 12 Jun 2022 11:30:49 +1200 Subject: [PATCH 28/41] Add MIT license file --- LICENSE | 21 +++++++++++++++++++++ README.md | 4 ++++ 2 files changed, 25 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..1ef15911 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present Simon Garner and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 15369854..9639a215 100644 --- a/README.md +++ b/README.md @@ -286,3 +286,7 @@ return new PersonFriendConnection({ edges, }); ``` + +## License + +[MIT](LICENSE) From 9db9e326639e57fef2cae7139559c862bf604c63 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:30:11 +1200 Subject: [PATCH 29/41] deps: add ts-class-initializable --- package-lock.json | 13 ++++++++++++- package.json | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58247908..c44528fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "class-validator": "^0.13.1", "graphql-relay": "^0.10.0", "joi": "^17.2.0", - "query-string": "^7.1.1" + "query-string": "^7.1.1", + "ts-class-initializable": "1.0.2" }, "devDependencies": { "@nestjs/common": "8.4.6", @@ -9572,6 +9573,11 @@ "node": ">=8" } }, + "node_modules/ts-class-initializable": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ts-class-initializable/-/ts-class-initializable-1.0.2.tgz", + "integrity": "sha512-dbJZGolgla+LCG5H/vRF/8m0ZIYh8IGZFWmI5lXcH6EUFYUEXfnh2OU2nTXDoHIbmejD8W6ab2O8YKv0UrSF+A==" + }, "node_modules/ts-jest": { "version": "28.0.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.4.tgz", @@ -17291,6 +17297,11 @@ "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", "dev": true }, + "ts-class-initializable": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ts-class-initializable/-/ts-class-initializable-1.0.2.tgz", + "integrity": "sha512-dbJZGolgla+LCG5H/vRF/8m0ZIYh8IGZFWmI5lXcH6EUFYUEXfnh2OU2nTXDoHIbmejD8W6ab2O8YKv0UrSF+A==" + }, "ts-jest": { "version": "28.0.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-28.0.4.tgz", diff --git a/package.json b/package.json index f23775d3..69c7b02e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "class-validator": "^0.13.1", "graphql-relay": "^0.10.0", "joi": "^17.2.0", - "query-string": "^7.1.1" + "query-string": "^7.1.1", + "ts-class-initializable": "1.0.2" }, "devDependencies": { "@nestjs/common": "8.4.6", From 4b800868c26b766aefce40c1eec4b59468dc43b0 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:31:34 +1200 Subject: [PATCH 30/41] Allow Edge types to be initialized with extra fields in constructor --- src/builder/ConnectionBuilder.spec.ts | 13 ++++--------- src/type/Edge.ts | 19 ++++--------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index ffd75c61..39577f83 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -8,12 +8,12 @@ class TestNode { id: string; } -class TestEdge extends createEdgeType(TestNode) { - customEdgeField?: number; +class TestEdge extends createEdgeType<{ customEdgeField?: number }>(TestNode) { + public customEdgeField?: number; } class TestConnection extends createConnectionType(TestEdge) { - customConnectionField?: number; + public customConnectionField?: number; } type TestCursorParams = { id: string }; @@ -178,12 +178,7 @@ describe('ConnectionBuilder', () => { return connection; }, - createEdge: ({ node, cursor }) => { - const edge = new TestEdge({ node, cursor }); - edge.customEdgeField = 99; - - return edge; - }, + createEdge: ({ node, cursor }) => new TestEdge({ node, cursor, customEdgeField: 99 }), }); expect(connection).toMatchObject({ diff --git a/src/type/Edge.ts b/src/type/Edge.ts index 448a6fe3..60735e8a 100644 --- a/src/type/Edge.ts +++ b/src/type/Edge.ts @@ -1,19 +1,20 @@ import * as GQL from '@nestjs/graphql'; import * as Relay from 'graphql-relay'; +import { Initializable } from 'ts-class-initializable'; export interface EdgeInterface extends Relay.Edge { node: TNode; cursor: string; } -export function createEdgeType( +export function createEdgeType = Record, TNode = any>( TNodeClass: new () => TNode, -): new (fields?: Partial>) => EdgeInterface { +): new (fields: EdgeInterface & TInitFields) => EdgeInterface { // This class should be further extended by concrete Edge types. It can't be marked as // an abstract class because TS lacks support for returning `abstract new()...` as a type // (https://github.com/Microsoft/TypeScript/issues/25606) @GQL.ObjectType({ isAbstract: true }) - class Edge implements EdgeInterface { + class Edge extends Initializable & TInitFields> implements EdgeInterface { @GQL.Field(_type => TNodeClass, { description: `The node object (belonging to type ${TNodeClass.name}) attached to the edge.`, }) @@ -23,18 +24,6 @@ export function createEdgeType( description: 'An opaque cursor that can be used to retrieve further pages of edges before or after this one.', }) public cursor: string; - - constructor(fields?: Partial>) { - if (fields != null) { - if (fields.node != null) { - this.node = fields.node; - } - - if (fields.cursor != null) { - this.cursor = fields.cursor; - } - } - } } return Edge; From 7f683a3bc24c59c8d97d7e055cda3f61d6457e65 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:33:18 +1200 Subject: [PATCH 31/41] Stop implementing graphql-relay types --- src/type/Connection.ts | 3 +-- src/type/Edge.ts | 3 +-- src/type/PageInfo.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/type/Connection.ts b/src/type/Connection.ts index 114e4e57..37f422e2 100644 --- a/src/type/Connection.ts +++ b/src/type/Connection.ts @@ -1,9 +1,8 @@ import * as GQL from '@nestjs/graphql'; -import * as Relay from 'graphql-relay'; import { EdgeInterface } from './Edge'; import { PageInfo } from './PageInfo'; -export interface ConnectionInterface extends Relay.Connection { +export interface ConnectionInterface { pageInfo: PageInfo; edges: EdgeInterface[]; } diff --git a/src/type/Edge.ts b/src/type/Edge.ts index 60735e8a..3ee4a32a 100644 --- a/src/type/Edge.ts +++ b/src/type/Edge.ts @@ -1,8 +1,7 @@ import * as GQL from '@nestjs/graphql'; -import * as Relay from 'graphql-relay'; import { Initializable } from 'ts-class-initializable'; -export interface EdgeInterface extends Relay.Edge { +export interface EdgeInterface { node: TNode; cursor: string; } diff --git a/src/type/PageInfo.ts b/src/type/PageInfo.ts index f8140685..d5ff4292 100644 --- a/src/type/PageInfo.ts +++ b/src/type/PageInfo.ts @@ -1,8 +1,7 @@ -import * as Relay from 'graphql-relay'; import * as GQL from '@nestjs/graphql'; @GQL.ObjectType() -export class PageInfo implements Relay.PageInfo { +export class PageInfo { @GQL.Field(_type => Boolean) public hasNextPage!: boolean; From fed34b56b513314dc09e79d0ca965a1ed3e8c103 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:38:48 +1200 Subject: [PATCH 32/41] Pass precise Edge type to ConnectionInterface instead of Node type --- src/builder/ConnectionBuilder.ts | 2 +- src/builder/OffsetPaginatedConnectionBuilder.ts | 2 +- src/type/Connection.ts | 14 +++++++------- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/builder/ConnectionBuilder.ts b/src/builder/ConnectionBuilder.ts index 5f10baf3..0211343e 100644 --- a/src/builder/ConnectionBuilder.ts +++ b/src/builder/ConnectionBuilder.ts @@ -9,7 +9,7 @@ export interface ConnectionBuilderOptions { } export abstract class ConnectionBuilder< - TConnection extends ConnectionInterface, + TConnection extends ConnectionInterface, TEdge extends EdgeInterface, TNode, TCursor extends Cursor = Cursor, diff --git a/src/builder/OffsetPaginatedConnectionBuilder.ts b/src/builder/OffsetPaginatedConnectionBuilder.ts index 0bf98886..02989bcf 100644 --- a/src/builder/OffsetPaginatedConnectionBuilder.ts +++ b/src/builder/OffsetPaginatedConnectionBuilder.ts @@ -4,7 +4,7 @@ import { ConnectionArgs, ConnectionInterface, EdgeInterface, PageInfo } from '.. import { ConnectionBuilder, ConnectionBuilderOptions } from './ConnectionBuilder'; export abstract class OffsetPaginatedConnectionBuilder< - TConnection extends ConnectionInterface, + TConnection extends ConnectionInterface, TEdge extends EdgeInterface, TNode, > extends ConnectionBuilder { diff --git a/src/type/Connection.ts b/src/type/Connection.ts index 37f422e2..f5098473 100644 --- a/src/type/Connection.ts +++ b/src/type/Connection.ts @@ -2,24 +2,24 @@ import * as GQL from '@nestjs/graphql'; import { EdgeInterface } from './Edge'; import { PageInfo } from './PageInfo'; -export interface ConnectionInterface { +export interface ConnectionInterface { pageInfo: PageInfo; - edges: EdgeInterface[]; + edges: TEdge[]; } -export function createConnectionType( - TEdgeClass: new (fields?: Partial>) => EdgeInterface, -): new (fields?: Partial>) => ConnectionInterface { +export function createConnectionType = EdgeInterface>( + TEdgeClass: new (...args: any[]) => TEdge, +): new (fields?: Partial>) => ConnectionInterface { // This class should be further extended by concrete Connection types. It can't be marked as // an abstract class because TS lacks support for returning `abstract new()...` as a type // (https://github.com/Microsoft/TypeScript/issues/25606) @GQL.ObjectType({ isAbstract: true }) - class Connection implements ConnectionInterface { + class Connection implements ConnectionInterface { @GQL.Field(_type => PageInfo) public pageInfo: PageInfo; @GQL.Field(_type => [TEdgeClass]) - public edges: EdgeInterface[]; + public edges: TEdge[]; constructor(fields?: Partial>) { if (fields != null) { From 953e89704aead92688c1b7c54ce6c4dd38118489 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:39:43 +1200 Subject: [PATCH 33/41] deps: remove graphql-relay --- package-lock.json | 20 ++------------------ package.json | 1 - 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index c44528fd..b5e5a359 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "MIT", "dependencies": { "class-validator": "^0.13.1", - "graphql-relay": "^0.10.0", "joi": "^17.2.0", "query-string": "^7.1.1", "ts-class-initializable": "1.0.2" @@ -4289,22 +4288,12 @@ "version": "16.5.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz", "integrity": "sha512-qbHgh8Ix+j/qY+a/ZcJnFQ+j8ezakqPiHwPiZhV/3PgGlgf96QMBB5/f2rkiC9sgLoy/xvT6TSiaf2nTHJh5iA==", + "dev": true, "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, - "node_modules/graphql-relay": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.10.0.tgz", - "integrity": "sha512-44yBuw2/DLNEiMypbNZBt1yMDbBmyVPVesPywnteGGALiBmdyy1JP8jSg8ClLePg8ZZxk0O4BLhd1a6U/1jDOQ==", - "engines": { - "node": "^12.20.0 || ^14.15.0 || >= 15.9.0" - }, - "peerDependencies": { - "graphql": "^16.2.0" - } - }, "node_modules/graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", @@ -13340,14 +13329,9 @@ "version": "16.5.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.5.0.tgz", "integrity": "sha512-qbHgh8Ix+j/qY+a/ZcJnFQ+j8ezakqPiHwPiZhV/3PgGlgf96QMBB5/f2rkiC9sgLoy/xvT6TSiaf2nTHJh5iA==", + "dev": true, "peer": true }, - "graphql-relay": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/graphql-relay/-/graphql-relay-0.10.0.tgz", - "integrity": "sha512-44yBuw2/DLNEiMypbNZBt1yMDbBmyVPVesPywnteGGALiBmdyy1JP8jSg8ClLePg8ZZxk0O4BLhd1a6U/1jDOQ==", - "requires": {} - }, "graphql-tag": { "version": "2.12.6", "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", diff --git a/package.json b/package.json index 69c7b02e..967d8bd6 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ }, "dependencies": { "class-validator": "^0.13.1", - "graphql-relay": "^0.10.0", "joi": "^17.2.0", "query-string": "^7.1.1", "ts-class-initializable": "1.0.2" From b49e74bd5f932e9b82753dea1558d285018735c1 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 09:49:57 +1200 Subject: [PATCH 34/41] Allow Connection types to be initialized with extra fields in constructor --- src/builder/ConnectionBuilder.spec.ts | 9 ++------- src/type/Connection.ts | 25 ++++++++++--------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index 39577f83..ddb35043 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -12,7 +12,7 @@ class TestEdge extends createEdgeType<{ customEdgeField?: number }>(TestNode) { public customEdgeField?: number; } -class TestConnection extends createConnectionType(TestEdge) { +class TestConnection extends createConnectionType<{ customConnectionField?: number }>(TestEdge) { public customConnectionField?: number; } @@ -172,12 +172,7 @@ describe('ConnectionBuilder', () => { const connection = builder.build({ totalEdges: 12, nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], - createConnection: ({ edges, pageInfo }) => { - const connection = new TestConnection({ edges, pageInfo }); - connection.customConnectionField = 99; - - return connection; - }, + createConnection: ({ edges, pageInfo }) => new TestConnection({ edges, pageInfo, customConnectionField: 99 }), createEdge: ({ node, cursor }) => new TestEdge({ node, cursor, customEdgeField: 99 }), }); diff --git a/src/type/Connection.ts b/src/type/Connection.ts index f5098473..bf1471c7 100644 --- a/src/type/Connection.ts +++ b/src/type/Connection.ts @@ -1,4 +1,5 @@ import * as GQL from '@nestjs/graphql'; +import { Initializable } from 'ts-class-initializable'; import { EdgeInterface } from './Edge'; import { PageInfo } from './PageInfo'; @@ -7,31 +8,25 @@ export interface ConnectionInterface { edges: TEdge[]; } -export function createConnectionType = EdgeInterface>( +export function createConnectionType< + TInitFields extends Record = Record, + TEdge extends EdgeInterface = EdgeInterface, +>( TEdgeClass: new (...args: any[]) => TEdge, -): new (fields?: Partial>) => ConnectionInterface { +): new (fields: ConnectionInterface & TInitFields) => ConnectionInterface { // This class should be further extended by concrete Connection types. It can't be marked as // an abstract class because TS lacks support for returning `abstract new()...` as a type // (https://github.com/Microsoft/TypeScript/issues/25606) @GQL.ObjectType({ isAbstract: true }) - class Connection implements ConnectionInterface { + class Connection + extends Initializable & TInitFields> + implements ConnectionInterface + { @GQL.Field(_type => PageInfo) public pageInfo: PageInfo; @GQL.Field(_type => [TEdgeClass]) public edges: TEdge[]; - - constructor(fields?: Partial>) { - if (fields != null) { - if (fields.pageInfo != null) { - this.pageInfo = fields.pageInfo; - } - - if (fields.edges != null) { - this.edges = fields.edges; - } - } - } } return Connection; From d3b92527117de2790209cc4b2ea01197bee3e9c7 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 10:04:58 +1200 Subject: [PATCH 35/41] Update Edge initialization in documentation --- README.md | 45 ++++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9639a215..c0527670 100644 --- a/README.md +++ b/README.md @@ -15,15 +15,14 @@ npm i nestjs-graphql-connection ### Create an Edge type Extend a class from `createEdgeType` function, passing it the class of objects to be represented by the edge's `node`. -For correct typings also indicate that your class implements `EdgeInterface`, as shown: ```ts import { ObjectType } from '@nestjs/graphql'; -import { createEdgeType, EdgeInterface } from 'nestjs-graphql-connection'; +import { createEdgeType } from 'nestjs-graphql-connection'; import { Person } from './entities'; @ObjectType() -export class PersonEdge extends createEdgeType(Person) implements EdgeInterface { +export class PersonEdge extends createEdgeType(Person) { } ``` @@ -213,9 +212,24 @@ properties -- such as the date that the friend was added, or the type of relatio 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.) -`ConnectionBuilder` supports this use case by enabling the `createConnection()` and `createEdge()` methods to be -overridden when calling `build()`. This allows you to enrich the connection and edges with data that is only available -at resolve time. +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 })`. + +```ts +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 @@ -250,10 +264,7 @@ export class PersonResolver { createEdge: ({ node, cursor }) => { const friend = friends.find(friend => friend.otherPerson.id === node.id); - const edge = new PersonFriendEdge({ node, cursor }); - edge.createdAt = friend.createdAt; - - return edge; + return new PersonFriendEdge({ node, cursor, createdAt: friend.createdAt }); } }); } @@ -265,15 +276,11 @@ with something like the following: ```ts // Resolve edges with cursor, node, and additional metadata -const edges = friends.map((friend, index) => { - const edge = new PersonFriendEdge({ - cursor: connectionBuilder.createCursor(friend.otherPerson, index), - node: friend.otherPerson, - }); - edge.createdAt = friend.createdAt; - - return edge; -}); +const edges = friends.map((friend, index) => new PersonFriendEdge({ + cursor: connectionBuilder.createCursor(friend.otherPerson, index), + node: friend.otherPerson, + createdAt: friend.createdAt, +})); // Return resolved PersonFriendConnection return new PersonFriendConnection({ From a2bc97f6f64e551d6f58efb70b5cbf3d1564785a Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 11:08:00 +1200 Subject: [PATCH 36/41] Save connectionArgs on ConnectionBuilder, add type argument --- README.md | 4 ++-- src/builder/ConnectionBuilder.spec.ts | 12 ++++++++++-- src/builder/ConnectionBuilder.ts | 6 +++++- src/builder/OffsetPaginatedConnectionBuilder.spec.ts | 11 +++++++++-- src/builder/OffsetPaginatedConnectionBuilder.ts | 3 ++- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c0527670..cf704dfe 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ import { ConnectionBuilder, Cursor, PageInfo, validateParamsUsingSchema } from ' export type PersonCursorParams = { id: string }; export type PersonCursor = Cursor; -export class PersonConnectionBuilder extends ConnectionBuilder { +export class PersonConnectionBuilder extends ConnectionBuilder { public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection { return new PersonConnection(fields); } @@ -153,7 +153,7 @@ import { validateParamsUsingSchema } from 'nestjs-graphql-connection'; -export class PersonConnectionBuilder extends OffsetPaginatedConnectionBuilder { +export class PersonConnectionBuilder extends OffsetPaginatedConnectionBuilder { public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection { return new PersonConnection(fields); } diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index ddb35043..af678257 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -1,7 +1,7 @@ import Joi from 'joi'; import { Cursor } from '../cursor/Cursor'; import { validateParamsUsingSchema } from '../cursor/validateParamsUsingSchema'; -import { createConnectionType, createEdgeType, PageInfo } from '../type'; +import { ConnectionArgs, createConnectionType, createEdgeType, PageInfo } from '../type'; import { ConnectionBuilder } from './ConnectionBuilder'; class TestNode { @@ -16,11 +16,19 @@ class TestConnection extends createConnectionType<{ customConnectionField?: numb public customConnectionField?: number; } +class TestConnectionArgs extends ConnectionArgs {} + type TestCursorParams = { id: string }; type TestCursor = Cursor; -class TestConnectionBuilder extends ConnectionBuilder { +class TestConnectionBuilder extends ConnectionBuilder< + TestConnection, + TestConnectionArgs, + TestEdge, + TestNode, + TestCursor +> { public createConnection(fields: { edges: TestEdge[]; pageInfo: PageInfo }): TestConnection { return new TestConnection(fields); } diff --git a/src/builder/ConnectionBuilder.ts b/src/builder/ConnectionBuilder.ts index 0211343e..2f143138 100644 --- a/src/builder/ConnectionBuilder.ts +++ b/src/builder/ConnectionBuilder.ts @@ -10,15 +10,19 @@ export interface ConnectionBuilderOptions { export abstract class ConnectionBuilder< TConnection extends ConnectionInterface, + TConnectionArgs extends ConnectionArgs, TEdge extends EdgeInterface, TNode, TCursor extends Cursor = Cursor, > { + public connectionArgs: TConnectionArgs; public edgesPerPage: number = 20; public beforeCursor?: TCursor; public afterCursor?: TCursor; - public constructor(connectionArgs: ConnectionArgs, options: ConnectionBuilderOptions = {}) { + public constructor(connectionArgs: TConnectionArgs, options: ConnectionBuilderOptions = {}) { + this.connectionArgs = connectionArgs; + this.applyConnectionArgs(connectionArgs, options); } diff --git a/src/builder/OffsetPaginatedConnectionBuilder.spec.ts b/src/builder/OffsetPaginatedConnectionBuilder.spec.ts index 59a1f60d..10f61671 100644 --- a/src/builder/OffsetPaginatedConnectionBuilder.spec.ts +++ b/src/builder/OffsetPaginatedConnectionBuilder.spec.ts @@ -1,5 +1,5 @@ import { Cursor } from '../cursor/Cursor'; -import { createConnectionType, createEdgeType, PageInfo } from '../type'; +import { ConnectionArgs, createConnectionType, createEdgeType, PageInfo } from '../type'; import { OffsetPaginatedConnectionBuilder } from './OffsetPaginatedConnectionBuilder'; class TestNode { @@ -10,7 +10,14 @@ class TestEdge extends createEdgeType(TestNode) {} class TestConnection extends createConnectionType(TestEdge) {} -class TestConnectionBuilder extends OffsetPaginatedConnectionBuilder { +class TestConnectionArgs extends ConnectionArgs {} + +class TestConnectionBuilder extends OffsetPaginatedConnectionBuilder< + TestConnection, + TestConnectionArgs, + TestEdge, + TestNode +> { public createConnection(fields: { edges: TestEdge[]; pageInfo: PageInfo }): TestConnection { return new TestConnection(fields); } diff --git a/src/builder/OffsetPaginatedConnectionBuilder.ts b/src/builder/OffsetPaginatedConnectionBuilder.ts index 02989bcf..cb77ebc9 100644 --- a/src/builder/OffsetPaginatedConnectionBuilder.ts +++ b/src/builder/OffsetPaginatedConnectionBuilder.ts @@ -5,9 +5,10 @@ import { ConnectionBuilder, ConnectionBuilderOptions } from './ConnectionBuilder export abstract class OffsetPaginatedConnectionBuilder< TConnection extends ConnectionInterface, + TConnectionArgs extends ConnectionArgs, TEdge extends EdgeInterface, TNode, -> extends ConnectionBuilder { +> extends ConnectionBuilder { public startOffset: number; public createCursor(node: TNode, index: number): OffsetCursor { From 81aa66b10445d73e4a17f41b63ade5e31a32c224 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 11:08:37 +1200 Subject: [PATCH 37/41] Bind this to overridden methods in ConnectionBuilder.build() --- src/builder/ConnectionBuilder.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/builder/ConnectionBuilder.ts b/src/builder/ConnectionBuilder.ts index 2f143138..2f8f55f6 100644 --- a/src/builder/ConnectionBuilder.ts +++ b/src/builder/ConnectionBuilder.ts @@ -61,9 +61,9 @@ export abstract class ConnectionBuilder< totalEdges, hasNextPage, hasPreviousPage, - createConnection = this.createConnection.bind(this), - createEdge = this.createEdge.bind(this), - createCursor = this.createCursor.bind(this), + createConnection = this.createConnection, + createEdge = this.createEdge, + createCursor = this.createCursor, }: { nodes: TNode[]; totalEdges?: number; @@ -74,13 +74,13 @@ export abstract class ConnectionBuilder< createCursor?: (node: TNode, index: number) => TCursor; }): TConnection { const edges = nodes.map((node, index) => - createEdge({ + createEdge.bind(this)({ node, - cursor: createCursor(node, index).encode(), + cursor: createCursor.bind(this)(node, index).encode(), }), ); - return createConnection({ + return createConnection.bind(this)({ edges, pageInfo: this.createPageInfo({ edges, From 7c869c1b11ed335b7193dfe14f306f8ed012236c Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 11:09:48 +1200 Subject: [PATCH 38/41] Add test for overriding createCursor using connectionArgs --- src/builder/ConnectionBuilder.spec.ts | 119 ++++++++++++++++++++------ 1 file changed, 94 insertions(+), 25 deletions(-) diff --git a/src/builder/ConnectionBuilder.spec.ts b/src/builder/ConnectionBuilder.spec.ts index af678257..9147a95b 100644 --- a/src/builder/ConnectionBuilder.spec.ts +++ b/src/builder/ConnectionBuilder.spec.ts @@ -6,6 +6,7 @@ import { ConnectionBuilder } from './ConnectionBuilder'; class TestNode { id: string; + name: string; } class TestEdge extends createEdgeType<{ customEdgeField?: number }>(TestNode) { @@ -16,9 +17,11 @@ class TestConnection extends createConnectionType<{ customConnectionField?: numb public customConnectionField?: number; } -class TestConnectionArgs extends ConnectionArgs {} +class TestConnectionArgs extends ConnectionArgs { + public sortOption?: string; +} -type TestCursorParams = { id: string }; +type TestCursorParams = { id?: string; name?: string }; type TestCursor = Cursor; @@ -62,7 +65,13 @@ describe('ConnectionBuilder', () => { const connection = builder.build({ totalEdges: 12, - nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], + nodes: [ + { id: 'node1', name: 'A' }, + { id: 'node2', name: 'B' }, + { id: 'node3', name: 'C' }, + { id: 'node4', name: 'D' }, + { id: 'node5', name: 'E' }, + ], }); expect(connection).toMatchObject({ @@ -74,11 +83,11 @@ describe('ConnectionBuilder', () => { endCursor: new Cursor({ id: 'node5' }).encode(), }, edges: [ - { node: { id: 'node1' }, cursor: new Cursor({ id: 'node1' }).encode() }, - { node: { id: 'node2' }, cursor: new Cursor({ id: 'node2' }).encode() }, - { node: { id: 'node3' }, cursor: new Cursor({ id: 'node3' }).encode() }, - { node: { id: 'node4' }, cursor: new Cursor({ id: 'node4' }).encode() }, - { node: { id: 'node5' }, cursor: new Cursor({ id: 'node5' }).encode() }, + { node: { id: 'node1', name: 'A' }, cursor: new Cursor({ id: 'node1' }).encode() }, + { node: { id: 'node2', name: 'B' }, cursor: new Cursor({ id: 'node2' }).encode() }, + { node: { id: 'node3', name: 'C' }, cursor: new Cursor({ id: 'node3' }).encode() }, + { node: { id: 'node4', name: 'D' }, cursor: new Cursor({ id: 'node4' }).encode() }, + { node: { id: 'node5', name: 'E' }, cursor: new Cursor({ id: 'node5' }).encode() }, ], }); }); @@ -95,7 +104,13 @@ describe('ConnectionBuilder', () => { const connection = builder.build({ totalEdges: 12, - nodes: [{ id: 'node6' }, { id: 'node7' }, { id: 'node8' }, { id: 'node9' }, { id: 'node10' }], + nodes: [ + { id: 'node6', name: 'F' }, + { id: 'node7', name: 'G' }, + { id: 'node8', name: 'H' }, + { id: 'node9', name: 'I' }, + { id: 'node10', name: 'J' }, + ], }); expect(connection).toMatchObject({ @@ -107,11 +122,11 @@ describe('ConnectionBuilder', () => { endCursor: new Cursor({ id: 'node10' }).encode(), }, edges: [ - { node: { id: 'node6' }, cursor: new Cursor({ id: 'node6' }).encode() }, - { node: { id: 'node7' }, cursor: new Cursor({ id: 'node7' }).encode() }, - { node: { id: 'node8' }, cursor: new Cursor({ id: 'node8' }).encode() }, - { node: { id: 'node9' }, cursor: new Cursor({ id: 'node9' }).encode() }, - { node: { id: 'node10' }, cursor: new Cursor({ id: 'node10' }).encode() }, + { node: { id: 'node6', name: 'F' }, cursor: new Cursor({ id: 'node6' }).encode() }, + { node: { id: 'node7', name: 'G' }, cursor: new Cursor({ id: 'node7' }).encode() }, + { node: { id: 'node8', name: 'H' }, cursor: new Cursor({ id: 'node8' }).encode() }, + { node: { id: 'node9', name: 'I' }, cursor: new Cursor({ id: 'node9' }).encode() }, + { node: { id: 'node10', name: 'J' }, cursor: new Cursor({ id: 'node10' }).encode() }, ], }); }); @@ -128,7 +143,10 @@ describe('ConnectionBuilder', () => { const connection = builder.build({ totalEdges: 12, - nodes: [{ id: 'node11' }, { id: 'node12' }], + nodes: [ + { id: 'node11', name: 'K' }, + { id: 'node12', name: 'L' }, + ], hasNextPage: false, // must be set explicitly when using Cursor pagination }); @@ -141,8 +159,8 @@ describe('ConnectionBuilder', () => { endCursor: new Cursor({ id: 'node12' }).encode(), }, edges: [ - { node: { id: 'node11' }, cursor: new Cursor({ id: 'node11' }).encode() }, - { node: { id: 'node12' }, cursor: new Cursor({ id: 'node12' }).encode() }, + { node: { id: 'node11', name: 'K' }, cursor: new Cursor({ id: 'node11' }).encode() }, + { node: { id: 'node12', name: 'L' }, cursor: new Cursor({ id: 'node12' }).encode() }, ], }); }); @@ -179,9 +197,19 @@ describe('ConnectionBuilder', () => { }); const connection = builder.build({ totalEdges: 12, - nodes: [{ id: 'node1' }, { id: 'node2' }, { id: 'node3' }, { id: 'node4' }, { id: 'node5' }], - createConnection: ({ edges, pageInfo }) => new TestConnection({ edges, pageInfo, customConnectionField: 99 }), - createEdge: ({ node, cursor }) => new TestEdge({ node, cursor, customEdgeField: 99 }), + nodes: [ + { id: 'node1', name: 'A' }, + { id: 'node2', name: 'B' }, + { id: 'node3', name: 'C' }, + { id: 'node4', name: 'D' }, + { id: 'node5', name: 'E' }, + ], + createConnection({ edges, pageInfo }) { + return new TestConnection({ edges, pageInfo, customConnectionField: 99 }); + }, + createEdge({ node, cursor }) { + return new TestEdge({ node, cursor, customEdgeField: 99 }); + }, }); expect(connection).toMatchObject({ @@ -193,13 +221,54 @@ describe('ConnectionBuilder', () => { endCursor: new Cursor({ id: 'node5' }).encode(), }, edges: [ - { node: { id: 'node1' }, cursor: new Cursor({ id: 'node1' }).encode(), customEdgeField: 99 }, - { node: { id: 'node2' }, cursor: new Cursor({ id: 'node2' }).encode(), customEdgeField: 99 }, - { node: { id: 'node3' }, cursor: new Cursor({ id: 'node3' }).encode(), customEdgeField: 99 }, - { node: { id: 'node4' }, cursor: new Cursor({ id: 'node4' }).encode(), customEdgeField: 99 }, - { node: { id: 'node5' }, cursor: new Cursor({ id: 'node5' }).encode(), customEdgeField: 99 }, + { node: { id: 'node1', name: 'A' }, cursor: new Cursor({ id: 'node1' }).encode(), customEdgeField: 99 }, + { node: { id: 'node2', name: 'B' }, cursor: new Cursor({ id: 'node2' }).encode(), customEdgeField: 99 }, + { node: { id: 'node3', name: 'C' }, cursor: new Cursor({ id: 'node3' }).encode(), customEdgeField: 99 }, + { node: { id: 'node4', name: 'D' }, cursor: new Cursor({ id: 'node4' }).encode(), customEdgeField: 99 }, + { node: { id: 'node5', name: 'E' }, cursor: new Cursor({ id: 'node5' }).encode(), customEdgeField: 99 }, ], customConnectionField: 99, }); }); + + test('Can override createCursor using connectionArgs when building Connection', () => { + const builder = new TestConnectionBuilder({ + first: 5, + sortOption: 'name', + }); + const connection = builder.build({ + totalEdges: 12, + nodes: [ + { id: 'node1', name: 'A' }, + { id: 'node2', name: 'B' }, + { id: 'node3', name: 'C' }, + { id: 'node4', name: 'D' }, + { id: 'node5', name: 'E' }, + ], + createCursor(this: TestConnectionBuilder, node) { + if (this.connectionArgs.sortOption === 'name') { + return new Cursor({ name: node.name }); + } + + return new Cursor({ id: node.id }); + }, + }); + + expect(connection).toMatchObject({ + pageInfo: { + totalEdges: 12, + hasNextPage: true, + hasPreviousPage: false, + startCursor: new Cursor({ name: 'A' }).encode(), + endCursor: new Cursor({ name: 'E' }).encode(), + }, + edges: [ + { node: { id: 'node1', name: 'A' }, cursor: new Cursor({ name: 'A' }).encode() }, + { node: { id: 'node2', name: 'B' }, cursor: new Cursor({ name: 'B' }).encode() }, + { node: { id: 'node3', name: 'C' }, cursor: new Cursor({ name: 'C' }).encode() }, + { node: { id: 'node4', name: 'D' }, cursor: new Cursor({ name: 'D' }).encode() }, + { node: { id: 'node5', name: 'E' }, cursor: new Cursor({ name: 'E' }).encode() }, + ], + }); + }); }); From 6043176d8512b5038a118c61151834c3a8518c5b Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 11:28:53 +1200 Subject: [PATCH 39/41] Add documentation for customising cursors --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/README.md b/README.md index cf704dfe..12ee89fa 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ export class PersonQueryResolver { } ``` +## Advanced Topics + ### Enriching Edges with additional metadata The previous examples are sufficient for resolving connections that represent simple lists of objects with pagination. @@ -294,6 +296,97 @@ return new PersonFriendConnection({ }); ``` +### 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: + +```ts +@ArgsType() +export class PersonConnectionArgs extends ConnectionArgs { + // In reality you might want an enum here, but we'll use a string for simplicity + @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: + +```ts +export class PersonConnectionBuilder extends ConnectionBuilder { + // ... (methods createConnection and createEdge remain unchanged) + + 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: + +```ts +import { Args, ResolveField, Resolver, Root } from '@nestjs/graphql'; + +@Resolver() +export class PersonQueryResolver { + @Query(returns => PersonConnection) + public async persons(@Args() connectionArgs: PersonConnectionArgs): Promise { + const { sortOption } = connectionArgs; + + // Create builder instance + const connectionBuilder = new PersonConnectionBuilder(connectionArgs); + + // EXAMPLE: Do whatever you need to do to fetch the current page of persons using the specified sort order + const persons = await fetchPersons({ + where: { personId }, + order: sortOption === 'name' ? { name: 'ASC' } : { createdAt: 'ASC' }, + take: connectionBuilder.edgesPerPage, // how many rows to fetch + }); + + // Return resolved PersonConnection with edges and pageInfo + return connectionBuilder.build({ + totalEdges: await countPersons(), + nodes: persons, + createCursor(node) { + return new Cursor(sortOption === 'name' ? { name: node.name } : { createdAt: node.createdAt.toISOString() }) + } + }); + } +} +``` + ## License [MIT](LICENSE) From 474ee10d465b455d27dde39afff15ae668f54f97 Mon Sep 17 00:00:00 2001 From: equabot Date: Sun, 12 Jun 2022 23:30:16 +0000 Subject: [PATCH 40/41] Fix code style issues with Prettier --- README.md | 82 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 12ee89fa..205fad2d 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,7 @@ import { createEdgeType } from 'nestjs-graphql-connection'; import { Person } from './entities'; @ObjectType() -export class PersonEdge extends createEdgeType(Person) { -} +export class PersonEdge extends createEdgeType(Person) {} ``` ### Create a Connection type @@ -36,8 +35,7 @@ import { ObjectType } from '@nestjs/graphql'; import { createConnectionType } from 'nestjs-graphql-connection'; @ObjectType() -export class PersonConnection extends createConnectionType(PersonEdge) { -} +export class PersonConnection extends createConnectionType(PersonEdge) {} ``` ### Create a Connection Arguments type @@ -72,7 +70,13 @@ import { ConnectionBuilder, Cursor, PageInfo, validateParamsUsingSchema } from ' export type PersonCursorParams = { id: string }; export type PersonCursor = Cursor; -export class PersonConnectionBuilder extends ConnectionBuilder { +export class PersonConnectionBuilder extends ConnectionBuilder< + PersonConnection, + PersonConnectionArgs, + PersonEdge, + Person, + PersonCursor +> { public createConnection(fields: { edges: PersonEdge[]; pageInfo: PageInfo }): PersonConnection { return new PersonConnection(fields); } @@ -147,13 +151,14 @@ determining what the last result was on page 9. To use offset cursors, extend your builder class from `OffsetPaginatedConnectionBuilder` instead of `ConnectionBuilder`: ```ts -import { - OffsetPaginatedConnectionBuilder, - PageInfo, - validateParamsUsingSchema -} from 'nestjs-graphql-connection'; - -export class PersonConnectionBuilder extends OffsetPaginatedConnectionBuilder { +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); } @@ -190,7 +195,7 @@ export class PersonQueryResolver { const persons = await fetchPersons({ where: { personId }, take: connectionBuilder.edgesPerPage, // how many rows to fetch - skip: connectionBuilder.startOffset, // row offset to start at + skip: connectionBuilder.startOffset, // row offset to start at }); // Return resolved PersonConnection with edges and pageInfo @@ -267,7 +272,7 @@ export class PersonResolver { const friend = friends.find(friend => friend.otherPerson.id === node.id); return new PersonFriendEdge({ node, cursor, createdAt: friend.createdAt }); - } + }, }); } } @@ -278,11 +283,14 @@ with something like the following: ```ts // Resolve edges with cursor, node, and additional metadata -const edges = friends.map((friend, index) => new PersonFriendEdge({ - cursor: connectionBuilder.createCursor(friend.otherPerson, index), - node: friend.otherPerson, - createdAt: friend.createdAt, -})); +const edges = friends.map( + (friend, index) => + new PersonFriendEdge({ + cursor: connectionBuilder.createCursor(friend.otherPerson, index), + node: friend.otherPerson, + createdAt: friend.createdAt, + }), +); // Return resolved PersonFriendConnection return new PersonFriendConnection({ @@ -322,7 +330,13 @@ You can customise your cursor based on the `sortOption` from the `ConnectionArgs `createCursor` and `decodeCursor` in your builder class like the following example: ```ts -export class PersonConnectionBuilder extends ConnectionBuilder { +export class PersonConnectionBuilder extends ConnectionBuilder< + PersonConnection, + PersonConnectionArgs, + PersonEdge, + Person, + PersonCursor +> { // ... (methods createConnection and createEdge remain unchanged) public createCursor(node: Person): PersonCursor { @@ -335,19 +349,23 @@ export class PersonConnectionBuilder extends ConnectionBuilder validateParamsUsingSchema( - params, - Joi.object({ - name: Joi.string().empty('').required(), - }).unknown(false)) + 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)) + return Cursor.fromString(encodedString, params => + validateParamsUsingSchema( + params, + Joi.object({ + id: Joi.string().empty('').required(), + }).unknown(false), + ), ); } } @@ -380,8 +398,8 @@ export class PersonQueryResolver { totalEdges: await countPersons(), nodes: persons, createCursor(node) { - return new Cursor(sortOption === 'name' ? { name: node.name } : { createdAt: node.createdAt.toISOString() }) - } + return new Cursor(sortOption === 'name' ? { name: node.name } : { createdAt: node.createdAt.toISOString() }); + }, }); } } From f795bec538c090ef333025917ada3c5b7f327313 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Mon, 13 Jun 2022 11:40:28 +1200 Subject: [PATCH 41/41] Tweak headings in README.md --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 205fad2d..9236fa2e 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ export class PersonConnectionArgs extends ConnectionArgs { } ``` -### Create a Connection Builder and resolve a Connection +### 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. @@ -104,6 +104,8 @@ export class PersonConnectionBuilder extends ConnectionBuilder< } ``` +### 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. @@ -137,7 +139,7 @@ export class PersonQueryResolver { } ``` -### Using Offset Pagination +## Using Offset Pagination 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