Skip to content

Commit

Permalink
feat(aggregations,typeorm): Add relation aggregation to typeorm
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Jul 16, 2020
1 parent 93e7c1b commit 2bf35a9
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,171 @@ describe('TypeOrmQueryService', (): void => {
});
});

describe('#aggregateRelations', () => {
describe('with one entity', () => {
it('call select and return the result', async () => {
const queryService = moduleRef.get(TestEntityService);
const aggResult = await queryService.aggregateRelations(
TestRelation,
'testRelations',
TEST_ENTITIES[0],
{ relationName: { isNot: null } },
{ count: ['testRelationPk'] },
);
return expect(aggResult).toEqual({
count: {
testRelationPk: 3,
},
});
});
});

describe('with multiple entities', () => {
it('call select and return the result', async () => {
const entities = TEST_ENTITIES.slice(0, 3);
const queryService = moduleRef.get(TestEntityService);
const queryResult = await queryService.aggregateRelations(
TestRelation,
'testRelations',
entities,
{ relationName: { isNot: null } },
{
count: ['testRelationPk', 'relationName', 'testEntityId'],
min: ['testRelationPk', 'relationName', 'testEntityId'],
max: ['testRelationPk', 'relationName', 'testEntityId'],
},
);

expect(queryResult.size).toBe(3);
expect(queryResult).toEqual(
new Map([
[
entities[0],
{
count: {
relationName: 3,
testEntityId: 3,
testRelationPk: 3,
},
max: {
relationName: 'foo1-test-relation',
testEntityId: 'test-entity-1',
testRelationPk: 'test-relations-test-entity-1-3',
},
min: {
relationName: 'foo1-test-relation',
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',
testEntityId: 'test-entity-2',
testRelationPk: 'test-relations-test-entity-2-3',
},
min: {
relationName: 'foo2-test-relation',
testEntityId: 'test-entity-2',
testRelationPk: 'test-relations-test-entity-2-1',
},
},
],
[
entities[2],
{
count: {
relationName: 3,
testEntityId: 3,
testRelationPk: 3,
},
max: {
relationName: 'foo3-test-relation',
testEntityId: 'test-entity-3',
testRelationPk: 'test-relations-test-entity-3-3',
},
min: {
relationName: 'foo3-test-relation',
testEntityId: 'test-entity-3',
testRelationPk: 'test-relations-test-entity-3-1',
},
},
],
]),
);
});

it('should return an empty array if no results are found.', async () => {
const entities: TestEntity[] = [TEST_ENTITIES[0], { testEntityPk: 'does-not-exist' } as TestEntity];
const queryService = moduleRef.get(TestEntityService);
const queryResult = await queryService.aggregateRelations(
TestRelation,
'testRelations',
entities,
{ relationName: { isNot: null } },
{
count: ['testRelationPk', 'relationName', 'testEntityId'],
min: ['testRelationPk', 'relationName', 'testEntityId'],
max: ['testRelationPk', 'relationName', 'testEntityId'],
},
);

expect(queryResult).toEqual(
new Map([
[
entities[0],
{
count: {
relationName: 3,
testEntityId: 3,
testRelationPk: 3,
},
max: {
relationName: 'foo1-test-relation',
testEntityId: 'test-entity-1',
testRelationPk: 'test-relations-test-entity-1-3',
},
min: {
relationName: 'foo1-test-relation',
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,
},
},
],
]),
);
});
});
});

describe('#countRelations', () => {
describe('with one entity', () => {
it('call count and return the result', async () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/query-typeorm/src/query/aggregate.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const AGG_REGEXP = /(AVG|SUM|COUNT|MAX|MIN)_(.*)/;
* Builds a WHERE clause from a Filter.
*/
export class AggregateBuilder<Entity> {
static async asyncConvertToAggregateResponse<Entity>(
responsePromise: Promise<Record<string, unknown>>,
): Promise<AggregateResponse<Entity>> {
const aggResponse = await responsePromise;
return this.convertToAggregateResponse(aggResponse);
}

static convertToAggregateResponse<Entity>(response: Record<string, unknown>): AggregateResponse<Entity> {
return Object.keys(response).reduce((agg, resultField: string) => {
const matchResult = AGG_REGEXP.exec(resultField);
Expand Down
10 changes: 3 additions & 7 deletions packages/query-typeorm/src/query/filter-query.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class FilterQueryBuilder<Entity> {
constructor(
readonly repo: Repository<Entity>,
readonly whereBuilder: WhereBuilder<Entity> = new WhereBuilder<Entity>(),
readonly aggregateBulder: AggregateBuilder<Entity> = new AggregateBuilder<Entity>(),
readonly aggregateBuilder: AggregateBuilder<Entity> = new AggregateBuilder<Entity>(),
) {}

/**
Expand Down Expand Up @@ -113,12 +113,8 @@ export class FilterQueryBuilder<Entity> {
* @param aggregate - the aggregates to select.
* @param alias - optional alias to use to qualify an identifier
*/
private applyAggregate<Qb extends SelectQueryBuilder<Entity>>(
qb: Qb,
aggregate: AggregateQuery<Entity>,
alias?: string,
): Qb {
return this.aggregateBulder.build(qb, aggregate, alias);
applyAggregate<Qb extends SelectQueryBuilder<Entity>>(qb: Qb, aggregate: AggregateQuery<Entity>, alias?: string): Qb {
return this.aggregateBuilder.build(qb, aggregate, alias);
}

/**
Expand Down
12 changes: 11 additions & 1 deletion packages/query-typeorm/src/query/relation-query.builder.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Class, Query } from '@nestjs-query/core';
import { AggregateQuery, Class, Query } from '@nestjs-query/core';
import { Repository, SelectQueryBuilder, ObjectLiteral, Brackets } from 'typeorm';
import { RelationMetadata } from 'typeorm/metadata/RelationMetadata';
import { FilterQueryBuilder } from './filter-query.builder';
Expand Down Expand Up @@ -50,6 +50,16 @@ export class RelationQueryBuilder<Entity, Relation> {
return this.filterQueryBuilder.applySorting(relationBuilder, query.sorting, relationBuilder.alias);
}

aggregate(
entity: Entity,
query: Query<Relation>,
aggregateQuery: AggregateQuery<Relation>,
): SelectQueryBuilder<Relation> {
let relationBuilder = this.createRelationQueryBuilder(entity);
relationBuilder = this.filterQueryBuilder.applyAggregate(relationBuilder, aggregateQuery, relationBuilder.alias);
return this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias);
}

private createRelationQueryBuilder(entity: Entity): SelectQueryBuilder<Relation> {
const meta = this.relationMeta;
const queryBuilder = this.relationRepo.createQueryBuilder(meta.fromAlias);
Expand Down
71 changes: 69 additions & 2 deletions packages/query-typeorm/src/services/relation-query.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Query, Class, AssemblerFactory, Filter } from '@nestjs-query/core';
import { Query, Class, AssemblerFactory, Filter, AggregateQuery, AggregateResponse } from '@nestjs-query/core';
import { Repository, RelationQueryBuilder as TypeOrmRelationQueryBuilder } from 'typeorm';
import { FilterQueryBuilder, RelationQueryBuilder } from '../query';
import { AggregateBuilder, FilterQueryBuilder, RelationQueryBuilder } from '../query';

interface RelationMetadata {
// eslint-disable-next-line @typescript-eslint/ban-types
Expand Down Expand Up @@ -62,6 +62,42 @@ export abstract class RelationQueryService<Entity> {
return assembler.convertAsyncToDTOs(relationQueryBuilder.select(dto, assembler.convertQuery(query)).getMany());
}

async aggregateRelations<Relation>(
RelationClass: Class<Relation>,
relationName: string,
entities: Entity[],
filter: Filter<Relation>,
aggregate: AggregateQuery<Relation>,
): Promise<Map<Entity, AggregateResponse<Relation>>>;

async aggregateRelations<Relation>(
RelationClass: Class<Relation>,
relationName: string,
dto: Entity,
filter: Filter<Relation>,
aggregate: AggregateQuery<Relation>,
): Promise<AggregateResponse<Relation>>;

async aggregateRelations<Relation>(
RelationClass: Class<Relation>,
relationName: string,
dto: Entity | Entity[],
filter: Filter<Relation>,
aggregate: AggregateQuery<Relation>,
): Promise<AggregateResponse<Relation> | Map<Entity, AggregateResponse<Relation>>> {
if (Array.isArray(dto)) {
return this.batchAggregateRelations(RelationClass, relationName, dto, filter, aggregate);
}
const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName));
const relationQueryBuilder = this.getRelationQueryBuilder(relationName);
const aggResponse = await AggregateBuilder.asyncConvertToAggregateResponse(
relationQueryBuilder
.aggregate(dto, assembler.convertQuery({ filter }), assembler.convertAggregateQuery(aggregate))
.getRawOne<Record<string, unknown>>(),
);
return assembler.convertAggregateResponse(aggResponse);
}

async countRelations<Relation>(
RelationClass: Class<Relation>,
relationName: string,
Expand Down Expand Up @@ -228,6 +264,37 @@ export abstract class RelationQueryService<Entity> {
}, new Map<Entity, Relation[]>());
}

/**
* Query for an array of relations for multiple dtos.
* @param RelationClass - The class to serialize the relations into.
* @param entities - The entities to query relations for.
* @param relationName - The name of relation to query for.
* @param query - A query to filter, page or sort relations.
*/
private async batchAggregateRelations<Relation>(
RelationClass: Class<Relation>,
relationName: string,
entities: Entity[],
filter: Filter<Relation>,
aggregate: AggregateQuery<Relation>,
): Promise<Map<Entity, AggregateResponse<Relation>>> {
const assembler = AssemblerFactory.getAssembler(RelationClass, this.getRelationEntity(relationName));
const relationQueryBuilder = this.getRelationQueryBuilder<Relation>(relationName);
const convertedQuery = assembler.convertQuery({ filter });
const entityRelations = await Promise.all(
entities.map(async (e) => {
return AggregateBuilder.asyncConvertToAggregateResponse(
relationQueryBuilder.aggregate(e, convertedQuery, aggregate).getRawOne<Record<string, unknown>>(),
);
}),
);
return entityRelations.reduce((results, relationAgg, index) => {
const e = entities[index];
results.set(e, relationAgg);
return results;
}, new Map<Entity, AggregateResponse<Relation>>());
}

/**
* Count the number of relations for multiple dtos.
* @param RelationClass - The class to serialize the relations into.
Expand Down
5 changes: 3 additions & 2 deletions packages/query-typeorm/src/services/typeorm-query.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ export class TypeOrmQueryService<Entity> extends RelationQueryService<Entity> im
}

async aggregate(filter: Filter<Entity>, aggregate: AggregateQuery<Entity>): Promise<AggregateResponse<Entity>> {
const result = await this.filterQueryBuilder.aggregate({ filter }, aggregate).getRawOne<Record<string, unknown>>();
return AggregateBuilder.convertToAggregateResponse(result);
return AggregateBuilder.asyncConvertToAggregateResponse(
this.filterQueryBuilder.aggregate({ filter }, aggregate).getRawOne<Record<string, unknown>>(),
);
}

async count(filter: Filter<Entity>): Promise<number> {
Expand Down

0 comments on commit 2bf35a9

Please sign in to comment.