diff --git a/packages/core/package.json b/packages/core/package.json index 9965b2806..a7582276d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,7 +35,8 @@ }, "peerDependencies": { "@nestjs/common": "^7.0.0", - "class-transformer": "^0.2.3 || ^0.3.0" + "class-transformer": "^0.2.3 || ^0.3.0", + "reflect-metadata": "^0.1.13" }, "devDependencies": { "@nestjs/common": "7.4.4", diff --git a/packages/core/src/common/reflect.utils.ts b/packages/core/src/common/reflect.utils.ts index 20d3a5d83..822a87dcf 100644 --- a/packages/core/src/common/reflect.utils.ts +++ b/packages/core/src/common/reflect.utils.ts @@ -1,3 +1,4 @@ +import 'reflect-metadata'; import { Class } from './class.type'; export type MetaValue = MetaType | undefined; @@ -25,14 +26,17 @@ abstract class Reflector { constructor(readonly metaKey: string) {} // eslint-disable-next-line @typescript-eslint/ban-types - protected getMetadata(target: Function): MetaValue { + protected getMetadata(target: Function, includeParents: boolean): MetaValue { + if (includeParents) { + return Reflect.getMetadata(this.metaKey, target) as MetaValue; + } return Reflect.getOwnMetadata(this.metaKey, target) as MetaValue; } // eslint-disable-next-line @typescript-eslint/ban-types - protected defineMetadata = (data: Data, target: Function): void => { + protected defineMetadata(data: Data, target: Function): void { Reflect.defineMetadata(this.metaKey, data, target); - }; + } } export class ValueReflector extends Reflector { @@ -40,8 +44,8 @@ export class ValueReflector extends Reflector { this.defineMetadata(data, DTOClass); } - get(DTOClass: Class): MetaValue { - return this.getMetadata(DTOClass); + get(DTOClass: Class, includeParents = false): MetaValue { + return this.getMetadata(DTOClass, includeParents); } isDefined(DTOClass: Class): boolean { @@ -66,8 +70,8 @@ export class ArrayReflector extends Reflector { this.defineMetadata(metadata, DTOClass); } - get(DTOClass: Class): MetaValue { - return this.getMetadata(DTOClass); + get(DTOClass: Class, includeParents = false): MetaValue { + return this.getMetadata(DTOClass, includeParents); } } @@ -78,12 +82,12 @@ export class MapReflector extends Reflector { this.defineMetadata(metadata, DTOClass); } - get(DTOClass: Class, key: K): MetaValue { - return this.getMetadata>(DTOClass)?.get(key); + get(DTOClass: Class, key: K, includeParents = false): MetaValue { + return this.getMetadata>(DTOClass, includeParents)?.get(key); } has(DTOClass: Class, key: K): boolean { - return this.getMetadata>(DTOClass)?.has(key) ?? false; + return this.getMetadata>(DTOClass, false)?.has(key) ?? false; } memoize(DTOClass: Class, key: K, fn: () => Data): Data { diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index 516ce82f6..24294ae1b 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -10,5 +10,6 @@ export { applySort, applyPaging, applyQuery, + invertSort, } from './query.helpers'; export { transformAggregateQuery, transformAggregateResponse } from './aggregate.helpers'; diff --git a/packages/core/src/helpers/query.helpers.ts b/packages/core/src/helpers/query.helpers.ts index ff9343a68..cfc3280be 100644 --- a/packages/core/src/helpers/query.helpers.ts +++ b/packages/core/src/helpers/query.helpers.ts @@ -1,5 +1,5 @@ import merge from 'lodash.merge'; -import { Filter, Paging, Query, SortField } from '../interfaces'; +import { Filter, Paging, Query, SortDirection, SortField, SortNulls } from '../interfaces'; import { FilterBuilder } from './filter.builder'; import { SortBuilder } from './sort.builder'; import { PageBuilder } from './page.builder'; @@ -53,7 +53,13 @@ export const transformQuery = (query: Query, fieldMap: QueryFiel }; export const mergeFilter = (base: Filter, source: Filter): Filter => { - return merge(base, source); + if (!Object.keys(base).length) { + return source; + } + if (!Object.keys(source).length) { + return base; + } + return { and: [source, base] } as Filter; }; export const mergeQuery = (base: Query, source: Query): Query => { @@ -100,3 +106,16 @@ export const applyQuery = (dtos: DTO[], query: Query): DTO[] => { const sorted = applySort(filtered, query.sorting ?? []); return applyPaging(sorted, query.paging ?? {}); }; + +export function invertSort(sortFields: SortField[]): SortField[] { + return sortFields.map((sf) => { + const direction = sf.direction === SortDirection.ASC ? SortDirection.DESC : SortDirection.ASC; + let nulls; + if (sf.nulls === SortNulls.NULLS_LAST) { + nulls = SortNulls.NULLS_FIRST; + } else if (sf.nulls === SortNulls.NULLS_FIRST) { + nulls = SortNulls.NULLS_LAST; + } + return { ...sf, direction, nulls }; + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 377fa92cc..4af30fbad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export { applyQuery, mergeQuery, mergeFilter, + invertSort, } from './helpers'; export { ClassTransformerAssembler, diff --git a/packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql b/packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql index eec5def2d..76407872b 100644 --- a/packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql +++ b/packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql @@ -1,5 +1,7 @@ type Test { stringField: String! + numberField: Float! + boolField: Boolean! } type TestTotalCount { diff --git a/packages/query-graphql/__tests__/__fixtures__/connection-object-type.graphql b/packages/query-graphql/__tests__/__fixtures__/connection-object-type.graphql index 840c00d81..d41965cbe 100644 --- a/packages/query-graphql/__tests__/__fixtures__/connection-object-type.graphql +++ b/packages/query-graphql/__tests__/__fixtures__/connection-object-type.graphql @@ -1,5 +1,7 @@ type Test { stringField: String! + numberField: Float! + boolField: Boolean! } type TestEdge { diff --git a/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts index 289878600..6ef240599 100644 --- a/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/delete.resolver.spec.ts @@ -160,7 +160,7 @@ describe('DeleteResolver', () => { const context = {}; const authorizeFilter: Filter = { stringField: { eq: 'foo' } }; when(mockAuthorizer.authorize(context)).thenResolve(authorizeFilter); - when(mockService.deleteMany(objectContaining({ ...input.filter, ...authorizeFilter }))).thenResolve(output); + when(mockService.deleteMany(objectContaining({ and: [authorizeFilter, input.filter] }))).thenResolve(output); const result = await resolver.deleteMany({ input }, context); return expect(result).toEqual(output); }); diff --git a/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts index a67befddc..91d383c89 100644 --- a/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/update.resolver.spec.ts @@ -185,7 +185,10 @@ describe('UpdateResolver', () => { const authorizeFilter: Filter = { stringField: { eq: 'foo' } }; when(mockAuthorizer.authorize(context)).thenResolve(authorizeFilter); when( - mockService.updateMany(objectContaining(input.input.update), objectContaining(input.input.filter)), + mockService.updateMany( + objectContaining(input.input.update), + objectContaining({ and: [authorizeFilter, input.input.filter] }), + ), ).thenResolve(output); const result = await resolver.updateMany(input, context); return expect(result).toEqual(output); diff --git a/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts b/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts index e30fc69fa..0cb3bcd10 100644 --- a/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts +++ b/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts @@ -1,13 +1,22 @@ +// eslint-disable-next-line max-classes-per-file import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; import { plainToClass } from 'class-transformer'; -import { ConnectionType, CursorPagingType } from '../../../src'; +import { SortDirection } from '@nestjs-query/core'; +import { ConnectionType, CursorPagingType, StaticConnectionType } from '../../../src'; import { connectionObjectTypeSDL, connectionObjectTypeWithTotalCountSDL, expectSDL } from '../../__fixtures__'; +import { KeySet } from '../../../src/decorators'; describe('ConnectionType', (): void => { @ObjectType('Test') class TestDto { @Field() stringField!: string; + + @Field() + numberField!: number; + + @Field() + boolField!: boolean; } @ObjectType('TestTotalCount') @@ -16,13 +25,16 @@ describe('ConnectionType', (): void => { stringField!: string; } - const TestConnection = ConnectionType(TestDto); - const createPage = (paging: CursorPagingType): CursorPagingType => { return plainToClass(CursorPagingType(), paging); }; + const createTestDTO = (index: number): TestDto => { + return { stringField: `foo${index}`, numberField: index, boolField: index % 2 === 0 }; + }; + it('should create the connection SDL', async () => { + const TestConnection = ConnectionType(TestDto); @Resolver() class TestConnectionTypeResolver { @Query(() => TestConnection) @@ -58,184 +70,611 @@ describe('ConnectionType', (): void => { ); }); - it('should create an empty connection when created with new', () => { - expect(new TestConnection()).toEqual({ - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - edges: [], - totalCountFn: expect.any(Function), - }); - }); + describe('limit offset offset connection', () => { + const TestConnection = ConnectionType(TestDto); - describe('.createFromPromise', () => { - it('should create a connections response with an empty query', async () => { - const queryMany = jest.fn(); - const response = await TestConnection.createFromPromise(queryMany, {}); - expect(queryMany).toHaveBeenCalledTimes(0); - expect(response).toEqual({ + it('should create an empty connection when created with new', () => { + expect(new TestConnection()).toEqual({ + pageInfo: { hasNextPage: false, hasPreviousPage: false }, edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, totalCountFn: expect.any(Function), }); }); - it('should create a connections response with an empty paging', async () => { - const queryMany = jest.fn(); - const response = await TestConnection.createFromPromise(queryMany, { paging: {} }); - expect(queryMany).toHaveBeenCalledTimes(0); - expect(response).toEqual({ - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - totalCountFn: expect.any(Function), + describe('.createFromPromise', () => { + it('should create a connections response with an empty query', async () => { + const queryMany = jest.fn(); + const response = await TestConnection.createFromPromise(queryMany, {}); + expect(queryMany).toHaveBeenCalledTimes(0); + expect(response).toEqual({ + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + totalCountFn: expect.any(Function), + }); }); - }); - describe('with first', () => { - it('should return hasNextPage and hasPreviousPage false when there are the exact number of records', async () => { + it('should create a connections response with an empty paging', async () => { const queryMany = jest.fn(); - queryMany.mockResolvedValueOnce([{ stringField: 'foo1' }, { stringField: 'foo2' }]); - const response = await TestConnection.createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); - expect(queryMany).toHaveBeenCalledTimes(1); - expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); + const response = await TestConnection.createFromPromise(queryMany, { paging: {} }); + expect(queryMany).toHaveBeenCalledTimes(0); expect(response).toEqual({ - edges: [ - { - cursor: 'YXJyYXljb25uZWN0aW9uOjA=', - node: { - stringField: 'foo1', - }, - }, - { - cursor: 'YXJyYXljb25uZWN0aW9uOjE=', - node: { - stringField: 'foo2', - }, - }, - ], + edges: [], pageInfo: { - endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, - startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, totalCountFn: expect.any(Function), }); }); - it('should return hasNextPage true and hasPreviousPage false when the number of records more than the first', async () => { + describe('with first', () => { + it('should return hasNextPage and hasPreviousPage false when there are the exact number of records', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await TestConnection.createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); + expect(response).toEqual({ + edges: [ + { cursor: 'YXJyYXljb25uZWN0aW9uOjA=', node: dtos[0] }, + { cursor: 'YXJyYXljb25uZWN0aW9uOjE=', node: dtos[1] }, + ], + pageInfo: { + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + totalCountFn: expect.any(Function), + }); + }); + + it('should return hasNextPage true and hasPreviousPage false when the number of records more than the first', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await TestConnection.createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); + expect(response).toEqual({ + edges: [ + { cursor: 'YXJyYXljb25uZWN0aW9uOjA=', node: dtos[0] }, + { cursor: 'YXJyYXljb25uZWN0aW9uOjE=', node: dtos[1] }, + ], + pageInfo: { + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + totalCountFn: expect.any(Function), + }); + }); + }); + + describe('with last', () => { + it("should return hasPreviousPage false if paging backwards and we're on the first page", async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await TestConnection.createFromPromise(queryMany, { + paging: createPage({ last: 2, before: 'YXJyYXljb25uZWN0aW9uOjE=' }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 1, offset: 0 } }); + expect(response).toEqual({ + edges: [{ cursor: 'YXJyYXljb25uZWN0aW9uOjA=', node: dtos[0] }], + pageInfo: { + endCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }, + totalCountFn: expect.any(Function), + }); + }); + + it('should return hasPreviousPage true if paging backwards and there is an additional node', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await TestConnection.createFromPromise(queryMany, { + paging: createPage({ last: 2, before: 'YXJyYXljb25uZWN0aW9uOjM=' }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); + expect(response).toEqual({ + edges: [ + { cursor: 'YXJyYXljb25uZWN0aW9uOjE=', node: dtos[1] }, + { cursor: 'YXJyYXljb25uZWN0aW9uOjI=', node: dtos[2] }, + ], + pageInfo: { + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + }, + totalCountFn: expect.any(Function), + }); + }); + }); + + it('should create an empty connection', async () => { const queryMany = jest.fn(); - queryMany.mockResolvedValueOnce([{ stringField: 'foo1' }, { stringField: 'foo2' }, { stringField: 'foo3' }]); - const response = await TestConnection.createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); + queryMany.mockResolvedValueOnce([]); + const response = await TestConnection.createFromPromise(queryMany, { + paging: createPage({ first: 2 }), + }); expect(queryMany).toHaveBeenCalledTimes(1); expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); expect(response).toEqual({ - edges: [ - { - cursor: 'YXJyYXljb25uZWN0aW9uOjA=', - node: { - stringField: 'foo1', - }, - }, - { - cursor: 'YXJyYXljb25uZWN0aW9uOjE=', - node: { - stringField: 'foo2', - }, - }, - ], + edges: [], pageInfo: { - endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', - hasNextPage: true, + hasNextPage: false, hasPreviousPage: false, - startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, totalCountFn: expect.any(Function), }); }); }); + }); + + describe('keyset connection', () => { + @ObjectType() + @KeySet(['stringField']) + class TestKeySetDTO extends TestDto {} + function getConnectionType(): StaticConnectionType { + return ConnectionType(TestKeySetDTO); + } - describe('with last', () => { - it("should return hasPreviousPage false if paging backwards and we're on the first page", async () => { + it('should create an empty connection when created with new', () => { + const CT = getConnectionType(); + expect(new CT()).toEqual({ + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + edges: [], + totalCountFn: expect.any(Function), + }); + }); + + describe('.createFromPromise', () => { + it('should create a connections response with an empty query', async () => { const queryMany = jest.fn(); - queryMany.mockResolvedValueOnce([{ stringField: 'foo1' }]); - const response = await TestConnection.createFromPromise(queryMany, { - paging: createPage({ last: 2, before: 'YXJyYXljb25uZWN0aW9uOjE=' }), - }); - expect(queryMany).toHaveBeenCalledTimes(1); - expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 1, offset: 0 } }); + const response = await getConnectionType().createFromPromise(queryMany, {}); + expect(queryMany).toHaveBeenCalledTimes(0); expect(response).toEqual({ - edges: [ - { - cursor: 'YXJyYXljb25uZWN0aW9uOjA=', - node: { - stringField: 'foo1', - }, - }, - ], + edges: [], pageInfo: { - endCursor: 'YXJyYXljb25uZWN0aW9uOjA=', - hasNextPage: true, + hasNextPage: false, hasPreviousPage: false, - startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, totalCountFn: expect.any(Function), }); }); - it('should return hasPreviousPage true if paging backwards and there is an additional node', async () => { + it('should create a connections response with an empty paging', async () => { const queryMany = jest.fn(); - queryMany.mockResolvedValueOnce([{ stringField: 'foo1' }, { stringField: 'foo2' }, { stringField: 'foo3' }]); - const response = await TestConnection.createFromPromise(queryMany, { - paging: createPage({ last: 2, before: 'YXJyYXljb25uZWN0aW9uOjM=' }), - }); - expect(queryMany).toHaveBeenCalledTimes(1); - expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); + const response = await getConnectionType().createFromPromise(queryMany, { paging: {} }); + expect(queryMany).toHaveBeenCalledTimes(0); expect(response).toEqual({ - edges: [ - { - cursor: 'YXJyYXljb25uZWN0aW9uOjE=', - node: { - stringField: 'foo2', + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + totalCountFn: expect.any(Function), + }); + }); + + describe('with first', () => { + it('should return hasNextPage and hasPreviousPage false when there are the exact number of records', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: {}, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.ASC }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + node: dtos[0], }, + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[1], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + hasNextPage: false, + hasPreviousPage: false, }, - { - cursor: 'YXJyYXljb25uZWN0aW9uOjI=', - node: { - stringField: 'foo3', + totalCountFn: expect.any(Function), + }); + }); + + it('should return hasNextPage true and hasPreviousPage false when the number of records more than the first', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { paging: createPage({ first: 2 }) }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: {}, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.ASC }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + node: dtos[0], + }, + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[1], }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + hasNextPage: true, + hasPreviousPage: false, }, - ], - pageInfo: { - endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', - hasNextPage: true, - hasPreviousPage: true, - startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', - }, - totalCountFn: expect.any(Function), + totalCountFn: expect.any(Function), + }); + }); + + it('should fetch nodes after the cursor', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(2), createTestDTO(3), createTestDTO(4)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { + paging: createPage({ + first: 2, + after: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { or: [{ and: [{ stringField: { gt: 'foo1' } }] }] }, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.ASC }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[0], + }, + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[1], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); + + describe('with additional filter', () => { + it('should merge the cursor filter and query filter', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(2), createTestDTO(3), createTestDTO(4)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { + filter: { boolField: { is: true } }, + paging: createPage({ + first: 2, + after: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { and: [{ or: [{ and: [{ stringField: { gt: 'foo1' } }] }] }, { boolField: { is: true } }] }, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.ASC }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[0], + }, + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[1], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + endCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); + }); + + describe('with additional sorting', () => { + it('should merge the cursor filter and query filter', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(2), createTestDTO(3), createTestDTO(4)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { + filter: { boolField: { is: true } }, + sorting: [{ field: 'boolField', direction: SortDirection.DESC }], + paging: createPage({ + first: 2, + after: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjpmYWxzZX0seyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { + and: [ + { + or: [ + { and: [{ boolField: { lt: false } }] }, + { and: [{ boolField: { eq: false } }, { stringField: { gt: 'foo1' } }] }, + ], + }, + { boolField: { is: true } }, + ], + }, + paging: { limit: 3 }, + sorting: [ + { field: 'boolField', direction: SortDirection.DESC }, + { field: 'stringField', direction: SortDirection.ASC }, + ], + }); + expect(response).toEqual({ + edges: [ + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjp0cnVlfSx7ImZpZWxkIjoic3RyaW5nRmllbGQiLCJ2YWx1ZSI6ImZvbzIifV19', + node: dtos[0], + }, + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjpmYWxzZX0seyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[1], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjp0cnVlfSx7ImZpZWxkIjoic3RyaW5nRmllbGQiLCJ2YWx1ZSI6ImZvbzIifV19', + endCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjpmYWxzZX0seyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); }); }); - }); - it('should create an empty connection', async () => { - const queryMany = jest.fn(); - queryMany.mockResolvedValueOnce([]); - const response = await TestConnection.createFromPromise(queryMany, { - paging: createPage({ first: 2 }), + describe('with last', () => { + it("should return hasPreviousPage false if paging backwards and we're on the first page", async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1)]; + queryMany.mockResolvedValueOnce([...dtos]); + const response = await getConnectionType().createFromPromise(queryMany, { + paging: createPage({ + last: 2, + before: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { or: [{ and: [{ stringField: { lt: 'foo2' } }] }] }, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.DESC, nulls: undefined }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + node: dtos[0], + }, + ], + pageInfo: { + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + hasNextPage: true, + hasPreviousPage: false, + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28xIn1dfQ==', + }, + totalCountFn: expect.any(Function), + }); + }); + + it('should return hasPreviousPage true if paging backwards and there is an additional node', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos].reverse()); + const response = await getConnectionType().createFromPromise(queryMany, { + paging: createPage({ + last: 2, + before: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb280In1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { or: [{ and: [{ stringField: { lt: 'foo4' } }] }] }, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.DESC, nulls: undefined }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[1], + }, + { + cursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[2], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + endCursor: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); + + describe('with additional filter', () => { + it('should merge the cursor filter and query filter', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos].reverse()); + const response = await getConnectionType().createFromPromise(queryMany, { + filter: { boolField: { is: true } }, + paging: createPage({ + last: 2, + before: 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb280In1dfQ==', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { and: [{ or: [{ and: [{ stringField: { lt: 'foo4' } }] }] }, { boolField: { is: true } }] }, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.DESC }], + }); + expect(response).toEqual({ + edges: [ + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + node: dtos[1], + }, + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[2], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28yIn1dfQ==', + endCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); + }); + + describe('with additional sort', () => { + it('should merge the cursor sort', async () => { + const queryMany = jest.fn(); + const dtos = [createTestDTO(1), createTestDTO(2), createTestDTO(3)]; + queryMany.mockResolvedValueOnce([...dtos].reverse()); + const response = await getConnectionType().createFromPromise(queryMany, { + filter: { boolField: { is: true } }, + sorting: [{ field: 'boolField', direction: SortDirection.DESC }], + paging: createPage({ + last: 2, + before: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjp0cnVlfSx7ImZpZWxkIjoic3RyaW5nRmllbGQiLCJ2YWx1ZSI6ImZvbzQifV19', + }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: { + and: [ + { + or: [ + { and: [{ boolField: { gt: true } }] }, + { and: [{ boolField: { eq: true } }, { stringField: { lt: 'foo4' } }] }, + ], + }, + { boolField: { is: true } }, + ], + }, + paging: { limit: 3 }, + sorting: [ + { field: 'boolField', direction: SortDirection.ASC }, + { field: 'stringField', direction: SortDirection.DESC }, + ], + }); + expect(response).toEqual({ + edges: [ + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjp0cnVlfSx7ImZpZWxkIjoic3RyaW5nRmllbGQiLCJ2YWx1ZSI6ImZvbzIifV19', + node: dtos[1], + }, + { + cursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjpmYWxzZX0seyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + node: dtos[2], + }, + ], + pageInfo: { + startCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjp0cnVlfSx7ImZpZWxkIjoic3RyaW5nRmllbGQiLCJ2YWx1ZSI6ImZvbzIifV19', + endCursor: + 'eyJ0eXBlIjoia2V5c2V0IiwiZmllbGRzIjpbeyJmaWVsZCI6ImJvb2xGaWVsZCIsInZhbHVlIjpmYWxzZX0seyJmaWVsZCI6InN0cmluZ0ZpZWxkIiwidmFsdWUiOiJmb28zIn1dfQ==', + hasNextPage: true, + hasPreviousPage: true, + }, + totalCountFn: expect.any(Function), + }); + }); + }); }); - expect(queryMany).toHaveBeenCalledTimes(1); - expect(queryMany).toHaveBeenCalledWith({ paging: { limit: 3, offset: 0 } }); - expect(response).toEqual({ - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - totalCountFn: expect.any(Function), + + it('should create an empty connection', async () => { + const queryMany = jest.fn(); + queryMany.mockResolvedValueOnce([]); + const response = await getConnectionType().createFromPromise(queryMany, { + paging: createPage({ first: 2 }), + }); + expect(queryMany).toHaveBeenCalledTimes(1); + expect(queryMany).toHaveBeenCalledWith({ + filter: {}, + paging: { limit: 3 }, + sorting: [{ field: 'stringField', direction: SortDirection.ASC, nulls: undefined }], + }); + expect(response).toEqual({ + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + totalCountFn: expect.any(Function), + }); }); }); }); diff --git a/packages/query-graphql/__tests__/types/query/paging.type.spec.ts b/packages/query-graphql/__tests__/types/query/paging.type.spec.ts index 416bb1b84..7671466b9 100644 --- a/packages/query-graphql/__tests__/types/query/paging.type.spec.ts +++ b/packages/query-graphql/__tests__/types/query/paging.type.spec.ts @@ -5,19 +5,6 @@ import { CursorPagingType } from '../../../src'; import { expectSDL, pagingInputTypeSDL } from '../../__fixtures__'; describe('PagingType', (): void => { - const createPagingAndValidate = (obj: CursorPagingType) => { - const paging = plainToClass(CursorPagingType(), obj); - const validateErrors = validateSync(paging); - return { paging, validateErrors }; - }; - - const assertLimitAndOffset = (obj: CursorPagingType, limit: number, offset: number) => { - const { paging, validateErrors } = createPagingAndValidate(obj); - expect(validateErrors).toHaveLength(0); - expect(paging.limit).toEqual(limit); - expect(paging.offset).toEqual(offset); - }; - it('should create the correct filter graphql schema', () => { @InputType() class Paging extends CursorPagingType() {} @@ -141,93 +128,4 @@ describe('PagingType', (): void => { }, ]); }); - - it('handle a negative before offset', () => { - const paging = plainToClass(CursorPagingType(), { - last: 10, - before: 'YXJyYXljb25uZWN0aW9uOi0x', - }); - const validateErrors = validateSync(paging); - expect(validateErrors).toHaveLength(0); - expect(paging.limit).toEqual(0); - expect(paging.offset).toEqual(0); - }); - - it('handle a negative after offset', () => { - const paging = plainToClass(CursorPagingType(), { - first: 10, - after: 'YXJyYXljb25uZWN0aW9uOi0xMA==', - }); - const validateErrors = validateSync(paging); - expect(validateErrors).toHaveLength(0); - expect(paging.limit).toEqual(10); - expect(paging.offset).toEqual(0); - }); - - it('handle a missing first', () => { - const paging = plainToClass(CursorPagingType(), { - after: 'YXJyYXljb25uZWN0aW9uOi0xMA==', - }); - expect(paging.limit).toEqual(0); - expect(paging.offset).toEqual(0); - }); - - it('handle an empty after cursor', () => { - const paging = plainToClass(CursorPagingType(), { - first: 10, - after: '', - }); - const validateErrors = validateSync(paging); - expect(validateErrors).toHaveLength(0); - expect(paging.limit).toEqual(10); - expect(paging.offset).toEqual(0); - }); - - it('handle an missing after', () => { - const paging = plainToClass(CursorPagingType(), { - first: 10, - }); - const validateErrors = validateSync(paging); - expect(validateErrors).toHaveLength(0); - expect(paging.limit).toEqual(10); - expect(paging.offset).toEqual(0); - }); - - it('handle an empty last limit', () => { - const paging = plainToClass(CursorPagingType(), { - before: 'YXJyYXljb25uZWN0aW9uOjEx', - }); - expect(paging.limit).toEqual(0); - expect(paging.offset).toEqual(0); - }); - - it('should return an undefined limit when the paging object is empty', () => { - const paging = new (CursorPagingType())(); - expect(paging.limit).toBeUndefined(); - }); - - it('should return an undefined offset when the paging object is empty', () => { - const paging = new (CursorPagingType())(); - expect(paging.offset).toBeUndefined(); - }); - - it('convert the cursor paging to a limit and offset going forward', () => - assertLimitAndOffset( - { - first: 10, - after: 'YXJyYXljb25uZWN0aW9uOjEx', - }, - 10, - 12, - )); - - it('convert the cursor paging to a limit and offset going backward', () => - assertLimitAndOffset( - { - last: 10, - before: 'YXJyYXljb25uZWN0aW9uOjEx', - }, - 10, - 1, - )); }); diff --git a/packages/query-graphql/src/decorators/constants.ts b/packages/query-graphql/src/decorators/constants.ts index ad465c38c..3cf204f01 100644 --- a/packages/query-graphql/src/decorators/constants.ts +++ b/packages/query-graphql/src/decorators/constants.ts @@ -14,6 +14,6 @@ export const FILTERABLE_FIELD_KEY = 'nestjs-query:filterable-field'; export const RELATION_KEY = 'nestjs-query:relation'; export const REFERENCE_KEY = 'nestjs-query:reference'; -export const AUTH_KEY = 'nestjs-query:auth'; - export const AUTHORIZER_KEY = 'nestjs-query:authorizer'; + +export const KEY_SET_KEY = 'nestjs-query:key-set'; diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 1e9e0aef9..039621799 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -28,3 +28,4 @@ export * from './decorator.utils'; export * from './hook-args.decorator'; export * from './authorizer.decorator'; export * from './inject-authorizer.decorator'; +export * from './key-set.decorator'; diff --git a/packages/query-graphql/src/decorators/key-set.decorator.ts b/packages/query-graphql/src/decorators/key-set.decorator.ts new file mode 100644 index 000000000..3e73e8511 --- /dev/null +++ b/packages/query-graphql/src/decorators/key-set.decorator.ts @@ -0,0 +1,13 @@ +import { Class, MetaValue, ValueReflector } from '@nestjs-query/core'; +import { KEY_SET_KEY } from './constants'; + +const reflector = new ValueReflector(KEY_SET_KEY); +export function KeySet(keys: (keyof DTO)[]) { + return (DTOClass: Class): void => { + return reflector.set(DTOClass, keys); + }; +} + +export const getKeySet = (DTOClass: Class): MetaValue<(keyof DTO)[]> => { + return reflector.get(DTOClass, true); +}; diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index 224d389eb..c3ad1d046 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -31,6 +31,7 @@ export { BeforeFindOneHook, InjectAuthorizer, Authorize, + KeySet, } from './decorators'; export * from './resolvers'; export * from './federation'; diff --git a/packages/query-graphql/src/providers/resolver.provider.ts b/packages/query-graphql/src/providers/resolver.provider.ts index d9e7990e8..a6d65f243 100644 --- a/packages/query-graphql/src/providers/resolver.provider.ts +++ b/packages/query-graphql/src/providers/resolver.provider.ts @@ -55,11 +55,11 @@ export type FederatedAutoResolverOpts = { Service: Class; }; -export type AutoResolverOpts = - | EntityCRUDAutoResolverOpts - | AssemblerCRUDAutoResolverOpts - | ServiceCRUDAutoResolverOpts - | FederatedAutoResolverOpts; +export type AutoResolverOpts = + | EntityCRUDAutoResolverOpts + | AssemblerCRUDAutoResolverOpts + | ServiceCRUDAutoResolverOpts + | FederatedAutoResolverOpts; export const isFederatedResolverOpts = ( opts: AutoResolverOpts, diff --git a/packages/query-graphql/src/resolvers/crud.resolver.ts b/packages/query-graphql/src/resolvers/crud.resolver.ts index 99927aa97..33ea2707d 100644 --- a/packages/query-graphql/src/resolvers/crud.resolver.ts +++ b/packages/query-graphql/src/resolvers/crud.resolver.ts @@ -11,6 +11,7 @@ import { DeleteResolver, DeleteResolverOpts } from './delete.resolver'; import { BaseResolverOptions } from '../decorators/resolver-method.decorator'; import { mergeBaseResolverOpts } from '../common'; import { RelatableOpts } from './relations/relations.resolver'; +import { CursorConnectionOptions } from '../types/connection/cursor'; export interface CRUDResolverOpts< DTO, @@ -18,7 +19,8 @@ export interface CRUDResolverOpts< U extends DeepPartial = DeepPartial, R extends ReadResolverOpts = ReadResolverOpts, PS extends PagingStrategies = PagingStrategies.CURSOR -> extends BaseResolverOptions { +> extends BaseResolverOptions, + Pick { /** * The DTO that should be used as input for create endpoints. */ @@ -29,7 +31,6 @@ export interface CRUDResolverOpts< UpdateDTOClass?: Class; enableSubscriptions?: boolean; pagingStrategy?: PS; - enableTotalCount?: boolean; enableAggregate?: boolean; create?: CreateResolverOpts; read?: R; diff --git a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts index 64f893946..a89532694 100644 --- a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts @@ -69,7 +69,8 @@ const ReadManyRelationMixin = (DTOClass: Class, relation: Re @ArgsType() class RelationQA extends QueryArgsType(relationDTO, relation) {} - const CT = ConnectionType(relationDTO, RelationQA, { ...relation, connectionName }); + // disable keyset pagination for relations otherwise recursive paging will not work + const CT = ConnectionType(relationDTO, RelationQA, { ...relation, connectionName, disableKeySetPagination: true }); @Resolver(() => DTOClass, { isAbstract: true }) class ReadManyMixin extends Base { @ResolverField( diff --git a/packages/query-graphql/src/types/connection/cursor/cursor-connection.type.ts b/packages/query-graphql/src/types/connection/cursor/cursor-connection.type.ts index 971cf5b80..7ac2b482a 100644 --- a/packages/query-graphql/src/types/connection/cursor/cursor-connection.type.ts +++ b/packages/query-graphql/src/types/connection/cursor/cursor-connection.type.ts @@ -12,6 +12,7 @@ import { getGraphqlObjectName } from '../../../common'; export type CursorConnectionOptions = { enableTotalCount?: boolean; connectionName?: string; + disableKeySetPagination?: boolean; }; export type StaticCursorConnectionType = StaticConnection< @@ -45,7 +46,7 @@ export function CursorConnectionType( ): StaticCursorConnectionType { const connectionName = getOrCreateConnectionName(TItemClass, opts); return reflector.memoize(TItemClass, connectionName, () => { - const pager = createPager(); + const pager = createPager(TItemClass, opts); const E = EdgeType(TItemClass); const PIT = PageInfoType(); @ObjectType(connectionName) diff --git a/packages/query-graphql/src/types/connection/cursor/pager/index.ts b/packages/query-graphql/src/types/connection/cursor/pager/index.ts index 1cf58674a..208fa6220 100644 --- a/packages/query-graphql/src/types/connection/cursor/pager/index.ts +++ b/packages/query-graphql/src/types/connection/cursor/pager/index.ts @@ -1,7 +1,20 @@ +import { Class } from '@nestjs-query/core'; +import { getKeySet } from '../../../../decorators'; import { Pager } from './interfaces'; -import { CursorPager } from './limit-offset.pager'; +import { CursorPager } from './pager'; +import { KeysetPagerStrategy, LimitOffsetPagerStrategy } from './strategies'; export { Pager, PagerResult, CountFn } from './interfaces'; +export type PagerOpts = { + disableKeySetPagination?: boolean; +}; + // default pager factory to plug in addition paging strategies later on. -export const createPager = (): Pager => new CursorPager(); +export const createPager = (DTOClass: Class, opts: PagerOpts): Pager => { + const keySet = opts.disableKeySetPagination ? undefined : getKeySet(DTOClass); + if (keySet && keySet.length) { + return new CursorPager(new KeysetPagerStrategy(DTOClass, keySet)); + } + return new CursorPager(new LimitOffsetPagerStrategy()); +}; diff --git a/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts b/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts index 0d132dbfb..3c5642431 100644 --- a/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts +++ b/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts @@ -1,13 +1,12 @@ +import { Query } from '@nestjs-query/core'; +import { PagingOpts } from './strategies'; import { CursorQueryArgsType } from '../../../query'; import { Count, QueryMany } from '../../interfaces'; import { CursorConnectionType } from '../cursor-connection.type'; -export interface PagingMeta { - offset: number; - limit: number; - isBackward: boolean; - isForward: boolean; - hasBefore: boolean; +export interface PagingMeta> { + opts: Opts; + query: Query; } export interface QueryResults { diff --git a/packages/query-graphql/src/types/connection/cursor/pager/limit-offset.pager.ts b/packages/query-graphql/src/types/connection/cursor/pager/limit-offset.pager.ts deleted file mode 100644 index 62578558e..000000000 --- a/packages/query-graphql/src/types/connection/cursor/pager/limit-offset.pager.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Query } from '@nestjs-query/core'; -import { offsetToCursor } from 'graphql-relay'; -import { Count, QueryMany } from '../../interfaces'; -import { EdgeType } from '../edge.type'; -import { CursorQueryArgsType } from '../../../query'; -import { PagerResult, PagingMeta, QueryResults } from './interfaces'; - -const EMPTY_PAGING_RESULTS = (): PagerResult => ({ - edges: [], - pageInfo: { hasNextPage: false, hasPreviousPage: false }, - totalCount: () => Promise.resolve(0), -}); - -const DEFAULT_PAGING_META = (): PagingMeta => ({ - offset: 0, - limit: 0, - isBackward: false, - isForward: true, - hasBefore: false, -}); - -export class CursorPager { - async page(queryMany: QueryMany, query: CursorQueryArgsType, count: Count): Promise> { - const pagingMeta = this.getPageMeta(query); - if (!CursorPager.pagingMetaHasLimitOrOffset(pagingMeta)) { - return EMPTY_PAGING_RESULTS(); - } - const results = await this.runQuery(queryMany, query, pagingMeta); - if (this.isEmptyPage(results, pagingMeta)) { - return EMPTY_PAGING_RESULTS(); - } - return this.createPagingResult(results, pagingMeta, () => count(query.filter ?? {})); - } - - private static pagingMetaHasLimitOrOffset(pagingMeta: PagingMeta): boolean { - return pagingMeta.offset > 0 || pagingMeta.limit > 0; - } - - async runQuery(queryMany: QueryMany, query: Query, pagingMeta: PagingMeta): Promise> { - // Add 1 to the limit so we will fetch an additional node - let limit = pagingMeta.limit + 1; - // if paging backwards remove one from the offset to check for a previous page. - let offset = pagingMeta.isBackward ? pagingMeta.offset - 1 : pagingMeta.offset; - if (offset < 0) { - // if the offset is < 0 it means we underflowed and that we cant have an extra page. - offset = 0; - limit = pagingMeta.limit; - } - const nodes = await queryMany({ ...query, paging: { limit, offset } }); - // check if we have an additional node - // if paging forward that indicates we have a next page - // if paging backward that indicates we have a previous page. - const hasExtraNode = nodes.length > pagingMeta.limit; - if (hasExtraNode) { - // remove the additional node so its not returned in the results. - if (pagingMeta.isForward) { - nodes.pop(); - } else { - nodes.shift(); - } - } - return { nodes, hasExtraNode }; - } - - getPageMeta(query: CursorQueryArgsType): PagingMeta { - const { paging } = query; - if (!paging) { - return DEFAULT_PAGING_META(); - } - const offset = paging.offset ?? 0; - const limit = paging.limit ?? 0; - const isBackward = typeof paging.last !== 'undefined'; - const isForward = !isBackward; - const hasBefore = isBackward && !!paging.before; - return { offset, limit, isBackward, isForward, hasBefore }; - } - - createPagingResult( - results: QueryResults, - pagingMeta: PagingMeta, - totalCount: () => Promise, - ): PagerResult { - const { nodes, hasExtraNode } = results; - const { offset, hasBefore, isBackward, isForward } = pagingMeta; - const endOffset = Math.max(0, offset + nodes.length - 1); - const pageInfo = { - startCursor: offsetToCursor(offset), - endCursor: offsetToCursor(endOffset), - // if we have are going forward and have an extra node or there was a before cursor - hasNextPage: isForward ? hasExtraNode : hasBefore, - // we have a previous page if we are going backwards and have an extra node. - hasPreviousPage: isBackward ? hasExtraNode : offset > 0, - }; - - const edges: EdgeType[] = nodes.map((node, i) => ({ node, cursor: offsetToCursor(offset + i) })); - return { edges, pageInfo, totalCount }; - } - - isEmptyPage(results: QueryResults, pagingMeta: PagingMeta): boolean { - // it is an empty page if - // 1. we dont have an extra node - // 2. there were no nodes returned - // 3. we're paging forward - // 4. and we dont have an offset - return !results.hasExtraNode && !results.nodes.length && pagingMeta.isForward && !pagingMeta.offset; - } -} diff --git a/packages/query-graphql/src/types/connection/cursor/pager/pager.ts b/packages/query-graphql/src/types/connection/cursor/pager/pager.ts new file mode 100644 index 000000000..efa96df6f --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/pager.ts @@ -0,0 +1,101 @@ +import { Query } from '@nestjs-query/core'; +import { OffsetPagingOpts, PagerStrategy, PagingOpts } from './strategies'; +import { Count, QueryMany } from '../../interfaces'; +import { EdgeType } from '../edge.type'; +import { CursorQueryArgsType } from '../../../query'; +import { Pager, PagerResult, PagingMeta, QueryResults } from './interfaces'; + +const EMPTY_PAGING_RESULTS = (): PagerResult => ({ + edges: [], + pageInfo: { hasNextPage: false, hasPreviousPage: false }, + totalCount: () => Promise.resolve(0), +}); + +const DEFAULT_PAGING_META = (query: Query): PagingMeta => ({ + opts: { offset: 0, limit: 0, isBackward: false, isForward: true, hasBefore: false }, + query, +}); + +export class CursorPager implements Pager { + constructor(readonly strategy: PagerStrategy) {} + + async page(queryMany: QueryMany, query: CursorQueryArgsType, count: Count): Promise> { + const pagingMeta = this.getPageMeta(query); + if (!this.isValidPaging(pagingMeta)) { + return EMPTY_PAGING_RESULTS(); + } + const results = await this.runQuery(queryMany, query, pagingMeta); + if (this.isEmptyPage(results, pagingMeta)) { + return EMPTY_PAGING_RESULTS(); + } + return this.createPagingResult(results, pagingMeta, () => count(query.filter ?? {})); + } + + private isValidPaging(pagingMeta: PagingMeta>): boolean { + if ('offset' in pagingMeta.opts) { + return pagingMeta.opts.offset > 0 || pagingMeta.opts.limit > 0; + } + return pagingMeta.opts.limit > 0; + } + + async runQuery( + queryMany: QueryMany, + query: Query, + pagingMeta: PagingMeta>, + ): Promise> { + const { opts } = pagingMeta; + const windowedQuery = this.strategy.createQuery(query, opts, true); + const nodes = await queryMany(windowedQuery); + const returnNodes = this.strategy.checkForExtraNode(nodes, opts); + const hasExtraNode = returnNodes.length !== nodes.length; + return { nodes: returnNodes, hasExtraNode }; + } + + getPageMeta(query: CursorQueryArgsType): PagingMeta> { + const { paging } = query; + if (!paging) { + return DEFAULT_PAGING_META(query); + } + return { opts: this.strategy.fromCursorArgs(paging), query }; + } + + createPagingResult( + results: QueryResults, + pagingMeta: PagingMeta>, + totalCount: () => Promise, + ): PagerResult { + const { nodes, hasExtraNode } = results; + const { isForward, hasBefore } = pagingMeta.opts; + const edges: EdgeType[] = nodes.map((node, i) => ({ + node, + cursor: this.strategy.toCursor(node, i, pagingMeta.opts, pagingMeta.query), + })); + const pageInfo = { + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + // if we have are going forward and have an extra node or there was a before cursor + hasNextPage: isForward ? hasExtraNode : hasBefore, + // we have a previous page if we are going backwards and have an extra node. + hasPreviousPage: this.hasPreviousPage(results, pagingMeta), + }; + + return { edges, pageInfo, totalCount }; + } + + private hasPreviousPage(results: QueryResults, pagingMeta: PagingMeta>): boolean { + const { hasExtraNode } = results; + const { opts } = pagingMeta; + return opts.isBackward ? hasExtraNode : !this.strategy.isEmptyCursor(opts); + } + + isEmptyPage(results: QueryResults, pagingMeta: PagingMeta>): boolean { + // it is an empty page if + // 1. we dont have an extra node + // 2. there were no nodes returned + // 3. we're paging forward + // 4. and we dont have an offset + const { opts } = pagingMeta; + const isEmpty = !results.hasExtraNode && !results.nodes.length && pagingMeta.opts.isForward; + return isEmpty && this.strategy.isEmptyCursor(opts); + } +} diff --git a/packages/query-graphql/src/types/connection/cursor/pager/strategies/helpers.ts b/packages/query-graphql/src/types/connection/cursor/pager/strategies/helpers.ts new file mode 100644 index 000000000..10afc4467 --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/strategies/helpers.ts @@ -0,0 +1,13 @@ +import { CursorPagingType } from '../../../../query'; + +export function isBackwardPaging(cursor: CursorPagingType): boolean { + return typeof cursor.last !== 'undefined'; +} + +export function isForwardPaging(cursor: CursorPagingType): boolean { + return !isBackwardPaging(cursor); +} + +export function hasBeforeCursor(cursor: CursorPagingType): boolean { + return isBackwardPaging(cursor) && !!cursor.before; +} diff --git a/packages/query-graphql/src/types/connection/cursor/pager/strategies/index.ts b/packages/query-graphql/src/types/connection/cursor/pager/strategies/index.ts new file mode 100644 index 000000000..f6f41ee7a --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/strategies/index.ts @@ -0,0 +1,4 @@ +export * from './pager-strategy'; +export * from './limit-offset.pager-strategy'; +export * from './keyset.pager-strategy'; +export * from './helpers'; diff --git a/packages/query-graphql/src/types/connection/cursor/pager/strategies/keyset.pager-strategy.ts b/packages/query-graphql/src/types/connection/cursor/pager/strategies/keyset.pager-strategy.ts new file mode 100644 index 000000000..59d853ab4 --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/strategies/keyset.pager-strategy.ts @@ -0,0 +1,141 @@ +import { Class, Filter, invertSort, mergeFilter, Query, SortDirection, SortField } from '@nestjs-query/core'; +import { plainToClass } from 'class-transformer'; +import { BadRequestException } from '@nestjs/common'; +import { KeySetCursorPayload, KeySetPagingOpts, PagerStrategy } from './pager-strategy'; +import { CursorPagingType } from '../../../../query'; +import { hasBeforeCursor, isBackwardPaging, isForwardPaging } from './helpers'; + +export class KeysetPagerStrategy implements PagerStrategy { + constructor(readonly DTOClass: Class, readonly pageFields: (keyof DTO)[]) {} + + fromCursorArgs(cursor: CursorPagingType): KeySetPagingOpts { + const { defaultSort } = this; + const isForward = isForwardPaging(cursor); + const isBackward = isBackwardPaging(cursor); + const hasBefore = hasBeforeCursor(cursor); + let payload; + let limit = 0; + if (isForwardPaging(cursor)) { + payload = cursor.after ? this.decodeCursor(cursor.after) : undefined; + limit = cursor.first ?? 0; + } + if (isBackwardPaging(cursor)) { + payload = cursor.before ? this.decodeCursor(cursor.before) : undefined; + limit = cursor.last ?? 0; + } + return { payload, defaultSort, limit, isBackward, isForward, hasBefore }; + } + + toCursor(dto: DTO, index: number, opts: KeySetPagingOpts, query: Query): string { + const cursorFields: (keyof DTO)[] = [ + ...(query.sorting ?? []).map((f: SortField) => f.field), + ...this.pageFields, + ]; + return this.encodeCursor(this.createKeySetPayload(dto, cursorFields)); + } + + isEmptyCursor(opts: KeySetPagingOpts): boolean { + return !opts.payload || !opts.payload.fields.length; + } + + createQuery(query: Query, opts: KeySetPagingOpts, includeExtraNode: boolean): Query { + const paging = { limit: opts.limit }; + if (includeExtraNode) { + // Add 1 to the limit so we will fetch an additional node + paging.limit += 1; + } + const { payload } = opts; + // Add 1 to the limit so we will fetch an additional node with the current node + const sorting = this.getSortFields(query, opts); + const filter = mergeFilter(query.filter ?? {}, this.createFieldsFilter(sorting, payload)); + return { filter, paging, sorting }; + } + + checkForExtraNode(nodes: DTO[], opts: KeySetPagingOpts): DTO[] { + const hasExtraNode = nodes.length > opts.limit; + const returnNodes = [...nodes]; + if (hasExtraNode) { + returnNodes.pop(); + } + if (opts.isBackward) { + returnNodes.reverse(); + } + return returnNodes; + } + + private get defaultSort(): SortField[] { + return this.pageFields.map((field) => { + return { field, direction: SortDirection.ASC }; + }); + } + + private encodeCursor(fields: KeySetCursorPayload): string { + return Buffer.from(JSON.stringify(fields), 'utf8').toString('base64'); + } + + private decodeCursor(cursor: string): KeySetCursorPayload { + try { + const payload = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as KeySetCursorPayload; + if (payload.type !== 'keyset') { + throw new BadRequestException('Invalid cursor'); + } + const partial: Partial = payload.fields.reduce((dtoPartial: Partial, { field, value }) => { + return { ...dtoPartial, [field]: value }; + }, {}); + const transformed = plainToClass(this.DTOClass, partial); + const typesafeFields = payload.fields.map(({ field }) => { + return { field, value: transformed[field] }; + }); + return { ...payload, fields: typesafeFields }; + } catch (e) { + throw new BadRequestException('Invalid cursor'); + } + } + + private createFieldsFilter(sortFields: SortField[], payload: KeySetCursorPayload | undefined): Filter { + if (!payload) { + return {}; + } + const { fields } = payload; + const equalities: Filter[] = []; + const oredFilter = sortFields.reduce((dtoFilters, sortField, index) => { + const keySetField = fields[index]; + if (keySetField.field !== sortField.field) { + throw new Error( + `Cursor Payload does not match query sort expected ${keySetField.field as string} found ${ + sortField.field as string + }`, + ); + } + const isAsc = sortField.direction === SortDirection.ASC; + const subFilter = { + and: [...equalities, { [keySetField.field]: { [isAsc ? 'gt' : 'lt']: keySetField.value } }], + } as Filter; + equalities.push({ [keySetField.field]: { eq: keySetField.value } } as Filter); + return [...dtoFilters, subFilter]; + }, [] as Filter[]); + return { or: oredFilter } as Filter; + } + + private getSortFields(query: Query, opts: KeySetPagingOpts): SortField[] { + const { sorting = [] } = query; + const defaultSort = opts.defaultSort.filter((dsf) => !sorting.some((sf) => dsf.field === sf.field)); + const sortFields = [...sorting, ...defaultSort]; + return opts.isForward ? sortFields : invertSort(sortFields); + } + + private createKeySetPayload(dto: DTO, fields: (keyof DTO)[]): KeySetCursorPayload { + const fieldSet = new Set(); + return fields.reduce( + (payload: KeySetCursorPayload, field) => { + if (fieldSet.has(field)) { + return payload; + } + fieldSet.add(field); + payload.fields.push({ field, value: dto[field] }); + return payload; + }, + { type: 'keyset', fields: [] }, + ); + } +} diff --git a/packages/query-graphql/src/types/connection/cursor/pager/strategies/limit-offset.pager-strategy.ts b/packages/query-graphql/src/types/connection/cursor/pager/strategies/limit-offset.pager-strategy.ts new file mode 100644 index 000000000..537354555 --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/strategies/limit-offset.pager-strategy.ts @@ -0,0 +1,87 @@ +import { cursorToOffset, offsetToCursor } from 'graphql-relay'; +import { Query } from '@nestjs-query/core'; +import { CursorPagingType } from '../../../../query'; +import { OffsetPagingOpts, PagerStrategy } from './pager-strategy'; +import { hasBeforeCursor, isBackwardPaging, isForwardPaging } from './helpers'; + +export class LimitOffsetPagerStrategy implements PagerStrategy { + toCursor(dto: DTO, index: number, pagingOpts: OffsetPagingOpts): string { + return offsetToCursor(pagingOpts.offset + index); + } + + fromCursorArgs(cursor: CursorPagingType): OffsetPagingOpts { + const isForward = isForwardPaging(cursor); + const isBackward = isBackwardPaging(cursor); + const hasBefore = hasBeforeCursor(cursor); + return { limit: this.getLimit(cursor), offset: this.getOffset(cursor), isForward, isBackward, hasBefore }; + } + + isEmptyCursor(opts: OffsetPagingOpts): boolean { + return opts.offset === 0; + } + + createQuery(query: Query, opts: OffsetPagingOpts, includeExtraNode: boolean): Query { + const { isBackward } = opts; + const paging = { limit: opts.limit, offset: opts.offset }; + if (includeExtraNode) { + // Add 1 to the limit so we will fetch an additional node + paging.limit += 1; + // if paging backwards remove one from the offset to check for a previous page. + if (isBackward) { + paging.offset -= 1; + } + if (paging.offset < 0) { + // if the offset is < 0 it means we underflowed and that we cant have an extra page. + paging.offset = 0; + paging.limit = opts.limit; + } + } + return { ...query, paging }; + } + + checkForExtraNode(nodes: DTO[], opts: OffsetPagingOpts): DTO[] { + const returnNodes = [...nodes]; + // check if we have an additional node + // if paging forward that indicates we have a next page + // if paging backward that indicates we have a previous page. + const hasExtraNode = nodes.length > opts.limit; + if (hasExtraNode) { + // remove the additional node so its not returned in the results. + if (opts.isForward) { + returnNodes.pop(); + } else { + returnNodes.shift(); + } + } + return returnNodes; + } + + private getLimit(cursor: CursorPagingType): number { + if (isBackwardPaging(cursor)) { + const { last = 0, before } = cursor; + const offsetFromCursor = before ? cursorToOffset(before) : 0; + const offset = offsetFromCursor - last; + // Check to see if our before-page is underflowing past the 0th item + if (offset < 0) { + // Adjust the limit with the underflow value + return Math.max(last + offset, 0); + } + return last; + } + return cursor.first || 0; + } + + private getOffset(cursor: CursorPagingType): number { + if (isBackwardPaging(cursor)) { + const { last, before } = cursor; + const beforeOffset = before ? cursorToOffset(before) : 0; + const offset = last ? beforeOffset - last : 0; + + // Check to see if our before-page is underflowing past the 0th item + return Math.max(offset, 0); + } + const { after } = cursor; + const offset = after ? cursorToOffset(after) + 1 : 0; + return Math.max(offset, 0); + } +} diff --git a/packages/query-graphql/src/types/connection/cursor/pager/strategies/pager-strategy.ts b/packages/query-graphql/src/types/connection/cursor/pager/strategies/pager-strategy.ts new file mode 100644 index 000000000..67c8c1582 --- /dev/null +++ b/packages/query-graphql/src/types/connection/cursor/pager/strategies/pager-strategy.ts @@ -0,0 +1,39 @@ +import { Query, SortField } from '@nestjs-query/core'; +import { CursorPagingType } from '../../../../query'; + +export interface OffsetPagingOpts { + offset: number; + limit: number; + isForward: boolean; + isBackward: boolean; + hasBefore: boolean; +} + +export type KeySetField = { + field: K; + value: DTO[K]; +}; + +export type KeySetCursorPayload = { + type: 'keyset'; + fields: KeySetField[]; +}; + +export interface KeySetPagingOpts { + payload?: KeySetCursorPayload; + limit: number; + defaultSort: SortField[]; + isForward: boolean; + isBackward: boolean; + hasBefore: boolean; +} + +export type PagingOpts = OffsetPagingOpts | KeySetPagingOpts; + +export interface PagerStrategy { + toCursor(dto: DTO, index: number, opts: PagingOpts, query: Query): string; + fromCursorArgs(cursor: CursorPagingType): PagingOpts; + isEmptyCursor(opts: PagingOpts): boolean; + createQuery(query: Query, opts: PagingOpts, includeExtraNode: boolean): Query; + checkForExtraNode(nodes: DTO[], opts: PagingOpts): DTO[]; +} diff --git a/packages/query-graphql/src/types/query/paging/cursor-paging.type.ts b/packages/query-graphql/src/types/query/paging/cursor-paging.type.ts index c015b9e7f..34309f4c5 100644 --- a/packages/query-graphql/src/types/query/paging/cursor-paging.type.ts +++ b/packages/query-graphql/src/types/query/paging/cursor-paging.type.ts @@ -1,7 +1,6 @@ -import { Paging } from '@nestjs-query/core'; import { Min, Validate, IsPositive } from 'class-validator'; import { Field, InputType, Int } from '@nestjs/graphql'; -import { cursorToOffset } from 'graphql-relay'; +import { Paging } from '@nestjs-query/core'; import { ConnectionCursorType, ConnectionCursorScalar } from '../../cursor.scalar'; import { CannotUseWith, CannotUseWithout, IsUndefined } from '../../validators'; import { PagingStrategies } from './constants'; @@ -65,50 +64,6 @@ export const CursorPagingType = (): StaticCursorPagingType => { @Min(1) @IsPositive() last?: number; - - get limit(): number | undefined { - if (this.isForwardPaging) { - return this.first || 0; - } - if (this.isBackwardPaging) { - const { last = 0, before } = this; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const offsetFromCursor = cursorToOffset(before!); - const offset = offsetFromCursor - last; - // Check to see if our before-page is underflowing past the 0th item - if (offset < 0) { - // Adjust the limit with the underflow value - return Math.max(last + offset, 0); - } - return last; - } - return undefined; - } - - get offset(): number | undefined { - if (this.isForwardPaging) { - const { after } = this; - const limit = after ? cursorToOffset(after) + 1 : 0; - return Math.max(limit, 0); - } - if (this.isBackwardPaging) { - const { last, before } = this; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const offset = last ? cursorToOffset(before!) - last : 0; - - // Check to see if our before-page is underflowing past the 0th item - return Math.max(offset, 0); - } - return undefined; - } - - private get isForwardPaging(): boolean { - return !!this.first || !!this.after; - } - - private get isBackwardPaging(): boolean { - return !!this.last || !!this.before; - } } graphQLCursorPaging = GraphQLCursorPagingImpl; return graphQLCursorPaging;