diff --git a/packages/core/__tests__/assemblers/abstract.assembler.spec.ts b/packages/core/__tests__/assemblers/abstract.assembler.spec.ts index 6614b357e..ecae51bf6 100644 --- a/packages/core/__tests__/assemblers/abstract.assembler.spec.ts +++ b/packages/core/__tests__/assemblers/abstract.assembler.spec.ts @@ -1,4 +1,13 @@ -import { transformQuery, Query, AbstractAssembler, Assembler } from '../../src'; +import { + transformQuery, + Query, + AbstractAssembler, + Assembler, + AggregateQuery, + AggregateResponse, + transformAggregateQuery, + transformAggregateResponse, +} from '../../src'; describe('ClassTransformerAssembler', () => { class TestDTO { @@ -35,6 +44,20 @@ describe('ClassTransformerAssembler', () => { lastName: 'last', }); } + + convertAggregateQuery(aggregate: AggregateQuery): AggregateQuery { + return transformAggregateQuery(aggregate, { + firstName: 'first', + lastName: 'last', + }); + } + + convertAggregateResponse(aggregate: AggregateResponse): AggregateResponse { + return transformAggregateResponse(aggregate, { + first: 'firstName', + last: 'lastName', + }); + } } const testDTO: TestDTO = { firstName: 'foo', lastName: 'bar' }; diff --git a/packages/core/__tests__/services/assembler-query.service.spec.ts b/packages/core/__tests__/services/assembler-query.service.spec.ts index 8d13513ca..2ae63ed18 100644 --- a/packages/core/__tests__/services/assembler-query.service.spec.ts +++ b/packages/core/__tests__/services/assembler-query.service.spec.ts @@ -1,5 +1,15 @@ import { mock, instance, objectContaining, when, deepEqual } from 'ts-mockito'; -import { AbstractAssembler, AssemblerQueryService, Query, QueryService, transformQuery } from '../../src'; +import { + AbstractAssembler, + AggregateQuery, + AggregateResponse, + AssemblerQueryService, + Query, + QueryService, + transformAggregateQuery, + transformAggregateResponse, + transformQuery, +} from '../../src'; describe('AssemblerQueryService', () => { class TestDTO { @@ -15,12 +25,6 @@ describe('AssemblerQueryService', () => { super(TestDTO, TestEntity); } - convertQuery(query: Query): Query { - return transformQuery(query, { - foo: 'bar', - }); - } - convertToDTO(entity: TestEntity): TestDTO { return { foo: entity.bar, @@ -32,6 +36,24 @@ describe('AssemblerQueryService', () => { bar: dto.foo, }; } + + convertQuery(query: Query): Query { + return transformQuery(query, { + foo: 'bar', + }); + } + + convertAggregateQuery(aggregate: AggregateQuery): AggregateQuery { + return transformAggregateQuery(aggregate, { + foo: 'bar', + }); + } + + convertAggregateResponse(aggregate: AggregateResponse): AggregateResponse { + return transformAggregateResponse(aggregate, { + bar: 'foo', + }); + } } describe('query', () => { diff --git a/packages/core/src/assemblers/abstract.assembler.ts b/packages/core/src/assemblers/abstract.assembler.ts index 69f15670c..c014affab 100644 --- a/packages/core/src/assemblers/abstract.assembler.ts +++ b/packages/core/src/assemblers/abstract.assembler.ts @@ -1,5 +1,5 @@ import { Class } from '../common'; -import { Query } from '../interfaces'; +import { AggregateQuery, Query, AggregateResponse } from '../interfaces'; import { getCoreMetadataStorage } from '../metadata'; import { Assembler } from './assembler'; @@ -39,6 +39,10 @@ export abstract class AbstractAssembler implements Assembler): Query; + abstract convertAggregateQuery(aggregate: AggregateQuery): AggregateQuery; + + abstract convertAggregateResponse(aggregate: AggregateResponse): AggregateResponse; + convertToDTOs(entities: Entity[]): DTO[] { return entities.map((e) => this.convertToDTO(e)); } diff --git a/packages/core/src/assemblers/assembler.ts b/packages/core/src/assemblers/assembler.ts index 29fe99780..e1a283433 100644 --- a/packages/core/src/assemblers/assembler.ts +++ b/packages/core/src/assemblers/assembler.ts @@ -1,5 +1,5 @@ import { Class } from '../common'; -import { Query } from '../interfaces'; +import { AggregateQuery, AggregateResponse, Query } from '../interfaces'; import { getCoreMetadataStorage } from '../metadata'; export interface Assembler { @@ -21,6 +21,18 @@ export interface Assembler { */ convertQuery(query: Query): Query; + /** + * Convert a DTO query to an entity query. + * @param aggregate - the aggregate query to convert. + */ + convertAggregateQuery(aggregate: AggregateQuery): AggregateQuery; + + /** + * Convert a Entity aggregate response query to an dto aggregate. + * @param aggregate - the aggregate query to convert. + */ + convertAggregateResponse(aggregate: AggregateResponse): AggregateResponse; + /** * Convert an array of entities to a an of DTOs * @param entities - the entities to convert. diff --git a/packages/core/src/assemblers/class-transformer.assembler.ts b/packages/core/src/assemblers/class-transformer.assembler.ts index 17ddaf6a7..a1d3b23da 100644 --- a/packages/core/src/assemblers/class-transformer.assembler.ts +++ b/packages/core/src/assemblers/class-transformer.assembler.ts @@ -1,5 +1,5 @@ import { plainToClass } from 'class-transformer'; -import { Query } from '../interfaces'; +import { AggregateQuery, AggregateResponse, Query } from '../interfaces'; import { getCoreMetadataStorage } from '../metadata'; import { AbstractAssembler } from './abstract.assembler'; import { Class } from '../common'; @@ -20,6 +20,14 @@ export abstract class ClassTransformerAssembler extends AbstractAss return query as Query; } + convertAggregateQuery(aggregate: AggregateQuery): AggregateQuery { + return (aggregate as unknown) as AggregateQuery; + } + + convertAggregateResponse(aggregate: AggregateResponse): AggregateResponse { + return aggregate as AggregateResponse; + } + // eslint-disable-next-line @typescript-eslint/ban-types convert(cls: Class, obj: object): T { const deserializer = getCoreMetadataStorage().getAssemblerDeserializer(cls); diff --git a/packages/core/src/helpers/aggregate.helpers.ts b/packages/core/src/helpers/aggregate.helpers.ts new file mode 100644 index 000000000..4cc5b3fae --- /dev/null +++ b/packages/core/src/helpers/aggregate.helpers.ts @@ -0,0 +1,76 @@ +import { AggregateQuery, AggregateResponse, NumberAggregate } from '../interfaces'; +import { QueryFieldMap } from './query.helpers'; + +const convertAggregateQueryFields = ( + fieldMap: QueryFieldMap, + fields?: (keyof From)[], +): (keyof To)[] | undefined => { + if (!fields) { + return fields; + } + return fields.map((fromField) => { + const otherKey = fieldMap[fromField]; + if (!otherKey) { + throw new Error(`No corresponding field found for '${fromField as string}' when transforming aggregateQuery`); + } + return otherKey as keyof To; + }); +}; + +const convertAggregateNumberFields = ( + fieldMap: QueryFieldMap, + response?: NumberAggregate, +): NumberAggregate | undefined => { + if (!response) { + return response; + } + return Object.keys(response).reduce((toResponse, fromField) => { + const otherKey = fieldMap[fromField as keyof From] as keyof To; + if (!otherKey) { + throw new Error(`No corresponding field found for '${fromField}' when transforming aggregateQuery`); + } + return { ...toResponse, [otherKey]: response[fromField as keyof From] }; + }, {} as Record); +}; + +const convertAggregateFields = ( + fieldMap: QueryFieldMap, + response?: Partial, +): Partial | undefined => { + if (!response) { + return response; + } + return Object.keys(response).reduce((toResponse, fromField) => { + const otherKey = fieldMap[fromField as keyof From] as keyof To; + if (!otherKey) { + throw new Error(`No corresponding field found for '${fromField}' when transforming aggregateQuery`); + } + return { ...toResponse, [otherKey]: response[fromField as keyof From] }; + }, {} as Partial); +}; + +export const transformAggregateQuery = ( + query: AggregateQuery, + fieldMap: QueryFieldMap, +): AggregateQuery => { + return { + count: convertAggregateQueryFields(fieldMap, query.count), + sum: convertAggregateQueryFields(fieldMap, query.sum), + avg: convertAggregateQueryFields(fieldMap, query.avg), + max: convertAggregateQueryFields(fieldMap, query.max), + min: convertAggregateQueryFields(fieldMap, query.min), + }; +}; + +export const transformAggregateResponse = ( + response: AggregateResponse, + fieldMap: QueryFieldMap, +): AggregateResponse => { + return { + count: convertAggregateNumberFields(fieldMap, response.count), + sum: convertAggregateNumberFields(fieldMap, response.sum), + avg: convertAggregateNumberFields(fieldMap, response.avg), + max: convertAggregateFields(fieldMap, response.max), + min: convertAggregateFields(fieldMap, response.min), + }; +}; diff --git a/packages/core/src/helpers/index.ts b/packages/core/src/helpers/index.ts index cba7ad9e8..4afb152c7 100644 --- a/packages/core/src/helpers/index.ts +++ b/packages/core/src/helpers/index.ts @@ -7,3 +7,4 @@ export { transformSort, getFilterFields, } from './query.helpers'; +export { transformAggregateQuery, transformAggregateResponse } from './aggregate.helpers'; diff --git a/packages/core/src/helpers/query.helpers.ts b/packages/core/src/helpers/query.helpers.ts index 649ccc109..5be6811eb 100644 --- a/packages/core/src/helpers/query.helpers.ts +++ b/packages/core/src/helpers/query.helpers.ts @@ -2,8 +2,8 @@ import merge from 'lodash.merge'; import { Filter, Query, SortField } from '../interfaces'; import { FilterBuilder } from './filter.builder'; -export type QueryFieldMap = { - [F in keyof From]?: keyof To; +export type QueryFieldMap = { + [F in keyof From]?: T; }; export const transformSort = ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fcda8b272..ab1ee0f08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,16 @@ export { NoOpQueryService, QueryServiceRelation, } from './services'; -export { transformFilter, transformQuery, transformSort, applyFilter, getFilterFields, QueryFieldMap } from './helpers'; +export { + transformFilter, + transformQuery, + transformSort, + applyFilter, + getFilterFields, + QueryFieldMap, + transformAggregateQuery, + transformAggregateResponse, +} from './helpers'; export { ClassTransformerAssembler, DefaultAssembler, diff --git a/packages/core/src/interfaces/aggregate-query.interface.ts b/packages/core/src/interfaces/aggregate-query.interface.ts new file mode 100644 index 000000000..6db2812d9 --- /dev/null +++ b/packages/core/src/interfaces/aggregate-query.interface.ts @@ -0,0 +1,7 @@ +export type AggregateQuery = { + count?: (keyof DTO)[]; + sum?: (keyof DTO)[]; + avg?: (keyof DTO)[]; + max?: (keyof DTO)[]; + min?: (keyof DTO)[]; +}; diff --git a/packages/core/src/interfaces/aggregate-response.interface.ts b/packages/core/src/interfaces/aggregate-response.interface.ts new file mode 100644 index 000000000..4da63417a --- /dev/null +++ b/packages/core/src/interfaces/aggregate-response.interface.ts @@ -0,0 +1,15 @@ +export type NumberAggregate = { + [K in keyof DTO]?: number; +}; + +export type TypeAggregate = { + [K in keyof DTO]?: DTO[K]; +}; + +export type AggregateResponse = { + count?: NumberAggregate; + sum?: NumberAggregate; + avg?: NumberAggregate; + max?: TypeAggregate; + min?: TypeAggregate; +}; diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts index 6eb6cf070..60a0641fb 100644 --- a/packages/core/src/interfaces/index.ts +++ b/packages/core/src/interfaces/index.ts @@ -5,3 +5,5 @@ export * from './query.inteface'; export * from './sort-field.interface'; export * from './update-many-response.interface'; export * from './delete-many-response.interface'; +export * from './aggregate-response.interface'; +export * from './aggregate-query.interface'; diff --git a/packages/core/src/services/assembler-query.service.ts b/packages/core/src/services/assembler-query.service.ts index c78c9f3f7..3cfc2f66e 100644 --- a/packages/core/src/services/assembler-query.service.ts +++ b/packages/core/src/services/assembler-query.service.ts @@ -1,6 +1,13 @@ import { Assembler } from '../assemblers'; import { Class, DeepPartial } from '../common'; -import { DeleteManyResponse, Filter, Query, UpdateManyResponse } from '../interfaces'; +import { + AggregateQuery, + AggregateResponse, + DeleteManyResponse, + Filter, + Query, + UpdateManyResponse, +} from '../interfaces'; import { QueryService } from './query.service'; export class AssemblerQueryService implements QueryService { @@ -46,6 +53,14 @@ export class AssemblerQueryService implements QueryService { return this.assembler.convertAsyncToDTOs(this.queryService.query(this.assembler.convertQuery(query))); } + async aggregate(filter: Filter, aggregate: AggregateQuery): Promise> { + const aggregateResponse = await this.queryService.aggregate( + this.assembler.convertQuery({ filter }).filter || {}, + this.assembler.convertAggregateQuery(aggregate), + ); + return this.assembler.convertAggregateResponse(aggregateResponse); + } + count(filter: Filter): Promise { return this.queryService.count(this.assembler.convertQuery({ filter }).filter || {}); } diff --git a/packages/core/src/services/noop-query.service.ts b/packages/core/src/services/noop-query.service.ts index 3b659d299..f5332c640 100644 --- a/packages/core/src/services/noop-query.service.ts +++ b/packages/core/src/services/noop-query.service.ts @@ -1,6 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { NotImplementedException } from '@nestjs/common'; -import { Filter, UpdateManyResponse, Query, DeleteManyResponse } from '../interfaces'; +import { + Filter, + UpdateManyResponse, + Query, + DeleteManyResponse, + AggregateQuery, + AggregateResponse, +} from '../interfaces'; import { QueryService } from './query.service'; import { DeepPartial, Class } from '../common'; @@ -66,6 +73,10 @@ export class NoOpQueryService implements QueryService { return Promise.reject(new NotImplementedException('query is not implemented')); } + aggregate(filter: Filter, aggregate: AggregateQuery): Promise> { + return Promise.reject(new NotImplementedException('aggregate is not implemented')); + } + count(filter: Filter): Promise { return Promise.reject(new NotImplementedException('count is not implemented')); } diff --git a/packages/core/src/services/proxy-query.service.ts b/packages/core/src/services/proxy-query.service.ts index afb5ebf1f..c76881191 100644 --- a/packages/core/src/services/proxy-query.service.ts +++ b/packages/core/src/services/proxy-query.service.ts @@ -1,5 +1,12 @@ import { Class, DeepPartial } from '../common'; -import { DeleteManyResponse, Filter, Query, UpdateManyResponse } from '../interfaces'; +import { + AggregateQuery, + AggregateResponse, + DeleteManyResponse, + Filter, + Query, + UpdateManyResponse, +} from '../interfaces'; import { QueryService } from './query.service'; export class ProxyQueryService implements QueryService { @@ -150,6 +157,10 @@ export class ProxyQueryService implements QueryService { return this.proxied.query(query); } + aggregate(filter: Filter, query: AggregateQuery): Promise> { + return this.proxied.aggregate(filter, query); + } + count(filter: Filter): Promise { return this.proxied.count(filter); } diff --git a/packages/core/src/services/query.service.ts b/packages/core/src/services/query.service.ts index 6f95b6202..1d63ae6b3 100644 --- a/packages/core/src/services/query.service.ts +++ b/packages/core/src/services/query.service.ts @@ -1,6 +1,13 @@ import { Injectable } from '@nestjs/common'; import { DeepPartial, Class } from '../common'; -import { DeleteManyResponse, Filter, Query, UpdateManyResponse } from '../interfaces'; +import { + AggregateQuery, + AggregateResponse, + DeleteManyResponse, + Filter, + Query, + UpdateManyResponse, +} from '../interfaces'; /** * Base interface for all QueryServices. @@ -15,6 +22,13 @@ export interface QueryService { */ query(query: Query): Promise; + /** + * Perform an aggregate query + * @param filter + * @param aggregate + */ + aggregate(filter: Filter, aggregate: AggregateQuery): Promise>; + /** * Count the number of records that match the filter. * @param filter - the filter