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
This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2023-02-07 15:01:04 +01:00 committed by GitHub
parent 4555211bcb
commit 7d1f1d6958
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 280 additions and 0 deletions

View File

@ -273,6 +273,30 @@ const count = await repository.count({
const count = await repository.countBy({ firstName: "Timber" })
```
- `sum` - Returns the sum of a numeric field for all entities that match `FindOptionsWhere`.
```typescript
const sum = await repository.sum("age", { firstName: "Timber" })
```
- `average` - Returns the average of a numeric field for all entities that match `FindOptionsWhere`.
```typescript
const average = await repository.average("age", { firstName: "Timber" })
```
- `minimum` - Returns the minimum of a numeric field for all entities that match `FindOptionsWhere`.
```typescript
const minimum = await repository.minimum("age", { firstName: "Timber" })
```
- `maximum` - Returns the maximum of a numeric field for all entities that match `FindOptionsWhere`.
```typescript
const maximum = await repository.maximum("age", { firstName: "Timber" })
```
- `find` - Finds entities that match given `FindOptions`.
```typescript

View File

@ -0,0 +1,7 @@
/**
* Pick only the keys that match the Type `U`
*/
export type PickKeysByType<T, U> = string &
keyof {
[P in keyof T as T[P] extends U ? P : never]: T[P]
}

View File

@ -37,6 +37,7 @@ import { getMetadataArgsStorage } from "../globals"
import { UpsertOptions } from "../repository/UpsertOptions"
import { InstanceChecker } from "../util/InstanceChecker"
import { ObjectLiteral } from "../common/ObjectLiteral"
import { PickKeysByType } from "../common/PickKeysByType"
/**
* Entity manager supposed to work with any entity, automatically find its repository and call its methods,
@ -1001,6 +1002,69 @@ export class EntityManager {
.getCount()
}
/**
* Return the SUM of a column
*/
sum<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.callAggregateFun(entityClass, "SUM", columnName, where)
}
/**
* Return the AVG of a column
*/
average<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.callAggregateFun(entityClass, "AVG", columnName, where)
}
/**
* Return the MIN of a column
*/
minimum<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.callAggregateFun(entityClass, "MIN", columnName, where)
}
/**
* Return the MAX of a column
*/
maximum<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.callAggregateFun(entityClass, "MAX", columnName, where)
}
private async callAggregateFun<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
fnName: "SUM" | "AVG" | "MIN" | "MAX",
columnName: PickKeysByType<Entity, number>,
where: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[] = {},
): Promise<number | null> {
const metadata = this.connection.getMetadata(entityClass)
const result = await this.createQueryBuilder(entityClass, metadata.name)
.setFindOptions({ where })
.select(
`${fnName}(${this.connection.driver.escape(
String(columnName),
)})`,
fnName,
)
.getRawOne()
return result[fnName] === null ? null : parseFloat(result[fnName])
}
/**
* Finds entities that match given find options.
*/

View File

@ -15,6 +15,7 @@ import { ObjectUtils } from "../util/ObjectUtils"
import { QueryDeepPartialEntity } from "../query-builder/QueryPartialEntity"
import { UpsertOptions } from "./UpsertOptions"
import { EntityTarget } from "../common/EntityTarget"
import { PickKeysByType } from "../common/PickKeysByType"
/**
* Base abstract entity for all entities, used in ActiveRecord patterns.
@ -408,6 +409,50 @@ export class BaseEntity {
return this.getRepository<T>().countBy(where)
}
/**
* Return the SUM of a column
*/
static sum<T extends BaseEntity>(
this: { new (): T } & typeof BaseEntity,
columnName: PickKeysByType<T, number>,
where: FindOptionsWhere<T>,
): Promise<number | null> {
return this.getRepository<T>().sum(columnName, where)
}
/**
* Return the AVG of a column
*/
static average<T extends BaseEntity>(
this: { new (): T } & typeof BaseEntity,
columnName: PickKeysByType<T, number>,
where: FindOptionsWhere<T>,
): Promise<number | null> {
return this.getRepository<T>().average(columnName, where)
}
/**
* Return the MIN of a column
*/
static minimum<T extends BaseEntity>(
this: { new (): T } & typeof BaseEntity,
columnName: PickKeysByType<T, number>,
where: FindOptionsWhere<T>,
): Promise<number | null> {
return this.getRepository<T>().minimum(columnName, where)
}
/**
* Return the MAX of a column
*/
static maximum<T extends BaseEntity>(
this: { new (): T } & typeof BaseEntity,
columnName: PickKeysByType<T, number>,
where: FindOptionsWhere<T>,
): Promise<number | null> {
return this.getRepository<T>().maximum(columnName, where)
}
/**
* Finds entities that match given options.
*/

View File

@ -15,6 +15,7 @@ import { ObjectID } from "../driver/mongodb/typings"
import { FindOptionsWhere } from "../find-options/FindOptionsWhere"
import { UpsertOptions } from "./UpsertOptions"
import { EntityTarget } from "../common/EntityTarget"
import { PickKeysByType } from "../common/PickKeysByType"
/**
* 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> {
return this.manager.countBy(this.metadata.target, where)
}
/**
* Return the SUM of a column
*/
sum(
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.manager.sum(this.metadata.target, columnName, where)
}
/**
* Return the AVG of a column
*/
average(
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.manager.average(this.metadata.target, columnName, where)
}
/**
* Return the MIN of a column
*/
minimum(
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.manager.minimum(this.metadata.target, columnName, where)
}
/**
* Return the MAX of a column
*/
maximum(
columnName: PickKeysByType<Entity, number>,
where?: FindOptionsWhere<Entity> | FindOptionsWhere<Entity>[],
): Promise<number | null> {
return this.manager.maximum(this.metadata.target, columnName, where)
}
/**
* Finds entities that match given find options.
*/

View File

@ -0,0 +1,12 @@
import { Entity } from "../../../../../src/decorator/entity/Entity"
import { Column } from "../../../../../src/decorator/columns/Column"
import { PrimaryColumn } from "../../../../../src/decorator/columns/PrimaryColumn"
@Entity()
export class Post {
@PrimaryColumn()
id: number
@Column()
counter: number
}

View File

@ -0,0 +1,87 @@
import "reflect-metadata"
import {
closeTestingConnections,
createTestingConnections,
} from "../../../utils/test-utils"
import { Repository } from "../../../../src/repository/Repository"
import { DataSource } from "../../../../src/data-source/DataSource"
import { Post } from "./entity/Post"
import { LessThan } from "../../../../src"
import { expect } from "chai"
describe("repository > aggregate methods", () => {
debugger
let connections: DataSource[]
let repository: Repository<Post>
before(async () => {
connections = await createTestingConnections({
entities: [Post],
schemaCreate: true,
dropSchema: true,
})
repository = connections[0].getRepository(Post)
for (let i = 0; i < 100; i++) {
const post = new Post()
post.id = i
post.counter = i + 1
await repository.save(post)
}
})
after(() => closeTestingConnections(connections))
describe("sum", () => {
it("should return the aggregate sum", async () => {
const sum = await repository.sum("counter")
expect(sum).to.equal(5050)
})
it("should return null when 0 rows match the query", async () => {
const sum = await repository.sum("counter", { id: LessThan(0) })
expect(sum).to.be.null
})
})
describe("average", () => {
it("should return the aggregate average", async () => {
const average = await repository.average("counter")
expect(average).to.equal(50.5)
})
it("should return null when 0 rows match the query", async () => {
const average = await repository.average("counter", {
id: LessThan(0),
})
expect(average).to.be.null
})
})
describe("minimum", () => {
it("should return the aggregate minimum", async () => {
const minimum = await repository.minimum("counter")
expect(minimum).to.equal(1)
})
it("should return null when 0 rows match the query", async () => {
const minimum = await repository.minimum("counter", {
id: LessThan(0),
})
expect(minimum).to.be.null
})
})
describe("maximum", () => {
it("should return the aggregate maximum", async () => {
const maximum = await repository.maximum("counter")
expect(maximum).to.equal(100)
})
it("should return null when 0 rows match the query", async () => {
const maximum = await repository.maximum("counter", {
id: LessThan(0),
})
expect(maximum).to.be.null
})
})
})