-
Notifications
You must be signed in to change notification settings - Fork 142
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(aggretations): Add aggregations support to typeorm
- Loading branch information
1 parent
d67e733
commit 7233c23
Showing
7 changed files
with
261 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
/* eslint-disable @typescript-eslint/naming-convention */ | ||
import { AggregateQuery } from '@nestjs-query/core'; | ||
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; | ||
import { TestEntity } from '../__fixtures__/test.entity'; | ||
import { AggregateBuilder } from '../../src/query'; | ||
|
||
describe('AggregateBuilder', (): void => { | ||
beforeEach(createTestConnection); | ||
afterEach(closeTestConnection); | ||
|
||
const getRepo = () => getTestConnection().getRepository(TestEntity); | ||
const getQueryBuilder = () => getRepo().createQueryBuilder(); | ||
const createAggregateBuilder = () => new AggregateBuilder<TestEntity>(); | ||
|
||
const assertSQL = (agg: AggregateQuery<TestEntity>, expectedSql: string, expectedArgs: any[]): void => { | ||
const selectQueryBuilder = createAggregateBuilder().build(getQueryBuilder(), agg, 'TestEntity'); | ||
const [sql, params] = selectQueryBuilder.getQueryAndParameters(); | ||
expect(sql).toEqual(expectedSql); | ||
expect(params).toEqual(expectedArgs); | ||
}; | ||
|
||
it('should throw an error if no selects are generated', (): void => { | ||
expect(() => createAggregateBuilder().build(getQueryBuilder(), {})).toThrow('No aggregate fields found.'); | ||
}); | ||
|
||
it('or multiple operators for a single field together', (): void => { | ||
assertSQL( | ||
{ | ||
count: ['testEntityPk'], | ||
avg: ['numberType'], | ||
sum: ['numberType'], | ||
max: ['stringType', 'dateType', 'numberType'], | ||
min: ['stringType', 'dateType', 'numberType'], | ||
}, | ||
'SELECT ' + | ||
'COUNT("TestEntity"."test_entity_pk") AS "COUNT_testEntityPk", ' + | ||
'SUM("TestEntity"."number_type") AS "SUM_numberType", ' + | ||
'AVG("TestEntity"."number_type") AS "AVG_numberType", ' + | ||
'MAX("TestEntity"."string_type") AS "MAX_stringType", ' + | ||
'MAX("TestEntity"."date_type") AS "MAX_dateType", ' + | ||
'MAX("TestEntity"."number_type") AS "MAX_numberType", ' + | ||
'MIN("TestEntity"."string_type") AS "MIN_stringType", ' + | ||
'MIN("TestEntity"."date_type") AS "MIN_dateType", ' + | ||
'MIN("TestEntity"."number_type") AS "MIN_numberType" ' + | ||
'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<TestEntity>(dbResult)).toEqual({ | ||
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, | ||
}; | ||
expect(() => AggregateBuilder.convertToAggregateResponse<TestEntity>(dbResult)).toThrow( | ||
'Unknown aggregate column encountered.', | ||
); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { SelectQueryBuilder } from 'typeorm'; | ||
import { AggregateQuery, AggregateResponse } from '@nestjs-query/core'; | ||
import { BadRequestException } from '@nestjs/common'; | ||
|
||
enum AggregateFuncs { | ||
AVG = 'AVG', | ||
SUM = 'SUM', | ||
COUNT = 'COUNT', | ||
MAX = 'MAX', | ||
MIN = 'MIN', | ||
} | ||
|
||
const AGG_REGEXP = /(AVG|SUM|COUNT|MAX|MIN)_(.*)/; | ||
|
||
/** | ||
* @internal | ||
* Builds a WHERE clause from a Filter. | ||
*/ | ||
export class AggregateBuilder<Entity> { | ||
static convertToAggregateResponse<Entity>(response: Record<string, unknown>): AggregateResponse<Entity> { | ||
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<Entity>; | ||
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<Entity>); | ||
} | ||
|
||
/** | ||
* Builds a aggregate SELECT clause from a aggregate. | ||
* @param qb - the `typeorm` SelectQueryBuilder | ||
* @param aggregate - the aggregates to select. | ||
* @param alias - optional alias to use to qualify an identifier | ||
*/ | ||
build<Qb extends SelectQueryBuilder<Entity>>(qb: Qb, aggregate: AggregateQuery<Entity>, alias?: string): Qb { | ||
const selects = [ | ||
...this.createAggSelect(AggregateFuncs.COUNT, aggregate.count, alias), | ||
...this.createAggSelect(AggregateFuncs.SUM, aggregate.sum, alias), | ||
...this.createAggSelect(AggregateFuncs.AVG, aggregate.avg, alias), | ||
...this.createAggSelect(AggregateFuncs.MAX, aggregate.max, alias), | ||
...this.createAggSelect(AggregateFuncs.MIN, aggregate.min, alias), | ||
]; | ||
if (!selects.length) { | ||
throw new BadRequestException('No aggregate fields found.'); | ||
} | ||
const [head, ...tail] = selects; | ||
return tail.reduce((acc: Qb, [select, selectAlias]) => { | ||
return acc.addSelect(select, selectAlias); | ||
}, qb.select(head[0], head[1])); | ||
} | ||
|
||
private createAggSelect(func: AggregateFuncs, fields?: (keyof Entity)[], alias?: string): [string, string][] { | ||
if (!fields) { | ||
return []; | ||
} | ||
return fields.map((field) => { | ||
const col = alias ? `${alias}.${field as string}` : (field as string); | ||
const aggAlias = `${func}_${field as string}`; | ||
return [`${func}(${col})`, aggAlias]; | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters