From ed1e84a2feb6f89c3b270fcbc1d0eaf6aec5e575 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Sun, 21 Jun 2020 17:51:26 -0500 Subject: [PATCH] feat(graphql,connection): Add totalCount to connections --- documentation/docs/graphql/relations.mdx | 65 ++++- documentation/docs/graphql/resolvers.mdx | 169 +++++++++++ .../migration-guides/v0.14.x-to-v0.15.x.mdx | 153 ++++++++++ documentation/sidebars.js | 2 + .../e2e/sub-task.resolver.spec.ts | 20 +- .../e2e/tag.resolver.spec.ts | 44 ++- .../e2e/todo-item.resolver.spec.ts | 52 +++- .../src/sub-task/sub-task.module.ts | 1 + .../src/tag/tag.module.ts | 1 + .../src/todo-item/todo-item.module.ts | 1 + .../src/todo-item/types.ts | 2 +- .../e2e/sub-task.resolver.spec.ts | 20 +- .../e2e/tag.resolver.spec.ts | 44 ++- .../e2e/todo-item.resolver.spec.ts | 52 +++- .../src/sub-task/sub-task.module.ts | 1 + .../src/tag/tag.module.ts | 1 + .../src/todo-item/todo-item.module.ts | 1 + .../src/todo-item/types.ts | 2 +- .../services/assembler-query.service.spec.ts | 46 +++ .../services/noop-query.service.spec.ts | 9 + .../services/proxy-query.service.spec.ts | 24 ++ .../services/relation-query.service.spec.ts | 265 +++++++++++------- .../src/services/assembler-query.service.ts | 35 +++ .../core/src/services/noop-query.service.ts | 27 ++ .../core/src/services/proxy-query.service.ts | 34 ++- packages/core/src/services/query.service.ts | 25 ++ .../src/services/relation-query.service.ts | 40 ++- ...ction-object-type-with-total-count.graphql | 55 ++++ .../__tests__/__fixtures__/index.ts | 3 + .../decorators/skip-if.decorator.spec.ts | 101 +++++++ .../loaders/count-relations.loader.spec.ts | 87 ++++++ .../__tests__/resolvers/__fixtures__/index.ts | 3 + ...nnection-with-total-count.resolver.graphql | 146 ++++++++++ .../read/read-custom-name.resolver.graphql | 4 +- ...relation-many-custom-name.resolver.graphql | 4 +- ...on-relation-many-nullable.resolver.graphql | 4 +- .../federation-relation-many.resolver.graphql | 4 +- .../federation/federation.resolver.spec.ts | 2 + .../__tests__/resolvers/read.resolver.spec.ts | 36 +++ ...relation-many-custom-name.resolver.graphql | 4 +- ...ad-relation-many-nullable.resolver.graphql | 4 +- .../read/read-relation-many.resolver.graphql | 4 +- .../relations/read-relation.resolver.spec.ts | 35 +++ .../types/connection/connection.type.spec.ts | 31 +- .../query-graphql/src/decorators/index.ts | 1 + .../src/decorators/skip-if.decorator.ts | 35 +++ .../src/loader/count-relations.loader.ts | 49 ++++ packages/query-graphql/src/loader/index.ts | 1 + .../src/metadata/metadata-storage.ts | 12 +- .../src/resolvers/crud.resolver.ts | 10 +- .../src/resolvers/read.resolver.ts | 16 +- .../relations/read-relations.resolver.ts | 35 ++- .../relations/relations.interface.ts | 4 +- .../resolvers/relations/relations.resolver.ts | 5 +- .../types/connection/array-connection.type.ts | 10 +- .../src/types/connection/connection.type.ts | 20 +- .../cursor/cursor-connection.type.ts | 54 +++- .../types/connection/cursor/pager/index.ts | 2 +- .../connection/cursor/pager/interfaces.ts | 10 +- .../cursor/pager/limit-offset.pager.ts | 19 +- .../src/types/connection/interfaces.ts | 7 +- .../field-comparison.factory.ts | 1 + .../src/types/query/query-args.type.ts | 5 + .../services/sequelize-query.service.spec.ts | 88 +++++- .../src/query/filter-query.builder.ts | 8 +- .../src/services/relation-query.service.ts | 56 ++++ .../src/services/sequelize-query.service.ts | 4 + .../services/typeorm-query.service.spec.ts | 87 ++++++ .../src/services/relation-query.service.ts | 57 ++++ .../src/services/typeorm-query.service.ts | 4 + 70 files changed, 2018 insertions(+), 245 deletions(-) create mode 100644 documentation/docs/migration-guides/v0.14.x-to-v0.15.x.mdx create mode 100644 packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql create mode 100644 packages/query-graphql/__tests__/decorators/skip-if.decorator.spec.ts create mode 100644 packages/query-graphql/__tests__/loaders/count-relations.loader.spec.ts create mode 100644 packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql create mode 100644 packages/query-graphql/src/decorators/skip-if.decorator.ts create mode 100644 packages/query-graphql/src/loader/count-relations.loader.ts diff --git a/documentation/docs/graphql/relations.mdx b/documentation/docs/graphql/relations.mdx index b6b96fcad..a9fb40f5b 100644 --- a/documentation/docs/graphql/relations.mdx +++ b/documentation/docs/graphql/relations.mdx @@ -400,7 +400,7 @@ type TodoItem { filter: SubTaskFilter = {} sorting: [SubTaskSort!] = [] - ): SubTaskConnection! + ): TodoItemSubTasksConnection! } ``` @@ -421,6 +421,69 @@ input RelationsInput { If `disableRemove` was set to `false` or not specified a `removeSubTasksFromTodoItem` mutation would also be exposed with the same arguments as `addSubTasksToTodoItem`. ::: +### Total Count + +:::warning +Enabling `totalCount` can be expensive. If your table is large the `totalCount` query may be expensive, use with caution. +::: +:::info +The `totalCount` field is not eagerly fetched. It will only be executed if the field is queried from the client. +::: + +When using the `@Connection` decorator you can enable the `totalCount` field. The `totalCount` field will return the total number of records included in the connection. + +```ts title="todo-item/todo-item.dto.ts" {6} +import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; +import { SubTaskDTO } from '../sub-task/sub-task.dto' + +@ObjectType('TodoItem') +@Connection('subTasks', () => SubTaskDTO, { disableRemove: true, enableTotalCount: true }) +export class TodoItemDTO { + @FilterableField(() => ID) + id!: string; + + @FilterableField() + title!: string; + + @FilterableField() + completed!: boolean; + + @FilterableField(() => GraphQLISODateTime) + created!: Date; + + @FilterableField(() => GraphQLISODateTime) + updated!: Date; +} + +``` + +The generated graphql will include a `TodoItemSubTasksConnection` with a `totalCount` field. + +```graphql {19} +type TodoItem { + id: ID! + title: String! + completed: Boolean! + created: DateTime! + updated: DateTime! + subTasks( + paging: CursorPaging = { first: 10 } + + filter: SubTaskFilter = {} + + sorting: [SubTaskSort!] = [] + ): TodoItemSubTasksConnection! +} + +type TodoItemSubTasksConnection { + pageInfo: PageInfo! + edges: [SubTaskEdge!]! + totalCount: Int! +} + +``` + ## Options The following options can be passed to the `@Relation` or `@Connection` decorators, to customize functionality. diff --git a/documentation/docs/graphql/resolvers.mdx b/documentation/docs/graphql/resolvers.mdx index 7bb7d2c2a..e2f5f5579 100644 --- a/documentation/docs/graphql/resolvers.mdx +++ b/documentation/docs/graphql/resolvers.mdx @@ -123,6 +123,8 @@ When using `NestjsQueryGraphQLModule` or `CRUDResolver` you can define a number * `pagingStrategy?` - Specify to use an alternate paging strategy (`CURSOR`, `OFFSET`, 'NONE'). See [Paging Strategy](#paging-strategy). +* `enableTotalCount?` - When using `CURSOR` based paging set to true to expose a `totalCount` field on all connection from this resolver. + * `create` - In addition to [`ResolverOptions`](#resolveroptions) you can also specify the following * `CreateDTOClass` - The input DTO to use for create mutations. * `CreateOneInput` - The `InputType` to use for create one mutations. @@ -132,6 +134,7 @@ When using `NestjsQueryGraphQLModule` or `CRUDResolver` you can define a number * `QueryArgs` - The `ArgsType` to use to filter records in `queryMany` endpoint. * `Connection` - The `ObjectType` to return from the `queryMany` endpoint. * `pagingStrategy?` - Specify to use an alternate paging strategy (`CURSOR`, `OFFSET`, 'NONE'). See [Paging Strategy](#paging-strategy). + * `enableTotalCount?` - When using `CURSOR` based paging set to true to expose a `totalCount` field on the connection. * `defaultResultSize=10` - The default number of results to return * `maxResultsSize=50` - The maximum number of results an end user can specify to return from a query. * `defaultSort=[]` - The default sort to use when querying for records. @@ -846,6 +849,172 @@ export class TodoItemResolver extends CRUDResolver(TodoItemDTO, { --- +### Paging with Total Count + +:::note +This section **ONLY** applies to `CURSOR` connections. +::: +:::warning +Enabling `totalCount` can be expensive. If your table is large the `totalCount` query may be expensive, use with caution. +::: +:::info +The `totalCount` field is not eagerly fetched. It will only be executed if the field is queried from the client. +::: + +When using the `CURSOR` paging strategy (the default) you have the option to expose a `totalCount` field to allow clients to fetch a total count of records in a connection. + +To enable the `totalCount` field for connections (including relations) in a resolver set the `enableTotalCount` option to `true`. + + + + +```ts title="todo-item.module.ts" {14} +import { NestjsQueryGraphQLModule, PagingStrategies } from '@nestjs-query/query-graphql'; +import { NestjsQueryTypeOrmModule } from '@nestjs-query/query-typeorm'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; + +@Module({ + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + resolvers: [{ + DTOClass: TodoItemDTO, + EntityClass: TodoItemEntity, + enableTotalCount: true, + }], + }), + ], +}) +export class TodoItemModule {} +``` + + + + +```ts title="todo-item.resolver.ts" {9} +import { QueryService, InjectQueryService } from '@nestjs-query/core'; +import { CRUDResolver, PagingStrategies } from '@nestjs-query/query-graphql'; +import { Resolver } from '@nestjs/graphql'; +import { TodoItemDTO } from './todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; + +@Resolver() +export class TodoItemResolver extends CRUDResolver(TodoItemDTO, { + enableTotalCount: true, +}) { + constructor( + @InjectQueryService(TodoItemEntity) readonly service: QueryService + ) { + super(service); + } +} +``` + + + + +When setting `enableTotalCount` to `true` you will be able to query for `totalCount` on `cursor` connections + + + + +```graphql +{ + todoItems { + totalCount + pageInfo{ + hasNextPage + hasPreviousPage + startCursor + endCursor + } + edges { + node { + id + title + description + } + } + } +} + +``` + + + + +```json +{ + "data": { + "todoItems": { + "totalCount": 5, + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "YXJyYXljb25uZWN0aW9uOjA=", + "endCursor": "YXJyYXljb25uZWN0aW9uOjQ=" + }, + "edges": [ + { + "node": { + "id": "1", + "title": "Create Nest App", + "description": null + } + }, + { + "node": { + "id": "2", + "title": "Create Entity", + "description": null + } + }, + { + "node": { + "id": "3", + "title": "Create Entity Service", + "description": null + } + }, + { + "node": { + "id": "4", + "title": "Add Todo Item Resolver", + "description": null + } + }, + { + "node": { + "id": "5", + "title": "How to create item With Sub Tasks", + "description": null + } + } + ] + } + } +} +``` + + + + +--- + ### Default Sort When querying the default sort is based on the persistence layer. You can override the default by providing the `defaultSort` option. diff --git a/documentation/docs/migration-guides/v0.14.x-to-v0.15.x.mdx b/documentation/docs/migration-guides/v0.14.x-to-v0.15.x.mdx new file mode 100644 index 000000000..f436cf156 --- /dev/null +++ b/documentation/docs/migration-guides/v0.14.x-to-v0.15.x.mdx @@ -0,0 +1,153 @@ +--- +title: v0.14.x to v0.15.x +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +In the `v0.15.x` the cursor connection type was updated to allow for enabling a `totalCount` field. When enabling this field `nestjs-query` needed to explicitly name each connection type to allow each relation connection to independently enable the `totalCount` field. + +In previous versions of `nestjs-query` the connection type was shared between all instances which caused the totalCount field to not always be exposed. In `v0.15.x` all instances of a connection are uniquely named. + +For example, suppose the following DTOS. + + + + +```ts title="todo-item.dto.ts" +import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; +import { SubTaskDTO } from '../../sub-task/dto/sub-task.dto'; + +@ObjectType('TodoItem') +@Connection('subTasks', () => SubTaskDTO, { enableTotalCount: true }) +export class TodoItemDTO { + @FilterableField(() => ID) + id!: number; + + @FilterableField() + title!: string; + + @FilterableField({ nullable: true }) + description?: string; + + @FilterableField() + completed!: boolean; + + @FilterableField(() => GraphQLISODateTime) + created!: Date; + + @FilterableField(() => GraphQLISODateTime) + updated!: Date; + + @FilterableField() + priority!: number; +} + +``` + + + + +```ts title="sub-task.dto.ts" +import { FilterableField, Relation } from '@nestjs-query/query-graphql'; +import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; +import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto'; + +@ObjectType('SubTask') +@Relation('todoItem', () => TodoItemDTO, { disableRemove: true }) +export class SubTaskDTO { + @FilterableField(() => ID) + id!: number; + + @FilterableField() + title!: string; + + @FilterableField({ nullable: true }) + description?: string; + + @FilterableField() + completed!: boolean; + + @FilterableField(() => GraphQLISODateTime) + created!: Date; + + @FilterableField(() => GraphQLISODateTime) + updated!: Date; + + @FilterableField() + todoItemId!: string; +} + +``` + + + + +In previous versions the generated graphql would have been + +```graphql +type TodoItem { + id: ID! + title: String! + description: String + completed: Boolean! + created: DateTime! + updated: DateTime! + age: Float! + priority: Float! + subTasks( + paging: CursorPaging = { first: 10 } + + filter: SubTaskFilter = {} + + sorting: [SubTaskSort!] = [] + ): SubTaskConnection! +} + +type SubTaskConnection { + pageInfo: PageInfo! + edges: [SubTaskEdge!]! +} +``` + +In the latest version the relation gets its own connection type. + +```graphql +type TodoItem { + id: ID! + title: String! + description: String + completed: Boolean! + created: DateTime! + updated: DateTime! + age: Float! + priority: Float! + subTasks( + paging: CursorPaging = { first: 10 } + + filter: SubTaskFilter = {} + + sorting: [SubTaskSort!] = [] + ): TodoItemSubTasksConnection! +} + +type TodoItemSubTasksConnection { + pageInfo: PageInfo! + edges: [SubTaskEdge!]! + totalCount: Int! +} +``` + + + + + + + diff --git a/documentation/sidebars.js b/documentation/sidebars.js index a5b7ecc06..4ef6de043 100755 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -55,6 +55,8 @@ module.exports = { 'migration-guides/v0.5.x-to-v0.6.x', 'migration-guides/v0.10.x-to-v0.11.x', 'migration-guides/v0.12.x-to-v0.13.x', + 'migration-guides/v0.13.x-to-v0.14.x', + 'migration-guides/v0.14.x-to-v0.15.x', ], }, }; diff --git a/examples/nest-graphql-sequelize/e2e/sub-task.resolver.spec.ts b/examples/nest-graphql-sequelize/e2e/sub-task.resolver.spec.ts index ae40f3198..e90809f8b 100644 --- a/examples/nest-graphql-sequelize/e2e/sub-task.resolver.spec.ts +++ b/examples/nest-graphql-sequelize/e2e/sub-task.resolver.spec.ts @@ -197,18 +197,20 @@ describe('SubTaskResolver (sequelize - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjk=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(10); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 10)); }); @@ -224,18 +226,20 @@ describe('SubTaskResolver (sequelize - e2e)', () => { subTasks(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 3)); }); @@ -251,18 +255,20 @@ describe('SubTaskResolver (sequelize - e2e)', () => { subTasks(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjk=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(10); expect(edges.map((e) => e.node)).toEqual(subTasks.slice().reverse().slice(0, 10)); }); @@ -279,18 +285,20 @@ describe('SubTaskResolver (sequelize - e2e)', () => { subTasks(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 2)); }); @@ -306,18 +314,20 @@ describe('SubTaskResolver (sequelize - e2e)', () => { subTasks(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(2, 4)); }); diff --git a/examples/nest-graphql-sequelize/e2e/tag.resolver.spec.ts b/examples/nest-graphql-sequelize/e2e/tag.resolver.spec.ts index dbd55a9cc..934baa7b1 100644 --- a/examples/nest-graphql-sequelize/e2e/tag.resolver.spec.ts +++ b/examples/nest-graphql-sequelize/e2e/tag.resolver.spec.ts @@ -88,19 +88,21 @@ describe('TagResolver (sequelize - e2e)', () => { todoItems(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes('id')} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tag.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tag.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.id)).toEqual(['1', '2']); }); @@ -118,18 +120,20 @@ describe('TagResolver (sequelize - e2e)', () => { tags { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual(tags); }); @@ -145,18 +149,20 @@ describe('TagResolver (sequelize - e2e)', () => { tags(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual(tags.slice(0, 3)); }); @@ -172,18 +178,20 @@ describe('TagResolver (sequelize - e2e)', () => { tags(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual(tags.slice().reverse()); }); @@ -200,18 +208,20 @@ describe('TagResolver (sequelize - e2e)', () => { tags(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(tags.slice(0, 2)); }); @@ -227,18 +237,20 @@ describe('TagResolver (sequelize - e2e)', () => { tags(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(tags.slice(2, 4)); }); @@ -642,21 +654,27 @@ describe('TagResolver (sequelize - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addTodoItemsToTag.todoItems; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.addTodoItemsToTag.todoItems; expect(body.data.addTodoItemsToTag.id).toBe('1'); - expect(edges).toHaveLength(5); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); + expect(edges).toHaveLength(5); expect(edges.map((e) => e.node.title)).toEqual([ 'Create Nest App', 'Create Entity', @@ -686,21 +704,27 @@ describe('TagResolver (sequelize - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.removeTodoItemsFromTag.todoItems; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.removeTodoItemsFromTag.todoItems; expect(body.data.removeTodoItemsFromTag.id).toBe('1'); - expect(edges).toHaveLength(2); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.title)).toEqual(['Create Nest App', 'Create Entity']); }); }); diff --git a/examples/nest-graphql-sequelize/e2e/todo-item.resolver.spec.ts b/examples/nest-graphql-sequelize/e2e/todo-item.resolver.spec.ts index 80b9871f0..925f9c3ee 100644 --- a/examples/nest-graphql-sequelize/e2e/todo-item.resolver.spec.ts +++ b/examples/nest-graphql-sequelize/e2e/todo-item.resolver.spec.ts @@ -96,20 +96,22 @@ describe('TodoItemResolver (sequelize - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItem.subTasks; - expect(edges).toHaveLength(3); + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItem.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); + expect(edges).toHaveLength(3); edges.forEach((e) => expect(e.node.todoItemId).toBe(1)); }); }); @@ -125,19 +127,21 @@ describe('TodoItemResolver (sequelize - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItem.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home']); }); @@ -155,18 +159,20 @@ describe('TodoItemResolver (sequelize - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -194,18 +200,20 @@ describe('TodoItemResolver (sequelize - e2e)', () => { todoItems(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -225,18 +233,20 @@ describe('TodoItemResolver (sequelize - e2e)', () => { todoItems(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual([ { @@ -265,18 +275,20 @@ describe('TodoItemResolver (sequelize - e2e)', () => { todoItems(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -295,18 +307,20 @@ describe('TodoItemResolver (sequelize - e2e)', () => { todoItems(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, @@ -895,21 +909,27 @@ describe('TodoItemResolver (sequelize - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addSubTasksToTodoItem.subTasks; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.addSubTasksToTodoItem.subTasks; expect(body.data.addSubTasksToTodoItem.id).toBe('1'); - expect(edges).toHaveLength(6); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjU=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(6); + expect(edges).toHaveLength(6); edges.forEach((e) => expect(e.node.todoItemId).toBe(1)); }); }); @@ -935,21 +955,23 @@ describe('TodoItemResolver (sequelize - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addTagsToTodoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.addTagsToTodoItem.tags; expect(body.data.addTagsToTodoItem.id).toBe('1'); - expect(edges).toHaveLength(5); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); + expect(edges).toHaveLength(5); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home', 'Work', 'Question', 'Blocked']); }); }); @@ -975,21 +997,23 @@ describe('TodoItemResolver (sequelize - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.removeTagsFromTodoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.removeTagsFromTodoItem.tags; expect(body.data.removeTagsFromTodoItem.id).toBe('1'); - expect(edges).toHaveLength(2); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home']); }); }); diff --git a/examples/nest-graphql-sequelize/src/sub-task/sub-task.module.ts b/examples/nest-graphql-sequelize/src/sub-task/sub-task.module.ts index c432298e2..c5ccaeba6 100644 --- a/examples/nest-graphql-sequelize/src/sub-task/sub-task.module.ts +++ b/examples/nest-graphql-sequelize/src/sub-task/sub-task.module.ts @@ -16,6 +16,7 @@ import { SubTaskEntity } from './sub-task.entity'; EntityClass: SubTaskEntity, CreateDTOClass: CreateSubTaskDTO, UpdateDTOClass: SubTaskUpdateDTO, + enableTotalCount: true, }, ], }), diff --git a/examples/nest-graphql-sequelize/src/tag/tag.module.ts b/examples/nest-graphql-sequelize/src/tag/tag.module.ts index 89fe81003..16167cb8f 100644 --- a/examples/nest-graphql-sequelize/src/tag/tag.module.ts +++ b/examples/nest-graphql-sequelize/src/tag/tag.module.ts @@ -15,6 +15,7 @@ import { TagEntity } from './tag.entity'; EntityClass: TagEntity, CreateDTOClass: TagInputDTO, UpdateDTOClass: TagInputDTO, + enableTotalCount: true, }, ], }), diff --git a/examples/nest-graphql-sequelize/src/todo-item/todo-item.module.ts b/examples/nest-graphql-sequelize/src/todo-item/todo-item.module.ts index 09e1160ef..01f4ee74e 100644 --- a/examples/nest-graphql-sequelize/src/todo-item/todo-item.module.ts +++ b/examples/nest-graphql-sequelize/src/todo-item/todo-item.module.ts @@ -24,6 +24,7 @@ const guards = [AuthGuard]; AssemblerClass: TodoItemAssembler, CreateDTOClass: TodoItemInputDTO, UpdateDTOClass: TodoItemUpdateDTO, + enableTotalCount: true, create: { guards }, update: { guards }, delete: { guards }, diff --git a/examples/nest-graphql-sequelize/src/todo-item/types.ts b/examples/nest-graphql-sequelize/src/todo-item/types.ts index 2b2314987..bd5158a1e 100644 --- a/examples/nest-graphql-sequelize/src/todo-item/types.ts +++ b/examples/nest-graphql-sequelize/src/todo-item/types.ts @@ -2,7 +2,7 @@ import { ConnectionType, QueryArgsType } from '@nestjs-query/query-graphql'; import { ArgsType } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; -export const TodoItemConnection = ConnectionType(TodoItemDTO); +export const TodoItemConnection = ConnectionType(TodoItemDTO, { enableTotalCount: true }); @ArgsType() export class TodoItemQuery extends QueryArgsType(TodoItemDTO, { defaultResultSize: 2 }) {} diff --git a/examples/nest-graphql-typeorm/e2e/sub-task.resolver.spec.ts b/examples/nest-graphql-typeorm/e2e/sub-task.resolver.spec.ts index 7603ee06e..3136601ec 100644 --- a/examples/nest-graphql-typeorm/e2e/sub-task.resolver.spec.ts +++ b/examples/nest-graphql-typeorm/e2e/sub-task.resolver.spec.ts @@ -197,18 +197,20 @@ describe('SubTaskResolver (typeorm - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjk=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(10); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 10)); }); @@ -224,18 +226,20 @@ describe('SubTaskResolver (typeorm - e2e)', () => { subTasks(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 3)); }); @@ -251,18 +255,20 @@ describe('SubTaskResolver (typeorm - e2e)', () => { subTasks(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjk=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(10); expect(edges.map((e) => e.node)).toEqual(subTasks.slice().reverse().slice(0, 10)); }); @@ -279,18 +285,20 @@ describe('SubTaskResolver (typeorm - e2e)', () => { subTasks(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(0, 2)); }); @@ -306,18 +314,20 @@ describe('SubTaskResolver (typeorm - e2e)', () => { subTasks(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.subTasks; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(15); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(subTasks.slice(2, 4)); }); diff --git a/examples/nest-graphql-typeorm/e2e/tag.resolver.spec.ts b/examples/nest-graphql-typeorm/e2e/tag.resolver.spec.ts index fed7d0156..9b1cc00b1 100644 --- a/examples/nest-graphql-typeorm/e2e/tag.resolver.spec.ts +++ b/examples/nest-graphql-typeorm/e2e/tag.resolver.spec.ts @@ -88,19 +88,21 @@ describe('TagResolver (typeorm - e2e)', () => { todoItems(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes('id')} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tag.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tag.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.id)).toEqual(['1', '2']); }); @@ -118,18 +120,20 @@ describe('TagResolver (typeorm - e2e)', () => { tags { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual(tags); }); @@ -145,18 +149,20 @@ describe('TagResolver (typeorm - e2e)', () => { tags(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual(tags.slice(0, 3)); }); @@ -172,18 +178,20 @@ describe('TagResolver (typeorm - e2e)', () => { tags(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual(tags.slice().reverse()); }); @@ -200,18 +208,20 @@ describe('TagResolver (typeorm - e2e)', () => { tags(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(tags.slice(0, 2)); }); @@ -227,18 +237,20 @@ describe('TagResolver (typeorm - e2e)', () => { tags(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual(tags.slice(2, 4)); }); @@ -642,21 +654,27 @@ describe('TagResolver (typeorm - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addTodoItemsToTag.todoItems; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.addTodoItemsToTag.todoItems; expect(body.data.addTodoItemsToTag.id).toBe('1'); - expect(edges).toHaveLength(5); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); + expect(edges).toHaveLength(5); expect(edges.map((e) => e.node.title)).toEqual([ 'Create Nest App', 'Create Entity', @@ -686,21 +704,27 @@ describe('TagResolver (typeorm - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.removeTodoItemsFromTag.todoItems; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.removeTodoItemsFromTag.todoItems; expect(body.data.removeTodoItemsFromTag.id).toBe('1'); - expect(edges).toHaveLength(2); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.title)).toEqual(['Create Nest App', 'Create Entity']); }); }); diff --git a/examples/nest-graphql-typeorm/e2e/todo-item.resolver.spec.ts b/examples/nest-graphql-typeorm/e2e/todo-item.resolver.spec.ts index 7c03c865b..14db79cdb 100644 --- a/examples/nest-graphql-typeorm/e2e/todo-item.resolver.spec.ts +++ b/examples/nest-graphql-typeorm/e2e/todo-item.resolver.spec.ts @@ -96,20 +96,22 @@ describe('TodoItemResolver (typeorm - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItem.subTasks; - expect(edges).toHaveLength(3); + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItem.subTasks; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); + expect(edges).toHaveLength(3); edges.forEach((e) => expect(e.node.todoItemId).toBe('1')); }); }); @@ -125,19 +127,21 @@ describe('TodoItemResolver (typeorm - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItem.tags; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home']); }); @@ -155,18 +159,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { todoItems { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -194,18 +200,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { todoItems(filter: { id: { in: [1, 2, 3] } }) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(3); expect(edges).toHaveLength(3); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -225,18 +233,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { todoItems(sorting: [{field: id, direction: DESC}]) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(5); expect(edges.map((e) => e.node)).toEqual([ { @@ -265,18 +275,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { todoItems(paging: {first: 2}) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: true, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, @@ -295,18 +307,20 @@ describe('TodoItemResolver (typeorm - e2e)', () => { todoItems(paging: {first: 2, after: "YXJyYXljb25uZWN0aW9uOjE="}) { ${pageInfoField} ${edgeNodes(todoItemFields)} + totalCount } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.todoItems; expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjM=', hasNextPage: true, hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjI=', }); + expect(totalCount).toBe(5); expect(edges).toHaveLength(2); expect(edges.map((e) => e.node)).toEqual([ { id: '3', title: 'Create Entity Service', completed: false, description: null, age: expect.any(Number) }, @@ -895,21 +909,27 @@ describe('TodoItemResolver (typeorm - e2e)', () => { subTasks { ${pageInfoField} ${edgeNodes(subTaskFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addSubTasksToTodoItem.subTasks; + const { + edges, + pageInfo, + totalCount, + }: CursorConnectionType = body.data.addSubTasksToTodoItem.subTasks; expect(body.data.addSubTasksToTodoItem.id).toBe('1'); - expect(edges).toHaveLength(6); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjU=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(6); + expect(edges).toHaveLength(6); edges.forEach((e) => expect(e.node.todoItemId).toBe('1')); }); }); @@ -935,21 +955,23 @@ describe('TodoItemResolver (typeorm - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.addTagsToTodoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.addTagsToTodoItem.tags; expect(body.data.addTagsToTodoItem.id).toBe('1'); - expect(edges).toHaveLength(5); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(5); + expect(edges).toHaveLength(5); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home', 'Work', 'Question', 'Blocked']); }); }); @@ -975,21 +997,23 @@ describe('TodoItemResolver (typeorm - e2e)', () => { tags(sorting: [{ field: id, direction: ASC }]) { ${pageInfoField} ${edgeNodes(tagFields)} + totalCount } } }`, }) .expect(200) .then(({ body }) => { - const { edges, pageInfo }: CursorConnectionType = body.data.removeTagsFromTodoItem.tags; + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.removeTagsFromTodoItem.tags; expect(body.data.removeTagsFromTodoItem.id).toBe('1'); - expect(edges).toHaveLength(2); expect(pageInfo).toEqual({ endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', hasNextPage: false, hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); expect(edges.map((e) => e.node.name)).toEqual(['Urgent', 'Home']); }); }); diff --git a/examples/nest-graphql-typeorm/src/sub-task/sub-task.module.ts b/examples/nest-graphql-typeorm/src/sub-task/sub-task.module.ts index 299153599..a0c718eca 100644 --- a/examples/nest-graphql-typeorm/src/sub-task/sub-task.module.ts +++ b/examples/nest-graphql-typeorm/src/sub-task/sub-task.module.ts @@ -16,6 +16,7 @@ import { SubTaskEntity } from './sub-task.entity'; EntityClass: SubTaskEntity, CreateDTOClass: CreateSubTaskDTO, UpdateDTOClass: SubTaskUpdateDTO, + enableTotalCount: true, }, ], }), diff --git a/examples/nest-graphql-typeorm/src/tag/tag.module.ts b/examples/nest-graphql-typeorm/src/tag/tag.module.ts index 861f107ae..cb12812f6 100644 --- a/examples/nest-graphql-typeorm/src/tag/tag.module.ts +++ b/examples/nest-graphql-typeorm/src/tag/tag.module.ts @@ -15,6 +15,7 @@ import { TagEntity } from './tag.entity'; EntityClass: TagEntity, CreateDTOClass: TagInputDTO, UpdateDTOClass: TagInputDTO, + enableTotalCount: true, }, ], }), diff --git a/examples/nest-graphql-typeorm/src/todo-item/todo-item.module.ts b/examples/nest-graphql-typeorm/src/todo-item/todo-item.module.ts index 39f13f2ba..279c9948b 100644 --- a/examples/nest-graphql-typeorm/src/todo-item/todo-item.module.ts +++ b/examples/nest-graphql-typeorm/src/todo-item/todo-item.module.ts @@ -22,6 +22,7 @@ const guards = [AuthGuard]; AssemblerClass: TodoItemAssembler, CreateDTOClass: TodoItemInputDTO, UpdateDTOClass: TodoItemUpdateDTO, + enableTotalCount: true, create: { guards }, update: { guards }, delete: { guards }, diff --git a/examples/nest-graphql-typeorm/src/todo-item/types.ts b/examples/nest-graphql-typeorm/src/todo-item/types.ts index 2b2314987..bd5158a1e 100644 --- a/examples/nest-graphql-typeorm/src/todo-item/types.ts +++ b/examples/nest-graphql-typeorm/src/todo-item/types.ts @@ -2,7 +2,7 @@ import { ConnectionType, QueryArgsType } from '@nestjs-query/query-graphql'; import { ArgsType } from '@nestjs/graphql'; import { TodoItemDTO } from './dto/todo-item.dto'; -export const TodoItemConnection = ConnectionType(TodoItemDTO); +export const TodoItemConnection = ConnectionType(TodoItemDTO, { enableTotalCount: true }); @ArgsType() export class TodoItemQuery extends QueryArgsType(TodoItemDTO, { defaultResultSize: 2 }) {} diff --git a/packages/core/__tests__/services/assembler-query.service.spec.ts b/packages/core/__tests__/services/assembler-query.service.spec.ts index 3ee89deb3..8d13513ca 100644 --- a/packages/core/__tests__/services/assembler-query.service.spec.ts +++ b/packages/core/__tests__/services/assembler-query.service.spec.ts @@ -44,6 +44,16 @@ describe('AssemblerQueryService', () => { }); }); + describe('count', () => { + it('transform the filter and results', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + when(mockQueryService.count(objectContaining({ bar: { eq: 'bar' } }))).thenResolve(1); + + return expect(assemblerService.count({ foo: { eq: 'bar' } })).resolves.toEqual(1); + }); + }); + describe('findById', () => { it('should transform the results', () => { const mockQueryService = mock>(); @@ -122,6 +132,42 @@ describe('AssemblerQueryService', () => { }); }); + describe('countRelations', () => { + it('should transform the results for a single entity', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + when( + mockQueryService.countRelations( + TestDTO, + 'test', + objectContaining({ bar: 'bar' }), + objectContaining({ foo: { eq: 'bar' } }), + ), + ).thenResolve(1); + + return expect( + assemblerService.countRelations(TestDTO, 'test', { foo: 'bar' }, { foo: { eq: 'bar' } }), + ).resolves.toEqual(1); + }); + + it('should transform multiple entities', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + const dto: TestDTO = { foo: 'bar' }; + const entity: TestEntity = { bar: 'bar' }; + when( + mockQueryService.countRelations(TestDTO, 'test', deepEqual([entity]), objectContaining({ foo: { eq: 'bar' } })), + ).thenCall((relationClass, relation, entities) => { + return Promise.resolve( + new Map([[entities[0], 1]]), + ); + }); + return expect( + assemblerService.countRelations(TestDTO, 'test', [{ foo: 'bar' }], { foo: { eq: 'bar' } }), + ).resolves.toEqual(new Map([[dto, 1]])); + }); + }); + describe('findRelation', () => { it('should transform the results for a single entity', () => { const mockQueryService = mock>(); diff --git a/packages/core/__tests__/services/noop-query.service.spec.ts b/packages/core/__tests__/services/noop-query.service.spec.ts index 9ecbfccfd..2ee625914 100644 --- a/packages/core/__tests__/services/noop-query.service.spec.ts +++ b/packages/core/__tests__/services/noop-query.service.spec.ts @@ -36,11 +36,20 @@ describe('NoOpQueryService', () => { it('should throw a NotImplementedException when calling query', () => { return expect(instance.query({})).rejects.toThrow('query is not implemented'); }); + + it('should throw a NotImplementedException when calling count', () => { + return expect(instance.count({})).rejects.toThrow('count is not implemented'); + }); it('should throw a NotImplementedException when calling queryRelations', () => { return expect(instance.queryRelations(TestType, 'test', new TestType(), {})).rejects.toThrow( 'queryRelations is not implemented', ); }); + it('should throw a NotImplementedException when calling countRelations', () => { + return expect(instance.countRelations(TestType, 'test', new TestType(), {})).rejects.toThrow( + 'countRelations is not implemented', + ); + }); it('should throw a NotImplementedException when calling removeRelation', () => { return expect(instance.removeRelation('test', 1, 2)).rejects.toThrow('removeRelation is not implemented'); }); diff --git a/packages/core/__tests__/services/proxy-query.service.spec.ts b/packages/core/__tests__/services/proxy-query.service.spec.ts index 137dc85a1..4b4fee321 100644 --- a/packages/core/__tests__/services/proxy-query.service.spec.ts +++ b/packages/core/__tests__/services/proxy-query.service.spec.ts @@ -73,6 +73,12 @@ describe('NoOpQueryService', () => { when(mockQueryService.query(query)).thenResolve(result); return expect(queryService.query(query)).resolves.toBe(result); }); + it('should proxy to the underlying service when calling count', () => { + const query = {}; + const result = 1; + when(mockQueryService.count(query)).thenResolve(result); + return expect(queryService.count(query)).resolves.toBe(result); + }); it('should proxy to the underlying service when calling queryRelations with one dto', () => { const relationName = 'test'; const dto = new TestType(); @@ -91,6 +97,24 @@ describe('NoOpQueryService', () => { return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toBe(result); }); + it('should proxy to the underlying service when calling countRelations with one dto', () => { + const relationName = 'test'; + const dto = new TestType(); + const query = {}; + const result = 1; + when(mockQueryService.countRelations(TestType, relationName, dto, query)).thenResolve(result); + return expect(queryService.countRelations(TestType, relationName, dto, query)).resolves.toBe(result); + }); + + it('should proxy to the underlying service when calling countRelations with many dtos', () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const query = {}; + const result = new Map([[{ foo: 'bar' }, 1]]); + when(mockQueryService.countRelations(TestType, relationName, dtos, query)).thenResolve(result); + return expect(queryService.countRelations(TestType, relationName, dtos, query)).resolves.toBe(result); + }); + it('should proxy to the underlying service when calling removeRelation', () => { const relationName = 'test'; const id = 1; diff --git a/packages/core/__tests__/services/relation-query.service.spec.ts b/packages/core/__tests__/services/relation-query.service.spec.ts index dfdbbd7d6..67cd13f7f 100644 --- a/packages/core/__tests__/services/relation-query.service.spec.ts +++ b/packages/core/__tests__/services/relation-query.service.spec.ts @@ -26,120 +26,177 @@ describe('RelationQueryService', () => { return expect(new RelationQueryService(relations).query({})).rejects.toThrow('query is not implemented'); }); - it('should proxy to the underlying service when calling addRelations', () => { - const relationName = 'test'; - const id = 1; - const relationIds = [1, 2, 3]; - const result = { foo: 'bar' }; - when(mockQueryService.addRelations(relationName, id, relationIds)).thenResolve(result); - return expect(queryService.addRelations(relationName, id, relationIds)).resolves.toBe(result); + describe('#addRelations', () => { + it('should proxy to the underlying service when calling addRelations', () => { + const relationName = 'test'; + const id = 1; + const relationIds = [1, 2, 3]; + const result = { foo: 'bar' }; + when(mockQueryService.addRelations(relationName, id, relationIds)).thenResolve(result); + return expect(queryService.addRelations(relationName, id, relationIds)).resolves.toBe(result); + }); }); - it('should proxy to the underlying service when calling findRelation with one dto', async () => { - const relationName = 'test'; - const dto = new TestType(); - const result = { foo: 'bar' }; - const query = {}; - testRelationFn.mockReturnValue(query); - when(mockRelationService.query(deepEqual({ ...query, paging: { limit: 1 } }))).thenResolve([result]); - await expect(queryService.findRelation(TestType, relationName, dto)).resolves.toBe(result); - return expect(testRelationFn).toHaveBeenCalledWith(dto); + describe('#findRelation', () => { + it('should proxy to the underlying service when calling findRelation with one dto', async () => { + const relationName = 'test'; + const dto = new TestType(); + const result = { foo: 'bar' }; + const query = {}; + testRelationFn.mockReturnValue(query); + when(mockRelationService.query(deepEqual({ ...query, paging: { limit: 1 } }))).thenResolve([result]); + await expect(queryService.findRelation(TestType, relationName, dto)).resolves.toBe(result); + return expect(testRelationFn).toHaveBeenCalledWith(dto); + }); + + it('should call the relationService findRelation with multiple dtos', async () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const query = {}; + testRelationFn.mockReturnValue(query); + const resultRelations = [{ foo: 'baz' }]; + const result = new Map([[dtos[0], resultRelations[0]]]); + when(mockRelationService.query(deepEqual({ ...query, paging: { limit: 1 } }))).thenResolve(resultRelations); + await expect(queryService.findRelation(TestType, relationName, dtos)).resolves.toEqual(result); + return expect(testRelationFn).toHaveBeenCalledWith(dtos[0]); + }); + + it('should call the original service if the relation is not in this relation query service', async () => { + const relationName = 'otherRelation'; + const dto = new TestType(); + const result = { foo: 'baz' }; + when(mockQueryService.findRelation(TestType, relationName, dto)).thenResolve(result); + await expect(queryService.findRelation(TestType, relationName, dto)).resolves.toEqual(result); + return expect(testRelationFn).not.toHaveBeenCalled(); + }); + + it('should call the original service if the relation is not in this relation query service with multiple DTOs', async () => { + const relationName = 'otherRelation'; + const dtos = [new TestType()]; + const result = new Map([[dtos[0], { foo: 'baz' }]]); + when(mockQueryService.findRelation(TestType, relationName, dtos)).thenResolve(result); + await expect(queryService.findRelation(TestType, relationName, dtos)).resolves.toEqual(result); + return expect(testRelationFn).not.toHaveBeenCalled(); + }); }); - it('should call the relationService findRelation with multiple dtos', async () => { - const relationName = 'test'; - const dtos = [new TestType()]; - const query = {}; - testRelationFn.mockReturnValue(query); - const resultRelations = [{ foo: 'baz' }]; - const result = new Map([[dtos[0], resultRelations[0]]]); - when(mockRelationService.query(deepEqual({ ...query, paging: { limit: 1 } }))).thenResolve(resultRelations); - await expect(queryService.findRelation(TestType, relationName, dtos)).resolves.toEqual(result); - return expect(testRelationFn).toHaveBeenCalledWith(dtos[0]); + describe('#queryRelations', () => { + it('should proxy to the underlying service when calling queryRelations with one dto', async () => { + const relationName = 'test'; + const dto = new TestType(); + const result = [{ foo: 'bar' }]; + const query = {}; + const relationQuery = {}; + testRelationFn.mockReturnValue(relationQuery); + when(mockRelationService.query(deepEqual({ ...relationQuery }))).thenResolve(result); + await expect(queryService.queryRelations(TestType, relationName, dto, query)).resolves.toBe(result); + return expect(testRelationFn).toHaveBeenCalledWith(dto); + }); + + it('should proxy to the underlying service when calling queryRelations with many dtos', () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const query = {}; + const relationQuery = {}; + const relationResult: TestType[] = []; + const result = new Map([[dtos[0], relationResult]]); + testRelationFn.mockReturnValue(relationQuery); + when(mockRelationService.query(deepEqual({ ...relationQuery }))).thenResolve(relationResult); + return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toEqual(result); + }); + + it('should proxy to the underlying service when calling queryRelations with one dto and a unknown relation', () => { + const relationName = 'unknown'; + const dto = new TestType(); + const query = {}; + const result = [{ foo: 'bar' }]; + when(mockQueryService.queryRelations(TestType, relationName, dto, query)).thenResolve(result); + return expect(queryService.queryRelations(TestType, relationName, dto, query)).resolves.toBe(result); + }); + + it('should proxy to the underlying service when calling queryRelations with many dtos and a unknown relation', () => { + const relationName = 'unknown'; + const dtos = [new TestType()]; + const query = {}; + const result = new Map([[{ foo: 'bar' }, []]]); + when(mockQueryService.queryRelations(TestType, relationName, dtos, query)).thenResolve(result); + return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toBe(result); + }); }); - it('should call the original service if the relation is not in this relation query service', async () => { - const relationName = 'otherRelation'; - const dto = new TestType(); - const result = { foo: 'baz' }; - when(mockQueryService.findRelation(TestType, relationName, dto)).thenResolve(result); - await expect(queryService.findRelation(TestType, relationName, dto)).resolves.toEqual(result); - return expect(testRelationFn).not.toHaveBeenCalled(); + describe('#countRelations', () => { + it('should proxy to the underlying service when calling queryRelations with one dto', async () => { + const relationName = 'test'; + const dto = new TestType(); + const result = 1; + const query = {}; + const relationQuery = {}; + testRelationFn.mockReturnValue(relationQuery); + when(mockRelationService.count(deepEqual({ ...relationQuery }))).thenResolve(result); + await expect(queryService.countRelations(TestType, relationName, dto, query)).resolves.toBe(result); + return expect(testRelationFn).toHaveBeenCalledWith(dto); + }); + + it('should proxy to the underlying service when calling queryRelations with many dtos', () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const query = {}; + const relationQuery = {}; + const relationResult = 1; + const result = new Map([[dtos[0], relationResult]]); + testRelationFn.mockReturnValue(relationQuery); + when(mockRelationService.count(deepEqual({ ...relationQuery }))).thenResolve(relationResult); + return expect(queryService.countRelations(TestType, relationName, dtos, query)).resolves.toEqual(result); + }); + + it('should proxy to the underlying service when calling queryRelations with one dto and a unknown relation', () => { + const relationName = 'unknown'; + const dto = new TestType(); + const query = {}; + const result = 1; + when(mockQueryService.countRelations(TestType, relationName, dto, query)).thenResolve(result); + return expect(queryService.countRelations(TestType, relationName, dto, query)).resolves.toBe(result); + }); + + it('should proxy to the underlying service when calling queryRelations with many dtos and a unknown relation', () => { + const relationName = 'unknown'; + const dtos = [new TestType()]; + const query = {}; + const result = new Map([[{ foo: 'bar' }, 1]]); + when(mockQueryService.countRelations(TestType, relationName, dtos, query)).thenResolve(result); + return expect(queryService.countRelations(TestType, relationName, dtos, query)).resolves.toBe(result); + }); }); - it('should call the original service if the relation is not in this relation query service with multiple DTOs', async () => { - const relationName = 'otherRelation'; - const dtos = [new TestType()]; - const result = new Map([[dtos[0], { foo: 'baz' }]]); - when(mockQueryService.findRelation(TestType, relationName, dtos)).thenResolve(result); - await expect(queryService.findRelation(TestType, relationName, dtos)).resolves.toEqual(result); - return expect(testRelationFn).not.toHaveBeenCalled(); + describe('#removeRelation', () => { + it('should proxy to the underlying service when calling removeRelation', () => { + const relationName = 'test'; + const id = 1; + const relationId = 2; + const result = { foo: 'bar' }; + when(mockQueryService.removeRelation(relationName, id, relationId)).thenResolve(result); + return expect(queryService.removeRelation(relationName, id, relationId)).resolves.toBe(result); + }); }); - it('should proxy to the underlying service when calling queryRelations with one dto', async () => { - const relationName = 'test'; - const dto = new TestType(); - const result = [{ foo: 'bar' }]; - const query = {}; - const relationQuery = {}; - testRelationFn.mockReturnValue(relationQuery); - when(mockRelationService.query(deepEqual({ ...relationQuery }))).thenResolve(result); - await expect(queryService.queryRelations(TestType, relationName, dto, query)).resolves.toBe(result); - return expect(testRelationFn).toHaveBeenCalledWith(dto); + describe('#removeRelations', () => { + it('should proxy to the underlying service when calling removeRelations', () => { + const relationName = 'test'; + const id = 1; + const relationIds = [2]; + const result = { foo: 'bar' }; + when(mockQueryService.removeRelations(relationName, id, relationIds)).thenResolve(result); + return expect(queryService.removeRelations(relationName, id, relationIds)).resolves.toBe(result); + }); }); - - it('should proxy to the underlying service when calling queryRelations with many dtos', () => { - const relationName = 'test'; - const dtos = [new TestType()]; - const query = {}; - const relationQuery = {}; - const relationResult: TestType[] = []; - const result = new Map([[dtos[0], relationResult]]); - testRelationFn.mockReturnValue(relationQuery); - when(mockRelationService.query(deepEqual({ ...relationQuery }))).thenResolve(relationResult); - return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toEqual(result); - }); - - it('should proxy to the underlying service when calling queryRelations with one dto and a unknown relation', () => { - const relationName = 'unknown'; - const dto = new TestType(); - const query = {}; - const result = [{ foo: 'bar' }]; - when(mockQueryService.queryRelations(TestType, relationName, dto, query)).thenResolve(result); - return expect(queryService.queryRelations(TestType, relationName, dto, query)).resolves.toBe(result); - }); - - it('should proxy to the underlying service when calling queryRelations with many dtos and a unknown relation', () => { - const relationName = 'unknown'; - const dtos = [new TestType()]; - const query = {}; - const result = new Map([[{ foo: 'bar' }, []]]); - when(mockQueryService.queryRelations(TestType, relationName, dtos, query)).thenResolve(result); - return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toBe(result); - }); - - it('should proxy to the underlying service when calling removeRelation', () => { - const relationName = 'test'; - const id = 1; - const relationId = 2; - const result = { foo: 'bar' }; - when(mockQueryService.removeRelation(relationName, id, relationId)).thenResolve(result); - return expect(queryService.removeRelation(relationName, id, relationId)).resolves.toBe(result); - }); - it('should proxy to the underlying service when calling removeRelations', () => { - const relationName = 'test'; - const id = 1; - const relationIds = [2]; - const result = { foo: 'bar' }; - when(mockQueryService.removeRelations(relationName, id, relationIds)).thenResolve(result); - return expect(queryService.removeRelations(relationName, id, relationIds)).resolves.toBe(result); - }); - it('should proxy to the underlying service when calling setRelation', () => { - const relationName = 'test'; - const id = 1; - const relationId = 2; - const result = { foo: 'bar' }; - when(mockQueryService.setRelation(relationName, id, relationId)).thenResolve(result); - return expect(queryService.setRelation(relationName, id, relationId)).resolves.toBe(result); + describe('#setRelation', () => { + it('should proxy to the underlying service when calling setRelation', () => { + const relationName = 'test'; + const id = 1; + const relationId = 2; + const result = { foo: 'bar' }; + when(mockQueryService.setRelation(relationName, id, relationId)).thenResolve(result); + return expect(queryService.setRelation(relationName, id, relationId)).resolves.toBe(result); + }); }); }); diff --git a/packages/core/src/services/assembler-query.service.ts b/packages/core/src/services/assembler-query.service.ts index 94a0c7ff0..c78c9f3f7 100644 --- a/packages/core/src/services/assembler-query.service.ts +++ b/packages/core/src/services/assembler-query.service.ts @@ -46,6 +46,10 @@ export class AssemblerQueryService implements QueryService { return this.assembler.convertAsyncToDTOs(this.queryService.query(this.assembler.convertQuery(query))); } + count(filter: Filter): Promise { + return this.queryService.count(this.assembler.convertQuery({ filter }).filter || {}); + } + /** * Query for relations for an array of DTOs. This method will return a map with the DTO as the key and the relations as the value. * @param RelationClass - The class of the relation. @@ -92,6 +96,37 @@ export class AssemblerQueryService implements QueryService { return this.queryService.queryRelations(RelationClass, relationName, this.assembler.convertToEntity(dto), query); } + countRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + ): Promise; + + countRelations( + RelationClass: Class, + relationName: string, + dto: DTO[], + filter: Filter, + ): Promise>; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + ): Promise> { + if (Array.isArray(dto)) { + const entities = this.assembler.convertToEntities(dto); + const relationMap = await this.queryService.countRelations(RelationClass, relationName, entities, filter); + return entities.reduce((map, e, index) => { + const entry = relationMap.get(e) ?? 0; + map.set(dto[index], entry); + return map; + }, new Map()); + } + return this.queryService.countRelations(RelationClass, relationName, this.assembler.convertToEntity(dto), filter); + } /** * Find a relation for an array of DTOs. This will return a Map where the key is the DTO and the value is to relation or undefined if not found. * @param RelationClass - the class of the relation diff --git a/packages/core/src/services/noop-query.service.ts b/packages/core/src/services/noop-query.service.ts index 276a2715e..3b659d299 100644 --- a/packages/core/src/services/noop-query.service.ts +++ b/packages/core/src/services/noop-query.service.ts @@ -66,6 +66,10 @@ export class NoOpQueryService implements QueryService { return Promise.reject(new NotImplementedException('query is not implemented')); } + count(filter: Filter): Promise { + return Promise.reject(new NotImplementedException('count is not implemented')); + } + queryRelations( RelationClass: Class, relationName: string, @@ -89,6 +93,29 @@ export class NoOpQueryService implements QueryService { return Promise.reject(new NotImplementedException('queryRelations is not implemented')); } + countRelations( + RelationClass: Class, + relationName: string, + entity: DTO, + filter: Filter, + ): Promise; + + countRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + ): Promise>; + + countRelations( + RelationClass: Class, + relationName: string, + entity: DTO | DTO[], + filter: Filter, + ): Promise> { + return Promise.reject(new NotImplementedException('countRelations is not implemented')); + } + removeRelation(relationName: string, id: string | number, relationId: string | number): Promise { return Promise.reject(new NotImplementedException('removeRelation is not implemented')); } diff --git a/packages/core/src/services/proxy-query.service.ts b/packages/core/src/services/proxy-query.service.ts index d7a3c71dc..afb5ebf1f 100644 --- a/packages/core/src/services/proxy-query.service.ts +++ b/packages/core/src/services/proxy-query.service.ts @@ -28,7 +28,7 @@ export class ProxyQueryService implements QueryService { * @param dtos - the dtos to find relations for. * @param query - A query to use to filter, page, and sort relations. */ - async queryRelations( + queryRelations( RelationClass: Class, relationName: string, dtos: DTO[], @@ -42,7 +42,7 @@ export class ProxyQueryService implements QueryService { * @param relationName - The name of relation to query for. * @param query - A query to filter, page and sort relations. */ - async queryRelations( + queryRelations( RelationClass: Class, relationName: string, dto: DTO, @@ -61,6 +61,32 @@ export class ProxyQueryService implements QueryService { return this.proxied.queryRelations(RelationClass, relationName, dto, query); } + countRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + ): Promise>; + + countRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + ): Promise; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + ): Promise> { + if (Array.isArray(dto)) { + return this.proxied.countRelations(RelationClass, relationName, dto, filter); + } + return this.proxied.countRelations(RelationClass, relationName, dto, filter); + } + /** * Find a relation for an array of DTOs. This will return a Map where the key is the DTO and the value is to relation or undefined if not found. * @param RelationClass - the class of the relation @@ -124,6 +150,10 @@ export class ProxyQueryService implements QueryService { return this.proxied.query(query); } + count(filter: Filter): Promise { + return this.proxied.count(filter); + } + updateMany>(update: U, filter: Filter): Promise { return this.proxied.updateMany(update, filter); } diff --git a/packages/core/src/services/query.service.ts b/packages/core/src/services/query.service.ts index e6b3e01d3..6f95b6202 100644 --- a/packages/core/src/services/query.service.ts +++ b/packages/core/src/services/query.service.ts @@ -15,6 +15,13 @@ export interface QueryService { */ query(query: Query): Promise; + /** + * Count the number of records that match the filter. + * @param filter - the filter + * @returns a promise with the total number of records. + */ + count(filter: Filter): Promise; + /** * Finds a record by `id`. * @param id - the id of the record to find. @@ -43,6 +50,24 @@ export interface QueryService { query: Query, ): Promise>; + /** + * Count the number of relations + * @param filter - Filter to create a where clause. + */ + countRelations( + RelationClass: Class, + relationName: string, + entity: DTO, + filter: Filter, + ): Promise; + + countRelations( + RelationClass: Class, + relationName: string, + entity: DTO[], + filter: Filter, + ): Promise>; + /** * Finds a single relation. * @param RelationClass - The class to serialize the Relation into diff --git a/packages/core/src/services/relation-query.service.ts b/packages/core/src/services/relation-query.service.ts index 1ee46a893..82233bf6a 100644 --- a/packages/core/src/services/relation-query.service.ts +++ b/packages/core/src/services/relation-query.service.ts @@ -1,6 +1,6 @@ import { Class } from '../common'; import { mergeQuery } from '../helpers'; -import { Query } from '../interfaces'; +import { Filter, Query } from '../interfaces'; import { NoOpQueryService } from './noop-query.service'; import { ProxyQueryService } from './proxy-query.service'; import { QueryService } from './query.service'; @@ -82,6 +82,44 @@ export class RelationQueryService extends ProxyQueryService { return service.query(mergeQuery(query, qf(dto))); } + countRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + ): Promise>; + + countRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + ): Promise; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + ): Promise> { + const serviceRelation = this.getRelation(relationName); + if (!serviceRelation) { + if (Array.isArray(dto)) { + return super.countRelations(RelationClass, relationName, dto, filter); + } + return super.countRelations(RelationClass, relationName, dto, filter); + } + const { query: qf, service } = serviceRelation; + if (Array.isArray(dto)) { + return dto.reduce(async (mapPromise, d) => { + const map = await mapPromise; + const relations = await service.count(mergeQuery({ filter }, qf(d)).filter || {}); + return map.set(d, relations); + }, Promise.resolve(new Map())); + } + return service.count(mergeQuery({ filter }, qf(dto)).filter || {}); + } + /** * Find a relation for an array of DTOs. This will return a Map where the key is the DTO and the value is to relation or undefined if not found. * @param RelationClass - the class of the relation 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 new file mode 100644 index 000000000..eec5def2d --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/connection-object-type-with-total-count.graphql @@ -0,0 +1,55 @@ +type Test { + stringField: String! +} + +type TestTotalCount { + stringField: String! +} + +type TestEdge { + """The node containing the Test""" + node: Test! + + """Cursor for this node.""" + cursor: ConnectionCursor! +} + +"""Cursor for paging through collections""" +scalar ConnectionCursor + +type PageInfo { + """true if paging forward and there are more records.""" + hasNextPage: Boolean + + """true if paging backwards and there are more records.""" + hasPreviousPage: Boolean + + """The cursor of the first returned record.""" + startCursor: ConnectionCursor + + """The cursor of the last returned record.""" + endCursor: ConnectionCursor +} + +type TestTotalCountEdge { + """The node containing the TestTotalCount""" + node: TestTotalCount! + + """Cursor for this node.""" + cursor: ConnectionCursor! +} + +type TestTotalCountConnection { + """Paging information""" + pageInfo: PageInfo! + + """Array of edges.""" + edges: [TestTotalCountEdge!]! + + """Fetch total count of records""" + totalCount: Int! +} + +type Query { + test: TestTotalCountConnection! +} diff --git a/packages/query-graphql/__tests__/__fixtures__/index.ts b/packages/query-graphql/__tests__/__fixtures__/index.ts index 34f3d700f..f88f9ac81 100644 --- a/packages/query-graphql/__tests__/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/__fixtures__/index.ts @@ -41,4 +41,7 @@ export const cursorQueryArgsTypeSDL = readGraphql(resolve(__dirname, './cursor-q export const offsetQueryArgsTypeSDL = readGraphql(resolve(__dirname, './offset-query-args-type.graphql')); export const noPagingQueryArgsTypeSDL = readGraphql(resolve(__dirname, './no-paging-query-args-type.graphql')); export const connectionObjectTypeSDL = readGraphql(resolve(__dirname, './connection-object-type.graphql')); +export const connectionObjectTypeWithTotalCountSDL = readGraphql( + resolve(__dirname, './connection-object-type-with-total-count.graphql'), +); export const edgeObjectTypeSDL = readGraphql(resolve(__dirname, './edge-object-type.graphql')); diff --git a/packages/query-graphql/__tests__/decorators/skip-if.decorator.spec.ts b/packages/query-graphql/__tests__/decorators/skip-if.decorator.spec.ts new file mode 100644 index 000000000..f3a13c426 --- /dev/null +++ b/packages/query-graphql/__tests__/decorators/skip-if.decorator.spec.ts @@ -0,0 +1,101 @@ +// eslint-disable-next-line max-classes-per-file +import { SkipIf } from '../../src/decorators'; + +describe('@SkipIf decorator', () => { + describe('class decorator', () => { + it('should call the decorator if the condition is false', () => { + const dec = jest.fn(); + @SkipIf(() => false, dec) + class TestSkipDecorator {} + expect(dec).toHaveBeenCalledWith(TestSkipDecorator); + }); + + it('should not call the decorator if the condition is true', () => { + const dec = jest.fn(); + + @SkipIf(() => true, dec) + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestSkipDecorator {} + expect(dec).not.toHaveBeenCalled(); + }); + }); + + describe('property decorator', () => { + it('should call the decorator if the condition is false', () => { + const dec = jest.fn(); + class TestSkipDecorator { + @SkipIf(() => false, dec) + prop!: string; + } + expect(dec).toHaveBeenCalledWith(TestSkipDecorator.prototype, 'prop', undefined); + }); + + it('should not call the decorator if the condition is true', () => { + const dec = jest.fn(); + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestSkipDecorator { + @SkipIf(() => true, dec) + prop!: string; + } + expect(dec).not.toHaveBeenCalled(); + }); + }); + + describe('method decorator', () => { + it('should call the decorator if the condition is false', () => { + const dec = jest.fn(); + class TestSkipDecorator { + @SkipIf(() => false, dec) + prop(): string { + return 'str'; + } + } + expect(dec).toHaveBeenCalledWith( + TestSkipDecorator.prototype, + 'prop', + Object.getOwnPropertyDescriptor(TestSkipDecorator.prototype, 'prop'), + ); + }); + + it('should not call the decorator if the condition is true', () => { + const dec = jest.fn(); + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestSkipDecorator { + @SkipIf(() => true, dec) + prop(): string { + return 'str'; + } + } + expect(dec).not.toHaveBeenCalled(); + }); + }); + + describe('parameter decorator', () => { + it('should call the decorator if the condition is false', () => { + const dec = jest.fn(); + class TestSkipDecorator { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prop(@SkipIf(() => false, dec) param: string): string { + return 'str'; + } + } + expect(dec).toHaveBeenCalledWith(TestSkipDecorator.prototype, 'prop', 0); + }); + + it('should not call the decorator if the condition is true', () => { + const dec = jest.fn(); + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unused-vars + class TestSkipDecorator { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prop(@SkipIf(() => true, dec) param: string): string { + return 'str'; + } + } + expect(dec).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/query-graphql/__tests__/loaders/count-relations.loader.spec.ts b/packages/query-graphql/__tests__/loaders/count-relations.loader.spec.ts new file mode 100644 index 000000000..d166a428d --- /dev/null +++ b/packages/query-graphql/__tests__/loaders/count-relations.loader.spec.ts @@ -0,0 +1,87 @@ +import { QueryService } from '@nestjs-query/core'; +import { mock, instance, when, deepEqual } from 'ts-mockito'; +import { CountRelationsLoader } from '../../src/loader'; + +describe('CountRelationsLoader', () => { + describe('createLoader', () => { + class DTO { + id!: string; + } + + class RelationDTO { + id!: string; + } + + it('should return a function that accepts a filter', () => { + const service = mock>(); + const countRelationsLoader = new CountRelationsLoader(RelationDTO, 'relation'); + expect(countRelationsLoader.createLoader(instance(service))).toBeInstanceOf(Function); + }); + + it('should try to load the relations with the filter', () => { + const service = mock>(); + const countRelationsLoader = new CountRelationsLoader(RelationDTO, 'relation').createLoader(instance(service)); + const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }]; + when(service.countRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual({}))).thenResolve( + new Map([ + [dtos[0], 1], + [dtos[1], 2], + ]), + ); + return expect( + countRelationsLoader([ + { dto: dtos[0], filter: {} }, + { dto: dtos[1], filter: {} }, + ]), + ).resolves.toEqual([1, 2]); + }); + + it('should try return an empty array for each dto is no results are found', () => { + const service = mock>(); + const countRelationsLoader = new CountRelationsLoader(RelationDTO, 'relation').createLoader(instance(service)); + const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }]; + when(service.countRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual({}))).thenResolve( + new Map([[dtos[0], 1]]), + ); + return expect( + countRelationsLoader([ + { dto: dtos[0], filter: {} }, + { dto: dtos[1], filter: {} }, + ]), + ).resolves.toEqual([1, 0]); + }); + + it('should group queryRelations calls by query and return in the correct order', () => { + const service = mock>(); + const countRelationsLoader = new CountRelationsLoader(RelationDTO, 'relation').createLoader(instance(service)); + const dtos: DTO[] = [{ id: 'dto-1' }, { id: 'dto-2' }, { id: 'dto-3' }, { id: 'dto-4' }]; + when( + service.countRelations( + RelationDTO, + 'relation', + deepEqual([dtos[0], dtos[2]]), + deepEqual({ id: { isNot: null } }), + ), + ).thenResolve( + new Map([ + [dtos[0], 1], + [dtos[2], 2], + ]), + ); + when(service.countRelations(RelationDTO, 'relation', deepEqual([dtos[1], dtos[3]]), deepEqual({}))).thenResolve( + new Map([ + [dtos[1], 3], + [dtos[3], 4], + ]), + ); + return expect( + countRelationsLoader([ + { dto: dtos[0], filter: { id: { isNot: null } } }, + { dto: dtos[1], filter: {} }, + { dto: dtos[2], filter: { id: { isNot: null } } }, + { dto: dtos[3], filter: {} }, + ]), + ).resolves.toEqual([1, 3, 2, 4]); + }); + }); +}); diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts b/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts index 56fe766bc..9e27469b6 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/index.ts @@ -92,6 +92,9 @@ export const readCustomConnectionResolverSDL = readGraphql( ); export const readCustomQueryResolverSDL = readGraphql(resolve(__dirname, 'read', 'read-custom-query.resolver.graphql')); export const readOffsetQueryResolverSDL = readGraphql(resolve(__dirname, 'read', 'read-offset-query.resolver.graphql')); +export const readConnectionWithTotalCountSDL = readGraphql( + resolve(__dirname, 'read', 'read-connection-with-total-count.resolver.graphql'), +); export const updateBasicResolverSDL = readGraphql(resolve(__dirname, 'update', 'update-basic.resolver.graphql')); export const updateDisabledResolverSDL = readGraphql(resolve(__dirname, 'update', 'update-disabled.resolver.graphql')); diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql new file mode 100644 index 000000000..c93fe52cd --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-connection-with-total-count.resolver.graphql @@ -0,0 +1,146 @@ +type TestResolverDTO { + id: ID! + stringField: String! +} + +type TestResolverDTOEdge { + """The node containing the TestResolverDTO""" + node: TestResolverDTO! + + """Cursor for this node.""" + cursor: ConnectionCursor! +} + +"""Cursor for paging through collections""" +scalar ConnectionCursor + +type PageInfo { + """true if paging forward and there are more records.""" + hasNextPage: Boolean + + """true if paging backwards and there are more records.""" + hasPreviousPage: Boolean + + """The cursor of the first returned record.""" + startCursor: ConnectionCursor + + """The cursor of the last returned record.""" + endCursor: ConnectionCursor +} + +type TotalCountDTO { + id: ID! + stringField: String! +} + +type TotalCountDTOEdge { + """The node containing the TotalCountDTO""" + node: TotalCountDTO! + + """Cursor for this node.""" + cursor: ConnectionCursor! +} + +type TotalCountDTOConnection { + """Paging information""" + pageInfo: PageInfo! + + """Array of edges.""" + edges: [TotalCountDTOEdge!]! + + """Fetch total count of records""" + totalCount: Int! +} + +type Query { + totalCountDTO(id: ID!): TotalCountDTO + totalCountDTOS( + """Limit or page results.""" + paging: CursorPaging = {first: 10} + + """Specify to filter the records returned.""" + filter: TotalCountDTOFilter = {} + + """Specify to sort results.""" + sorting: [TotalCountDTOSort!] = [] + ): TotalCountDTOConnection! + test: TotalCountDTO! +} + +input CursorPaging { + """Paginate before opaque cursor""" + before: ConnectionCursor + + """Paginate after opaque cursor""" + after: ConnectionCursor + + """Paginate first""" + first: Int + + """Paginate last""" + last: Int +} + +input TotalCountDTOFilter { + and: [TotalCountDTOFilter!] + or: [TotalCountDTOFilter!] + id: IDFilterComparison + stringField: StringFieldComparison +} + +input IDFilterComparison { + is: Boolean + isNot: Boolean + eq: ID + neq: ID + gt: ID + gte: ID + lt: ID + lte: ID + like: ID + notLike: ID + iLike: ID + notILike: ID + in: [ID!] + notIn: [ID!] +} + +input StringFieldComparison { + is: Boolean + isNot: Boolean + eq: String + neq: String + gt: String + gte: String + lt: String + lte: String + like: String + notLike: String + iLike: String + notILike: String + in: [String!] + notIn: [String!] +} + +input TotalCountDTOSort { + field: TotalCountDTOSortFields! + direction: SortDirection! + nulls: SortNulls +} + +enum TotalCountDTOSortFields { + id + stringField +} + +"""Sort Directions""" +enum SortDirection { + ASC + DESC +} + +"""Sort Nulls Options""" +enum SortNulls { + NULLS_FIRST + NULLS_LAST +} diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-name.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-name.resolver.graphql index 98ec28122..1ed7c42b2 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-name.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/read/read-custom-name.resolver.graphql @@ -28,7 +28,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestResolverDTOConnection { +type TestConnection { """Paging information""" pageInfo: PageInfo! @@ -47,7 +47,7 @@ type Query { """Specify to sort results.""" sorting: [TestResolverDTOSort!] = [] - ): TestResolverDTOConnection! + ): TestConnection! } input CursorPaging { diff --git a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-custom-name.resolver.graphql b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-custom-name.resolver.graphql index 2f98dce2e..c95cfe65d 100644 --- a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-custom-name.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-custom-name.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection! + ): TestResolverDTOTestsConnection! } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTOTestsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-nullable.resolver.graphql b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-nullable.resolver.graphql index 1118459dc..6f9463be9 100644 --- a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-nullable.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many-nullable.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection + ): TestResolverDTORelationsConnection } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTORelationsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many.resolver.graphql b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many.resolver.graphql index 640061eb7..62a0fadcb 100644 --- a/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/federation/__fixtures__/federation/federation-relation-many.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection! + ): TestResolverDTORelationsConnection! } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTORelationsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts index 22c288f42..8c6bdba5c 100644 --- a/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/federation/federation.resolver.spec.ts @@ -177,6 +177,7 @@ describe('FederationResolver', () => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); @@ -223,6 +224,7 @@ describe('FederationResolver', () => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); }); diff --git a/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts index 4b01b8382..256793c25 100644 --- a/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/read.resolver.spec.ts @@ -15,6 +15,7 @@ import { expectSDL } from '../__fixtures__'; import { createResolverFromNest, readBasicResolverSDL, + readConnectionWithTotalCountSDL, readCustomConnectionResolverSDL, readCustomNameResolverSDL, readCustomQueryResolverSDL, @@ -134,8 +135,29 @@ describe('ReadResolver', () => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); + + it('should call the service count with the provided input', async () => { + const { resolver, mockService } = await createResolverFromNest(TestResolver); + const input: CursorQueryArgsType = { + filter: { + stringField: { eq: 'foo' }, + }, + paging: { first: 1 }, + }; + const output: TestResolverDTO[] = [ + { + id: 'id-1', + stringField: 'foo', + }, + ]; + when(mockService.query(objectContaining({ ...input, paging: { limit: 2, offset: 0 } }))).thenResolve(output); + const result = await resolver.queryMany(input); + when(mockService.count(objectContaining(input.filter!))).thenResolve(10); + return expect(result.totalCount).resolves.toBe(10); + }); }); describe('queryMany array connection', () => { @@ -216,4 +238,18 @@ describe('ReadResolver', () => { return expect(result).toEqual(output); }); }); + + it('should expose totalCount on connections if enableTotalCount is true ', () => { + @ObjectType('TotalCountDTO') + class TotalCountDTO extends TestResolverDTO {} + @Resolver(() => TotalCountDTO) + class TestTotalCountSDLResolver extends ReadResolver(TotalCountDTO, { enableTotalCount: true }) { + @Query(() => TotalCountDTO) + test(): TotalCountDTO { + return { id: '1', stringField: 'foo' }; + } + } + + return expectSDL([TestTotalCountSDLResolver], readConnectionWithTotalCountSDL); + }); }); diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-custom-name.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-custom-name.resolver.graphql index 2f98dce2e..c95cfe65d 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-custom-name.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-custom-name.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection! + ): TestResolverDTOTestsConnection! } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTOTestsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-nullable.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-nullable.resolver.graphql index 1118459dc..6f9463be9 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-nullable.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many-nullable.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection + ): TestResolverDTORelationsConnection } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTORelationsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many.resolver.graphql index 640061eb7..62a0fadcb 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/read/read-relation-many.resolver.graphql @@ -10,7 +10,7 @@ type TestResolverDTO { """Specify to sort results.""" sorting: [TestRelationDTOSort!] = [] - ): TestRelationDTOConnection! + ): TestResolverDTORelationsConnection! } input CursorPaging { @@ -121,7 +121,7 @@ type PageInfo { endCursor: ConnectionCursor } -type TestRelationDTOConnection { +type TestResolverDTORelationsConnection { """Paging information""" pageInfo: PageInfo! diff --git a/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts index 07aebd1b8..0b9b8ed82 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/read-relation.resolver.spec.ts @@ -190,9 +190,43 @@ describe('ReadRelationsResolver', () => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); + it('should call the service countRelations with the provided dto', async () => { + const { resolver, mockService } = await createResolverFromNest(TestResolver); + const dto: TestResolverDTO = { + id: 'id-1', + stringField: 'foo', + }; + const query: CursorQueryArgsType = { + filter: { id: { eq: 'id-2' } }, + paging: { first: 1 }, + }; + const output: TestRelationDTO[] = [ + { + id: 'id-2', + testResolverId: dto.id, + }, + ]; + when( + mockService.queryRelations( + TestRelationDTO, + 'relations', + deepEqual([dto]), + objectContaining({ ...query, paging: { limit: 2, offset: 0 } }), + ), + ).thenResolve(new Map([[dto, output]])); + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const result = await resolver.queryRelations(dto, query, {}); + when( + mockService.countRelations(TestRelationDTO, 'relations', deepEqual([dto]), objectContaining(query.filter!)), + ).thenResolve(new Map([[dto, 10]])); + return expect(result.totalCount).resolves.toBe(10); + }); + it('should call the service findRelation with the provided dto and correct relation name', async () => { const { resolver, mockService } = await createResolverFromNest(TestResolver); const dto: TestResolverDTO = { @@ -236,6 +270,7 @@ describe('ReadRelationsResolver', () => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); }); 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 dd111d8b0..e30fc69fa 100644 --- a/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts +++ b/packages/query-graphql/__tests__/types/connection/connection.type.spec.ts @@ -1,7 +1,7 @@ import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; import { plainToClass } from 'class-transformer'; import { ConnectionType, CursorPagingType } from '../../../src'; -import { connectionObjectTypeSDL, expectSDL } from '../../__fixtures__'; +import { connectionObjectTypeSDL, connectionObjectTypeWithTotalCountSDL, expectSDL } from '../../__fixtures__'; describe('ConnectionType', (): void => { @ObjectType('Test') @@ -10,13 +10,19 @@ describe('ConnectionType', (): void => { stringField!: string; } + @ObjectType('TestTotalCount') + class TestTotalCountDto { + @Field() + stringField!: string; + } + const TestConnection = ConnectionType(TestDto); const createPage = (paging: CursorPagingType): CursorPagingType => { return plainToClass(CursorPagingType(), paging); }; - it('should store metadata', async () => { + it('should create the connection SDL', async () => { @Resolver() class TestConnectionTypeResolver { @Query(() => TestConnection) @@ -28,6 +34,19 @@ describe('ConnectionType', (): void => { return expectSDL([TestConnectionTypeResolver], connectionObjectTypeSDL); }); + it('should create the connection SDL with totalCount if enabled', async () => { + const TestConnectionWithTotalCount = ConnectionType(TestTotalCountDto, { enableTotalCount: true }); + @Resolver() + class TestConnectionTypeResolver { + @Query(() => TestConnectionWithTotalCount) + test(): ConnectionType | undefined { + return undefined; + } + } + + return expectSDL([TestConnectionTypeResolver], connectionObjectTypeWithTotalCountSDL); + }); + it('should throw an error if the object is not registered with @nestjs/graphql', () => { class TestBadDto { @Field() @@ -43,6 +62,7 @@ describe('ConnectionType', (): void => { expect(new TestConnection()).toEqual({ pageInfo: { hasNextPage: false, hasPreviousPage: false }, edges: [], + totalCountFn: expect.any(Function), }); }); @@ -57,6 +77,7 @@ describe('ConnectionType', (): void => { hasNextPage: false, hasPreviousPage: false, }, + totalCountFn: expect.any(Function), }); }); @@ -70,6 +91,7 @@ describe('ConnectionType', (): void => { hasNextPage: false, hasPreviousPage: false, }, + totalCountFn: expect.any(Function), }); }); @@ -101,6 +123,7 @@ describe('ConnectionType', (): void => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); @@ -131,6 +154,7 @@ describe('ConnectionType', (): void => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); }); @@ -159,6 +183,7 @@ describe('ConnectionType', (): void => { hasPreviousPage: false, startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', }, + totalCountFn: expect.any(Function), }); }); @@ -191,6 +216,7 @@ describe('ConnectionType', (): void => { hasPreviousPage: true, startCursor: 'YXJyYXljb25uZWN0aW9uOjE=', }, + totalCountFn: expect.any(Function), }); }); }); @@ -209,6 +235,7 @@ describe('ConnectionType', (): void => { hasNextPage: false, hasPreviousPage: false, }, + totalCountFn: expect.any(Function), }); }); }); diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 4bd5e746e..8c99d1fc3 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -7,3 +7,4 @@ export * from './resolver-field.decorator'; export { Reference, ReferenceDecoratorOpts, ReferenceTypeFunc } from './reference.decorator'; export { ResolverSubscription, SubscriptionResolverMethodOpts } from './resolver-subscription.decorator'; export { InjectPubSub } from './inject-pub-sub.decorator'; +export * from './skip-if.decorator'; diff --git a/packages/query-graphql/src/decorators/skip-if.decorator.ts b/packages/query-graphql/src/decorators/skip-if.decorator.ts new file mode 100644 index 000000000..5984733c9 --- /dev/null +++ b/packages/query-graphql/src/decorators/skip-if.decorator.ts @@ -0,0 +1,35 @@ +/** + * @internal + * Wraps Args to allow skipping decorating + * @param check - checker to run. + * @param decorators - The decorators to apply + */ +export function SkipIf( + check: () => boolean, + ...decorators: (MethodDecorator | PropertyDecorator | ClassDecorator | ParameterDecorator)[] +): MethodDecorator & PropertyDecorator & ClassDecorator & ParameterDecorator { + if (check()) { + return (): void => {}; + } + // eslint-disable-next-line @typescript-eslint/ban-types + return ( + // eslint-disable-next-line @typescript-eslint/ban-types + target: TFunction | object, + propertyKey?: string | symbol, + descriptorOrIndex?: TypedPropertyDescriptor | number, + ) => { + decorators.forEach((decorator) => { + if (target instanceof Function && !descriptorOrIndex) { + return (decorator as ClassDecorator)(target); + } + if (typeof descriptorOrIndex === 'number') { + return (decorator as ParameterDecorator)(target, propertyKey as string | symbol, descriptorOrIndex); + } + return (decorator as MethodDecorator | PropertyDecorator)( + target, + propertyKey as string | symbol, + descriptorOrIndex as TypedPropertyDescriptor, + ); + }); + }; +} diff --git a/packages/query-graphql/src/loader/count-relations.loader.ts b/packages/query-graphql/src/loader/count-relations.loader.ts new file mode 100644 index 000000000..93d29dea3 --- /dev/null +++ b/packages/query-graphql/src/loader/count-relations.loader.ts @@ -0,0 +1,49 @@ +import { Class, Filter, QueryService } from '@nestjs-query/core'; +import { NestjsQueryDataloader } from './relations.loader'; + +type CountRelationsArgs = { dto: DTO; filter: Filter }; +type CountRelationsMap = Map & { index: number })[]>; + +export class CountRelationsLoader + implements NestjsQueryDataloader, number | Error> { + constructor(readonly RelationDTO: Class, readonly relationName: string) {} + + createLoader(service: QueryService) { + return async (queryArgs: ReadonlyArray>): Promise<(number | Error)[]> => { + // group + const queryMap = this.groupQueries(queryArgs); + return this.loadResults(service, queryMap); + }; + } + + private async loadResults( + service: QueryService, + countRelationsMap: CountRelationsMap, + ): Promise { + const results: number[] = []; + await Promise.all( + [...countRelationsMap.values()].map(async (args) => { + const { filter } = args[0]; + const dtos = args.map((a) => a.dto); + const relationCountResults = await service.countRelations(this.RelationDTO, this.relationName, dtos, filter); + const dtoRelations = dtos.map((dto) => relationCountResults.get(dto) ?? 0); + dtoRelations.forEach((relationCount, index) => { + results[args[index].index] = relationCount; + }); + }), + ); + return results; + } + + private groupQueries(countArgs: ReadonlyArray>): CountRelationsMap { + // group + return countArgs.reduce((map, args, index) => { + const filterJson = JSON.stringify(args.filter); + if (!map.has(filterJson)) { + map.set(filterJson, []); + } + map.get(filterJson)?.push({ ...args, index }); + return map; + }, new Map & { index: number })[]>()); + } +} diff --git a/packages/query-graphql/src/loader/index.ts b/packages/query-graphql/src/loader/index.ts index 828833171..36b979f7d 100644 --- a/packages/query-graphql/src/loader/index.ts +++ b/packages/query-graphql/src/loader/index.ts @@ -1,3 +1,4 @@ export * from './find-relations.loader'; export * from './query-relations.loader'; +export * from './count-relations.loader'; export * from './dataloader.factory'; diff --git a/packages/query-graphql/src/metadata/metadata-storage.ts b/packages/query-graphql/src/metadata/metadata-storage.ts index 122b85d35..4ed4053c3 100644 --- a/packages/query-graphql/src/metadata/metadata-storage.ts +++ b/packages/query-graphql/src/metadata/metadata-storage.ts @@ -103,22 +103,22 @@ export class GraphQLQueryMetadataStorage { return this.getValue(this.sortTypeStorage, type); } - addConnectionType>( + addConnectionType( connectionType: ConnectionTypes, - type: Class, - staticConnectionType: SCT, + connectionName: string, + staticConnectionType: StaticConnectionType, ): void { this.connectionTypeStorage.set( - `${connectionType}-${type.name}`, + `${connectionType}-${connectionName}`, staticConnectionType as StaticConnectionType, ); } getConnectionType>( connectionType: ConnectionTypes, - type: Class, + connectionName: string, ): SCT | undefined { - return this.getValue(this.connectionTypeStorage, `${connectionType}-${type.name}`); + return this.getValue(this.connectionTypeStorage, `${connectionType}-${connectionName}`); } addEdgeType(type: Class, edgeType: Class>): void { diff --git a/packages/query-graphql/src/resolvers/crud.resolver.ts b/packages/query-graphql/src/resolvers/crud.resolver.ts index b0c675276..16a71b2cc 100644 --- a/packages/query-graphql/src/resolvers/crud.resolver.ts +++ b/packages/query-graphql/src/resolvers/crud.resolver.ts @@ -25,6 +25,7 @@ export interface CRUDResolverOpts< UpdateDTOClass?: Class; enableSubscriptions?: boolean; pagingStrategy?: PS; + enableTotalCount?: boolean; create?: CreateResolverOpts; read?: R; update?: UpdateResolverOpts; @@ -77,6 +78,7 @@ export const CRUDResolver = < UpdateDTOClass, enableSubscriptions, pagingStrategy, + enableTotalCount, relations = {}, references = {}, create = {}, @@ -87,9 +89,13 @@ export const CRUDResolver = < } = opts; const referencable = Refereceable(DTOClass, referenceBy); - const relatable = Relatable(DTOClass, { relations, references, pagingStrategy }); + const relatable = Relatable(DTOClass, { relations, references, pagingStrategy, enableTotalCount }); const creatable = Creatable(DTOClass, { CreateDTOClass, enableSubscriptions, ...create }); - const readable = Readable(DTOClass, { pagingStrategy, ...read } as MergePagingStrategyOpts); + const readable = Readable(DTOClass, { enableTotalCount, pagingStrategy, ...read } as MergePagingStrategyOpts< + DTO, + R, + PS + >); const updateable = Updateable(DTOClass, { UpdateDTOClass, enableSubscriptions, ...update }); const deleteResolver = DeleteResolver(DTOClass, { enableSubscriptions, ...deleteArgs }); diff --git a/packages/query-graphql/src/resolvers/read.resolver.ts b/packages/query-graphql/src/resolvers/read.resolver.ts index 462a4719a..aaef538ea 100644 --- a/packages/query-graphql/src/resolvers/read.resolver.ts +++ b/packages/query-graphql/src/resolvers/read.resolver.ts @@ -4,6 +4,7 @@ import omit from 'lodash.omit'; import { getDTONames } from '../common'; import { ResolverQuery } from '../decorators'; import { ConnectionType, QueryArgsType, QueryArgsTypeOpts, StaticConnectionType, StaticQueryArgsType } from '../types'; +import { CursorConnectionOptions } from '../types/connection/cursor'; import { CursorQueryArgsTypeOpts } from '../types/query/query-args'; import { transformAndValidate } from './helpers'; import { @@ -25,7 +26,8 @@ export type ReadResolverOpts = { QueryArgs?: StaticQueryArgsType; Connection?: StaticConnectionType; } & ResolverOpts & - QueryArgsTypeOpts; + QueryArgsTypeOpts & + Pick; export interface ReadResolver, CT extends ConnectionType> extends ServiceResolver { @@ -42,9 +44,11 @@ export const Readable = >(DTOClass: >( BaseClass: B, ): Class> & B => { + const { baseNameLower, pluralBaseNameLower, baseName } = getDTONames(DTOClass, opts); const { QueryArgs = QueryArgsType(DTOClass, opts) } = opts; - const { Connection = ConnectionType(DTOClass, QueryArgs) } = opts; - const { baseNameLower, pluralBaseNameLower } = getDTONames(DTOClass, opts); + const { + Connection = ConnectionType(DTOClass, QueryArgs, { ...opts, connectionName: `${baseName}Connection` }), + } = opts; const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'QueryArgs', 'Connection'); @ArgsType() @@ -60,7 +64,11 @@ export const Readable = >(DTOClass: @ResolverQuery(() => Connection.resolveType, { name: pluralBaseNameLower }, commonResolverOpts, opts.many ?? {}) async queryMany(@Args() query: QA): Promise> { const qa = await transformAndValidate(QA, query); - return Connection.createFromPromise((q) => this.service.query(q), qa); + return Connection.createFromPromise( + (q) => this.service.query(q), + qa, + (filter) => this.service.count(filter), + ); } } return ReadResolverBase as Class> & B; 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 bde476a68..9a2650eff 100644 --- a/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/read-relations.resolver.ts @@ -3,7 +3,7 @@ import { ExecutionContext } from '@nestjs/common'; import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql'; import { getDTONames } from '../../common'; import { ResolverField } from '../../decorators'; -import { DataLoaderFactory, FindRelationsLoader, QueryRelationsLoader } from '../../loader'; +import { CountRelationsLoader, DataLoaderFactory, FindRelationsLoader, QueryRelationsLoader } from '../../loader'; import { ConnectionType, PagingStrategies, QueryArgsType } from '../../types'; import { transformAndValidate } from '../helpers'; import { BaseServiceResolver, ServiceResolver } from '../resolver.interface'; @@ -12,6 +12,7 @@ import { RelationsOpts, ResolverRelation } from './relations.interface'; export interface ReadRelationsResolverOpts extends RelationsOpts { pagingStrategy?: PagingStrategies; + enableTotalCount?: boolean; } const ReadOneRelationMixin = (DTOClass: Class, relation: ResolverRelation) => < @@ -49,14 +50,18 @@ const ReadManyRelationMixin = (DTOClass: Class, relation: Re } const commonResolverOpts = removeRelationOpts(relation); const relationDTO = relation.DTO; + const dtoName = getDTONames(DTOClass).baseName; const { pluralBaseNameLower, pluralBaseName } = getDTONames(relationDTO, { dtoName: relation.dtoName }); const relationName = relation.relationName ?? pluralBaseNameLower; - const loaderName = `load${pluralBaseName}For${DTOClass.name}`; + const relationLoaderName = `load${pluralBaseName}For${DTOClass.name}`; + const countRelationLoaderName = `count${pluralBaseName}For${DTOClass.name}`; const queryLoader = new QueryRelationsLoader(relationDTO, relationName); + const countLoader = new CountRelationsLoader(relationDTO, relationName); + const connectionName = `${dtoName}${pluralBaseName}Connection`; @ArgsType() class RelationQA extends QueryArgsType(relationDTO, relation) {} - const CT = ConnectionType(relationDTO, RelationQA); + const CT = ConnectionType(relationDTO, RelationQA, { ...relation, connectionName }); @Resolver(() => DTOClass, { isAbstract: true }) class ReadManyMixin extends Base { @ResolverField(pluralBaseNameLower, () => CT.resolveType, { nullable: relation.nullable }, commonResolverOpts) @@ -66,8 +71,21 @@ const ReadManyRelationMixin = (DTOClass: Class, relation: Re @Context() context: ExecutionContext, ): Promise> { const qa = await transformAndValidate(RelationQA, q); - const loader = DataLoaderFactory.getOrCreateLoader(context, loaderName, queryLoader.createLoader(this.service)); - return CT.createFromPromise((query) => loader.load({ dto, query }), qa); + const relationLoader = DataLoaderFactory.getOrCreateLoader( + context, + relationLoaderName, + queryLoader.createLoader(this.service), + ); + const relationCountLoader = DataLoaderFactory.getOrCreateLoader( + context, + countRelationLoaderName, + countLoader.createLoader(this.service), + ); + return CT.createFromPromise( + (query) => relationLoader.load({ dto, query }), + qa, + (filter) => relationCountLoader.load({ dto, filter }), + ); } } return ReadManyMixin; @@ -78,10 +96,13 @@ export const ReadRelationsMixin = (DTOClass: Class, relations: ReadRel >( Base: B, ): B => { - const { many, one, pagingStrategy } = relations; + const { many, one, pagingStrategy, enableTotalCount } = relations; const manyRelations = flattenRelations(many ?? {}); const oneRelations = flattenRelations(one ?? {}); - const WithMany = manyRelations.reduce((RB, a) => ReadManyRelationMixin(DTOClass, { pagingStrategy, ...a })(RB), Base); + const WithMany = manyRelations.reduce( + (RB, a) => ReadManyRelationMixin(DTOClass, { enableTotalCount, pagingStrategy, ...a })(RB), + Base, + ); return oneRelations.reduce((RB, a) => ReadOneRelationMixin(DTOClass, a)(RB), WithMany); }; diff --git a/packages/query-graphql/src/resolvers/relations/relations.interface.ts b/packages/query-graphql/src/resolvers/relations/relations.interface.ts index 79285c4d8..a3f5cbe33 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.interface.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.interface.ts @@ -2,6 +2,7 @@ import { Class } from '@nestjs-query/core'; import { DTONamesOpts } from '../../common'; import { ResolverMethodOpts } from '../../decorators'; import { QueryArgsTypeOpts } from '../../types'; +import { CursorConnectionOptions } from '../../types/connection/cursor'; export type ReferencesKeys = { [F in keyof Reference]?: keyof DTO; @@ -52,7 +53,8 @@ export type ResolverRelation = { disableRemove?: boolean; } & DTONamesOpts & ResolverMethodOpts & - QueryArgsTypeOpts; + QueryArgsTypeOpts & + Pick; export type RelationTypeMap = Record; diff --git a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts index af258498d..d8a582c87 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts @@ -10,6 +10,7 @@ import { UpdateRelationsMixin } from './update-relations.resolver'; export interface RelatableOpts { pagingStrategy?: PagingStrategies; + enableTotalCount?: boolean; relations: RelationsOpts; references: ReferencesOpts; } @@ -19,7 +20,7 @@ export const Relatable = (DTOClass: Class, opts: RelatableOpts) = >( Base: B, ): B => { - const { pagingStrategy, references, relations } = opts; + const { pagingStrategy, enableTotalCount, references, relations } = opts; const metaRelations = getRelationsFromMetadata(DTOClass); const mergedRelations = mergeRelations(relations, metaRelations); @@ -27,7 +28,7 @@ export const Relatable = (DTOClass: Class, opts: RelatableOpts) = const mergedReferences = mergeReferences(references, metaReferences); const referencesMixin = ReferencesRelationMixin(DTOClass, mergedReferences); - const readRelationsMixin = ReadRelationsMixin(DTOClass, { ...mergedRelations, pagingStrategy }); + const readRelationsMixin = ReadRelationsMixin(DTOClass, { ...mergedRelations, enableTotalCount, pagingStrategy }); const updateRelationsMixin = UpdateRelationsMixin(DTOClass, mergedRelations); return referencesMixin( diff --git a/packages/query-graphql/src/types/connection/array-connection.type.ts b/packages/query-graphql/src/types/connection/array-connection.type.ts index 5bd475db7..31cc4c7a8 100644 --- a/packages/query-graphql/src/types/connection/array-connection.type.ts +++ b/packages/query-graphql/src/types/connection/array-connection.type.ts @@ -1,10 +1,8 @@ -import { Class, Query } from '@nestjs-query/core'; +import { Class } from '@nestjs-query/core'; import { OffsetQueryArgsType, NoPagingQueryArgsType } from '../query/query-args'; -import { StaticConnection } from './interfaces'; +import { QueryMany, StaticConnection } from './interfaces'; import { getMetadataStorage } from '../../metadata'; -export type QueryMany = (query: Query) => Promise; - export type StaticArrayConnectionType = StaticConnection< DTO, OffsetQueryArgsType | NoPagingQueryArgsType, @@ -14,7 +12,7 @@ export type StaticArrayConnectionType = StaticConnection< export type ArrayConnectionType = DTO[]; export function ArrayConnectionType(TItemClass: Class): StaticArrayConnectionType { const metadataStorage = getMetadataStorage(); - const existing = metadataStorage.getConnectionType>('array', TItemClass); + const existing = metadataStorage.getConnectionType>('array', TItemClass.name); if (existing) { return existing; } @@ -28,6 +26,6 @@ export function ArrayConnectionType(TItemClass: Class): StaticArrayCon return queryMany(query); } } - metadataStorage.addConnectionType('array', TItemClass, AbstractConnection); + metadataStorage.addConnectionType('array', TItemClass.name, AbstractConnection); return AbstractConnection; } diff --git a/packages/query-graphql/src/types/connection/connection.type.ts b/packages/query-graphql/src/types/connection/connection.type.ts index e48600016..0ccb9c0de 100644 --- a/packages/query-graphql/src/types/connection/connection.type.ts +++ b/packages/query-graphql/src/types/connection/connection.type.ts @@ -6,8 +6,9 @@ import { StaticQueryArgsType, } from '../query'; import { ArrayConnectionType, StaticArrayConnectionType } from './array-connection.type'; -import { StaticCursorConnectionType, CursorConnectionType } from './cursor'; +import { StaticCursorConnectionType, CursorConnectionType, CursorConnectionOptions } from './cursor'; import { Connection } from './interfaces'; +import { isStaticQueryArgsType } from '../query/query-args.type'; export type StaticConnectionType = StaticArrayConnectionType | StaticCursorConnectionType; export type ConnectionType = Connection; @@ -19,17 +20,26 @@ export function ConnectionType( export function ConnectionType( DTOClass: Class, QueryArgsType: StaticQueryArgsType, + opts?: CursorConnectionOptions, +): StaticCursorConnectionType; +export function ConnectionType( + DTOClass: Class, + opts?: CursorConnectionOptions, ): StaticCursorConnectionType; -export function ConnectionType(DTOClass: Class): StaticCursorConnectionType; export function ConnectionType>( DTOClass: Class, - QueryArgsType?: QueryType, + QueryArgsType?: QueryType | CursorConnectionOptions, + opts?: CursorConnectionOptions, ): StaticConnectionType { - if (QueryArgsType) { + if (isStaticQueryArgsType(QueryArgsType)) { const { PageType } = QueryArgsType; if (!PageType || PageType.strategy === PagingStrategies.OFFSET) { return ArrayConnectionType(DTOClass); } } - return CursorConnectionType(DTOClass); + let cursorOpts: CursorConnectionOptions | undefined = opts; + if (!cursorOpts && !isStaticQueryArgsType(QueryArgsType)) { + cursorOpts = QueryArgsType as CursorConnectionOptions; + } + return CursorConnectionType(DTOClass, cursorOpts); } 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 f5b63df75..fc9fd667d 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 @@ -1,13 +1,20 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, ObjectType, Int } from '@nestjs/graphql'; import { Class } from '@nestjs-query/core'; +import { NotImplementedException } from '@nestjs/common'; +import { SkipIf } from '../../../decorators'; import { CursorQueryArgsType } from '../../query'; import { getMetadataStorage } from '../../../metadata'; import { UnregisteredObjectType } from '../../type.errors'; -import { createPager, QueryMany } from './pager'; -import { StaticConnection } from '../interfaces'; +import { CountFn, createPager } from './pager'; +import { Count, QueryMany, StaticConnection } from '../interfaces'; import { EdgeType } from './edge.type'; import { PageInfoType } from './page-info.type'; +export type CursorConnectionOptions = { + enableTotalCount?: boolean; + connectionName?: string; +}; + export type StaticCursorConnectionType = StaticConnection< DTO, CursorQueryArgsType, @@ -17,22 +24,33 @@ export type StaticCursorConnectionType = StaticConnection< export type CursorConnectionType = { pageInfo: PageInfoType; edges: EdgeType[]; + totalCount?: Promise; }; -export function CursorConnectionType(TItemClass: Class): StaticCursorConnectionType { +const DEFAULT_COUNT = () => Promise.reject(new NotImplementedException('totalCount not implemented')); + +export function CursorConnectionType( + TItemClass: Class, + opts: CursorConnectionOptions = {}, +): StaticCursorConnectionType { const metadataStorage = getMetadataStorage(); - const existing = metadataStorage.getConnectionType>('cursor', TItemClass); + let { connectionName } = opts; + if (!connectionName) { + const objMetadata = metadataStorage.getGraphqlObjectMetadata(TItemClass); + if (!objMetadata) { + throw new UnregisteredObjectType(TItemClass, 'Unable to make ConnectionType.'); + } + connectionName = `${objMetadata.name}Connection`; + } + const existing = metadataStorage.getConnectionType>('cursor', connectionName); if (existing) { return existing; } - const objMetadata = metadataStorage.getGraphqlObjectMetadata(TItemClass); - if (!objMetadata) { - throw new UnregisteredObjectType(TItemClass, 'Unable to make ConnectionType.'); - } + const pager = createPager(); const E = EdgeType(TItemClass); const PIT = PageInfoType(); - @ObjectType(`${objMetadata.name}Connection`) + @ObjectType(connectionName) class AbstractConnection implements CursorConnectionType { static get resolveType() { return this; @@ -41,18 +59,23 @@ export function CursorConnectionType(TItemClass: Class): StaticCursorC static async createFromPromise( queryMany: QueryMany, query: CursorQueryArgsType, + count?: Count, ): Promise { - const { pageInfo, edges } = await pager.page(queryMany, query); + const { pageInfo, edges, totalCount } = await pager.page(queryMany, query, count ?? DEFAULT_COUNT); return new AbstractConnection( // create the appropriate graphql instance new PIT(pageInfo.hasNextPage, pageInfo.hasPreviousPage, pageInfo.startCursor, pageInfo.endCursor), edges.map(({ node, cursor }) => new E(node, cursor)), + totalCount, ); } - constructor(pageInfo?: PageInfoType, edges?: EdgeType[]) { + private readonly totalCountFn: CountFn; + + constructor(pageInfo?: PageInfoType, edges?: EdgeType[], totalCountFn?: CountFn) { this.pageInfo = pageInfo ?? { hasNextPage: false, hasPreviousPage: false }; this.edges = edges ?? []; + this.totalCountFn = totalCountFn ?? DEFAULT_COUNT; } @Field(() => PIT, { description: 'Paging information' }) @@ -60,7 +83,12 @@ export function CursorConnectionType(TItemClass: Class): StaticCursorC @Field(() => [E], { description: 'Array of edges.' }) edges!: EdgeType[]; + + @SkipIf(() => !opts.enableTotalCount, Field(() => Int, { description: 'Fetch total count of records' })) + get totalCount(): Promise { + return this.totalCountFn(); + } } - metadataStorage.addConnectionType('cursor', TItemClass, AbstractConnection); + metadataStorage.addConnectionType('cursor', connectionName, AbstractConnection); return AbstractConnection; } 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 04fa65186..1cf58674a 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,7 @@ import { Pager } from './interfaces'; import { CursorPager } from './limit-offset.pager'; -export { Pager, QueryMany } from './interfaces'; +export { Pager, PagerResult, CountFn } from './interfaces'; // default pager factory to plug in addition paging strategies later on. export const createPager = (): Pager => new CursorPager(); 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 b4b7022b1..0d132dbfb 100644 --- a/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts +++ b/packages/query-graphql/src/types/connection/cursor/pager/interfaces.ts @@ -1,5 +1,5 @@ -import { Query } from '@nestjs-query/core'; import { CursorQueryArgsType } from '../../../query'; +import { Count, QueryMany } from '../../interfaces'; import { CursorConnectionType } from '../cursor-connection.type'; export interface PagingMeta { @@ -15,8 +15,12 @@ export interface QueryResults { hasExtraNode: boolean; } -export type QueryMany = (query: Query) => Promise; +export type CountFn = () => Promise; + +export type PagerResult = { + totalCount: CountFn; +} & Omit, 'totalCount'>; export interface Pager { - page(queryMany: QueryMany, query: CursorQueryArgsType): Promise>; + page(queryMany: QueryMany, query: CursorQueryArgsType, count: Count): Promise>; } 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 index 2eca37f99..62578558e 100644 --- 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 @@ -1,13 +1,14 @@ 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 { CursorConnectionType } from '../cursor-connection.type'; -import { PagingMeta, QueryMany, QueryResults } from './interfaces'; +import { PagerResult, PagingMeta, QueryResults } from './interfaces'; -const EMPTY_PAGING_RESULTS = (): CursorConnectionType => ({ +const EMPTY_PAGING_RESULTS = (): PagerResult => ({ edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false }, + totalCount: () => Promise.resolve(0), }); const DEFAULT_PAGING_META = (): PagingMeta => ({ @@ -19,7 +20,7 @@ const DEFAULT_PAGING_META = (): PagingMeta => ({ }); export class CursorPager { - async page(queryMany: QueryMany, query: CursorQueryArgsType): Promise> { + async page(queryMany: QueryMany, query: CursorQueryArgsType, count: Count): Promise> { const pagingMeta = this.getPageMeta(query); if (!CursorPager.pagingMetaHasLimitOrOffset(pagingMeta)) { return EMPTY_PAGING_RESULTS(); @@ -28,7 +29,7 @@ export class CursorPager { if (this.isEmptyPage(results, pagingMeta)) { return EMPTY_PAGING_RESULTS(); } - return this.createPagingResult(results, pagingMeta); + return this.createPagingResult(results, pagingMeta, () => count(query.filter ?? {})); } private static pagingMetaHasLimitOrOffset(pagingMeta: PagingMeta): boolean { @@ -74,7 +75,11 @@ export class CursorPager { return { offset, limit, isBackward, isForward, hasBefore }; } - createPagingResult(results: QueryResults, pagingMeta: PagingMeta): CursorConnectionType { + 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); @@ -88,7 +93,7 @@ export class CursorPager { }; const edges: EdgeType[] = nodes.map((node, i) => ({ node, cursor: offsetToCursor(offset + i) })); - return { edges, pageInfo }; + return { edges, pageInfo, totalCount }; } isEmptyPage(results: QueryResults, pagingMeta: PagingMeta): boolean { diff --git a/packages/query-graphql/src/types/connection/interfaces.ts b/packages/query-graphql/src/types/connection/interfaces.ts index f56386a7e..5edffd0c8 100644 --- a/packages/query-graphql/src/types/connection/interfaces.ts +++ b/packages/query-graphql/src/types/connection/interfaces.ts @@ -1,12 +1,15 @@ -import { Query } from '@nestjs-query/core'; +import { Filter, Query } from '@nestjs-query/core'; import { ReturnTypeFuncValue } from '@nestjs/graphql'; import { QueryArgsType } from '../query'; import { ArrayConnectionType } from './array-connection.type'; import { CursorConnectionType } from './cursor'; +export type QueryMany = (query: Query) => Promise; +export type Count = (filter: Filter) => Promise; + export interface StaticConnection, ConnectionType extends Connection> { resolveType: ReturnTypeFuncValue; - createFromPromise(queryMany: (query: Query) => Promise, query: QueryType): Promise; + createFromPromise(queryMany: QueryMany, query: QueryType, count?: Count): Promise; new (): ConnectionType; } diff --git a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts index be2d74855..a58db1149 100644 --- a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts +++ b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts @@ -45,6 +45,7 @@ const knownTypes: Set = new Set([ GraphQLTimestamp, ]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any const isNamed = (Type: any): Type is { name: string } => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access return 'name' in Type && typeof Type.name === 'string'; diff --git a/packages/query-graphql/src/types/query/query-args.type.ts b/packages/query-graphql/src/types/query/query-args.type.ts index 6001e2d28..af2f347c5 100644 --- a/packages/query-graphql/src/types/query/query-args.type.ts +++ b/packages/query-graphql/src/types/query/query-args.type.ts @@ -18,6 +18,11 @@ export type StaticQueryArgsType = | StaticCursorQueryArgsType | StaticOffsetQueryArgsType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types +export const isStaticQueryArgsType = (obj: any): obj is StaticQueryArgsType => { + return typeof obj === 'function' && ('PageType' in obj || 'SortType' in obj || 'FilterType' in obj); +}; + export type QueryArgsType = NoPagingQueryArgsType | CursorQueryArgsType | OffsetQueryArgsType; export function QueryArgsType( diff --git a/packages/query-sequelize/__tests__/services/sequelize-query.service.spec.ts b/packages/query-sequelize/__tests__/services/sequelize-query.service.spec.ts index 45603ecc4..110a5757e 100644 --- a/packages/query-sequelize/__tests__/services/sequelize-query.service.spec.ts +++ b/packages/query-sequelize/__tests__/services/sequelize-query.service.spec.ts @@ -1,5 +1,5 @@ import { DeepPartial, Filter, Query, QueryService } from '@nestjs-query/core'; -import { FindOptions } from 'sequelize'; +import { CountOptions, FindOptions } from 'sequelize'; import { instance, mock, when, objectContaining, deepEqual } from 'ts-mockito'; import { ModelCtor } from 'sequelize-typescript'; import { SequelizeQueryService } from '../../src'; @@ -84,6 +84,19 @@ describe('SequelizeQueryService', (): void => { }); }); + describe('#count', () => { + it('call select and return the result', async () => { + const entities = testEntities(); + const filter: Filter = { stringType: { eq: 'foo' } }; + const countOptions: CountOptions = {}; + const { queryService, mockQueryBuilder, mockModelCtor } = createQueryService(); + when(mockQueryBuilder.countOptions(deepEqual({ filter }))).thenReturn(countOptions); + when(mockModelCtor.count(countOptions)).thenResolve(entities.length); + const queryResult = await queryService.count(filter); + return expect(queryResult).toEqual(entities.length); + }); + }); + describe('#queryRelations', () => { const relationName = 'testRelations'; describe('with one entity', () => { @@ -156,6 +169,79 @@ describe('SequelizeQueryService', (): void => { }); }); }); + describe('#countRelations', () => { + const relationName = 'testRelations'; + describe('with one entity', () => { + it('call count and return the result', async () => { + const entity = testEntities()[0]; + const relations = testRelations(entity.testEntityPk); + const filter: Filter = { relationName: { eq: 'name' } }; + const countOptions: CountOptions = {}; + const mockModel = mock(TestEntity); + const mockModelInstance = instance(mockModel); + const { queryService, mockModelCtor, mockRelationQueryBuilder } = createQueryService(); + // @ts-ignore + when(mockModelCtor.associations).thenReturn({ [relationName]: { target: TestRelation } }); + when(mockModelCtor.build(mockModelInstance)).thenReturn(mockModelInstance); + when(mockRelationQueryBuilder.countOptions(deepEqual({ filter }))).thenReturn(countOptions); + when(mockModel.$count(relationName, countOptions)).thenResolve(relations.length); + const queryResult = await queryService.countRelations(TestRelation, relationName, instance(mockModel), filter); + return expect(queryResult).toEqual(relations.length); + }); + }); + describe('with multiple entities', () => { + it('call select and return the result', async () => { + const entities = testEntities(); + const entityOneRelations = testRelations(entities[0].testEntityPk); + const entityTwoRelations = testRelations(entities[1].testEntityPk); + const filter: Filter = { + relationName: { isNot: null }, + }; + const countOptions: CountOptions = {}; + const mockModel = mock(TestEntity); + const mockModelInstances = [instance(mockModel), instance(mockModel)]; + const { queryService, mockModelCtor, mockRelationQueryBuilder } = createQueryService(); + // @ts-ignore + when(mockModelCtor.associations).thenReturn({ [relationName]: { target: TestRelation } }); + mockModelInstances.forEach((mi) => when(mockModelCtor.build(mi)).thenReturn(mi)); + when(mockRelationQueryBuilder.countOptions(deepEqual({ filter }))).thenReturn(countOptions); + when(mockModel.$count(relationName, countOptions)) + .thenResolve(entityOneRelations.length) + .thenResolve(entityTwoRelations.length); + const queryResult = await queryService.countRelations(TestRelation, relationName, mockModelInstances, filter); + return expect(queryResult).toEqual( + new Map([ + [mockModelInstances[0], entityOneRelations.length], + [mockModelInstances[1], entityTwoRelations.length], + ]), + ); + }); + + it('should return an empty array if no results are found.', async () => { + const entities = testEntities(); + const entityOneRelations = testRelations(entities[0].testEntityPk); + const filter: Filter = { + relationName: { isNot: null }, + }; + const countOptions: CountOptions = {}; + const mockModel = mock(TestEntity); + const mockModelInstances = [instance(mockModel), instance(mockModel)]; + const { queryService, mockModelCtor, mockRelationQueryBuilder } = createQueryService(); + // @ts-ignore + when(mockModelCtor.associations).thenReturn({ [relationName]: { target: TestRelation } }); + mockModelInstances.forEach((mi) => when(mockModelCtor.build(mi)).thenReturn(mi)); + when(mockRelationQueryBuilder.countOptions(deepEqual({ filter }))).thenReturn(countOptions); + when(mockModel.$count(relationName, countOptions)).thenResolve(entityOneRelations.length).thenResolve(0); + const queryResult = await queryService.countRelations(TestRelation, relationName, mockModelInstances, filter); + return expect(queryResult).toEqual( + new Map([ + [mockModelInstances[0], entityOneRelations.length], + [mockModelInstances[0], 0], + ]), + ); + }); + }); + }); describe('#findRelation', () => { const relationName = 'oneTestRelation'; diff --git a/packages/query-sequelize/src/query/filter-query.builder.ts b/packages/query-sequelize/src/query/filter-query.builder.ts index 80bd8fec0..df1e64b43 100644 --- a/packages/query-sequelize/src/query/filter-query.builder.ts +++ b/packages/query-sequelize/src/query/filter-query.builder.ts @@ -1,5 +1,5 @@ import { Filter, Paging, Query, SortField } from '@nestjs-query/core'; -import { FindOptions, Filterable, DestroyOptions, Order, OrderItem, UpdateOptions } from 'sequelize'; +import { FindOptions, Filterable, DestroyOptions, Order, OrderItem, UpdateOptions, CountOptions } from 'sequelize'; import { WhereBuilder } from './where.builder'; /** @@ -42,6 +42,12 @@ export class FilterQueryBuilder { return opts; } + countOptions(query: Query): CountOptions { + let opts: CountOptions = {}; + opts = this.applyFilter(opts, query.filter); + return opts; + } + /** * Create a `sequelize` DeleteQueryBuilder with a WHERE clause. * diff --git a/packages/query-sequelize/src/services/relation-query.service.ts b/packages/query-sequelize/src/services/relation-query.service.ts index c0addb9ae..c1698c169 100644 --- a/packages/query-sequelize/src/services/relation-query.service.ts +++ b/packages/query-sequelize/src/services/relation-query.service.ts @@ -1,4 +1,5 @@ import { Query, Class, AssemblerFactory } from '@nestjs-query/core'; +import { Filter } from '@nestjs-query/core/src'; import { Model, ModelCtor } from 'sequelize-typescript'; import { FilterQueryBuilder } from '../query'; @@ -59,6 +60,37 @@ export abstract class RelationQueryService { return assembler.convertToDTOs((relations as unknown) as Model[]); } + countRelations( + RelationClass: Class, + relationName: string, + entities: Entity[], + filter: Filter, + ): Promise>; + + countRelations( + RelationClass: Class, + relationName: string, + dto: Entity, + filter: Filter, + ): Promise; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: Entity | Entity[], + filter: Filter, + ): Promise> { + if (Array.isArray(dto)) { + return this.batchCountRelations(RelationClass, relationName, dto, filter); + } + const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName)); + const relationQueryBuilder = this.getRelationQueryBuilder(); + return this.ensureIsEntity(dto).$count( + relationName, + relationQueryBuilder.countOptions(assembler.convertQuery({ filter })), + ); + } + /** * Find a relation for an array of Entities. This will return a Map where the key is the Entity and the value is to * relation or undefined if not found. @@ -190,6 +222,30 @@ export abstract class RelationQueryService { }, Promise.resolve(new Map())); } + /** + * Query for an array of relations for multiple dtos. + * @param RelationClass - The class to serialize the relations into. + * @param entities - The entities to query relations for. + * @param relationName - The name of relation to query for. + * @param query - A query to filter, page or sort relations. + */ + private async batchCountRelations( + RelationClass: Class, + relationName: string, + entities: Entity[], + filter: Filter, + ): Promise> { + const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName)); + const relationQueryBuilder = this.getRelationQueryBuilder(); + const findOptions = relationQueryBuilder.countOptions(assembler.convertQuery({ filter })); + return entities.reduce(async (mapPromise, e) => { + const map = await mapPromise; + const count = await this.ensureIsEntity(e).$count(relationName, findOptions); + map.set(e, count); + return map; + }, Promise.resolve(new Map())); + } + /** * Query for a relation for multiple dtos. * @param RelationClass - The class to serialize the relations into. diff --git a/packages/query-sequelize/src/services/sequelize-query.service.ts b/packages/query-sequelize/src/services/sequelize-query.service.ts index 4156c9f89..008dee376 100644 --- a/packages/query-sequelize/src/services/sequelize-query.service.ts +++ b/packages/query-sequelize/src/services/sequelize-query.service.ts @@ -46,6 +46,10 @@ export class SequelizeQueryService> extends Relatio return this.model.findAll(this.filterQueryBuilder.findOptions(query)); } + async count(filter: Filter): Promise { + return this.model.count(this.filterQueryBuilder.countOptions({ filter })); + } + /** * Find an entity by it's `id`. * diff --git a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts index 22156fa90..796a13641 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -86,6 +86,20 @@ describe('TypeOrmQueryService', (): void => { }); }); + describe('#count', () => { + it('call select and return the result', async () => { + const entities = testEntities(); + const filter: Filter = { stringType: { eq: 'foo' } }; + const { queryService, mockQueryBuilder, mockRepo } = createQueryService(); + const selectQueryBuilder: SelectQueryBuilder = mock(SelectQueryBuilder); + when(mockRepo.target).thenReturn(TestEntity); + when(mockQueryBuilder.select(deepEqual({ filter }))).thenReturn(instance(selectQueryBuilder)); + when(selectQueryBuilder.getCount()).thenResolve(entities.length); + const count = await queryService.count(filter); + return expect(count).toEqual(entities.length); + }); + }); + describe('#queryRelations', () => { describe('with one entity', () => { it('call select and return the result', async () => { @@ -182,6 +196,79 @@ describe('TypeOrmQueryService', (): void => { }); }); + describe('#countRelations', () => { + describe('with one entity', () => { + it('call count and return the result', async () => { + const entity = testEntities()[0]; + const relations = testRelations(entity.testEntityPk); + const filter: Filter = { relationName: { eq: 'name' } }; + const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService(); + const selectQueryBuilder: SelectQueryBuilder = mock(SelectQueryBuilder); + // @ts-ignore + when(mockRepo.metadata).thenReturn({ relations: [{ propertyName: relationName, type: TestRelation }] }); + when(mockRepo.target).thenReturn(TestEntity); + when(mockRelationQueryBuilder.select(objectContaining(entity), deepEqual({ filter }))).thenReturn( + instance(selectQueryBuilder), + ); + when(selectQueryBuilder.getCount()).thenResolve(relations.length); + const count = await queryService.countRelations(TestRelation, relationName, entity, filter); + return expect(count).toEqual(relations.length); + }); + + it('should look up the type when the relation.type is a string', async () => { + const entity = testEntities()[0]; + const relations = testRelations(entity.testEntityPk); + const filter: Filter = { relationName: { eq: 'name' } }; + const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService(); + const selectQueryBuilder: SelectQueryBuilder = mock(SelectQueryBuilder); + const mockManger = mock(EntityManager); + const mockRelationRepo = mock>(); + // @ts-ignore + when(mockRepo.metadata).thenReturn({ relations: [{ propertyName: relationName, type: 'TestRelation' }] }); + when(mockRepo.manager).thenReturn(instance(mockManger)); + when(mockManger.getRepository('TestRelation')).thenReturn(instance(mockRelationRepo)); + when(mockRelationRepo.target).thenReturn(TestRelation); + when(mockRelationQueryBuilder.select(objectContaining(entity), deepEqual({ filter }))).thenReturn( + instance(selectQueryBuilder), + ); + when(selectQueryBuilder.getCount()).thenResolve(relations.length); + const count = await queryService.countRelations(TestRelation, relationName, entity, filter); + return expect(count).toEqual(relations.length); + }); + }); + + describe('with multiple entities', () => { + it('call count and return the result', async () => { + const entities = testEntities(); + const entityOneRelations = testRelations(entities[0].testEntityPk); + const entityTwoRelations = testRelations(entities[1].testEntityPk); + const { queryService, mockRepo, mockRelationQueryBuilder } = createQueryService(); + const relationQueryBuilder: SelectQueryBuilder = mock(SelectQueryBuilder); + const filter: Filter = { relationName: { isNot: null } }; + when(mockRepo.target).thenReturn(TestEntity); + // @ts-ignore + when(mockRepo.metadata).thenReturn({ relations: [{ propertyName: relationName, type: TestRelation }] }); + when(mockRelationQueryBuilder.select(entities[0], objectContaining({ filter }))).thenReturn( + instance(relationQueryBuilder), + ); + when(mockRelationQueryBuilder.select(entities[1], objectContaining({ filter }))).thenReturn( + instance(relationQueryBuilder), + ); + when(relationQueryBuilder.getCount()) + .thenResolve(entityOneRelations.length) + .thenResolve(entityTwoRelations.length); + + const countResult = await queryService.countRelations(TestRelation, relationName, entities, filter); + return expect(countResult).toEqual( + new Map([ + [entities[0], entityOneRelations.length], + [entities[1], entityTwoRelations.length], + ]), + ); + }); + }); + }); + describe('#findRelation', () => { describe('with one entity', () => { it('call select and return the result', async () => { diff --git a/packages/query-typeorm/src/services/relation-query.service.ts b/packages/query-typeorm/src/services/relation-query.service.ts index 686ed1bee..cbfde78b9 100644 --- a/packages/query-typeorm/src/services/relation-query.service.ts +++ b/packages/query-typeorm/src/services/relation-query.service.ts @@ -1,4 +1,5 @@ import { Query, Class, AssemblerFactory } from '@nestjs-query/core'; +import { Filter } from '@nestjs-query/core/src'; import { Repository, RelationQueryBuilder as TypeOrmRelationQueryBuilder } from 'typeorm'; import { FilterQueryBuilder, RelationQueryBuilder } from '../query'; @@ -55,6 +56,34 @@ export abstract class RelationQueryService { return assembler.convertAsyncToDTOs(relationQueryBuilder.select(dto, assembler.convertQuery(query)).getMany()); } + async countRelations( + RelationClass: Class, + relationName: string, + entities: Entity[], + filter: Filter, + ): Promise>; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: Entity, + filter: Filter, + ): Promise; + + async countRelations( + RelationClass: Class, + relationName: string, + dto: Entity | Entity[], + filter: Filter, + ): Promise> { + if (Array.isArray(dto)) { + return this.batchCountRelations(RelationClass, relationName, dto, filter); + } + const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName)); + const relationQueryBuilder = this.getRelationQueryBuilder(relationName); + return relationQueryBuilder.select(dto, assembler.convertQuery({ filter })).getCount(); + } + /** * Find a relation for an array of Entities. This will return a Map where the key is the Entity and the value is to * relation or undefined if not found. @@ -187,6 +216,34 @@ export abstract class RelationQueryService { }, new Map()); } + /** + * Count the number of relations for multiple dtos. + * @param RelationClass - The class to serialize the relations into. + * @param entities - The entities to query relations for. + * @param relationName - The name of relation to query for. + * @param filter - The filter to apply to the relation query. + */ + private async batchCountRelations( + RelationClass: Class, + relationName: string, + entities: Entity[], + filter: Filter, + ): Promise> { + const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName)); + const relationQueryBuilder = this.getRelationQueryBuilder(relationName); + const convertedQuery = assembler.convertQuery({ filter }); + const entityRelations = await Promise.all( + entities.map((e) => { + return relationQueryBuilder.select(e, convertedQuery).getCount(); + }), + ); + return entityRelations.reduce((results, relationCount, index) => { + const e = entities[index]; + results.set(e, relationCount); + return results; + }, new Map()); + } + /** * Query for a relation for multiple dtos. * @param RelationClass - The class to serialize the relations into. diff --git a/packages/query-typeorm/src/services/typeorm-query.service.ts b/packages/query-typeorm/src/services/typeorm-query.service.ts index 3ed9abf3d..d6ea846e0 100644 --- a/packages/query-typeorm/src/services/typeorm-query.service.ts +++ b/packages/query-typeorm/src/services/typeorm-query.service.ts @@ -67,6 +67,10 @@ export class TypeOrmQueryService extends RelationQueryService im return this.filterQueryBuilder.select(query).getMany(); } + async count(filter: Filter): Promise { + return this.filterQueryBuilder.select({ filter }).getCount(); + } + /** * Find an entity by it's `id`. *