From 922e696df1c56d5d0181cbb769ffbfba943157dd Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Sun, 28 Mar 2021 22:30:24 -0600 Subject: [PATCH] feat(graphql): Add new aggregate groupBy --- .../migration-guides/v0.24.x-to-v0.25.x.mdx | 200 ++++++++++++++++++ ...ate-response-type-with-custom-name.graphql | 25 ++- .../aggregate-response-type.graphql | 18 +- .../aggregate-relations.loader.spec.ts | 22 +- .../aggregate-disabled.resolver.graphql | 5 + .../aggregate/aggregate.resolver.graphql | 8 +- .../resolvers/aggregate.resolver.spec.ts | 8 +- ...gate-relation-custom-name.resolver.graphql | 13 +- ...gregate-relation-disabled.resolver.graphql | 10 + .../aggregate-relation.resolver.graphql | 8 +- .../aggregate-relation.resolver.spec.ts | 10 +- .../aggregate-query-param.decorator.ts | 2 +- .../src/resolvers/aggregate.resolver.ts | 6 +- .../relations/aggregate-relations.resolver.ts | 2 +- .../aggregate/aggregate-response.type.ts | 15 ++ 15 files changed, 316 insertions(+), 36 deletions(-) create mode 100644 documentation/docs/migration-guides/v0.24.x-to-v0.25.x.mdx diff --git a/documentation/docs/migration-guides/v0.24.x-to-v0.25.x.mdx b/documentation/docs/migration-guides/v0.24.x-to-v0.25.x.mdx new file mode 100644 index 000000000..3e61dd85e --- /dev/null +++ b/documentation/docs/migration-guides/v0.24.x-to-v0.25.x.mdx @@ -0,0 +1,200 @@ +--- +title: v0.24.x to v0.25.x +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Aggregate Queryies Return Arrays + +In versions prior to `v0.24.0` aggregate queries returned a single response with just the aggregated values. In `v0.25.0` we have introduced a new `groupBy` option that requires aggregates to return arrays. + +When using aggregates without the groupBy the response will always contain a single record with your aggregated +values. If you specify a `groupBy` you will get a response with a distinct group and the corresponding aggregated +values. + +Given the following query + +```graphql +{ + todoItemAggregate { + count { + id + } + sum { + id + } + avg { + id + } + min { + id + title + created + } + max { + id + title + created + } + } +} +``` + +### Old + +The old response would look like + +```json +{ + "data": { + "todoItemAggregate": { + "count": { + "id": 5 + }, + "sum": { + "id": 15 + }, + "avg": { + "id": 3 + }, + "min": { + "id": "1", + "title": "Add Todo Item Resolver", + "created": "2021-03-29T06:51:26.061Z" + }, + "max": { + "id": "5", + "title": "How to create item With Sub Tasks", + "created": "2021-03-29T06:51:26.061Z" + } + } + } +} +``` + +### New +The new response will look like +```json +{ + "data": { + "todoItemAggregate": [ + { + "count": { + "id": 5 + }, + "sum": { + "id": 15 + }, + "avg": { + "id": 3 + }, + "min": { + "id": "1", + "title": "Add Todo Item Resolver", + "created": "2021-03-29T06:51:26.061Z" + }, + "max": { + "id": "5", + "title": "How to create item With Sub Tasks", + "created": "2021-03-29T06:51:26.061Z" + } + } + ] + } +} +``` + +## New Aggregate GroupBy + +The new response format really shines when using the `groupBy` query + +Given the following aggregate grouping on `completed` + +```graphql {3-5} +{ + todoItemAggregate { + groupBy { + completed + } + count { + id + } + sum { + id + } + avg { + id + } + min { + id + title + created + } + max { + id + title + created + } + } +} +``` + +You'll get the following response with record for each distinct group + +```json +{ + "data": { + "todoItemAggregate": [ + { + "groupBy": { + "completed": false + }, + "count": { + "id": 4 + }, + "sum": { + "id": 14 + }, + "avg": { + "id": 3.5 + }, + "min": { + "id": "2", + "title": "Add Todo Item Resolver", + "created": "2021-03-29T06:51:26.061Z" + }, + "max": { + "id": "5", + "title": "How to create item With Sub Tasks", + "created": "2021-03-29T06:51:26.061Z" + } + }, + { + "groupBy": { + "completed": true + }, + "count": { + "id": 1 + }, + "sum": { + "id": 1 + }, + "avg": { + "id": 1 + }, + "min": { + "id": "1", + "title": "Create Nest App", + "created": "2021-03-29T06:51:26.061Z" + }, + "max": { + "id": "1", + "title": "Create Nest App", + "created": "2021-03-29T06:51:26.061Z" + } + } + ] + } +} +``` diff --git a/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type-with-custom-name.graphql b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type-with-custom-name.graphql index 3efaf4e73..7c13b7d0c 100644 --- a/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type-with-custom-name.graphql +++ b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type-with-custom-name.graphql @@ -1,3 +1,15 @@ +type FakeTypeAggregateGroupBy { + stringField: String + numberField: Float + boolField: Boolean + dateField: DateTime +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + type FakeTypeCountAggregate { stringField: Int numberField: Int @@ -19,17 +31,19 @@ type FakeTypeMinAggregate { dateField: DateTime } -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type FakeTypeMaxAggregate { stringField: String numberField: Float dateField: DateTime } +type CustomPrefixAggregateGroupBy { + stringField: String + numberField: Float + boolField: Boolean + dateField: DateTime +} + type CustomPrefixCountAggregate { stringField: Int numberField: Int @@ -58,6 +72,7 @@ type CustomPrefixMaxAggregate { } type CustomPrefixAggregateResponse { + groupBy: CustomPrefixAggregateGroupBy count: CustomPrefixCountAggregate sum: CustomPrefixSumAggregate avg: CustomPrefixAvgAggregate diff --git a/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql index ee045eaac..e237b5fa7 100644 --- a/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql +++ b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql @@ -1,3 +1,15 @@ +type FakeTypeAggregateGroupBy { + stringField: String + numberField: Float + boolField: Boolean + dateField: DateTime +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + type FakeTypeCountAggregate { stringField: Int numberField: Int @@ -19,11 +31,6 @@ type FakeTypeMinAggregate { dateField: DateTime } -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type FakeTypeMaxAggregate { stringField: String numberField: Float @@ -31,6 +38,7 @@ type FakeTypeMaxAggregate { } type FakeTypeAggregateResponse { + groupBy: FakeTypeAggregateGroupBy count: FakeTypeCountAggregate sum: FakeTypeSumAggregate avg: FakeTypeAvgAggregate diff --git a/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts b/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts index bc4bffbd1..568871c44 100644 --- a/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts +++ b/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts @@ -26,8 +26,8 @@ describe('AggregateRelationsLoader', () => { const filter = {}; const aggregate: AggregateQuery = { count: ['id'] }; const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }]; - const dto1Aggregate = { count: { id: 2 } }; - const dto2Aggregate = { count: { id: 3 } }; + const dto1Aggregate = [{ count: { id: 2 } }]; + const dto2Aggregate = [{ count: { id: 3 } }]; when( service.aggregateRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual(filter), deepEqual(aggregate)), ).thenResolve( @@ -52,7 +52,7 @@ describe('AggregateRelationsLoader', () => { const filter = {}; const aggregate: AggregateQuery = { count: ['id'] }; const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }]; - const dto1Aggregate = { count: { id: 2 } }; + const dto1Aggregate = [{ count: { id: 2 } }]; when( service.aggregateRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual(filter), deepEqual(aggregate)), ).thenResolve(new Map([[dtos[0], dto1Aggregate]])); @@ -73,10 +73,10 @@ describe('AggregateRelationsLoader', () => { const filter2 = {}; const aggregate: AggregateQuery = { count: ['id'] }; const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }, { id: 'dto-3' }, { id: 'dto-4' }]; - const dto1Aggregate = { count: { id: 2 } }; - const dto2Aggregate = { count: { id: 3 } }; - const dto3Aggregate = { count: { id: 4 } }; - const dto4Aggregate = { count: { id: 5 } }; + const dto1Aggregate = [{ count: { id: 2 } }]; + const dto2Aggregate = [{ count: { id: 3 } }]; + const dto3Aggregate = [{ count: { id: 4 } }]; + const dto4Aggregate = [{ count: { id: 5 } }]; when( service.aggregateRelations( RelationDTO, @@ -124,10 +124,10 @@ describe('AggregateRelationsLoader', () => { const aggregate1: AggregateQuery = { count: ['id'] }; const aggregate2: AggregateQuery = { sum: ['id'] }; const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }, { id: 'dto-3' }, { id: 'dto-4' }]; - const dto1Aggregate = { count: { id: 2 } }; - const dto2Aggregate = { sum: { id: 3 } }; - const dto3Aggregate = { count: { id: 4 } }; - const dto4Aggregate = { sum: { id: 5 } }; + const dto1Aggregate = [{ count: { id: 2 } }]; + const dto2Aggregate = [{ sum: { id: 3 } }]; + const dto3Aggregate = [{ count: { id: 4 } }]; + const dto4Aggregate = [{ sum: { id: 5 } }]; when( service.aggregateRelations( RelationDTO, diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate-disabled.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate-disabled.resolver.graphql index 35cee7145..b9a82b98f 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate-disabled.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate-disabled.resolver.graphql @@ -3,6 +3,11 @@ type TestResolverDTO { stringField: String! } +type TestResolverDTOAggregateGroupBy { + id: ID + stringField: String +} + type TestResolverDTOCountAggregate { id: Int stringField: Int diff --git a/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate.resolver.graphql b/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate.resolver.graphql index d75219c9a..0f5122535 100644 --- a/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/__fixtures__/aggregate/aggregate.resolver.graphql @@ -3,6 +3,11 @@ type TestResolverDTO { stringField: String! } +type TestResolverDTOAggregateGroupBy { + id: ID + stringField: String +} + type TestResolverDTOCountAggregate { id: Int stringField: Int @@ -19,6 +24,7 @@ type TestResolverDTOMaxAggregate { } type TestResolverDTOAggregateResponse { + groupBy: TestResolverDTOAggregateGroupBy count: TestResolverDTOCountAggregate min: TestResolverDTOMinAggregate max: TestResolverDTOMaxAggregate @@ -28,7 +34,7 @@ type Query { testResolverDTOAggregate( """Filter to find records to aggregate on""" filter: TestResolverDTOAggregateFilter - ): TestResolverDTOAggregateResponse! + ): [TestResolverDTOAggregateResponse!]! test: TestResolverDTO! } diff --git a/packages/query-graphql/__tests__/resolvers/aggregate.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/aggregate.resolver.spec.ts index d40db20ae..f408583a2 100644 --- a/packages/query-graphql/__tests__/resolvers/aggregate.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/aggregate.resolver.spec.ts @@ -44,9 +44,11 @@ describe('AggregateResolver', () => { }, }; const aggregateQuery: AggregateQuery = { count: ['id'] }; - const output: AggregateResponse = { - count: { id: 10 }, - }; + const output: AggregateResponse[] = [ + { + count: { id: 10 }, + }, + ]; when(mockService.aggregate(objectContaining(input.filter!), deepEqual(aggregateQuery))).thenResolve(output); const result = await resolver.aggregate(input, aggregateQuery); return expect(result).toEqual(output); diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-custom-name.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-custom-name.resolver.graphql index 813ee851b..3e8e3c329 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-custom-name.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-custom-name.resolver.graphql @@ -4,7 +4,7 @@ type TestResolverDTO { testsAggregate( """Filter to find records to aggregate on""" filter: TestRelationDTOAggregateFilter - ): TestResolverDTOTestsAggregateResponse! + ): [TestResolverDTOTestsAggregateResponse!]! } input TestRelationDTOAggregateFilter { @@ -48,6 +48,11 @@ input StringFieldComparison { notIn: [String!] } +type TestResolverDTORelationsAggregateGroupBy { + id: ID + testResolverId: String +} + type TestResolverDTORelationsCountAggregate { id: Int testResolverId: Int @@ -63,6 +68,11 @@ type TestResolverDTORelationsMaxAggregate { testResolverId: String } +type TestResolverDTOTestsAggregateGroupBy { + id: ID + testResolverId: String +} + type TestResolverDTOTestsCountAggregate { id: Int testResolverId: Int @@ -79,6 +89,7 @@ type TestResolverDTOTestsMaxAggregate { } type TestResolverDTOTestsAggregateResponse { + groupBy: TestResolverDTOTestsAggregateGroupBy count: TestResolverDTOTestsCountAggregate min: TestResolverDTOTestsMinAggregate max: TestResolverDTOTestsMaxAggregate diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-disabled.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-disabled.resolver.graphql index 0e30ad121..978146d64 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-disabled.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-disabled.resolver.graphql @@ -3,6 +3,11 @@ type TestResolverDTO { stringField: String! } +type TestResolverDTORelationsAggregateGroupBy { + id: ID + testResolverId: String +} + type TestResolverDTORelationsCountAggregate { id: Int testResolverId: Int @@ -18,6 +23,11 @@ type TestResolverDTORelationsMaxAggregate { testResolverId: String } +type TestResolverDTOTestsAggregateGroupBy { + id: ID + testResolverId: String +} + type TestResolverDTOTestsCountAggregate { id: Int testResolverId: Int diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation.resolver.graphql index b16d4aeb0..471e474db 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation.resolver.graphql +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation.resolver.graphql @@ -4,7 +4,7 @@ type TestResolverDTO { relationsAggregate( """Filter to find records to aggregate on""" filter: TestRelationDTOAggregateFilter - ): TestResolverDTORelationsAggregateResponse! + ): [TestResolverDTORelationsAggregateResponse!]! } input TestRelationDTOAggregateFilter { @@ -48,6 +48,11 @@ input StringFieldComparison { notIn: [String!] } +type TestResolverDTORelationsAggregateGroupBy { + id: ID + testResolverId: String +} + type TestResolverDTORelationsCountAggregate { id: Int testResolverId: Int @@ -64,6 +69,7 @@ type TestResolverDTORelationsMaxAggregate { } type TestResolverDTORelationsAggregateResponse { + groupBy: TestResolverDTORelationsAggregateGroupBy count: TestResolverDTORelationsCountAggregate min: TestResolverDTORelationsMinAggregate max: TestResolverDTORelationsMaxAggregate diff --git a/packages/query-graphql/__tests__/resolvers/relations/aggregate-relation.resolver.spec.ts b/packages/query-graphql/__tests__/resolvers/relations/aggregate-relation.resolver.spec.ts index 69882ab6e..e262270d4 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/aggregate-relation.resolver.spec.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/aggregate-relation.resolver.spec.ts @@ -68,10 +68,12 @@ describe('AggregateRelationsResolver', () => { count: ['id'], sum: ['testResolverId'], }; - const output: AggregateResponse = { - count: { id: 10 }, - sum: { testResolverId: 100 }, - }; + const output: AggregateResponse[] = [ + { + count: { id: 10 }, + sum: { testResolverId: 100 }, + }, + ]; when( mockService.aggregateRelations( TestRelationDTO, diff --git a/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts b/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts index 71f5962f0..ff68baf03 100644 --- a/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts +++ b/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts @@ -5,7 +5,7 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import graphqlFields from 'graphql-fields'; const EXCLUDED_FIELDS = ['__typename']; -const QUERY_OPERATORS: (keyof AggregateQuery)[] = ['count', 'avg', 'sum', 'min', 'max']; +const QUERY_OPERATORS: (keyof AggregateQuery)[] = ['groupBy', 'count', 'avg', 'sum', 'min', 'max']; export const AggregateQueryParam = createParamDecorator((data: unknown, ctx: ExecutionContext) => { const info = GqlExecutionContext.create(ctx).getInfo(); const fields = graphqlFields(info, {}, { excludedFields: EXCLUDED_FIELDS }) as Record< diff --git a/packages/query-graphql/src/resolvers/aggregate.resolver.ts b/packages/query-graphql/src/resolvers/aggregate.resolver.ts index 3c63f8ff6..e30d4911b 100644 --- a/packages/query-graphql/src/resolvers/aggregate.resolver.ts +++ b/packages/query-graphql/src/resolvers/aggregate.resolver.ts @@ -18,7 +18,7 @@ export interface AggregateResolver, aggregateQuery: AggregateQuery, authFilter?: Filter, - ): Promise>; + ): Promise[]>; } /** @@ -41,7 +41,7 @@ export const Aggregateable = !opts || !opts.enabled, ResolverQuery( - () => AR, + () => [AR], { name: queryName }, commonResolverOpts, { interceptors: [AuthorizerInterceptor(DTOClass)] }, @@ -52,7 +52,7 @@ export const Aggregateable = , @AuthorizerFilter() authFilter?: Filter, - ): Promise> { + ): Promise[]> { const qa = await transformAndValidate(AA, args); return this.service.aggregate(mergeFilter(qa.filter || {}, authFilter ?? {}), query); } diff --git a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts index 56e0fd9da..c2f138491 100644 --- a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts @@ -45,7 +45,7 @@ const AggregateRelationMixin = (DTOClass: Class, relation: A const AR = AggregateResponseType(relationDTO, { prefix: `${dtoName}${pluralBaseName}` }); @Resolver(() => DTOClass, { isAbstract: true }) class AggregateMixin extends Base { - @ResolverField(`${pluralBaseNameLower}Aggregate`, () => AR, {}, commonResolverOpts, { + @ResolverField(`${pluralBaseNameLower}Aggregate`, () => [AR], {}, commonResolverOpts, { interceptors: [AuthorizerInterceptor(DTOClass)], }) async [`aggregate${pluralBaseName}`]( diff --git a/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts b/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts index 260ff6e69..8b15eb95c 100644 --- a/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts +++ b/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts @@ -20,6 +20,17 @@ function NumberAggregatedType( return Aggregated; } +function AggregateGroupByType(name: string, fields: FilterableFieldDescriptor[]): Class> { + @ObjectType(name) + class Aggregated {} + fields.forEach(({ propertyName, target, returnTypeFunc }) => { + const rt = returnTypeFunc ? returnTypeFunc() : target; + Field(() => rt, { nullable: true })(Aggregated.prototype, propertyName); + }); + + return Aggregated; +} + function AggregatedType(name: string, fields: FilterableFieldDescriptor[]): Class> { @ObjectType(name) class Aggregated {} @@ -49,6 +60,7 @@ export function AggregateResponseType( } const numberFields = fields.filter(({ target }) => target === Number); const minMaxFields = fields.filter(({ target }) => target !== Boolean); + const GroupType = AggregateGroupByType(`${prefix}AggregateGroupBy`, fields); const CountType = NumberAggregatedType(`${prefix}CountAggregate`, fields, Int); const SumType = NumberAggregatedType(`${prefix}SumAggregate`, numberFields, Float); const AvgType = NumberAggregatedType(`${prefix}AvgAggregate`, numberFields, Float); @@ -57,6 +69,9 @@ export function AggregateResponseType( @ObjectType(aggName) class AggResponse { + @Field(() => GroupType, { nullable: true }) + groupBy?: Partial; + @Field(() => CountType, { nullable: true }) count?: NumberAggregate;