diff --git a/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts b/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts new file mode 100644 index 000000000..bc4bffbd1 --- /dev/null +++ b/packages/query-graphql/__tests__/loaders/aggregate-relations.loader.spec.ts @@ -0,0 +1,169 @@ +import { AggregateQuery, QueryService } from '@nestjs-query/core'; +import { mock, instance, when, deepEqual } from 'ts-mockito'; +import { AggregateRelationsLoader } from '../../src/loader'; + +describe('AggregateRelationsLoader', () => { + describe('createLoader', () => { + class DTO { + id!: string; + } + + class RelationDTO { + id!: string; + } + + it('should return a function that accepts aggregate args', () => { + const service = mock>(); + const queryRelationsLoader = new AggregateRelationsLoader(RelationDTO, 'relation'); + expect(queryRelationsLoader.createLoader(instance(service))).toBeInstanceOf(Function); + }); + + it('should try to load the relations with the query args', () => { + const service = mock>(); + const aggregateRelationsLoader = new AggregateRelationsLoader(RelationDTO, 'relation').createLoader( + instance(service), + ); + 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 } }; + when( + service.aggregateRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual(filter), deepEqual(aggregate)), + ).thenResolve( + new Map([ + [dtos[0], dto1Aggregate], + [dtos[1], dto2Aggregate], + ]), + ); + return expect( + aggregateRelationsLoader([ + { dto: dtos[0], filter, aggregate }, + { dto: dtos[1], filter, aggregate }, + ]), + ).resolves.toEqual([dto1Aggregate, dto2Aggregate]); + }); + + it('should try return an empty aggregate result for each dto if no results are found', () => { + const service = mock>(); + const aggregateRelationsLoader = new AggregateRelationsLoader(RelationDTO, 'relation').createLoader( + instance(service), + ); + const filter = {}; + const aggregate: AggregateQuery = { count: ['id'] }; + const dtos = [{ id: 'dto-1' }, { id: 'dto-2' }]; + const dto1Aggregate = { count: { id: 2 } }; + when( + service.aggregateRelations(RelationDTO, 'relation', deepEqual(dtos), deepEqual(filter), deepEqual(aggregate)), + ).thenResolve(new Map([[dtos[0], dto1Aggregate]])); + return expect( + aggregateRelationsLoader([ + { dto: dtos[0], filter, aggregate }, + { dto: dtos[1], filter, aggregate }, + ]), + ).resolves.toEqual([dto1Aggregate, {}]); + }); + + it('should group queryRelations calls by filter and return in the correct order', () => { + const service = mock>(); + const queryRelationsLoader = new AggregateRelationsLoader(RelationDTO, 'relation').createLoader( + instance(service), + ); + const filter1 = { id: { gt: 'a' } }; + 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 } }; + when( + service.aggregateRelations( + RelationDTO, + 'relation', + deepEqual([dtos[0], dtos[2]]), + deepEqual(filter1), + deepEqual(aggregate), + ), + ).thenResolve( + new Map([ + [dtos[0], dto1Aggregate], + [dtos[2], dto3Aggregate], + ]), + ); + when( + service.aggregateRelations( + RelationDTO, + 'relation', + deepEqual([dtos[1], dtos[3]]), + deepEqual(filter2), + deepEqual(aggregate), + ), + ).thenResolve( + new Map([ + [dtos[1], dto2Aggregate], + [dtos[3], dto4Aggregate], + ]), + ); + return expect( + queryRelationsLoader([ + { dto: dtos[0], filter: filter1, aggregate }, + { dto: dtos[1], filter: filter2, aggregate }, + { dto: dtos[2], filter: filter1, aggregate }, + { dto: dtos[3], filter: filter2, aggregate }, + ]), + ).resolves.toEqual([dto1Aggregate, dto2Aggregate, dto3Aggregate, dto4Aggregate]); + }); + + it('should group queryRelations calls by aggregate and return in the correct order', () => { + const service = mock>(); + const queryRelationsLoader = new AggregateRelationsLoader(RelationDTO, 'relation').createLoader( + instance(service), + ); + const filter = {}; + 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 } }; + when( + service.aggregateRelations( + RelationDTO, + 'relation', + deepEqual([dtos[0], dtos[2]]), + deepEqual(filter), + deepEqual(aggregate1), + ), + ).thenResolve( + new Map([ + [dtos[0], dto1Aggregate], + [dtos[2], dto3Aggregate], + ]), + ); + when( + service.aggregateRelations( + RelationDTO, + 'relation', + deepEqual([dtos[1], dtos[3]]), + deepEqual(filter), + deepEqual(aggregate2), + ), + ).thenResolve( + new Map([ + [dtos[1], dto2Aggregate], + [dtos[3], dto4Aggregate], + ]), + ); + return expect( + queryRelationsLoader([ + { dto: dtos[0], filter, aggregate: aggregate1 }, + { dto: dtos[1], filter, aggregate: aggregate2 }, + { dto: dtos[2], filter, aggregate: aggregate1 }, + { dto: dtos[3], filter, aggregate: aggregate2 }, + ]), + ).resolves.toEqual([dto1Aggregate, dto2Aggregate, dto3Aggregate, dto4Aggregate]); + }); + }); +}); 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 new file mode 100644 index 000000000..813ee851b --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-custom-name.resolver.graphql @@ -0,0 +1,89 @@ +type TestResolverDTO { + id: ID! + stringField: String! + testsAggregate( + """Filter to find records to aggregate on""" + filter: TestRelationDTOAggregateFilter + ): TestResolverDTOTestsAggregateResponse! +} + +input TestRelationDTOAggregateFilter { + and: [TestRelationDTOAggregateFilter!] + or: [TestRelationDTOAggregateFilter!] + id: IDFilterComparison + testResolverId: 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!] +} + +type TestResolverDTORelationsCountAggregate { + id: Int + testResolverId: Int +} + +type TestResolverDTORelationsMinAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTORelationsMaxAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTOTestsCountAggregate { + id: Int + testResolverId: Int +} + +type TestResolverDTOTestsMinAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTOTestsMaxAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTOTestsAggregateResponse { + count: TestResolverDTOTestsCountAggregate + min: TestResolverDTOTestsMinAggregate + max: TestResolverDTOTestsMaxAggregate +} + +type Query { + test: TestResolverDTO! +} 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 new file mode 100644 index 000000000..0e30ad121 --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-disabled.resolver.graphql @@ -0,0 +1,38 @@ +type TestResolverDTO { + id: ID! + stringField: String! +} + +type TestResolverDTORelationsCountAggregate { + id: Int + testResolverId: Int +} + +type TestResolverDTORelationsMinAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTORelationsMaxAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTOTestsCountAggregate { + id: Int + testResolverId: Int +} + +type TestResolverDTOTestsMinAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTOTestsMaxAggregate { + id: ID + testResolverId: String +} + +type Query { + test: TestResolverDTO! +} diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-empty.resolver.graphql b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-empty.resolver.graphql new file mode 100644 index 000000000..82657e068 --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation-empty.resolver.graphql @@ -0,0 +1,8 @@ +type TestResolverDTO { + id: ID! + stringField: String! +} + +type Query { + test: TestResolverDTO! +} 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 new file mode 100644 index 000000000..b16d4aeb0 --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/aggregate/aggregate-relation.resolver.graphql @@ -0,0 +1,74 @@ +type TestResolverDTO { + id: ID! + stringField: String! + relationsAggregate( + """Filter to find records to aggregate on""" + filter: TestRelationDTOAggregateFilter + ): TestResolverDTORelationsAggregateResponse! +} + +input TestRelationDTOAggregateFilter { + and: [TestRelationDTOAggregateFilter!] + or: [TestRelationDTOAggregateFilter!] + id: IDFilterComparison + testResolverId: 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!] +} + +type TestResolverDTORelationsCountAggregate { + id: Int + testResolverId: Int +} + +type TestResolverDTORelationsMinAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTORelationsMaxAggregate { + id: ID + testResolverId: String +} + +type TestResolverDTORelationsAggregateResponse { + count: TestResolverDTORelationsCountAggregate + min: TestResolverDTORelationsMinAggregate + max: TestResolverDTORelationsMaxAggregate +} + +type Query { + test: TestResolverDTO! +} diff --git a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/index.ts b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/index.ts index aca00ff8d..1c308f82f 100644 --- a/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/resolvers/relations/__fixtures__/index.ts @@ -71,3 +71,16 @@ export const referenceRelationSDL = readGraphql(resolve(__dirname, 'reference', export const referenceRelationNullableSDL = readGraphql( resolve(__dirname, 'reference', 'reference-relation-nullable.resolver.graphql'), ); + +export const aggregateRelationEmptyResolverSDL = readGraphql( + resolve(__dirname, 'aggregate', 'aggregate-relation-empty.resolver.graphql'), +); +export const aggregateRelationResolverSDL = readGraphql( + resolve(__dirname, 'aggregate', 'aggregate-relation.resolver.graphql'), +); +export const aggregateRelationCustomNameSDL = readGraphql( + resolve(__dirname, 'aggregate', 'aggregate-relation-custom-name.resolver.graphql'), +); +export const aggregateRelationDisabledSDL = readGraphql( + resolve(__dirname, 'aggregate', 'aggregate-relation-disabled.resolver.graphql'), +); 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 new file mode 100644 index 000000000..2205a9cb7 --- /dev/null +++ b/packages/query-graphql/__tests__/resolvers/relations/aggregate-relation.resolver.spec.ts @@ -0,0 +1,95 @@ +import { Query, Resolver } from '@nestjs/graphql'; +import { deepEqual, objectContaining, when } from 'ts-mockito'; +import { AggregateQuery, AggregateResponse, Filter } from '@nestjs-query/core'; +import { AggregateRelationsResolver } from '../../../src/resolvers/relations'; +import { AggregateRelationsResolverOpts } from '../../../src/resolvers/relations/aggregate-relations.resolver'; +import { expectSDL } from '../../__fixtures__'; +import { createResolverFromNest, TestResolverDTO, TestService } from '../__fixtures__'; +import { + aggregateRelationCustomNameSDL, + aggregateRelationDisabledSDL, + aggregateRelationEmptyResolverSDL, + aggregateRelationResolverSDL, + TestRelationDTO, +} from './__fixtures__'; + +describe('AggregateRelationsResolver', () => { + const expectResolverSDL = (sdl: string, opts?: AggregateRelationsResolverOpts) => { + @Resolver(() => TestResolverDTO) + class TestSDLResolver extends AggregateRelationsResolver(TestResolverDTO, opts ?? {}) { + @Query(() => TestResolverDTO) + test(): TestResolverDTO { + return { id: '1', stringField: 'foo' }; + } + } + return expectSDL([TestSDLResolver], sdl); + }; + + it('should not add read methods if one and many are empty', () => { + return expectResolverSDL(aggregateRelationEmptyResolverSDL); + }); + describe('aggregate', () => { + it('should use the object type name', () => { + return expectResolverSDL(aggregateRelationResolverSDL, { + enableAggregate: true, + many: { relations: { DTO: TestRelationDTO } }, + }); + }); + + it('should use the dtoName if provided', () => { + return expectResolverSDL(aggregateRelationCustomNameSDL, { + enableAggregate: true, + many: { relations: { DTO: TestRelationDTO, dtoName: 'Test' } }, + }); + }); + + it('should not add read methods if enableAggregate is not true', () => { + return expectResolverSDL(aggregateRelationDisabledSDL, { + many: { relations: { DTO: TestRelationDTO, disableRead: true } }, + }); + }); + + describe('aggregate query', () => { + it('should call the service aggregateRelations with the provided dto', async () => { + @Resolver(() => TestResolverDTO) + class TestResolver extends AggregateRelationsResolver(TestResolverDTO, { + enableAggregate: true, + one: { relation: { DTO: TestRelationDTO }, custom: { DTO: TestRelationDTO, relationName: 'other' } }, + many: { relations: { DTO: TestRelationDTO }, customs: { DTO: TestRelationDTO, relationName: 'others' } }, + }) { + constructor(service: TestService) { + super(service); + } + } + + const { resolver, mockService } = await createResolverFromNest(TestResolver); + const dto: TestResolverDTO = { + id: 'id-1', + stringField: 'foo', + }; + const filter: Filter = { id: { eq: 'id-2' } }; + const aggregateQuery: AggregateQuery = { + count: ['id'], + sum: ['testResolverId'], + }; + const output: AggregateResponse = { + count: { id: 10 }, + sum: { testResolverId: 100 }, + }; + when( + mockService.aggregateRelations( + TestRelationDTO, + 'relations', + deepEqual([dto]), + objectContaining(filter), + objectContaining(aggregateQuery), + ), + ).thenResolve(new Map([[dto, output]])); + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const result = await resolver.aggregateRelations(dto, { filter }, aggregateQuery, {}); + return expect(result).toEqual(output); + }); + }); + }); +}); diff --git a/packages/query-graphql/src/loader/aggregate-relations.loader.ts b/packages/query-graphql/src/loader/aggregate-relations.loader.ts new file mode 100644 index 000000000..0612dc273 --- /dev/null +++ b/packages/query-graphql/src/loader/aggregate-relations.loader.ts @@ -0,0 +1,64 @@ +import { AggregateQuery, Class, QueryService, Filter, AggregateResponse } from '@nestjs-query/core'; +import { NestjsQueryDataloader } from './relations.loader'; + +type AggregateRelationsArgs = { + dto: DTO; + filter: Filter; + aggregate: AggregateQuery; +}; +type AggregateRelationsMap = Map & { index: number })[]>; + +export class AggregateRelationsLoader + implements NestjsQueryDataloader, AggregateResponse | Error> { + constructor(readonly RelationDTO: Class, readonly relationName: string) {} + + createLoader(service: QueryService) { + return async ( + queryArgs: ReadonlyArray>, + ): Promise<(AggregateResponse | Error)[]> => { + // group + const queryMap = this.groupQueries(queryArgs); + return this.loadResults(service, queryMap); + }; + } + + private async loadResults( + service: QueryService, + queryRelationsMap: AggregateRelationsMap, + ): Promise[]> { + const results: AggregateResponse[] = []; + await Promise.all( + [...queryRelationsMap.values()].map(async (args) => { + const { filter, aggregate } = args[0]; + const dtos = args.map((a) => a.dto); + const aggregationResults = await service.aggregateRelations( + this.RelationDTO, + this.relationName, + dtos, + filter, + aggregate, + ); + const dtoRelationAggregates = dtos.map((dto) => aggregationResults.get(dto) ?? {}); + dtoRelationAggregates.forEach((relationAggregate, index) => { + results[args[index].index] = relationAggregate; + }); + }), + ); + return results; + } + + private groupQueries( + queryArgs: ReadonlyArray>, + ): AggregateRelationsMap { + // group + return queryArgs.reduce((map, args, index) => { + const queryJson = JSON.stringify({ filter: args.filter, aggregate: args.aggregate }); + if (!map.has(queryJson)) { + map.set(queryJson, []); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + map.get(queryJson)!.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 36b979f7d..af7758a12 100644 --- a/packages/query-graphql/src/loader/index.ts +++ b/packages/query-graphql/src/loader/index.ts @@ -1,4 +1,5 @@ export * from './find-relations.loader'; export * from './query-relations.loader'; export * from './count-relations.loader'; +export * from './aggregate-relations.loader'; export * from './dataloader.factory'; diff --git a/packages/query-graphql/src/resolvers/aggregate.resolver.ts b/packages/query-graphql/src/resolvers/aggregate.resolver.ts index b3d4681f1..f2c9e4825 100644 --- a/packages/query-graphql/src/resolvers/aggregate.resolver.ts +++ b/packages/query-graphql/src/resolvers/aggregate.resolver.ts @@ -8,7 +8,7 @@ import { transformAndValidate } from './helpers'; import { BaseServiceResolver, ResolverClass, ServiceResolver } from './resolver.interface'; export type AggregateResolverOpts = { - enabled: boolean; + enabled?: boolean; } & ResolverMethodOpts; export interface AggregateResolver extends ServiceResolver { diff --git a/packages/query-graphql/src/resolvers/crud.resolver.ts b/packages/query-graphql/src/resolvers/crud.resolver.ts index a4b592b7d..823c99620 100644 --- a/packages/query-graphql/src/resolvers/crud.resolver.ts +++ b/packages/query-graphql/src/resolvers/crud.resolver.ts @@ -27,6 +27,7 @@ export interface CRUDResolverOpts< enableSubscriptions?: boolean; pagingStrategy?: PS; enableTotalCount?: boolean; + enableAggregate?: boolean; create?: CreateResolverOpts; read?: R; update?: UpdateResolverOpts; @@ -78,6 +79,7 @@ export const CRUDResolver = < enableSubscriptions, pagingStrategy, enableTotalCount, + enableAggregate, create = {}, read = {}, update = {}, @@ -87,8 +89,8 @@ export const CRUDResolver = < } = opts; const referencable = Refereceable(DTOClass, referenceBy); - const relatable = Relatable(DTOClass, { enableTotalCount }); - const aggregateable = Aggregateable(DTOClass, aggregate); + const relatable = Relatable(DTOClass, { enableTotalCount, enableAggregate }); + const aggregateable = Aggregateable(DTOClass, { enabled: enableAggregate, ...aggregate }); const creatable = Creatable(DTOClass, { CreateDTOClass, enableSubscriptions, ...create }); const readable = Readable(DTOClass, { enableTotalCount, pagingStrategy, ...read } as MergePagingStrategyOpts< DTO, diff --git a/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts new file mode 100644 index 000000000..02752a62d --- /dev/null +++ b/packages/query-graphql/src/resolvers/relations/aggregate-relations.resolver.ts @@ -0,0 +1,79 @@ +import { AggregateQuery, AggregateResponse, Class } from '@nestjs-query/core'; +import { ExecutionContext } from '@nestjs/common'; +import { Args, ArgsType, Context, Parent, Resolver } from '@nestjs/graphql'; +import { getDTONames } from '../../common'; +import { AggregateQueryParam, ResolverField } from '../../decorators'; +import { AggregateRelationsLoader, DataLoaderFactory } from '../../loader'; +import { AggregateArgsType, AggregateResponseType } from '../../types'; +import { transformAndValidate } from '../helpers'; +import { BaseServiceResolver, ServiceResolver } from '../resolver.interface'; +import { flattenRelations, removeRelationOpts } from './helpers'; +import { RelationsOpts, ResolverRelation } from './relations.interface'; + +export interface AggregateRelationsResolverOpts extends RelationsOpts { + /** + * Enable relation aggregation queries on relation + */ + enableAggregate?: boolean; +} + +type AggregateRelationOpts = { + enableAggregate?: boolean; +} & ResolverRelation; + +const AggregateRelationMixin = (DTOClass: Class, relation: AggregateRelationOpts) => < + B extends Class> +>( + Base: B, +): B => { + if (!relation.enableAggregate) { + return Base; + } + 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 aggregateRelationLoaderName = `aggregate${pluralBaseName}For${dtoName}`; + const aggregateLoader = new AggregateRelationsLoader(relationDTO, relationName); + @ArgsType() + class RelationQA extends AggregateArgsType(relationDTO) {} + + const AR = AggregateResponseType(relationDTO, { prefix: `${dtoName}${pluralBaseName}` }); + @Resolver(() => DTOClass, { isAbstract: true }) + class AggregateMixin extends Base { + @ResolverField(`${pluralBaseNameLower}Aggregate`, () => AR, {}, commonResolverOpts) + async [`aggregate${pluralBaseName}`]( + @Parent() dto: DTO, + @Args() q: RelationQA, + @AggregateQueryParam() aggregateQuery: AggregateQuery, + @Context() context: ExecutionContext, + ): Promise> { + const qa = await transformAndValidate(RelationQA, q); + const loader = DataLoaderFactory.getOrCreateLoader( + context, + aggregateRelationLoaderName, + aggregateLoader.createLoader(this.service), + ); + return loader.load({ dto, filter: qa.filter ?? {}, aggregate: aggregateQuery }); + } + } + return AggregateMixin; +}; + +export const AggregateRelationsMixin = (DTOClass: Class, relations: AggregateRelationsResolverOpts) => < + B extends Class> +>( + Base: B, +): B => { + const { many, enableAggregate } = relations; + const manyRelations = flattenRelations(many ?? {}); + return manyRelations.reduce((RB, a) => AggregateRelationMixin(DTOClass, { enableAggregate, ...a })(RB), Base); +}; + +export const AggregateRelationsResolver = ( + DTOClass: Class, + relations: AggregateRelationsResolverOpts, +): Class> => { + return AggregateRelationsMixin(DTOClass, relations)(BaseServiceResolver); +}; diff --git a/packages/query-graphql/src/resolvers/relations/index.ts b/packages/query-graphql/src/resolvers/relations/index.ts index efd12ffb3..416bb4417 100644 --- a/packages/query-graphql/src/resolvers/relations/index.ts +++ b/packages/query-graphql/src/resolvers/relations/index.ts @@ -3,6 +3,7 @@ export { ReadRelationsResolver } from './read-relations.resolver'; export { UpdateRelationsResolver } from './update-relations.resolver'; export { RemoveRelationsResolver } from './remove-relations.resolver'; export { ReferencesRelationsResolver } from './references-relation.resolver'; +export { AggregateRelationsResolver } from './aggregate-relations.resolver'; export { RelationTypeMap, RelationTypeOpts, diff --git a/packages/query-graphql/src/resolvers/relations/relations.interface.ts b/packages/query-graphql/src/resolvers/relations/relations.interface.ts index 81fe5d0fa..08991f22b 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.interface.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.interface.ts @@ -52,6 +52,11 @@ export type ResolverRelation = { */ disableRemove?: boolean; + /** + * Enable aggregation queries. + */ + enableAggregate?: boolean; + /** * Set to true if you should be able to filter on this relation. * diff --git a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts index 9e95df373..014717bbc 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.resolver.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.resolver.ts @@ -1,6 +1,7 @@ import { Class } from '@nestjs-query/core'; import { getMetadataStorage } from '../../metadata'; import { ServiceResolver } from '../resolver.interface'; +import { AggregateRelationsMixin } from './aggregate-relations.resolver'; import { ReadRelationsMixin } from './read-relations.resolver'; import { ReferencesRelationMixin } from './references-relation.resolver'; import { RemoveRelationsMixin } from './remove-relations.resolver'; @@ -8,6 +9,7 @@ import { UpdateRelationsMixin } from './update-relations.resolver'; export interface RelatableOpts { enableTotalCount?: boolean; + enableAggregate?: boolean; } export const Relatable = (DTOClass: Class, opts: RelatableOpts) => < @@ -16,13 +18,16 @@ export const Relatable = (DTOClass: Class, opts: RelatableOpts) = Base: B, ): B => { const metadataStorage = getMetadataStorage(); - const { enableTotalCount } = opts; + const { enableTotalCount, enableAggregate } = opts; const relations = metadataStorage.getRelations(DTOClass); const references = metadataStorage.getReferences(DTOClass); const referencesMixin = ReferencesRelationMixin(DTOClass, references); + const aggregateRelationsMixin = AggregateRelationsMixin(DTOClass, { ...relations, enableAggregate }); const readRelationsMixin = ReadRelationsMixin(DTOClass, { ...relations, enableTotalCount }); const updateRelationsMixin = UpdateRelationsMixin(DTOClass, relations); - return referencesMixin(readRelationsMixin(updateRelationsMixin(RemoveRelationsMixin(DTOClass, relations)(Base)))); + return referencesMixin( + aggregateRelationsMixin(readRelationsMixin(updateRelationsMixin(RemoveRelationsMixin(DTOClass, relations)(Base)))), + ); };