From e2a4f3066834ae7fddf0239ab647a0a9de667149 Mon Sep 17 00:00:00 2001 From: Doug Martin Date: Sun, 28 Mar 2021 22:32:00 -0600 Subject: [PATCH] feat(typeorm): Update to support new aggregate with groupBy --- .../__tests__/query/aggregate.builder.spec.ts | 63 +- .../services/typeorm-query.service.spec.ts | 606 +++++++++++++----- packages/query-typeorm/package.json | 3 +- .../src/query/aggregate.builder.ts | 69 +- .../src/query/filter-query.builder.ts | 15 + .../src/query/relation-query.builder.ts | 10 +- .../src/services/relation-query.service.ts | 25 +- .../src/services/typeorm-query.service.ts | 4 +- 8 files changed, 566 insertions(+), 229 deletions(-) diff --git a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts index 179cae05e..7797e9a5d 100644 --- a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts @@ -23,7 +23,7 @@ describe('AggregateBuilder', (): void => { expect(() => createAggregateBuilder().build(getQueryBuilder(), {})).toThrow('No aggregate fields found.'); }); - it('or multiple operators for a single field together', (): void => { + it('should create selects for all aggregate functions', (): void => { assertSQL( { count: ['testEntityPk'], @@ -47,30 +47,53 @@ describe('AggregateBuilder', (): void => { ); }); + it('should create selects for all aggregate functions and group bys', (): void => { + assertSQL( + { + groupBy: ['stringType', 'boolType'], + count: ['testEntityPk'], + }, + 'SELECT ' + + '"TestEntity"."string_type" AS "GROUP_BY_stringType", ' + + '"TestEntity"."bool_type" AS "GROUP_BY_boolType", ' + + 'COUNT("TestEntity"."test_entity_pk") AS "COUNT_testEntityPk" ' + + 'FROM "test_entity" "TestEntity"', + [], + ); + }); + describe('.convertToAggregateResponse', () => { it('should convert a flat response into an Aggregtate response', () => { - const dbResult = { - COUNT_testEntityPk: 10, - SUM_numberType: 55, - AVG_numberType: 5, - MAX_stringType: 'z', - MAX_numberType: 10, - MIN_stringType: 'a', - MIN_numberType: 1, - }; - expect(AggregateBuilder.convertToAggregateResponse(dbResult)).toEqual({ - count: { testEntityPk: 10 }, - sum: { numberType: 55 }, - avg: { numberType: 5 }, - max: { stringType: 'z', numberType: 10 }, - min: { stringType: 'a', numberType: 1 }, - }); + const dbResult = [ + { + GROUP_BY_stringType: 'z', + COUNT_testEntityPk: 10, + SUM_numberType: 55, + AVG_numberType: 5, + MAX_stringType: 'z', + MAX_numberType: 10, + MIN_stringType: 'a', + MIN_numberType: 1, + }, + ]; + expect(AggregateBuilder.convertToAggregateResponse(dbResult)).toEqual([ + { + groupBy: { stringType: 'z' }, + count: { testEntityPk: 10 }, + sum: { numberType: 55 }, + avg: { numberType: 5 }, + max: { stringType: 'z', numberType: 10 }, + min: { stringType: 'a', numberType: 1 }, + }, + ]); }); it('should throw an error if a column is not expected', () => { - const dbResult = { - COUNTtestEntityPk: 10, - }; + const dbResult = [ + { + COUNTtestEntityPk: 10, + }, + ]; expect(() => AggregateBuilder.convertToAggregateResponse(dbResult)).toThrow( 'Unknown aggregate column encountered.', ); 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 b02e604b9..d7f9bc462 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -272,29 +272,100 @@ describe('TypeOrmQueryService', (): void => { min: ['testEntityPk', 'dateType', 'numberType', 'stringType'], }, ); - return expect(queryResult).toEqual({ - avg: { - numberType: 5.5, - }, - count: { - testEntityPk: 10, + return expect(queryResult).toEqual([ + { + avg: { + numberType: 5.5, + }, + count: { + testEntityPk: 10, + }, + max: { + dateType: expect.stringMatching('2020-02-10'), + numberType: 10, + stringType: 'foo9', + testEntityPk: 'test-entity-9', + }, + min: { + dateType: expect.stringMatching('2020-02-01'), + numberType: 1, + stringType: 'foo1', + testEntityPk: 'test-entity-1', + }, + sum: { + numberType: 55, + }, }, - max: { - dateType: expect.stringMatching('2020-02-10'), - numberType: 10, - stringType: 'foo9', - testEntityPk: 'test-entity-9', + ]); + }); + + it('call aggregate with a group by', async () => { + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.aggregate( + {}, + { + groupBy: ['boolType'], + count: ['testEntityPk'], + avg: ['numberType'], + sum: ['numberType'], + max: ['testEntityPk', 'dateType', 'numberType', 'stringType'], + min: ['testEntityPk', 'dateType', 'numberType', 'stringType'], }, - min: { - dateType: expect.stringMatching('2020-02-01'), - numberType: 1, - stringType: 'foo1', - testEntityPk: 'test-entity-1', + ); + return expect(queryResult).toEqual([ + { + groupBy: { + boolType: 0, + }, + avg: { + numberType: 5, + }, + count: { + testEntityPk: 5, + }, + max: { + dateType: expect.stringMatching('2020-02-09'), + numberType: 9, + stringType: 'foo9', + testEntityPk: 'test-entity-9', + }, + min: { + dateType: expect.stringMatching('2020-02-01'), + numberType: 1, + stringType: 'foo1', + testEntityPk: 'test-entity-1', + }, + sum: { + numberType: 25, + }, }, - sum: { - numberType: 55, + { + groupBy: { + boolType: 1, + }, + avg: { + numberType: 6, + }, + count: { + testEntityPk: 5, + }, + max: { + dateType: expect.stringMatching('2020-02-10'), + numberType: 10, + stringType: 'foo8', + testEntityPk: 'test-entity-8', + }, + min: { + dateType: expect.stringMatching('2020-02-02'), + numberType: 2, + stringType: 'foo10', + testEntityPk: 'test-entity-10', + }, + sum: { + numberType: 30, + }, }, - }); + ]); }); it('call select with the aggregate columns and return the result with a filter', async () => { @@ -309,29 +380,100 @@ describe('TypeOrmQueryService', (): void => { min: ['testEntityPk', 'dateType', 'numberType', 'stringType'], }, ); - return expect(queryResult).toEqual({ - avg: { - numberType: 2, - }, - count: { - testEntityPk: 3, + return expect(queryResult).toEqual([ + { + avg: { + numberType: 2, + }, + count: { + testEntityPk: 3, + }, + max: { + dateType: expect.stringMatching('2020-02-03'), + numberType: 3, + stringType: 'foo3', + testEntityPk: 'test-entity-3', + }, + min: { + dateType: expect.stringMatching('2020-02-01'), + numberType: 1, + stringType: 'foo1', + testEntityPk: 'test-entity-1', + }, + sum: { + numberType: 6, + }, }, - max: { - dateType: expect.stringMatching('2020-02-03'), - numberType: 3, - stringType: 'foo3', - testEntityPk: 'test-entity-3', + ]); + }); + + it('call aggregate with a group and filter', async () => { + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.aggregate( + { stringType: { in: ['foo1', 'foo2', 'foo3'] } }, + { + groupBy: ['boolType'], + count: ['testEntityPk'], + avg: ['numberType'], + sum: ['numberType'], + max: ['testEntityPk', 'dateType', 'numberType', 'stringType'], + min: ['testEntityPk', 'dateType', 'numberType', 'stringType'], }, - min: { - dateType: expect.stringMatching('2020-02-01'), - numberType: 1, - stringType: 'foo1', - testEntityPk: 'test-entity-1', + ); + return expect(queryResult).toEqual([ + { + groupBy: { + boolType: 0, + }, + avg: { + numberType: 2, + }, + count: { + testEntityPk: 2, + }, + max: { + dateType: expect.stringMatching('2020-02-03'), + numberType: 3, + stringType: 'foo3', + testEntityPk: 'test-entity-3', + }, + min: { + dateType: expect.stringMatching('2020-02-01'), + numberType: 1, + stringType: 'foo1', + testEntityPk: 'test-entity-1', + }, + sum: { + numberType: 4, + }, }, - sum: { - numberType: 6, + { + groupBy: { + boolType: 1, + }, + avg: { + numberType: 2, + }, + count: { + testEntityPk: 1, + }, + max: { + dateType: expect.stringMatching('2020-02-02'), + numberType: 2, + stringType: 'foo2', + testEntityPk: 'test-entity-2', + }, + min: { + dateType: expect.stringMatching('2020-02-02'), + numberType: 2, + stringType: 'foo2', + testEntityPk: 'test-entity-2', + }, + sum: { + numberType: 2, + }, }, - }); + ]); }); }); @@ -492,11 +634,13 @@ describe('TypeOrmQueryService', (): void => { {}, { count: ['testRelationPk'] }, ); - return expect(aggResult).toEqual({ - count: { - testRelationPk: 3, + return expect(aggResult).toEqual([ + { + count: { + testRelationPk: 3, + }, }, - }); + ]); }); it('should apply a filter', async () => { @@ -508,16 +652,18 @@ describe('TypeOrmQueryService', (): void => { { testRelationPk: { notLike: '%-1' } }, { count: ['testRelationPk'] }, ); - return expect(aggResult).toEqual({ - count: { - testRelationPk: 2, + return expect(aggResult).toEqual([ + { + count: { + testRelationPk: 2, + }, }, - }); + ]); }); }); describe('with multiple entities', () => { - it('call select and return the result', async () => { + it('aggregate for each entities relation', async () => { const entities = TEST_ENTITIES.slice(0, 3); const queryService = moduleRef.get(TestEntityService); const queryResult = await queryService.aggregateRelations( @@ -537,63 +683,167 @@ describe('TypeOrmQueryService', (): void => { new Map([ [ entities[0], - { - count: { - relationName: 3, - testEntityId: 3, - testRelationPk: 3, - }, - max: { - relationName: 'foo1-test-relation-two', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-3', - }, - min: { - relationName: 'foo1-test-relation-one', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-1', + [ + { + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo1-test-relation-two', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-3', + }, + min: { + relationName: 'foo1-test-relation-one', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-1', + }, }, - }, + ], ], [ entities[1], - { - count: { - relationName: 3, - testEntityId: 3, - testRelationPk: 3, - }, - max: { - relationName: 'foo2-test-relation-two', - testEntityId: 'test-entity-2', - testRelationPk: 'test-relations-test-entity-2-3', - }, - min: { - relationName: 'foo2-test-relation-one', - testEntityId: 'test-entity-2', - testRelationPk: 'test-relations-test-entity-2-1', + [ + { + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo2-test-relation-two', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-3', + }, + min: { + relationName: 'foo2-test-relation-one', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-1', + }, }, - }, + ], ], [ entities[2], - { - count: { - relationName: 3, - testEntityId: 3, - testRelationPk: 3, + [ + { + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo3-test-relation-two', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-3', + }, + min: { + relationName: 'foo3-test-relation-one', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-1', + }, }, - max: { - relationName: 'foo3-test-relation-two', - testEntityId: 'test-entity-3', - testRelationPk: 'test-relations-test-entity-3-3', + ], + ], + ]), + ); + }); + + it('aggregate and group for each entities relation', async () => { + const entities = TEST_ENTITIES.slice(0, 3); + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.aggregateRelations( + TestRelation, + 'testRelations', + entities, + {}, + { + groupBy: ['testEntityId'], + count: ['testRelationPk', 'relationName', 'testEntityId'], + min: ['testRelationPk', 'relationName', 'testEntityId'], + max: ['testRelationPk', 'relationName', 'testEntityId'], + }, + ); + + expect(queryResult.size).toBe(3); + expect(queryResult).toEqual( + new Map([ + [ + entities[0], + [ + { + groupBy: { + testEntityId: 'test-entity-1', + }, + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo1-test-relation-two', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-3', + }, + min: { + relationName: 'foo1-test-relation-one', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-1', + }, }, - min: { - relationName: 'foo3-test-relation-one', - testEntityId: 'test-entity-3', - testRelationPk: 'test-relations-test-entity-3-1', + ], + ], + [ + entities[1], + [ + { + groupBy: { + testEntityId: 'test-entity-2', + }, + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo2-test-relation-two', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-3', + }, + min: { + relationName: 'foo2-test-relation-one', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-1', + }, }, - }, + ], + ], + [ + entities[2], + [ + { + groupBy: { + testEntityId: 'test-entity-3', + }, + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo3-test-relation-two', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-3', + }, + min: { + relationName: 'foo3-test-relation-one', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-1', + }, + }, + ], ], ]), ); @@ -619,63 +869,69 @@ describe('TypeOrmQueryService', (): void => { new Map([ [ entities[0], - { - count: { - relationName: 2, - testEntityId: 2, - testRelationPk: 2, - }, - max: { - relationName: 'foo1-test-relation-two', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-3', - }, - min: { - relationName: 'foo1-test-relation-three', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-2', + [ + { + count: { + relationName: 2, + testEntityId: 2, + testRelationPk: 2, + }, + max: { + relationName: 'foo1-test-relation-two', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-3', + }, + min: { + relationName: 'foo1-test-relation-three', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-2', + }, }, - }, + ], ], [ entities[1], - { - count: { - relationName: 2, - testEntityId: 2, - testRelationPk: 2, - }, - max: { - relationName: 'foo2-test-relation-two', - testEntityId: 'test-entity-2', - testRelationPk: 'test-relations-test-entity-2-3', - }, - min: { - relationName: 'foo2-test-relation-three', - testEntityId: 'test-entity-2', - testRelationPk: 'test-relations-test-entity-2-2', + [ + { + count: { + relationName: 2, + testEntityId: 2, + testRelationPk: 2, + }, + max: { + relationName: 'foo2-test-relation-two', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-3', + }, + min: { + relationName: 'foo2-test-relation-three', + testEntityId: 'test-entity-2', + testRelationPk: 'test-relations-test-entity-2-2', + }, }, - }, + ], ], [ entities[2], - { - count: { - relationName: 2, - testEntityId: 2, - testRelationPk: 2, - }, - max: { - relationName: 'foo3-test-relation-two', - testEntityId: 'test-entity-3', - testRelationPk: 'test-relations-test-entity-3-3', - }, - min: { - relationName: 'foo3-test-relation-three', - testEntityId: 'test-entity-3', - testRelationPk: 'test-relations-test-entity-3-2', + [ + { + count: { + relationName: 2, + testEntityId: 2, + testRelationPk: 2, + }, + max: { + relationName: 'foo3-test-relation-two', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-3', + }, + min: { + relationName: 'foo3-test-relation-three', + testEntityId: 'test-entity-3', + testRelationPk: 'test-relations-test-entity-3-2', + }, }, - }, + ], ], ]), ); @@ -700,43 +956,47 @@ describe('TypeOrmQueryService', (): void => { new Map([ [ entities[0], - { - count: { - relationName: 3, - testEntityId: 3, - testRelationPk: 3, - }, - max: { - relationName: 'foo1-test-relation-two', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-3', - }, - min: { - relationName: 'foo1-test-relation-one', - testEntityId: 'test-entity-1', - testRelationPk: 'test-relations-test-entity-1-1', + [ + { + count: { + relationName: 3, + testEntityId: 3, + testRelationPk: 3, + }, + max: { + relationName: 'foo1-test-relation-two', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-3', + }, + min: { + relationName: 'foo1-test-relation-one', + testEntityId: 'test-entity-1', + testRelationPk: 'test-relations-test-entity-1-1', + }, }, - }, + ], ], [ { testEntityPk: 'does-not-exist' } as TestEntity, - { - count: { - relationName: 0, - testEntityId: 0, - testRelationPk: 0, - }, - max: { - relationName: null, - testEntityId: null, - testRelationPk: null, - }, - min: { - relationName: null, - testEntityId: null, - testRelationPk: null, + [ + { + count: { + relationName: 0, + testEntityId: 0, + testRelationPk: 0, + }, + max: { + relationName: null, + testEntityId: null, + testRelationPk: null, + }, + min: { + relationName: null, + testEntityId: null, + testRelationPk: null, + }, }, - }, + ], ], ]), ); diff --git a/packages/query-typeorm/package.json b/packages/query-typeorm/package.json index 6affb2f19..872ec4692 100644 --- a/packages/query-typeorm/package.json +++ b/packages/query-typeorm/package.json @@ -22,7 +22,8 @@ "lodash.filter": "^4.6.0", "lodash.omit": "^4.5.0", "tslib": "^2.1.0", - "uuid": "8.3.2" + "uuid": "^8.3.2", + "camel-case": "^4.1.2" }, "peerDependencies": { "@nestjs/common": "^7.0.0", diff --git a/packages/query-typeorm/src/query/aggregate.builder.ts b/packages/query-typeorm/src/query/aggregate.builder.ts index b92ab3ee2..c20b0e48d 100644 --- a/packages/query-typeorm/src/query/aggregate.builder.ts +++ b/packages/query-typeorm/src/query/aggregate.builder.ts @@ -1,6 +1,7 @@ import { SelectQueryBuilder } from 'typeorm'; import { AggregateQuery, AggregateResponse } from '@nestjs-query/core'; import { BadRequestException } from '@nestjs/common'; +import { camelCase } from 'camel-case'; enum AggregateFuncs { AVG = 'AVG', @@ -10,7 +11,7 @@ enum AggregateFuncs { MIN = 'MIN', } -const AGG_REGEXP = /(AVG|SUM|COUNT|MAX|MIN)_(.*)/; +const AGG_REGEXP = /(AVG|SUM|COUNT|MAX|MIN|GROUP_BY)_(.*)/; /** * @internal @@ -19,14 +20,24 @@ const AGG_REGEXP = /(AVG|SUM|COUNT|MAX|MIN)_(.*)/; export class AggregateBuilder { // eslint-disable-next-line @typescript-eslint/no-shadow static async asyncConvertToAggregateResponse( - responsePromise: Promise>, - ): Promise> { + responsePromise: Promise[]>, + ): Promise[]> { const aggResponse = await responsePromise; return this.convertToAggregateResponse(aggResponse); } // eslint-disable-next-line @typescript-eslint/no-shadow - static getAggregateAliases(query: AggregateQuery): string[] { + static getAggregateSelects(query: AggregateQuery): string[] { + return [...this.getAggregateGroupBySelects(query), ...this.getAggregateFuncSelects(query)]; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + private static getAggregateGroupBySelects(query: AggregateQuery): string[] { + return (query.groupBy ?? []).map((f) => this.getGroupByAlias(f)); + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + private static getAggregateFuncSelects(query: AggregateQuery): string[] { const aggs: [AggregateFuncs, (keyof Entity)[] | undefined][] = [ [AggregateFuncs.COUNT, query.count], [AggregateFuncs.SUM, query.sum], @@ -46,22 +57,29 @@ export class AggregateBuilder { } // eslint-disable-next-line @typescript-eslint/no-shadow - static convertToAggregateResponse(response: Record): AggregateResponse { - return Object.keys(response).reduce((agg, resultField: string) => { - const matchResult = AGG_REGEXP.exec(resultField); - if (!matchResult) { - throw new Error('Unknown aggregate column encountered.'); - } - const [matchedFunc, matchedFieldName] = matchResult.slice(1); - const aggFunc = matchedFunc.toLowerCase() as keyof AggregateResponse; - const fieldName = matchedFieldName as keyof Entity; - const aggResult = agg[aggFunc] || {}; - return { - ...agg, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - [aggFunc]: { ...aggResult, [fieldName]: response[resultField] }, - }; - }, {} as AggregateResponse); + static getGroupByAlias(field: keyof Entity): string { + return `GROUP_BY_${field as string}`; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + static convertToAggregateResponse(rawAggregates: Record[]): AggregateResponse[] { + return rawAggregates.map((response) => { + return Object.keys(response).reduce((agg: AggregateResponse, resultField: string) => { + const matchResult = AGG_REGEXP.exec(resultField); + if (!matchResult) { + throw new Error('Unknown aggregate column encountered.'); + } + const [matchedFunc, matchedFieldName] = matchResult.slice(1); + const aggFunc = camelCase(matchedFunc.toLowerCase()) as keyof AggregateResponse; + const fieldName = matchedFieldName as keyof Entity; + const aggResult = agg[aggFunc] || {}; + return { + ...agg, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + [aggFunc]: { ...aggResult, [fieldName]: response[resultField] }, + }; + }, {}); + }); } /** @@ -72,6 +90,7 @@ export class AggregateBuilder { */ build>(qb: Qb, aggregate: AggregateQuery, alias?: string): Qb { const selects = [ + ...this.createGroupBySelect(aggregate.groupBy, alias), ...this.createAggSelect(AggregateFuncs.COUNT, aggregate.count, alias), ...this.createAggSelect(AggregateFuncs.SUM, aggregate.sum, alias), ...this.createAggSelect(AggregateFuncs.AVG, aggregate.avg, alias), @@ -97,4 +116,14 @@ export class AggregateBuilder { return [`${func}(${col})`, AggregateBuilder.getAggregateAlias(func, field)]; }); } + + private createGroupBySelect(fields?: (keyof Entity)[], alias?: string): [string, string][] { + if (!fields) { + return []; + } + return fields.map((field) => { + const col = alias ? `${alias}.${field as string}` : (field as string); + return [`${col}`, AggregateBuilder.getGroupByAlias(field)]; + }); + } } diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index 74c933627..06c3a68ff 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -20,6 +20,10 @@ interface Sortable extends QueryBuilder { addOrderBy(sort: string, order?: 'ASC' | 'DESC', nulls?: 'NULLS FIRST' | 'NULLS LAST'): this; } +interface Groupable extends QueryBuilder { + addGroupBy(groupBy: string): this; +} + /** * @internal * @@ -74,6 +78,7 @@ export class FilterQueryBuilder { let qb = this.createQueryBuilder(); qb = this.applyAggregate(qb, aggregate, qb.alias); qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyGroupBy(qb, aggregate.groupBy, qb.alias); return qb; } @@ -167,6 +172,16 @@ export class FilterQueryBuilder { }, qb); } + applyGroupBy>(qb: T, groupBy?: (keyof Entity)[], alias?: string): T { + if (!groupBy) { + return qb; + } + return groupBy.reduce((prevQb, group) => { + const col = alias ? `${alias}.${group as string}` : `${group as string}`; + return prevQb.addGroupBy(col); + }, qb); + } + /** * Create a `typeorm` SelectQueryBuilder which can be used as an entry point to create update, delete or insert * QueryBuilders. diff --git a/packages/query-typeorm/src/query/relation-query.builder.ts b/packages/query-typeorm/src/query/relation-query.builder.ts index b2b719986..33c30a09c 100644 --- a/packages/query-typeorm/src/query/relation-query.builder.ts +++ b/packages/query-typeorm/src/query/relation-query.builder.ts @@ -96,7 +96,7 @@ export class RelationQueryBuilder { query: Query, aggregateQuery: AggregateQuery, ): SelectQueryBuilder>> { - const selects = [...AggregateBuilder.getAggregateAliases(aggregateQuery), this.entityIndexColName].map((c) => + const selects = [...AggregateBuilder.getAggregateSelects(aggregateQuery), this.entityIndexColName].map((c) => this.escapeName(c), ); const unionFragment = this.createUnionAggregateSubQuery(entities, query, aggregateQuery); @@ -114,7 +114,13 @@ export class RelationQueryBuilder { ): SelectQueryBuilder { let relationBuilder = this.createRelationQueryBuilder(entity); relationBuilder = this.filterQueryBuilder.applyAggregate(relationBuilder, aggregateQuery, relationBuilder.alias); - return this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + relationBuilder = this.filterQueryBuilder.applyGroupBy( + relationBuilder, + aggregateQuery.groupBy, + relationBuilder.alias, + ); + return relationBuilder; } private createUnionAggregateSubQuery( diff --git a/packages/query-typeorm/src/services/relation-query.service.ts b/packages/query-typeorm/src/services/relation-query.service.ts index e05a8725f..d2b0aa684 100644 --- a/packages/query-typeorm/src/services/relation-query.service.ts +++ b/packages/query-typeorm/src/services/relation-query.service.ts @@ -82,7 +82,7 @@ export abstract class RelationQueryService { entities: Entity[], filter: Filter, aggregate: AggregateQuery, - ): Promise>>; + ): Promise[]>>; async aggregateRelations( RelationClass: Class, @@ -90,7 +90,7 @@ export abstract class RelationQueryService { dto: Entity, filter: Filter, aggregate: AggregateQuery, - ): Promise>; + ): Promise[]>; async aggregateRelations( RelationClass: Class, @@ -98,7 +98,7 @@ export abstract class RelationQueryService { dto: Entity | Entity[], filter: Filter, aggregate: AggregateQuery, - ): Promise | Map>> { + ): Promise[] | Map[]>> { if (Array.isArray(dto)) { return this.batchAggregateRelations(RelationClass, relationName, dto, filter, aggregate); } @@ -107,9 +107,9 @@ export abstract class RelationQueryService { const aggResponse = await AggregateBuilder.asyncConvertToAggregateResponse( relationQueryBuilder .aggregate(dto, assembler.convertQuery({ filter }), assembler.convertAggregateQuery(aggregate)) - .getRawOne>(), + .getRawMany>(), ); - return assembler.convertAggregateResponse(aggResponse); + return aggResponse.map((agg) => assembler.convertAggregateResponse(agg)); } async countRelations( @@ -326,7 +326,7 @@ export abstract class RelationQueryService { entities: Entity[], filter: Filter, aggregate: AggregateQuery, - ): Promise>> { + ): Promise[]>> { const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName)); const relationQueryBuilder = this.getRelationQueryBuilder(relationName); const convertedQuery = assembler.convertQuery({ filter }); @@ -337,12 +337,15 @@ export abstract class RelationQueryService { // eslint-disable-next-line no-underscore-dangle const index = relationAgg.__nestjsQuery__entityIndex__; const e = entities[index]; - results.set( - e, - AggregateBuilder.convertToAggregateResponse(lodashOmit(relationAgg, relationQueryBuilder.entityIndexColName)), - ); + const resultingAgg = results.get(e) ?? []; + results.set(e, [ + ...resultingAgg, + ...AggregateBuilder.convertToAggregateResponse([ + lodashOmit(relationAgg, relationQueryBuilder.entityIndexColName), + ]), + ]); return results; - }, new Map>()); + }, new Map[]>()); } /** diff --git a/packages/query-typeorm/src/services/typeorm-query.service.ts b/packages/query-typeorm/src/services/typeorm-query.service.ts index eb9046725..9046165b1 100644 --- a/packages/query-typeorm/src/services/typeorm-query.service.ts +++ b/packages/query-typeorm/src/services/typeorm-query.service.ts @@ -76,9 +76,9 @@ export class TypeOrmQueryService return this.filterQueryBuilder.select(query).getMany(); } - async aggregate(filter: Filter, aggregate: AggregateQuery): Promise> { + async aggregate(filter: Filter, aggregate: AggregateQuery): Promise[]> { return AggregateBuilder.asyncConvertToAggregateResponse( - this.filterQueryBuilder.aggregate({ filter }, aggregate).getRawOne>(), + this.filterQueryBuilder.aggregate({ filter }, aggregate).getRawMany>(), ); }