-
Notifications
You must be signed in to change notification settings - Fork 139
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(aggregations): Add aggregation support to sequelize
- Loading branch information
1 parent
7233c23
commit c37b7ae
Showing
6 changed files
with
284 additions
and
3 deletions.
There are no files selected for viewing
92 changes: 92 additions & 0 deletions
92
packages/query-sequelize/__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,92 @@ | ||
/* eslint-disable @typescript-eslint/naming-convention */ | ||
import { AggregateQuery } from '@nestjs-query/core'; | ||
import sequelize, { Projectable } from 'sequelize'; | ||
import { Test, TestingModule } from '@nestjs/testing'; | ||
import { SequelizeModule } from '@nestjs/sequelize'; | ||
import { Sequelize } from 'sequelize-typescript'; | ||
import { CONNECTION_OPTIONS } from '../__fixtures__/sequelize.fixture'; | ||
import { TestEntityTestRelationEntity } from '../__fixtures__/test-entity-test-relation.entity'; | ||
import { TestRelation } from '../__fixtures__/test-relation.entity'; | ||
import { TestEntity } from '../__fixtures__/test.entity'; | ||
import { AggregateBuilder } from '../../src/query'; | ||
|
||
describe('AggregateBuilder', (): void => { | ||
let moduleRef: TestingModule; | ||
const createAggregateBuilder = () => new AggregateBuilder<TestEntity>(TestEntity); | ||
|
||
const assertSQL = (agg: AggregateQuery<TestEntity>, expected: Projectable): void => { | ||
const actual = createAggregateBuilder().build(agg); | ||
expect(actual).toEqual(expected); | ||
}; | ||
|
||
afterEach(() => moduleRef.get(Sequelize).close()); | ||
|
||
beforeEach(async () => { | ||
moduleRef = await Test.createTestingModule({ | ||
imports: [ | ||
SequelizeModule.forRoot(CONNECTION_OPTIONS), | ||
SequelizeModule.forFeature([TestEntity, TestRelation, TestEntityTestRelationEntity]), | ||
], | ||
}).compile(); | ||
await moduleRef.get(Sequelize).sync(); | ||
}); | ||
|
||
it('should throw an error if no selects are generated', (): void => { | ||
expect(() => createAggregateBuilder().build({})).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'], | ||
}, | ||
{ | ||
attributes: [ | ||
[sequelize.fn('COUNT', sequelize.col('test_entity_pk')), 'COUNT_testEntityPk'], | ||
[sequelize.fn('SUM', sequelize.col('number_type')), 'SUM_numberType'], | ||
[sequelize.fn('AVG', sequelize.col('number_type')), 'AVG_numberType'], | ||
[sequelize.fn('MAX', sequelize.col('string_type')), 'MAX_stringType'], | ||
[sequelize.fn('MAX', sequelize.col('date_type')), 'MAX_dateType'], | ||
[sequelize.fn('MAX', sequelize.col('number_type')), 'MAX_numberType'], | ||
[sequelize.fn('MIN', sequelize.col('string_type')), 'MIN_stringType'], | ||
[sequelize.fn('MIN', sequelize.col('date_type')), 'MIN_dateType'], | ||
[sequelize.fn('MIN', sequelize.col('number_type')), 'MIN_numberType'], | ||
], | ||
}, | ||
); | ||
}); | ||
|
||
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,74 @@ | ||
import sequelize, { Projectable } from 'sequelize'; | ||
import { AggregateQuery, AggregateResponse } from '@nestjs-query/core'; | ||
import { Model, ModelCtor } from 'sequelize-typescript'; | ||
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 extends Model<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>); | ||
} | ||
|
||
constructor(readonly model: ModelCtor<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(aggregate: AggregateQuery<Entity>): Projectable { | ||
const selects = [ | ||
...this.createAggSelect(AggregateFuncs.COUNT, aggregate.count), | ||
...this.createAggSelect(AggregateFuncs.SUM, aggregate.sum), | ||
...this.createAggSelect(AggregateFuncs.AVG, aggregate.avg), | ||
...this.createAggSelect(AggregateFuncs.MAX, aggregate.max), | ||
...this.createAggSelect(AggregateFuncs.MIN, aggregate.min), | ||
]; | ||
if (!selects.length) { | ||
throw new BadRequestException('No aggregate fields found.'); | ||
} | ||
return { | ||
attributes: selects, | ||
}; | ||
} | ||
|
||
private createAggSelect(func: AggregateFuncs, fields?: (keyof Entity)[]): [sequelize.Utils.Fn, string][] { | ||
if (!fields) { | ||
return []; | ||
} | ||
return fields.map((field) => { | ||
const aggAlias = `${func}_${field as string}`; | ||
const colName = this.model.rawAttributes[field as string].field; | ||
const fn = sequelize.fn(func, sequelize.col(colName || (field as string))); | ||
return [fn, 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
export * from './filter-query.builder'; | ||
export * from './where.builder'; | ||
export * from './sql-comparison.builder'; | ||
export * from './aggregate.builder'; |
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