diff --git a/packages/core/__tests__/helpers.spec.ts b/packages/core/__tests__/helpers.spec.ts index 0c911b0bd..5c6f87c9b 100644 --- a/packages/core/__tests__/helpers.spec.ts +++ b/packages/core/__tests__/helpers.spec.ts @@ -1,31 +1,40 @@ import { + AggregateResponse, applyFilter, Filter, Query, QueryFieldMap, SortDirection, SortField, + transformAggregateQuery, + transformAggregateResponse, transformFilter, transformQuery, transformSort, } from '../src'; import { getFilterFields } from '../src/helpers/query.helpers'; +import { AggregateQuery } from '../src/interfaces/aggregate-query.interface'; class TestDTO { first!: string; last!: string; + + age?: number; } class TestEntity { firstName!: string; lastName!: string; + + ageInYears?: number; } const fieldMap: QueryFieldMap = { first: 'firstName', last: 'lastName', + age: 'ageInYears', }; describe('transformSort', () => { @@ -386,3 +395,112 @@ describe('getFilterFields', () => { expect(getFilterFields(filter).sort()).toEqual(['boolField', 'strField', 'testRelation']); }); }); + +describe('transformAggregateQuery', () => { + it('should transform an aggregate query', () => { + const aggQuery: AggregateQuery = { + count: ['first'], + sum: ['age'], + max: ['first', 'last', 'age'], + min: ['first', 'last', 'age'], + }; + const entityAggQuery: AggregateQuery = { + count: ['firstName'], + sum: ['ageInYears'], + max: ['firstName', 'lastName', 'ageInYears'], + min: ['firstName', 'lastName', 'ageInYears'], + }; + expect(transformAggregateQuery(aggQuery, fieldMap)).toEqual(entityAggQuery); + }); + + it('should throw an error if an unknown field is encountered', () => { + const aggQuery: AggregateQuery = { + count: ['first'], + sum: ['age'], + max: ['first', 'last', 'age'], + min: ['first', 'last', 'age'], + }; + // @ts-ignore + expect(() => transformAggregateQuery(aggQuery, { last: 'lastName' })).toThrow( + "No corresponding field found for 'first' when transforming aggregateQuery", + ); + }); +}); + +describe('transformAggregateResponse', () => { + it('should transform an aggregate query', () => { + const aggResponse: AggregateResponse = { + count: { + first: 2, + }, + sum: { + age: 101, + }, + max: { + first: 'firstz', + last: 'lastz', + age: 100, + }, + min: { + first: 'firsta', + last: 'lasta', + age: 1, + }, + }; + const entityAggResponse: AggregateResponse = { + count: { + firstName: 2, + }, + sum: { + ageInYears: 101, + }, + max: { + firstName: 'firstz', + lastName: 'lastz', + ageInYears: 100, + }, + min: { + firstName: 'firsta', + lastName: 'lasta', + ageInYears: 1, + }, + }; + expect(transformAggregateResponse(aggResponse, fieldMap)).toEqual(entityAggResponse); + }); + + it('should handle empty aggregate fields', () => { + const aggResponse: AggregateResponse = { + count: { + first: 2, + }, + }; + const entityAggResponse: AggregateResponse = { + count: { + firstName: 2, + }, + }; + expect(transformAggregateResponse(aggResponse, fieldMap)).toEqual(entityAggResponse); + }); + + it('should throw an error if the field is not found', () => { + let aggResponse: AggregateResponse = { + count: { + first: 2, + }, + }; + // @ts-ignore + expect(() => transformAggregateResponse(aggResponse, { last: 'lastName' })).toThrow( + "No corresponding field found for 'first' when transforming aggregateQuery", + ); + + aggResponse = { + max: { + age: 10, + }, + }; + // @ts-ignore + expect(() => transformAggregateResponse(aggResponse, { last: 'lastName' })).toThrow( + "No corresponding field found for 'age' when transforming aggregateQuery", + ); + }); +}); diff --git a/packages/core/__tests__/services/noop-query.service.spec.ts b/packages/core/__tests__/services/noop-query.service.spec.ts index 2ee625914..3cfeffebc 100644 --- a/packages/core/__tests__/services/noop-query.service.spec.ts +++ b/packages/core/__tests__/services/noop-query.service.spec.ts @@ -37,6 +37,10 @@ describe('NoOpQueryService', () => { return expect(instance.query({})).rejects.toThrow('query is not implemented'); }); + it('should throw a NotImplementedException when calling aggregate', () => { + return expect(instance.aggregate({}, {})).rejects.toThrow('aggregate is not implemented'); + }); + it('should throw a NotImplementedException when calling count', () => { return expect(instance.count({})).rejects.toThrow('count 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 4b4fee321..b935a2ba2 100644 --- a/packages/core/__tests__/services/proxy-query.service.spec.ts +++ b/packages/core/__tests__/services/proxy-query.service.spec.ts @@ -1,5 +1,5 @@ import { mock, reset, instance, when } from 'ts-mockito'; -import { QueryService } from '../../src'; +import { QueryService, AggregateQuery } from '../../src'; import { ProxyQueryService } from '../../src/services/proxy-query.service'; describe('NoOpQueryService', () => { @@ -73,6 +73,14 @@ 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 aggregate', () => { + const filter = {}; + const aggregate: AggregateQuery = { count: ['foo'] }; + const result = { count: { foo: 1 } }; + when(mockQueryService.aggregate(filter, aggregate)).thenResolve(result); + return expect(queryService.aggregate(filter, aggregate)).resolves.toBe(result); + }); it('should proxy to the underlying service when calling count', () => { const query = {}; const result = 1; diff --git a/packages/query-graphql/__tests__/__fixtures__/aggregate-args-type.graphql b/packages/query-graphql/__tests__/__fixtures__/aggregate-args-type.graphql new file mode 100644 index 000000000..ef03178cb --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/aggregate-args-type.graphql @@ -0,0 +1,82 @@ +type Query { + aggregate( + """Filter to find records to aggregate on""" + filter: FakeTypeAggregateFilter + ): Int! +} + +input FakeTypeAggregateFilter { + and: [FakeTypeAggregateFilter!] + or: [FakeTypeAggregateFilter!] + stringField: StringFieldComparison + numberField: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison +} + +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 NumberFieldComparison { + is: Boolean + isNot: Boolean + eq: Float + neq: Float + gt: Float + gte: Float + lt: Float + lte: Float + in: [Float!] + notIn: [Float!] + between: NumberFieldComparisonBetween + notBetween: NumberFieldComparisonBetween +} + +input NumberFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input BooleanFieldComparison { + is: Boolean + isNot: Boolean +} + +input DateFieldComparison { + is: Boolean + isNot: Boolean + eq: DateTime + neq: DateTime + gt: DateTime + gte: DateTime + lt: DateTime + lte: DateTime + in: [DateTime!] + notIn: [DateTime!] + between: DateFieldComparisonBetween + notBetween: DateFieldComparisonBetween +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +input DateFieldComparisonBetween { + lower: DateTime! + upper: DateTime! +} 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 new file mode 100644 index 000000000..3efaf4e73 --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type-with-custom-name.graphql @@ -0,0 +1,70 @@ +type FakeTypeCountAggregate { + stringField: Int + numberField: Int + boolField: Int + dateField: Int +} + +type FakeTypeSumAggregate { + numberField: Float +} + +type FakeTypeAvgAggregate { + numberField: Float +} + +type FakeTypeMinAggregate { + stringField: String + numberField: Float + 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 CustomPrefixCountAggregate { + stringField: Int + numberField: Int + boolField: Int + dateField: Int +} + +type CustomPrefixSumAggregate { + numberField: Float +} + +type CustomPrefixAvgAggregate { + numberField: Float +} + +type CustomPrefixMinAggregate { + stringField: String + numberField: Float + dateField: DateTime +} + +type CustomPrefixMaxAggregate { + stringField: String + numberField: Float + dateField: DateTime +} + +type CustomPrefixAggregateResponse { + count: CustomPrefixCountAggregate + sum: CustomPrefixSumAggregate + avg: CustomPrefixAvgAggregate + min: CustomPrefixMinAggregate + max: CustomPrefixMaxAggregate +} + +type Query { + aggregate: CustomPrefixAggregateResponse! +} diff --git a/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql new file mode 100644 index 000000000..ee045eaac --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/aggregate-response-type.graphql @@ -0,0 +1,43 @@ +type FakeTypeCountAggregate { + stringField: Int + numberField: Int + boolField: Int + dateField: Int +} + +type FakeTypeSumAggregate { + numberField: Float +} + +type FakeTypeAvgAggregate { + numberField: Float +} + +type FakeTypeMinAggregate { + stringField: String + numberField: Float + 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 FakeTypeAggregateResponse { + count: FakeTypeCountAggregate + sum: FakeTypeSumAggregate + avg: FakeTypeAvgAggregate + min: FakeTypeMinAggregate + max: FakeTypeMaxAggregate +} + +type Query { + aggregate: FakeTypeAggregateResponse! +} diff --git a/packages/query-graphql/__tests__/__fixtures__/index.ts b/packages/query-graphql/__tests__/__fixtures__/index.ts index 7eb3f2d3a..53660ff6a 100644 --- a/packages/query-graphql/__tests__/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/__fixtures__/index.ts @@ -22,6 +22,11 @@ export const expectSDL = async (resolvers: Function[], sdl: string): Promise { + const expectResolverSDL = (sdl: string, opts?: AggregateResolverOpts) => { + @Resolver(() => TestResolverDTO) + class TestSDLResolver extends AggregateResolver(TestResolverDTO, opts) { + @Query(() => TestResolverDTO) + test(): TestResolverDTO { + return { id: '1', stringField: 'foo' }; + } + } + + return expectSDL([TestSDLResolver], sdl); + }; + + it('should create a AggregateResolver for the DTO', () => { + return expectResolverSDL(aggregateResolverSDL, { enabled: true }); + }); + + it('should not expose read methods if not enabled', () => { + return expectResolverSDL(aggregateDisabledResolverSDL); + }); + + describe('#aggregate', () => { + @Resolver(() => TestResolverDTO) + class TestResolver extends AggregateResolver(TestResolverDTO, { enabled: true }) { + constructor(service: TestService) { + super(service); + } + } + it('should call the service query with the provided input', async () => { + const { resolver, mockService } = await createResolverFromNest(TestResolver); + const input: AggregateArgsType = { + filter: { + stringField: { eq: 'foo' }, + }, + }; + const aggregateQuery: AggregateQuery = { count: ['id'] }; + 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__/types/aggregate/aggregate-args.type.spec.ts b/packages/query-graphql/__tests__/types/aggregate/aggregate-args.type.spec.ts new file mode 100644 index 000000000..a45eb21bc --- /dev/null +++ b/packages/query-graphql/__tests__/types/aggregate/aggregate-args.type.spec.ts @@ -0,0 +1,35 @@ +// eslint-disable-next-line max-classes-per-file +import { Resolver, Query, ObjectType, GraphQLISODateTime, Args, Int, ArgsType } from '@nestjs/graphql'; +import { FilterableField, AggregateArgsType } from '../../../src'; +import { expectSDL, aggregateArgsTypeSDL } from '../../__fixtures__'; + +describe('AggregateArgsType', (): void => { + @ObjectType() + class FakeType { + @FilterableField() + stringField!: string; + + @FilterableField() + numberField!: number; + + @FilterableField() + boolField!: boolean; + + @FilterableField(() => GraphQLISODateTime) + dateField!: Date; + } + + @ArgsType() + class AggArgs extends AggregateArgsType(FakeType) {} + + it('should create an aggregate type with the correct fields for each type', async () => { + @Resolver() + class AggregateArgsTypeSpec { + @Query(() => Int) + aggregate(@Args() args: AggArgs): number { + return 1; + } + } + return expectSDL([AggregateArgsTypeSpec], aggregateArgsTypeSDL); + }); +}); diff --git a/packages/query-graphql/__tests__/types/aggregate/aggregate-response.type.spec.ts b/packages/query-graphql/__tests__/types/aggregate/aggregate-response.type.spec.ts new file mode 100644 index 000000000..f8b6128ec --- /dev/null +++ b/packages/query-graphql/__tests__/types/aggregate/aggregate-response.type.spec.ts @@ -0,0 +1,77 @@ +// eslint-disable-next-line max-classes-per-file +import { AggregateResponse } from '@nestjs-query/core'; +import { Resolver, Query, ObjectType, GraphQLISODateTime } from '@nestjs/graphql'; +import { AggregateResponseType, FilterableField } from '../../../src'; +import { aggregateResponseTypeSDL, aggregateResponseTypeWithCustomNameSDL, expectSDL } from '../../__fixtures__'; + +describe('AggregateResponseType', (): void => { + @ObjectType() + class FakeType { + @FilterableField() + stringField!: string; + + @FilterableField() + numberField!: number; + + @FilterableField() + boolField!: boolean; + + @FilterableField(() => GraphQLISODateTime) + dateField!: Date; + } + + it('should create an aggregate type with the correct fields for each type', async () => { + const AggResponse = AggregateResponseType(FakeType); + @Resolver() + class AggregateResponseTypeSpec { + @Query(() => AggResponse) + aggregate(): AggregateResponse { + return {}; + } + } + return expectSDL([AggregateResponseTypeSpec], aggregateResponseTypeSDL); + }); + + it('should return the same class if called multiple times', async () => { + AggregateResponseType(FakeType); + const AggResponse = AggregateResponseType(FakeType); + @Resolver() + class AggregateResponseTypeSpec { + @Query(() => AggResponse) + aggregate(): AggregateResponse { + return {}; + } + } + return expectSDL([AggregateResponseTypeSpec], aggregateResponseTypeSDL); + }); + + it('should create an aggregate type with a custom name', async () => { + const AggResponse = AggregateResponseType(FakeType, { prefix: 'CustomPrefix' }); + @Resolver(() => AggResponse) + class AggregateResponseTypeSpec { + @Query(() => AggResponse) + aggregate(): AggregateResponse { + return {}; + } + } + return expectSDL([AggregateResponseTypeSpec], aggregateResponseTypeWithCustomNameSDL); + }); + + it('throw an error if the type is not registered', () => { + class BadType { + id!: number; + } + expect(() => AggregateResponseType(BadType)).toThrow( + 'Unable to make AggregationResponseType. Ensure BadType is annotated with @nestjs/graphql @ObjectType', + ); + }); + it('throw an error if fields are not found', () => { + @ObjectType() + class BadType { + id!: number; + } + expect(() => AggregateResponseType(BadType)).toThrow( + 'No fields found to create AggregationResponseType for BadType. Ensure fields are annotated with @FilterableField', + ); + }); +}); diff --git a/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts b/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts new file mode 100644 index 000000000..fdbd15f9c --- /dev/null +++ b/packages/query-graphql/src/decorators/aggregate-query-param.decorator.ts @@ -0,0 +1,30 @@ +import { AggregateQuery } from '@nestjs-query/core'; +import { GraphQLResolveInfo, SelectionNode, FieldNode, Kind } from 'graphql'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +const isFieldNode = (node: SelectionNode): node is FieldNode => { + return node.kind === Kind.FIELD; +}; + +export const AggregateQueryParam = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const info = GqlExecutionContext.create(ctx).getInfo(); + const query = info.fieldNodes.map(({ selectionSet }) => { + return selectionSet?.selections.reduce((aggQuery, selection) => { + if (isFieldNode(selection)) { + const aggType = selection.name.value; + const fields = selection.selectionSet?.selections + .map((s) => { + if (isFieldNode(s)) { + return s.name.value; + } + return undefined; + }) + .filter((f) => !!f); + return { ...aggQuery, [aggType]: fields }; + } + return aggQuery; + }, {} as AggregateQuery); + })[0]; + return query || {}; +}); diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 85c46c925..001bb2ff1 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -15,3 +15,4 @@ export { Reference, ReferenceDecoratorOpts, ReferenceTypeFunc } from './referenc export { ResolverSubscription, SubscriptionResolverMethodOpts } from './resolver-subscription.decorator'; export { InjectPubSub } from './inject-pub-sub.decorator'; export * from './skip-if.decorator'; +export * from './aggregate-query-param.decorator'; diff --git a/packages/query-graphql/src/metadata/index.ts b/packages/query-graphql/src/metadata/index.ts index faf6c3403..6a0591e1d 100644 --- a/packages/query-graphql/src/metadata/index.ts +++ b/packages/query-graphql/src/metadata/index.ts @@ -1,5 +1,7 @@ import { GraphQLQueryMetadataStorage } from './metadata-storage'; +export { FilterableFieldDescriptor } from './metadata-storage'; + /** @internal */ let storage: GraphQLQueryMetadataStorage; /** @internal */ diff --git a/packages/query-graphql/src/metadata/metadata-storage.ts b/packages/query-graphql/src/metadata/metadata-storage.ts index df43ec5e0..ea32e2fd2 100644 --- a/packages/query-graphql/src/metadata/metadata-storage.ts +++ b/packages/query-graphql/src/metadata/metadata-storage.ts @@ -1,6 +1,6 @@ import { TypeMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storages/type-metadata.storage'; import { LazyMetadataStorage } from '@nestjs/graphql/dist/schema-builder/storages/lazy-metadata.storage'; -import { Class, Filter, SortField } from '@nestjs-query/core'; +import { AggregateResponse, Class, Filter, SortField } from '@nestjs-query/core'; import { ObjectTypeMetadata } from '@nestjs/graphql/dist/schema-builder/metadata/object-type.metadata'; import { ReturnTypeFunc, FieldOptions } from '@nestjs/graphql'; import { EnumMetadata } from '@nestjs/graphql/dist/schema-builder/metadata'; @@ -11,7 +11,7 @@ import { EdgeType, StaticConnectionType } from '../types/connection'; /** * @internal */ -interface FilterableFieldDescriptor { +export interface FilterableFieldDescriptor { propertyName: string; target: Class; returnTypeFunc?: ReturnTypeFunc; @@ -52,6 +52,8 @@ export class GraphQLQueryMetadataStorage { private readonly referenceStorage: Map, ReferenceDescriptor[]>; + private readonly aggregateStorage: Map>>; + constructor() { this.filterableObjectStorage = new Map, FilterableFieldDescriptor[]>(); this.filterTypeStorage = new Map>>(); @@ -60,6 +62,7 @@ export class GraphQLQueryMetadataStorage { this.edgeTypeStorage = new Map, Class>>(); this.relationStorage = new Map, RelationDescriptor[]>(); this.referenceStorage = new Map, ReferenceDescriptor[]>(); + this.aggregateStorage = new Map>>(); } addFilterableObjectField(type: Class, field: FilterableFieldDescriptor): void { @@ -179,6 +182,14 @@ export class GraphQLQueryMetadataStorage { }, {} as ReferencesOpts); } + addAggregateResponseType(name: string, agg: Class>): void { + this.aggregateStorage.set(name, agg); + } + + getAggregateResponseType(name: string): Class> | undefined { + return this.aggregateStorage.get(name); + } + getGraphqlObjectMetadata(objType: Class): ObjectTypeMetadata | undefined { return TypeMetadataStorage.getObjectTypesMetadata().find((o) => o.target === objType); } diff --git a/packages/query-graphql/src/resolvers/aggregate.resolver.ts b/packages/query-graphql/src/resolvers/aggregate.resolver.ts new file mode 100644 index 000000000..b3d4681f1 --- /dev/null +++ b/packages/query-graphql/src/resolvers/aggregate.resolver.ts @@ -0,0 +1,51 @@ +import { AggregateQuery, AggregateResponse, Class } from '@nestjs-query/core'; +import { Args, ArgsType, Resolver } from '@nestjs/graphql'; +import omit from 'lodash.omit'; +import { getDTONames } from '../common'; +import { AggregateQueryParam, ResolverMethodOpts, ResolverQuery, SkipIf } from '../decorators'; +import { AggregateArgsType, AggregateResponseType } from '../types'; +import { transformAndValidate } from './helpers'; +import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface'; + +export type AggregateResolverOpts = { + enabled: boolean; +} & ResolverMethodOpts; + +export interface AggregateResolver extends ServiceResolver { + aggregate(filter: AggregateArgsType, aggregateQuery: AggregateQuery): Promise>; +} + +/** + * @internal + * Mixin to add `read` graphql endpoints. + */ +export const Aggregateable = (DTOClass: Class, opts?: AggregateResolverOpts) => < + B extends Class> +>( + BaseClass: B, +): Class> & B => { + const { baseNameLower } = getDTONames(DTOClass); + const commonResolverOpts = omit(opts, 'dtoName', 'one', 'many', 'QueryArgs', 'Connection'); + const queryName = `${baseNameLower}Aggregate`; + const AR = AggregateResponseType(DTOClass); + @ArgsType() + class AA extends AggregateArgsType(DTOClass) {} + + @Resolver(() => AR, { isAbstract: true }) + class AggregateResolverBase extends BaseClass { + @SkipIf(() => !opts || !opts.enabled, ResolverQuery(() => AR, { name: queryName }, commonResolverOpts, opts ?? {})) + async aggregate( + @Args() args: AA, + @AggregateQueryParam() query: AggregateQuery, + ): Promise> { + const qa = await transformAndValidate(AA, args); + return this.service.aggregate(qa.filter || {}, query); + } + } + return AggregateResolverBase; +}; + +export const AggregateResolver = ( + DTOClass: Class, + opts?: AggregateResolverOpts, +): ResolverClass> => Aggregateable(DTOClass, opts)(BaseServiceResolver); diff --git a/packages/query-graphql/src/resolvers/crud.resolver.ts b/packages/query-graphql/src/resolvers/crud.resolver.ts index d2e7025ae..a4b592b7d 100644 --- a/packages/query-graphql/src/resolvers/crud.resolver.ts +++ b/packages/query-graphql/src/resolvers/crud.resolver.ts @@ -1,5 +1,6 @@ import { Class, DeepPartial } from '@nestjs-query/core'; import { PagingStrategies } from '../types'; +import { Aggregateable, AggregateResolverOpts } from './aggregate.resolver'; import { Relatable } from './relations'; import { Readable, ReadResolverFromOpts, ReadResolverOpts } from './read.resolver'; import { Creatable, CreateResolver, CreateResolverOpts } from './create.resolver'; @@ -31,6 +32,7 @@ export interface CRUDResolverOpts< update?: UpdateResolverOpts; delete?: DeleteResolverOpts; referenceBy?: ReferenceResolverOpts; + aggregate?: AggregateResolverOpts; } export interface CRUDResolver< @@ -81,10 +83,12 @@ export const CRUDResolver = < update = {}, delete: deleteArgs = {}, referenceBy = {}, + aggregate, } = opts; const referencable = Refereceable(DTOClass, referenceBy); const relatable = Relatable(DTOClass, { enableTotalCount }); + const aggregateable = Aggregateable(DTOClass, aggregate); const creatable = Creatable(DTOClass, { CreateDTOClass, enableSubscriptions, ...create }); const readable = Readable(DTOClass, { enableTotalCount, pagingStrategy, ...read } as MergePagingStrategyOpts< DTO, @@ -94,5 +98,5 @@ export const CRUDResolver = < const updateable = Updateable(DTOClass, { UpdateDTOClass, enableSubscriptions, ...update }); const deleteResolver = DeleteResolver(DTOClass, { enableSubscriptions, ...deleteArgs }); - return referencable(relatable(creatable(readable(updateable(deleteResolver))))); + return referencable(relatable(aggregateable(creatable(readable(updateable(deleteResolver)))))); }; diff --git a/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts b/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts new file mode 100644 index 000000000..6c46942cb --- /dev/null +++ b/packages/query-graphql/src/types/aggregate/aggregate-args.type.ts @@ -0,0 +1,25 @@ +import { Filter, Class } from '@nestjs-query/core'; +import { Field, ArgsType } from '@nestjs/graphql'; +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { AggregateFilterType } from '../query'; + +export interface AggregateArgsType { + filter?: Filter; +} + +/** + * The args type for aggregate queries + * @param DTOClass - The class the aggregate is for. This will be used to create FilterType. + */ +export function AggregateArgsType(DTOClass: Class): Class> { + const F = AggregateFilterType(DTOClass); + @ArgsType() + class AggregateArgs implements AggregateArgsType { + @Type(() => F) + @ValidateNested() + @Field(() => F, { nullable: true, description: 'Filter to find records to aggregate on' }) + filter?: Filter; + } + return AggregateArgs; +} diff --git a/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts b/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts new file mode 100644 index 000000000..18410a1c1 --- /dev/null +++ b/packages/query-graphql/src/types/aggregate/aggregate-response.type.ts @@ -0,0 +1,84 @@ +import { AggregateResponse, Class, NumberAggregate, TypeAggregate } from '@nestjs-query/core'; +import { Field, ObjectType, Float, Int } from '@nestjs/graphql'; +import { GraphQLScalarType } from 'graphql'; +import { SkipIf } from '../../decorators'; +import { FilterableFieldDescriptor, getMetadataStorage } from '../../metadata'; +import { UnregisteredObjectType } from '../type.errors'; + +function NumberAggregatedType( + name: string, + fields: FilterableFieldDescriptor[], + NumberType: GraphQLScalarType, +): Class> { + const fieldNames = fields.map((f) => f.propertyName); + @ObjectType(name) + class Aggregated {} + fieldNames.forEach((propertyName) => { + Field(() => NumberType, { nullable: true })(Aggregated.prototype, propertyName); + }); + + return Aggregated; +} + +function AggregatedType(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; +} + +export type AggregateResponseOpts = { prefix: string }; + +export function AggregateResponseType( + DTOClass: Class, + opts?: AggregateResponseOpts, +): Class> { + const metadataStorage = getMetadataStorage(); + const objMetadata = metadataStorage.getGraphqlObjectMetadata(DTOClass); + if (!objMetadata) { + throw new UnregisteredObjectType(DTOClass, 'Unable to make AggregationResponseType.'); + } + const prefix = opts?.prefix ?? objMetadata.name; + const aggName = `${prefix}AggregateResponse`; + const existing = metadataStorage.getAggregateResponseType(aggName); + if (existing) { + return existing; + } + const fields = metadataStorage.getFilterableObjectFields(DTOClass); + if (!fields) { + throw new Error( + `No fields found to create AggregationResponseType for ${DTOClass.name}. Ensure fields are annotated with @FilterableField`, + ); + } + const numberFields = fields.filter(({ target }) => target === Number); + const minMaxFields = fields.filter(({ target }) => target !== Boolean); + const CountType = NumberAggregatedType(`${prefix}CountAggregate`, fields, Int); + const SumType = NumberAggregatedType(`${prefix}SumAggregate`, numberFields, Float); + const AvgType = NumberAggregatedType(`${prefix}AvgAggregate`, numberFields, Float); + const MinType = AggregatedType(`${prefix}MinAggregate`, minMaxFields); + const MaxType = AggregatedType(`${prefix}MaxAggregate`, minMaxFields); + + @ObjectType(aggName) + class AggResponse { + @Field(() => CountType, { nullable: true }) + count?: NumberAggregate; + + @SkipIf(() => numberFields.length === 0, Field(() => SumType, { nullable: true })) + sum?: NumberAggregate; + + @SkipIf(() => numberFields.length === 0, Field(() => AvgType, { nullable: true })) + avg?: NumberAggregate; + + @SkipIf(() => minMaxFields.length === 0, Field(() => MinType, { nullable: true })) + min?: TypeAggregate; + + @SkipIf(() => minMaxFields.length === 0, Field(() => MaxType, { nullable: true })) + max?: TypeAggregate; + } + metadataStorage.addAggregateResponseType(aggName, AggResponse); + return AggResponse; +} diff --git a/packages/query-graphql/src/types/aggregate/index.ts b/packages/query-graphql/src/types/aggregate/index.ts new file mode 100644 index 000000000..76315579c --- /dev/null +++ b/packages/query-graphql/src/types/aggregate/index.ts @@ -0,0 +1,2 @@ +export { AggregateResponseType, AggregateResponseOpts } from './aggregate-response.type'; +export { AggregateArgsType } from './aggregate-args.type'; diff --git a/packages/query-graphql/src/types/index.ts b/packages/query-graphql/src/types/index.ts index 1b346d583..7c4284268 100644 --- a/packages/query-graphql/src/types/index.ts +++ b/packages/query-graphql/src/types/index.ts @@ -43,3 +43,4 @@ export { RelationsInputType } from './relations-input.type'; export { RelationInputType } from './relation-input.type'; export { SubscriptionArgsType } from './subscription-args.type'; export { SubscriptionFilterInputType } from './subscription-filter-input.type'; +export { AggregateResponseType, AggregateResponseOpts, AggregateArgsType } from './aggregate'; diff --git a/packages/query-graphql/src/types/query/filter.type.ts b/packages/query-graphql/src/types/query/filter.type.ts index 165129d11..236b6ef30 100644 --- a/packages/query-graphql/src/types/query/filter.type.ts +++ b/packages/query-graphql/src/types/query/filter.type.ts @@ -93,3 +93,7 @@ export function UpdateFilterType(TClass: Class): Class> { export function SubscriptionFilterType(TClass: Class): Class> { return getOrCreateFilterType(TClass, `${getObjectTypeName(TClass)}SubscriptionFilter`); } + +export function AggregateFilterType(TClass: Class): Class> { + return getOrCreateFilterType(TClass, `${getObjectTypeName(TClass)}AggregateFilter`); +} diff --git a/packages/query-graphql/src/types/query/index.ts b/packages/query-graphql/src/types/query/index.ts index 6874925be..f032f8dcf 100644 --- a/packages/query-graphql/src/types/query/index.ts +++ b/packages/query-graphql/src/types/query/index.ts @@ -8,7 +8,13 @@ export { StaticOffsetQueryArgsType, StaticCursorQueryArgsType, } from './query-args'; -export { FilterType, DeleteFilterType, UpdateFilterType, SubscriptionFilterType } from './filter.type'; +export { + FilterType, + DeleteFilterType, + UpdateFilterType, + SubscriptionFilterType, + AggregateFilterType, +} from './filter.type'; export { CursorPagingType, OffsetPagingType, diff --git a/packages/query-sequelize/__tests__/query/aggregate.builder.spec.ts b/packages/query-sequelize/__tests__/query/aggregate.builder.spec.ts index 5c9690f26..5845e8354 100644 --- a/packages/query-sequelize/__tests__/query/aggregate.builder.spec.ts +++ b/packages/query-sequelize/__tests__/query/aggregate.builder.spec.ts @@ -38,7 +38,7 @@ describe('AggregateBuilder', (): void => { it('or multiple operators for a single field together', (): void => { assertSQL( { - count: ['testEntityPk'], + count: ['testEntityPk', 'stringType'], avg: ['numberType'], sum: ['numberType'], max: ['stringType', 'dateType', 'numberType'], @@ -47,6 +47,7 @@ describe('AggregateBuilder', (): void => { { attributes: [ [sequelize.fn('COUNT', sequelize.col('test_entity_pk')), 'COUNT_testEntityPk'], + [sequelize.fn('COUNT', sequelize.col('string_type')), 'COUNT_stringType'], [sequelize.fn('SUM', sequelize.col('number_type')), 'SUM_numberType'], [sequelize.fn('AVG', sequelize.col('number_type')), 'AVG_numberType'], [sequelize.fn('MAX', sequelize.col('string_type')), 'MAX_stringType'], diff --git a/packages/query-sequelize/package.json b/packages/query-sequelize/package.json index e4b6d15ec..dd1724bc7 100644 --- a/packages/query-sequelize/package.json +++ b/packages/query-sequelize/package.json @@ -33,7 +33,7 @@ "@nestjs/testing": "7.3.2", "@types/lodash.pick": "4.4.6", "@types/sequelize": "4.28.9", - "sequelize": "5.22.1", + "sequelize": "5.22.3", "sequelize-typescript": "1.1.0", "sqlite3": "5.0.0", "ts-mockito": "2.6.1", diff --git a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts index 4098a03c9..3f168e16a 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts @@ -12,7 +12,7 @@ export const CONNECTION_OPTIONS: ConnectionOptions = { dropSchema: true, entities: [TestEntity, TestSoftDeleteEntity, TestRelation, TestEntityRelationEntity], synchronize: true, - logging: true, + logging: false, }; export function createTestConnection(): Promise { 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 a1cdb9d4d..a69871ea4 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -403,7 +403,7 @@ describe('TypeOrmQueryService', (): void => { expect(queryResult).toEqual(entity); const relation = await queryService.findRelation(TestRelation, 'oneTestRelation', entity); - expect(relation!.testRelationPk).toBe(TEST_RELATIONS[1].testRelationPk); + expect(relation?.testRelationPk).toBe(TEST_RELATIONS[1].testRelationPk); }); });