Skip to content

Commit

Permalink
feat: support for SQL aggregate functions SUM, AVG, MIN, and MAX to t…
Browse files Browse the repository at this point in the history
…he 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
  • Loading branch information
netroy committed Feb 7, 2023
1 parent 4555211 commit 7d1f1d6
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 0 deletions.
24 changes: 24 additions & 0 deletions docs/repository-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/common/PickKeysByType.ts
Original file line number Diff line number Diff line change
@@ -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]
}
64 changes: 64 additions & 0 deletions src/entity-manager/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand Down
45 changes: 45 additions & 0 deletions src/repository/BaseEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down
41 changes: 41 additions & 0 deletions src/repository/Repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*/
Expand Down
12 changes: 12 additions & 0 deletions test/functional/repository/aggregate-methods/entity/Post.ts
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
})
})
})

0 comments on commit 7d1f1d6

Please sign in to comment.