Skip to content

Commit 7d1f1d6

Browse files
authored
feat: support for SQL aggregate functions SUM, AVG, MIN, and MAX to the Repository API (#9737)
* feat: Add support for SQL aggregate functions SUM, AVG, MIN, and MAX to the Repository API * rename field name to make tests work in oracle * fix the comments * update the docs * escape column name * address PR comment * format the code
1 parent 4555211 commit 7d1f1d6

File tree

7 files changed

+280
-0
lines changed

7 files changed

+280
-0
lines changed

docs/repository-api.md

+24
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,30 @@ const count = await repository.count({
273273
const count = await repository.countBy({ firstName: "Timber" })
274274
```
275275

276+
- `sum` - Returns the sum of a numeric field for all entities that match `FindOptionsWhere`.
277+
278+
```typescript
279+
const sum = await repository.sum("age", { firstName: "Timber" })
280+
```
281+
282+
- `average` - Returns the average of a numeric field for all entities that match `FindOptionsWhere`.
283+
284+
```typescript
285+
const average = await repository.average("age", { firstName: "Timber" })
286+
```
287+
288+
- `minimum` - Returns the minimum of a numeric field for all entities that match `FindOptionsWhere`.
289+
290+
```typescript
291+
const minimum = await repository.minimum("age", { firstName: "Timber" })
292+
```
293+
294+
- `maximum` - Returns the maximum of a numeric field for all entities that match `FindOptionsWhere`.
295+
296+
```typescript
297+
const maximum = await repository.maximum("age", { firstName: "Timber" })
298+
```
299+
276300
- `find` - Finds entities that match given `FindOptions`.
277301

278302
```typescript

src/common/PickKeysByType.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Pick only the keys that match the Type `U`
3+
*/
4+
export type PickKeysByType<T, U> = string &
5+
keyof {
6+
[P in keyof T as T[P] extends U ? P : never]: T[P]
7+
}

src/entity-manager/EntityManager.ts

+64
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { getMetadataArgsStorage } from "../globals"
3737
import { UpsertOptions } from "../repository/UpsertOptions"
3838
import { InstanceChecker } from "../util/InstanceChecker"
3939
import { ObjectLiteral } from "../common/ObjectLiteral"
40+
import { PickKeysByType } from "../common/PickKeysByType"
4041

4142
/**
4243
* Entity manager supposed to work with any entity, automatically find its repository and call its methods,
@@ -1001,6 +1002,69 @@ export class EntityManager {
10011002
.getCount()
10021003
}
10031004

1005+
/**
1006+
* Return the SUM of a column
1007+
*/
1008+
sum<Entity extends ObjectLiteral>(
1009+
entityClass: EntityTarget<Entity>,
1010+
columnName: PickKeysByType<Entity, number>,
1011+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
1012+
): Promise<number | null> {
1013+
return this.callAggregateFun(entityClass, "SUM", columnName, where)
1014+
}
1015+
1016+
/**
1017+
* Return the AVG of a column
1018+
*/
1019+
average<Entity extends ObjectLiteral>(
1020+
entityClass: EntityTarget<Entity>,
1021+
columnName: PickKeysByType<Entity, number>,
1022+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
1023+
): Promise<number | null> {
1024+
return this.callAggregateFun(entityClass, "AVG", columnName, where)
1025+
}
1026+
1027+
/**
1028+
* Return the MIN of a column
1029+
*/
1030+
minimum<Entity extends ObjectLiteral>(
1031+
entityClass: EntityTarget<Entity>,
1032+
columnName: PickKeysByType<Entity, number>,
1033+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
1034+
): Promise<number | null> {
1035+
return this.callAggregateFun(entityClass, "MIN", columnName, where)
1036+
}
1037+
1038+
/**
1039+
* Return the MAX of a column
1040+
*/
1041+
maximum<Entity extends ObjectLiteral>(
1042+
entityClass: EntityTarget<Entity>,
1043+
columnName: PickKeysByType<Entity, number>,
1044+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
1045+
): Promise<number | null> {
1046+
return this.callAggregateFun(entityClass, "MAX", columnName, where)
1047+
}
1048+
1049+
private async callAggregateFun<Entity extends ObjectLiteral>(
1050+
entityClass: EntityTarget<Entity>,
1051+
fnName: "SUM" | "AVG" | "MIN" | "MAX",
1052+
columnName: PickKeysByType<Entity, number>,
1053+
where: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[] = {},
1054+
): Promise<number | null> {
1055+
const metadata = this.connection.getMetadata(entityClass)
1056+
const result = await this.createQueryBuilder(entityClass, metadata.name)
1057+
.setFindOptions({ where })
1058+
.select(
1059+
`${fnName}(${this.connection.driver.escape(
1060+
String(columnName),
1061+
)})`,
1062+
fnName,
1063+
)
1064+
.getRawOne()
1065+
return result[fnName] === null ? null : parseFloat(result[fnName])
1066+
}
1067+
10041068
/**
10051069
* Finds entities that match given find options.
10061070
*/

src/repository/BaseEntity.ts

+45
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ObjectUtils } from "../util/ObjectUtils"
1515
import { QueryDeepPartialEntity } from "../query-builder/QueryPartialEntity"
1616
import { UpsertOptions } from "./UpsertOptions"
1717
import { EntityTarget } from "../common/EntityTarget"
18+
import { PickKeysByType } from "../common/PickKeysByType"
1819

1920
/**
2021
* Base abstract entity for all entities, used in ActiveRecord patterns.
@@ -408,6 +409,50 @@ export class BaseEntity {
408409
return this.getRepository<T>().countBy(where)
409410
}
410411

412+
/**
413+
* Return the SUM of a column
414+
*/
415+
static sum<T extends BaseEntity>(
416+
this: { new (): T } & typeof BaseEntity,
417+
columnName: PickKeysByType<T, number>,
418+
where: FindOptionsWhere<T>,
419+
): Promise<number | null> {
420+
return this.getRepository<T>().sum(columnName, where)
421+
}
422+
423+
/**
424+
* Return the AVG of a column
425+
*/
426+
static average<T extends BaseEntity>(
427+
this: { new (): T } & typeof BaseEntity,
428+
columnName: PickKeysByType<T, number>,
429+
where: FindOptionsWhere<T>,
430+
): Promise<number | null> {
431+
return this.getRepository<T>().average(columnName, where)
432+
}
433+
434+
/**
435+
* Return the MIN of a column
436+
*/
437+
static minimum<T extends BaseEntity>(
438+
this: { new (): T } & typeof BaseEntity,
439+
columnName: PickKeysByType<T, number>,
440+
where: FindOptionsWhere<T>,
441+
): Promise<number | null> {
442+
return this.getRepository<T>().minimum(columnName, where)
443+
}
444+
445+
/**
446+
* Return the MAX of a column
447+
*/
448+
static maximum<T extends BaseEntity>(
449+
this: { new (): T } & typeof BaseEntity,
450+
columnName: PickKeysByType<T, number>,
451+
where: FindOptionsWhere<T>,
452+
): Promise<number | null> {
453+
return this.getRepository<T>().maximum(columnName, where)
454+
}
455+
411456
/**
412457
* Finds entities that match given options.
413458
*/

src/repository/Repository.ts

+41
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ObjectID } from "../driver/mongodb/typings"
1515
import { FindOptionsWhere } from "../find-options/FindOptionsWhere"
1616
import { UpsertOptions } from "./UpsertOptions"
1717
import { EntityTarget } from "../common/EntityTarget"
18+
import { PickKeysByType } from "../common/PickKeysByType"
1819

1920
/**
2021
* Repository is supposed to work with your entity objects. Find entities, insert, update, delete, etc.
@@ -476,6 +477,46 @@ export class Repository<Entity extends ObjectLiteral> {
476477
return this.manager.countBy(this.metadata.target, where)
477478
}
478479

480+
/**
481+
* Return the SUM of a column
482+
*/
483+
sum(
484+
columnName: PickKeysByType<Entity, number>,
485+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
486+
): Promise<number | null> {
487+
return this.manager.sum(this.metadata.target, columnName, where)
488+
}
489+
490+
/**
491+
* Return the AVG of a column
492+
*/
493+
average(
494+
columnName: PickKeysByType<Entity, number>,
495+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
496+
): Promise<number | null> {
497+
return this.manager.average(this.metadata.target, columnName, where)
498+
}
499+
500+
/**
501+
* Return the MIN of a column
502+
*/
503+
minimum(
504+
columnName: PickKeysByType<Entity, number>,
505+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
506+
): Promise<number | null> {
507+
return this.manager.minimum(this.metadata.target, columnName, where)
508+
}
509+
510+
/**
511+
* Return the MAX of a column
512+
*/
513+
maximum(
514+
columnName: PickKeysByType<Entity, number>,
515+
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
516+
): Promise<number | null> {
517+
return this.manager.maximum(this.metadata.target, columnName, where)
518+
}
519+
479520
/**
480521
* Finds entities that match given find options.
481522
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Entity } from "../../../../../src/decorator/entity/Entity"
2+
import { Column } from "../../../../../src/decorator/columns/Column"
3+
import { PrimaryColumn } from "../../../../../src/decorator/columns/PrimaryColumn"
4+
5+
@Entity()
6+
export class Post {
7+
@PrimaryColumn()
8+
id: number
9+
10+
@Column()
11+
counter: number
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import "reflect-metadata"
2+
import {
3+
closeTestingConnections,
4+
createTestingConnections,
5+
} from "../../../utils/test-utils"
6+
import { Repository } from "../../../../src/repository/Repository"
7+
import { DataSource } from "../../../../src/data-source/DataSource"
8+
import { Post } from "./entity/Post"
9+
import { LessThan } from "../../../../src"
10+
import { expect } from "chai"
11+
12+
describe("repository > aggregate methods", () => {
13+
debugger
14+
let connections: DataSource[]
15+
let repository: Repository<Post>
16+
17+
before(async () => {
18+
connections = await createTestingConnections({
19+
entities: [Post],
20+
schemaCreate: true,
21+
dropSchema: true,
22+
})
23+
repository = connections[0].getRepository(Post)
24+
for (let i = 0; i < 100; i++) {
25+
const post = new Post()
26+
post.id = i
27+
post.counter = i + 1
28+
await repository.save(post)
29+
}
30+
})
31+
32+
after(() => closeTestingConnections(connections))
33+
34+
describe("sum", () => {
35+
it("should return the aggregate sum", async () => {
36+
const sum = await repository.sum("counter")
37+
expect(sum).to.equal(5050)
38+
})
39+
40+
it("should return null when 0 rows match the query", async () => {
41+
const sum = await repository.sum("counter", { id: LessThan(0) })
42+
expect(sum).to.be.null
43+
})
44+
})
45+
46+
describe("average", () => {
47+
it("should return the aggregate average", async () => {
48+
const average = await repository.average("counter")
49+
expect(average).to.equal(50.5)
50+
})
51+
52+
it("should return null when 0 rows match the query", async () => {
53+
const average = await repository.average("counter", {
54+
id: LessThan(0),
55+
})
56+
expect(average).to.be.null
57+
})
58+
})
59+
60+
describe("minimum", () => {
61+
it("should return the aggregate minimum", async () => {
62+
const minimum = await repository.minimum("counter")
63+
expect(minimum).to.equal(1)
64+
})
65+
66+
it("should return null when 0 rows match the query", async () => {
67+
const minimum = await repository.minimum("counter", {
68+
id: LessThan(0),
69+
})
70+
expect(minimum).to.be.null
71+
})
72+
})
73+
74+
describe("maximum", () => {
75+
it("should return the aggregate maximum", async () => {
76+
const maximum = await repository.maximum("counter")
77+
expect(maximum).to.equal(100)
78+
})
79+
80+
it("should return null when 0 rows match the query", async () => {
81+
const maximum = await repository.maximum("counter", {
82+
id: LessThan(0),
83+
})
84+
expect(maximum).to.be.null
85+
})
86+
})
87+
})

0 commit comments

Comments
 (0)