feat: support explicit resource management in QueryRunner (#11701)

* feat: support explicit resource management in QueryRunner

* test: improve tests for explicit resource management

* feat: commit transaction on QueryRunner dispose
This commit is contained in:
Lucian Mocanu 2025-10-02 22:24:14 +02:00 committed by GitHub
parent ea7a99a3c3
commit bcee921ee0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 206 additions and 19 deletions

View File

@ -15,34 +15,80 @@ const queryRunner = dataSource.createQueryRunner()
## Using `QueryRunner`
After you create a new instance of `QueryRunner` use `connect` method to actually get a connection from the connection pool:
After creating a new instance of `QueryRunner`, a connection will be acquired from the pool when you issue the first query:
```typescript
const queryRunner = dataSource.createQueryRunner()
await queryRunner.connect()
await queryRunner.query("SELECT 1")
await queryRunner.release()
```
**Important**: make sure to release it when it is not necessary anymore to make it available to the connection pool again:
You can also use `connect` method to directly get a connection from the connection pool:
```typescript
const queryRunner = dataSource.createQueryRunner()
const clientConnection = await queryRunner.connect()
await queryRunner.release()
```
**Important**: make sure to release the `QueryRunner` when it is no longer necessary to return the connection back to the connection pool:
```typescript
await queryRunner.release()
```
After connection is released, it is not possible to use the query runner methods.
After `QueryRunner` is released, it is no longer possible to use the query runner methods.
`QueryRunner` has a bunch of methods you can use, it also has its own `EntityManager` instance,
which you can use through `manager` property to run `EntityManager` methods on a particular database connection
used by `QueryRunner` instance:
`QueryRunner` also has its own `EntityManager` instance, which you can use through the `manager` property to run `EntityManager` queries on a particular database connection used by the `QueryRunner` instance:
```typescript
const queryRunner = dataSource.createQueryRunner()
// take a connection from the connection pool
await queryRunner.connect()
// use this particular connection to execute queries
const users = await queryRunner.manager.find(User)
// remember to release connection after you are done using it
await queryRunner.release()
let queryRunner: QueryRunner
try {
queryRunner = dataSource.createQueryRunner()
// use a single database connection to execute multiple queries
await queryRunner.manager.update(
Employee,
{ level: "junior" },
{ bonus: 0.2 },
)
await queryRunner.manager.update(
Employee,
{ level: "senior" },
{ bonus: 0.1 },
)
} catch (error) {
console.error(error)
} finally {
// remember to release connection after you are done using it
await queryRunner.release()
}
```
## Explicit Resource Management
`QueryRunner` also supports explicit resource management:
```typescript
async function updateSalaries() {
await using queryRunner = dataSource.createQueryRunner()
await queryRunner.manager.update(
Employee,
{ level: "junior" },
{ bonus: 0.2 },
)
await queryRunner.manager.update(
Employee,
{ level: "senior" },
{ bonus: 0.1 },
)
// no need anymore to manually release the QueryRunner
}
try {
await updateSalaries()
} catch (error) {
console.error(error)
}
```
When declaring a query runner like this, it will be automatically released after the last statement in the containing scope was executed.

View File

@ -544,6 +544,10 @@ export class MongoQueryRunner implements QueryRunner {
// releasing connection are not supported by mongodb driver, so simply don't do anything here
}
async [Symbol.asyncDispose](): Promise<void> {
// there's no clean-up necessary, so simply don't do anything here
}
/**
* Starts transaction.
*/

View File

@ -18,7 +18,7 @@ import { MetadataTableType } from "../driver/types/MetadataTableType"
import { InstanceChecker } from "../util/InstanceChecker"
import { buildSqlTag } from "../util/SqlTagUtils"
export abstract class BaseQueryRunner {
export abstract class BaseQueryRunner implements AsyncDisposable {
// -------------------------------------------------------------------------
// Public Properties
// -------------------------------------------------------------------------
@ -103,6 +103,29 @@ export abstract class BaseQueryRunner {
// Public Abstract Methods
// -------------------------------------------------------------------------
/**
* Releases used database connection.
* You cannot use query runner methods after connection is released.
*/
abstract release(): Promise<void>
async [Symbol.asyncDispose](): Promise<void> {
try {
if (this.isTransactionActive) {
this.transactionDepth = 1 // ignore all savepoints and commit directly
await this.commitTransaction()
}
} finally {
await this.release()
}
}
/**
* Commits transaction.
* Error will be thrown if transaction was not started.
*/
abstract commitTransaction(): Promise<void>
/**
* Executes a given SQL query.
*/

View File

@ -19,7 +19,7 @@ import { ReplicationMode } from "../driver/types/ReplicationMode"
/**
* Runs queries on a single database connection.
*/
export interface QueryRunner {
export interface QueryRunner extends AsyncDisposable {
/**
* Connection used by this query runner.
*/
@ -88,6 +88,8 @@ export interface QueryRunner {
*/
release(): Promise<void>
[Symbol.asyncDispose](): Promise<void>
/**
* Removes all tables from the currently connected database.
* Be careful with using this method and avoid using it in production or migrations

View File

@ -69,4 +69,24 @@ describe("driver > sap > connection pool", () => {
expect(poolClient.getPooledCount()).to.equal(3)
}),
))
it("should be managed correctly with explicit resource management ", () =>
Promise.all(
dataSources.map(async (dataSource) => {
const poolClient = (dataSource.driver as SapDriver)
.master as ConnectionPool
expect(poolClient.getInUseCount()).to.equal(0)
expect(poolClient.getPooledCount()).to.be.at.most(3)
{
await using queryRunner = dataSource.createQueryRunner()
await queryRunner.connect()
expect(poolClient.getInUseCount()).to.equal(1)
expect(poolClient.getPooledCount()).to.be.at.most(2)
}
expect(poolClient.getInUseCount()).to.equal(0)
expect(poolClient.getPooledCount()).to.be.at.most(3)
}),
))
})

View File

@ -0,0 +1,63 @@
import { expect } from "chai"
import "reflect-metadata"
import sinon from "sinon"
import { QueryFailedError } from "../../../src"
import { DataSource } from "../../../src/data-source/DataSource"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../utils/test-utils"
import { Company } from "./entity/Company"
import { Employee } from "./entity/Employee"
describe("query runner > async dispose", () => {
let dataSources: DataSource[]
before(async () => {
dataSources = await createTestingConnections({
entities: [Employee, Company],
enabledDrivers: ["postgres"], // this is rather a unit test, so a single driver is enough
})
})
beforeEach(() => reloadTestingDatabases(dataSources))
after(() => closeTestingConnections(dataSources))
it("should release query runner", () =>
Promise.all(
dataSources.map(async (dataSource) => {
let releaseSpy: sinon.SinonSpy
{
await using queryRunner = dataSource.createQueryRunner()
releaseSpy = sinon.spy(queryRunner, "release")
await queryRunner.connect()
}
expect(releaseSpy).to.have.been.calledOnce
}),
))
it("should commit the transaction in progress", () =>
Promise.all(
dataSources.map(async (dataSource) => {
let releaseSpy: sinon.SinonSpy | null = null
let error: Error | null = null
async function insertEmployee() {
await using queryRunner = dataSource.createQueryRunner()
releaseSpy = sinon.spy(queryRunner, "release")
await queryRunner.startTransaction("READ UNCOMMITTED")
await queryRunner.sql`INSERT INTO "employee"("name", "companyId") VALUES ('John Doe', 100)`
}
try {
await insertEmployee()
} catch (e) {
error = e
}
expect(error).to.be.instanceOf(QueryFailedError)
expect((error as QueryFailedError).query).to.equal("COMMIT")
expect(releaseSpy).to.have.been.calledOnce
}),
))
})

View File

@ -0,0 +1,10 @@
import { Column, Entity, PrimaryGeneratedColumn } from "../../../../src"
@Entity()
export class Company {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
}

View File

@ -0,0 +1,19 @@
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from "../../../../src"
import { Company } from "./Company"
@Entity()
export class Employee {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@ManyToOne(() => Company, { deferrable: "INITIALLY DEFERRED" })
company: Company
}