From a4895881a1e9ff76811b264cc58eeea116b3edfd Mon Sep 17 00:00:00 2001 From: doug-martin Date: Mon, 13 Jul 2020 17:04:09 -0500 Subject: [PATCH] feat(aggregations,relations,core): Add relation aggregation to core --- .../services/assembler-query.service.spec.ts | 67 +++++++++++++++++++ .../services/noop-query.service.spec.ts | 6 ++ .../services/proxy-query.service.spec.ts | 24 ++++++- .../services/relation-query.service.spec.ts | 64 +++++++++++++++++- .../src/services/assembler-query.service.ts | 45 +++++++++++++ .../core/src/services/noop-query.service.ts | 42 +++++++++--- .../core/src/services/proxy-query.service.ts | 27 ++++++++ packages/core/src/services/query.service.ts | 60 +++++++++++------ .../src/services/relation-query.service.ts | 43 +++++++++++- 9 files changed, 345 insertions(+), 33 deletions(-) diff --git a/packages/core/__tests__/services/assembler-query.service.spec.ts b/packages/core/__tests__/services/assembler-query.service.spec.ts index 2ae63ed18..4ce3ab0f4 100644 --- a/packages/core/__tests__/services/assembler-query.service.spec.ts +++ b/packages/core/__tests__/services/assembler-query.service.spec.ts @@ -154,6 +154,73 @@ describe('AssemblerQueryService', () => { }); }); + describe('aggregateRelations', () => { + it('should transform the results for a single entity', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + const aggQuery: AggregateQuery = { count: ['foo'] }; + const result: AggregateResponse = { count: { foo: 1 } }; + when( + mockQueryService.aggregateRelations( + TestDTO, + 'test', + objectContaining({ bar: 'bar' }), + objectContaining({ foo: { eq: 'bar' } }), + aggQuery, + ), + ).thenResolve(result); + + return expect( + assemblerService.aggregateRelations(TestDTO, 'test', { foo: 'bar' }, { foo: { eq: 'bar' } }, aggQuery), + ).resolves.toEqual(result); + }); + + it('should transform the results for multiple entities', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + const dto: TestDTO = { foo: 'bar' }; + const entity: TestEntity = { bar: 'bar' }; + const aggQuery: AggregateQuery = { count: ['foo'] }; + const result: AggregateResponse = { count: { foo: 1 } }; + when( + mockQueryService.aggregateRelations( + TestDTO, + 'test', + deepEqual([entity]), + objectContaining({ foo: { eq: 'bar' } }), + aggQuery, + ), + ).thenCall((relationClass, relation, entities) => { + return Promise.resolve( + new Map>([[entities[0], result]]), + ); + }); + return expect( + assemblerService.aggregateRelations(TestDTO, 'test', [{ foo: 'bar' }], { foo: { eq: 'bar' } }, aggQuery), + ).resolves.toEqual(new Map([[dto, result]])); + }); + + it('should return an empty array for dtos with no aggregateRelations', () => { + const mockQueryService = mock>(); + const assemblerService = new AssemblerQueryService(new TestAssembler(), instance(mockQueryService)); + const dto: TestDTO = { foo: 'bar' }; + const entity: TestEntity = { bar: 'bar' }; + const aggQuery: AggregateQuery = { count: ['foo'] }; + when( + mockQueryService.aggregateRelations( + TestDTO, + 'test', + deepEqual([entity]), + objectContaining({ foo: { eq: 'bar' } }), + aggQuery, + ), + ).thenResolve(new Map>()); + return expect( + assemblerService.aggregateRelations(TestDTO, 'test', [{ foo: 'bar' }], { foo: { eq: 'bar' } }, aggQuery), + ).resolves.toEqual(new Map([[dto, {}]])); + }); + }); + describe('countRelations', () => { 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 3cfeffebc..3acf2dc5e 100644 --- a/packages/core/__tests__/services/noop-query.service.spec.ts +++ b/packages/core/__tests__/services/noop-query.service.spec.ts @@ -69,4 +69,10 @@ describe('NoOpQueryService', () => { it('should throw a NotImplementedException when calling updateOne', () => { return expect(instance.updateOne(1, { foo: 'bar' })).rejects.toThrow('updateOne is not implemented'); }); + + it('should throw a NotImplementedException when calling aggregateRelations', () => { + return expect(instance.aggregateRelations(TestType, 'test', new TestType(), {}, {})).rejects.toThrow( + 'aggregateRelations 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 b935a2ba2..dbe9977a5 100644 --- a/packages/core/__tests__/services/proxy-query.service.spec.ts +++ b/packages/core/__tests__/services/proxy-query.service.spec.ts @@ -2,7 +2,7 @@ import { mock, reset, instance, when } from 'ts-mockito'; import { QueryService, AggregateQuery } from '../../src'; import { ProxyQueryService } from '../../src/services/proxy-query.service'; -describe('NoOpQueryService', () => { +describe('ProxyQueryService', () => { class TestType { foo!: string; } @@ -105,6 +105,28 @@ describe('NoOpQueryService', () => { return expect(queryService.queryRelations(TestType, relationName, dtos, query)).resolves.toBe(result); }); + it('should proxy to the underlying service when calling aggregateRelations with one dto', () => { + const relationName = 'test'; + const dto = new TestType(); + const filter = {}; + const aggQuery: AggregateQuery = { count: ['foo'] }; + const result = { count: { foo: 1 } }; + when(mockQueryService.aggregateRelations(TestType, relationName, dto, filter, aggQuery)).thenResolve(result); + return expect(queryService.aggregateRelations(TestType, relationName, dto, filter, aggQuery)).resolves.toBe(result); + }); + + it('should proxy to the underlying service when calling aggregateRelations with many dtos', () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const filter = {}; + const aggQuery: AggregateQuery = { count: ['foo'] }; + const result = new Map([[{ foo: 'bar' }, { count: { foo: 1 } }]]); + when(mockQueryService.aggregateRelations(TestType, relationName, dtos, filter, aggQuery)).thenResolve(result); + return expect(queryService.aggregateRelations(TestType, relationName, dtos, filter, aggQuery)).resolves.toBe( + result, + ); + }); + it('should proxy to the underlying service when calling countRelations with one dto', () => { const relationName = 'test'; const dto = new TestType(); diff --git a/packages/core/__tests__/services/relation-query.service.spec.ts b/packages/core/__tests__/services/relation-query.service.spec.ts index 67cd13f7f..04441c4d5 100644 --- a/packages/core/__tests__/services/relation-query.service.spec.ts +++ b/packages/core/__tests__/services/relation-query.service.spec.ts @@ -1,5 +1,5 @@ import { mock, reset, instance, when, deepEqual } from 'ts-mockito'; -import { QueryService, RelationQueryService } from '../../src'; +import { AggregateQuery, QueryService, RelationQueryService } from '../../src'; describe('RelationQueryService', () => { const mockQueryService: QueryService = mock>(); @@ -124,6 +124,68 @@ describe('RelationQueryService', () => { }); }); + describe('#aggregateRelations', () => { + it('should proxy to the underlying service when calling queryRelations with one dto', async () => { + const relationName = 'test'; + const dto = new TestType(); + const result = { count: { foo: 1 } }; + const filter = {}; + const relationFilter = {}; + const relationAggregateQuery: AggregateQuery = { count: ['foo'] }; + testRelationFn.mockReturnValue({ filter: relationFilter }); + when(mockRelationService.aggregate(deepEqual(relationFilter), relationAggregateQuery)).thenResolve(result); + await expect( + queryService.aggregateRelations(TestType, relationName, dto, filter, relationAggregateQuery), + ).resolves.toBe(result); + return expect(testRelationFn).toHaveBeenCalledWith(dto); + }); + + it('should proxy to the underlying service when calling queryRelations with many dtos', async () => { + const relationName = 'test'; + const dtos = [new TestType()]; + const relationResults = { count: { foo: 1 } }; + const result = new Map([[dtos[0], relationResults]]); + const filter = {}; + const relationFilter = {}; + const relationAggregateQuery: AggregateQuery = { count: ['foo'] }; + testRelationFn.mockReturnValue({ filter: relationFilter }); + when(mockRelationService.aggregate(deepEqual(relationFilter), relationAggregateQuery)).thenResolve( + relationResults, + ); + return expect( + queryService.aggregateRelations(TestType, relationName, dtos, filter, relationAggregateQuery), + ).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 filter = {}; + const aggregateQuery: AggregateQuery = { count: ['foo'] }; + const result = { count: { foo: 1 } }; + when(mockQueryService.aggregateRelations(TestType, relationName, dto, filter, aggregateQuery)).thenResolve( + result, + ); + return expect(queryService.aggregateRelations(TestType, relationName, dto, filter, aggregateQuery)).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 filter = {}; + const aggregateQuery: AggregateQuery = { count: ['foo'] }; + const result = new Map([[dtos[0], { count: { foo: 1 } }]]); + when(mockQueryService.aggregateRelations(TestType, relationName, dtos, filter, aggregateQuery)).thenResolve( + result, + ); + return expect( + queryService.aggregateRelations(TestType, relationName, dtos, filter, aggregateQuery), + ).resolves.toBe(result); + }); + }); + describe('#countRelations', () => { it('should proxy to the underlying service when calling queryRelations with one dto', async () => { const relationName = 'test'; diff --git a/packages/core/src/services/assembler-query.service.ts b/packages/core/src/services/assembler-query.service.ts index 3cfc2f66e..63496a6db 100644 --- a/packages/core/src/services/assembler-query.service.ts +++ b/packages/core/src/services/assembler-query.service.ts @@ -207,4 +207,49 @@ export class AssemblerQueryService implements QueryService { this.queryService.updateOne(id, this.assembler.convertToEntity((update as unknown) as DTO)), ); } + + aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + aggregate: AggregateQuery, + ): Promise>; + aggregateRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise>>; + async aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise | Map>> { + if (Array.isArray(dto)) { + const entities = this.assembler.convertToEntities(dto); + const relationMap = await this.queryService.aggregateRelations( + RelationClass, + relationName, + entities, + filter, + aggregate, + ); + return entities.reduce((map, e, index) => { + const entry = relationMap.get(e) ?? {}; + map.set(dto[index], entry); + return map; + }, new Map>()); + } + return this.queryService.aggregateRelations( + RelationClass, + relationName, + this.assembler.convertToEntity(dto), + filter, + aggregate, + ); + } } diff --git a/packages/core/src/services/noop-query.service.ts b/packages/core/src/services/noop-query.service.ts index f5332c640..4e01a7336 100644 --- a/packages/core/src/services/noop-query.service.ts +++ b/packages/core/src/services/noop-query.service.ts @@ -45,11 +45,7 @@ export class NoOpQueryService implements QueryService { return Promise.reject(new NotImplementedException('findById is not implemented')); } - findRelation( - RelationClass: Class, - relationName: string, - entity: DTO, - ): Promise; + findRelation(RelationClass: Class, relationName: string, dto: DTO): Promise; findRelation( RelationClass: Class, @@ -60,7 +56,7 @@ export class NoOpQueryService implements QueryService { findRelation( RelationClass: Class, relationName: string, - entity: DTO | DTO[], + dto: DTO | DTO[], ): Promise<(Relation | undefined) | Map> { return Promise.reject(new NotImplementedException('findRelation is not implemented')); } @@ -84,7 +80,7 @@ export class NoOpQueryService implements QueryService { queryRelations( RelationClass: Class, relationName: string, - entity: DTO, + dto: DTO, query: Query, ): Promise; @@ -98,7 +94,7 @@ export class NoOpQueryService implements QueryService { queryRelations( RelationClass: Class, relationName: string, - entity: DTO | DTO[], + dto: DTO | DTO[], query: Query, ): Promise> { return Promise.reject(new NotImplementedException('queryRelations is not implemented')); @@ -107,7 +103,7 @@ export class NoOpQueryService implements QueryService { countRelations( RelationClass: Class, relationName: string, - entity: DTO, + dto: DTO, filter: Filter, ): Promise; @@ -121,7 +117,7 @@ export class NoOpQueryService implements QueryService { countRelations( RelationClass: Class, relationName: string, - entity: DTO | DTO[], + dto: DTO | DTO[], filter: Filter, ): Promise> { return Promise.reject(new NotImplementedException('countRelations is not implemented')); @@ -146,4 +142,30 @@ export class NoOpQueryService implements QueryService { updateOne>(id: string | number, update: U): Promise { return Promise.reject(new NotImplementedException('updateOne is not implemented')); } + + aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + aggregate: AggregateQuery, + ): Promise>; + + aggregateRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise>>; + + aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise | Map>> { + return Promise.reject(new NotImplementedException('aggregateRelations is not implemented')); + } } diff --git a/packages/core/src/services/proxy-query.service.ts b/packages/core/src/services/proxy-query.service.ts index c76881191..b03ec05d3 100644 --- a/packages/core/src/services/proxy-query.service.ts +++ b/packages/core/src/services/proxy-query.service.ts @@ -172,4 +172,31 @@ export class ProxyQueryService implements QueryService { updateOne>(id: string | number, update: U): Promise { return this.proxied.updateOne(id, update); } + + aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + aggregate: AggregateQuery, + ): Promise>; + aggregateRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise>>; + async aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise | Map>> { + if (Array.isArray(dto)) { + return this.proxied.aggregateRelations(RelationClass, relationName, dto, filter, aggregate); + } + return this.proxied.aggregateRelations(RelationClass, relationName, dto, filter, aggregate); + } } diff --git a/packages/core/src/services/query.service.ts b/packages/core/src/services/query.service.ts index 1d63ae6b3..eba5c372f 100644 --- a/packages/core/src/services/query.service.ts +++ b/packages/core/src/services/query.service.ts @@ -46,14 +46,14 @@ export interface QueryService { /** * Query for an array of relations. * * @param RelationClass - The class to serialize the Relations into - * @param entity - The entity to query relations for. + * @param dto - The dto to query relations for. * @param relationName - The name of relation to query for. * @param query - A query to filter, page or sort relations. */ queryRelations( RelationClass: Class, relationName: string, - entity: DTO, + dto: DTO, query: Query, ): Promise; @@ -64,6 +64,30 @@ export interface QueryService { query: Query, ): Promise>; + /** + * Aggregate relations for a DTO. + * * @param RelationClass - The class to serialize the Relations into + * @param dto - The DTO to query relations for. + * @param relationName - The name of relation to query for. + * @param filter - A filter to apply to relations. + * @param aggregate - The aggregate query + */ + aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + aggregate: AggregateQuery, + ): Promise>; + + aggregateRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise>>; + /** * Count the number of relations * @param filter - Filter to create a where clause. @@ -71,35 +95,31 @@ export interface QueryService { countRelations( RelationClass: Class, relationName: string, - entity: DTO, + dto: DTO, filter: Filter, ): Promise; countRelations( RelationClass: Class, relationName: string, - entity: DTO[], + dto: DTO[], filter: Filter, ): Promise>; /** * Finds a single relation. * @param RelationClass - The class to serialize the Relation into - * @param entity - The entity to find the relation on. + * @param dto - The dto to find the relation on. * @param relationName - The name of the relation to query for. */ - findRelation( - RelationClass: Class, - relationName: string, - entity: DTO, - ): Promise; + findRelation(RelationClass: Class, relationName: string, dto: DTO): Promise; /** * Finds a single relation for each DTO passed in. * - * @param RelationClass - The class to serialize the Relation into - * @param entity - The entity to find the relation on. + * @param RelationClass - The class to serialize the Relation into* * @param relationName - The name of the relation to query for. + * @param dtos - The dto to find the relation on. */ findRelation( RelationClass: Class, @@ -110,34 +130,34 @@ export interface QueryService { /** * Adds multiple relations. * @param relationName - The name of the relation to query for. - * @param id - The id of the entity to add the relation to. + * @param id - The id of the dto to add the relation to. * @param relationIds - The ids of the relations to add. */ addRelations(relationName: string, id: string | number, relationIds: (string | number)[]): Promise; /** - * Set the relation on the entity. + * Set the relation on the dto. * * @param relationName - The name of the relation to query for. - * @param id - The id of the entity to set the relation on. - * @param relationId - The id of the relation to set on the entity. + * @param id - The id of the dto to set the relation on. + * @param relationId - The id of the relation to set on the dto. */ setRelation(relationName: string, id: string | number, relationId: string | number): Promise; /** * Removes multiple relations. * @param relationName - The name of the relation to query for. - * @param id - The id of the entity to add the relation to. + * @param id - The id of the dto to add the relation to. * @param relationIds - The ids of the relations to add. */ removeRelations(relationName: string, id: string | number, relationIds: (string | number)[]): Promise; /** - * Remove the relation on the entity. + * Remove the relation on the dto. * * @param relationName - The name of the relation to query for. - * @param id - The id of the entity to set the relation on. - * @param relationId - The id of the relation to set on the entity. + * @param id - The id of the dto to set the relation on. + * @param relationId - The id of the relation to set on the dto. */ removeRelation(relationName: string, id: string | number, relationId: string | number): Promise; diff --git a/packages/core/src/services/relation-query.service.ts b/packages/core/src/services/relation-query.service.ts index 82233bf6a..8d67dea30 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 { Filter, Query } from '../interfaces'; +import { Filter, Query, AggregateQuery, AggregateResponse } from '../interfaces'; import { NoOpQueryService } from './noop-query.service'; import { ProxyQueryService } from './proxy-query.service'; import { QueryService } from './query.service'; @@ -82,6 +82,47 @@ export class RelationQueryService extends ProxyQueryService { return service.query(mergeQuery(query, qf(dto))); } + async aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO, + filter: Filter, + aggregate: AggregateQuery, + ): Promise>; + + async aggregateRelations( + RelationClass: Class, + relationName: string, + dtos: DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise>>; + + async aggregateRelations( + RelationClass: Class, + relationName: string, + dto: DTO | DTO[], + filter: Filter, + aggregate: AggregateQuery, + ): Promise | Map>> { + const serviceRelation = this.getRelation(relationName); + if (!serviceRelation) { + if (Array.isArray(dto)) { + return super.aggregateRelations(RelationClass, relationName, dto, filter, aggregate); + } + return super.aggregateRelations(RelationClass, relationName, dto, filter, aggregate); + } + const { query: qf, service } = serviceRelation; + if (Array.isArray(dto)) { + return dto.reduce(async (mapPromise, d) => { + const map = await mapPromise; + const relations = await service.aggregate(mergeQuery({ filter }, qf(d)).filter ?? {}, aggregate); + return map.set(d, relations); + }, Promise.resolve(new Map>())); + } + return service.aggregate(mergeQuery({ filter }, qf(dto)).filter ?? {}, aggregate); + } + countRelations( RelationClass: Class, relationName: string,