feat: implement exists query method (#9303)

Adding `Exists` method to query builder and EntityManager, to check whether a row exists given the conditions

Closes: #2815

Co-authored-by: mortzprk <mortz.prk@gmail.com>
This commit is contained in:
Morteza PRK 2022-12-03 19:30:18 +03:30 committed by GitHub
parent 53fad8f235
commit 598e26980d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 296 additions and 0 deletions

View File

@ -107,6 +107,11 @@ export interface Driver {
cteCapabilities: CteCapabilities
/**
* Dummy table name
*/
dummyTableName?: string
/**
* Performs connection to the database.
* Depend on driver type it may create a connection pool.

View File

@ -225,6 +225,8 @@ export class OracleDriver implements Driver {
enabled: false, // TODO: enable
}
dummyTableName = "DUAL";
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------

View File

@ -209,6 +209,8 @@ export class SapDriver implements Driver {
enabled: true,
}
dummyTableName = `SYS.DUMMY`;
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------

View File

@ -948,6 +948,23 @@ export class EntityManager {
}
}
/**
* Checks whether any entity exists with the given condition
*/
exists<Entity>(
entityClass: EntityTarget<Entity>,
options?: FindManyOptions<Entity>,
): Promise<boolean> {
const metadata = this.connection.getMetadata(entityClass)
return this.createQueryBuilder(
entityClass,
FindOptionsUtils.extractFindManyOptionsAlias(options) ||
metadata.name,
)
.setFindOptions(options || {})
.getExists()
}
/**
* Counts entities that match given options.
* Useful for pagination.

View File

@ -1178,6 +1178,21 @@ export abstract class QueryBuilder<Entity extends ObjectLiteral> {
})
}
protected getExistsCondition(subQuery: any): [string, any[]] {
const query = subQuery
.clone()
.orderBy()
.groupBy()
.offset(undefined)
.limit(undefined)
.skip(undefined)
.take(undefined)
.select("1")
.setOption("disable-global-order")
return [`EXISTS (${query.getQuery()})`, query.getParameters()]
}
private findColumnsForPropertyPath(
propertyPath: string,
): [Alias, string[], ColumnMetadata[]] {

View File

@ -249,6 +249,10 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
return this
}
fromDummy(): SelectQueryBuilder<any> {
return this.from(this.connection.driver.dummyTableName ?? "(SELECT 1 AS dummy_column)", "dummy_table");
}
/**
* Specifies FROM which entity's table select/update/delete will be executed.
* Also sets a main string alias of the selection data.
@ -1190,6 +1194,27 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
return this
}
/**
* Sets a new where EXISTS clause
*/
whereExists(subQuery: SelectQueryBuilder<any>): this {
return this.where(...this.getExistsCondition(subQuery))
}
/**
* Adds a new AND where EXISTS clause
*/
andWhereExists(subQuery: SelectQueryBuilder<any>): this {
return this.andWhere(...this.getExistsCondition(subQuery))
}
/**
* Adds a new OR where EXISTS clause
*/
orWhereExists(subQuery: SelectQueryBuilder<any>): this {
return this.orWhere(...this.getExistsCondition(subQuery))
}
/**
* Adds new AND WHERE with conditions for the given ids.
*
@ -1752,6 +1777,50 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
}
}
/**
* Gets exists
* Returns whether any rows exists matching current query.
*/
async getExists(): Promise<boolean> {
if (this.expressionMap.lockMode === "optimistic")
throw new OptimisticLockCanNotBeUsedError()
const queryRunner = this.obtainQueryRunner()
let transactionStartedByUs: boolean = false
try {
// start transaction if it was enabled
if (
this.expressionMap.useTransaction === true &&
queryRunner.isTransactionActive === false
) {
await queryRunner.startTransaction()
transactionStartedByUs = true
}
this.expressionMap.queryEntity = false
const results = await this.executeExistsQuery(queryRunner)
// close transaction if we started it
if (transactionStartedByUs) {
await queryRunner.commitTransaction()
}
return results
} catch (error) {
// rollback transaction if we started it
if (transactionStartedByUs) {
try {
await queryRunner.rollbackTransaction()
} catch (rollbackError) {}
}
throw error
} finally {
if (queryRunner !== this.queryRunner)
// means we created our own query runner
await queryRunner.release()
}
}
/**
* Executes built SQL query and returns entities and overall entities count (without limitation).
* This method is useful to build pagination.
@ -2912,6 +2981,20 @@ export class SelectQueryBuilder<Entity extends ObjectLiteral>
return parseInt(results[0]["cnt"])
}
protected async executeExistsQuery(
queryRunner: QueryRunner,
): Promise<boolean> {
const results = await this.connection
.createQueryBuilder()
.fromDummy()
.select("1", "row_exists")
.whereExists(this)
.limit(1)
.loadRawResults(queryRunner)
return results.length > 0
}
protected applyFindOptions() {
// todo: convert relations: string[] to object map to simplify code
// todo: same with selects

View File

@ -451,6 +451,13 @@ export class Repository<Entity extends ObjectLiteral> {
)
}
/**
* Checks whether any entity exists that match given options.
*/
exist(options?: FindManyOptions<Entity>): Promise<boolean> {
return this.manager.exists(this.metadata.target, options)
}
/**
* Counts entities that match given options.
* Useful for pagination.

View File

@ -0,0 +1,7 @@
import { Entity, PrimaryColumn } from "../../../../../src"
@Entity("tests")
export class Test {
@PrimaryColumn()
id: string
}

View File

@ -0,0 +1,45 @@
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
import { DataSource } from "../../../../src/data-source/DataSource"
import { expect } from "chai"
import { Test } from "./entity/Test"
describe("query builder > exist", () => {
let connections: DataSource[]
before(
async () =>
(connections = await createTestingConnections({
entities: [Test],
schemaCreate: true,
dropSchema: true,
})),
)
beforeEach(() => reloadTestingDatabases(connections))
after(() => closeTestingConnections(connections))
it("Exists query of empty table should be false", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(Test)
const exist = await repo.exist()
expect(exist).to.be.equal(false)
}),
))
it("Exists query of non empty table should be true", () =>
Promise.all(
connections.map(async (connection) => {
const repo = connection.getRepository(Test)
await repo.save({ id: "ok" })
await repo.save({ id: "nok" })
const exist = await repo.exist()
expect(exist).to.be.equal(true)
}),
))
})

View File

@ -135,6 +135,119 @@ describe("repository > find methods", () => {
))
})
describe("exists", function () {
it("should return a True when no criteria given", () =>
Promise.all(
connections.map(async (connection) => {
const postRepository = connection.getRepository(Post)
for (let i = 0; i < 100; i++) {
const post = new Post()
post.id = i
post.title = "post #" + i
post.categoryName = "other"
await postRepository.save(post)
}
// check exist method
const exists = await postRepository.exist({
order: { id: "ASC" },
})
exists.should.be.equal(true)
}),
))
it("should return True when matches the given criteria", () =>
Promise.all(
connections.map(async (connection) => {
const postRepository = connection.getRepository(Post)
for (let i = 1; i <= 100; i++) {
const post = new Post()
post.id = i
post.title = "post #" + i
post.categoryName = i % 2 === 0 ? "even" : "odd"
await postRepository.save(post)
}
// check exist method
const exists = await postRepository.exist({
where: { categoryName: "odd" },
order: { id: "ASC" },
})
exists.should.be.equal(true)
}),
))
it("should return True when matches the given multiple criteria", () =>
Promise.all(
connections.map(async (connection) => {
const postRepository = connection.getRepository(Post)
for (let i = 1; i <= 100; i++) {
const post = new Post()
post.id = i
post.title = "post #" + i
post.categoryName = i % 2 === 0 ? "even" : "odd"
post.isNew = i > 90
await postRepository.save(post)
}
// check exist method
const exists = await postRepository.exist({
where: { categoryName: "odd", isNew: true },
order: { id: "ASC" },
})
exists.should.be.equal(true)
}),
))
it("should return True when matches the given find options", () =>
Promise.all(
connections.map(async (connection) => {
const postRepository = connection.getRepository(Post)
for (let i = 1; i <= 100; i++) {
const post = new Post()
post.id = i
post.isNew = i > 90
post.title = post.isNew
? "new post #" + i
: "post #" + i
post.categoryName = i % 2 === 0 ? "even" : "odd"
await postRepository.save(post)
}
// check exist method
const exists = await postRepository.exist()
exists.should.be.equal(true)
}),
))
it("should return True when matches both criteria and find options", () =>
Promise.all(
connections.map(async (connection) => {
const postRepository = connection.getRepository(Post)
for (let i = 1; i <= 100; i++) {
const post = new Post()
post.id = i
post.isNew = i > 90
post.title = post.isNew
? "new post #" + i
: "post #" + i
post.categoryName = i % 2 === 0 ? "even" : "odd"
await postRepository.save(post)
}
// check exist method
const exists = await postRepository.exist({
where: { categoryName: "even", isNew: true },
skip: 1,
take: 2,
order: { id: "ASC" },
})
exists.should.be.equal(true)
}),
))
})
describe("find and findAndCount", function () {
it("should return everything when no criteria given", () =>
Promise.all(