Skip to content

Commit

Permalink
feat(query-builder): allow awaiting the QueryBuilder instance (#2446)
Browse files Browse the repository at this point in the history
  • Loading branch information
B4nan committed Nov 21, 2021
1 parent c7a75e0 commit c1c4d51
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 104 deletions.
220 changes: 137 additions & 83 deletions docs/docs/query-builder.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/docs/upgrading-v4-to-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,8 @@ You can use destructing if you want to have a single entity return type:
```ts
const [loadedAuthor] = await em.populate(author, ...);
```

## QueryBuilder is awaitable

Previously awaiting of QB instance was a no-op. In v5, QB is promise-like interface,
so we can await it. More about this in [Awaiting the QueryBuilder](./query-builder.md#awaiting-the-querybuilder) section.
7 changes: 7 additions & 0 deletions packages/knex/src/SqlEntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export class SqlEntityManager<D extends AbstractSqlDriver = AbstractSqlDriver> e
return new QueryBuilder<T>(entityName, this.getMetadata(), this.getDriver(), this.getTransactionContext(), alias, type, this);
}

/**
* Shortcut for `createQueryBuilder()`
*/
qb<T>(entityName: EntityName<T>, alias?: string, type?: 'read' | 'write') {
return this.createQueryBuilder(entityName, alias, type);
}

/**
* Creates raw SQL query that won't be escaped when used as a parameter.
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/knex/src/SqlEntityRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export class SqlEntityRepository<T> extends EntityRepository<T> {
return this.em.createQueryBuilder(this.entityName, alias);
}

/**
* Shortcut for `createQueryBuilder()`
*/
qb(alias?: string): QueryBuilder<T> {
return this.createQueryBuilder(alias);
}

/**
* Returns configured knex instance.
*/
Expand Down
50 changes: 41 additions & 9 deletions packages/knex/src/query/QueryBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {
private lockMode?: LockMode;
private lockTables?: string[];
private subQueries: Dictionary<string> = {};
private innerPromise?: Promise<T[] | number | QueryResult<T>>;
private readonly platform = this.driver.getPlatform();
private readonly knex = this.driver.getConnection(this.connectionType).getKnex();
private readonly helper: QueryBuilderHelper;
Expand Down Expand Up @@ -111,31 +112,31 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {
this.flags.add(QueryFlag.DISTINCT);
}

return this.init(QueryType.SELECT);
return this.init(QueryType.SELECT) as SelectQueryBuilder<T>;
}

addSelect(fields: Field<T> | Field<T>[]): SelectQueryBuilder<T> {
if (this.type && this.type !== QueryType.SELECT) {
return this;
return this as SelectQueryBuilder<T>;
}

return this.select([...Utils.asArray(this._fields), ...Utils.asArray(fields)]);
}

insert(data: EntityData<T> | EntityData<T>[]): InsertQueryBuilder<T> {
return this.init(QueryType.INSERT, data);
return this.init(QueryType.INSERT, data) as InsertQueryBuilder<T>;
}

update(data: EntityData<T>): UpdateQueryBuilder<T> {
return this.init(QueryType.UPDATE, data);
return this.init(QueryType.UPDATE, data) as UpdateQueryBuilder<T>;
}

delete(cond?: QBFilterQuery): DeleteQueryBuilder<T> {
return this.init(QueryType.DELETE, undefined, cond);
return this.init(QueryType.DELETE, undefined, cond) as DeleteQueryBuilder<T>;
}

truncate(): TruncateQueryBuilder<T> {
return this.init(QueryType.TRUNCATE);
return this.init(QueryType.TRUNCATE) as TruncateQueryBuilder<T>;
}

count(field?: string | string[], distinct = false): CountQueryBuilder<T> {
Expand All @@ -145,7 +146,7 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {
this.flags.add(QueryFlag.DISTINCT);
}

return this.init(QueryType.COUNT);
return this.init(QueryType.COUNT) as CountQueryBuilder<T>;
}

join(field: string, alias: string, cond: QBFilterQuery = {}, type: 'leftJoin' | 'innerJoin' | 'pivotJoin' = 'innerJoin', path?: string): this {
Expand Down Expand Up @@ -517,6 +518,33 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {
return res ? +res.count : 0;
}

/**
* Provides promise-like interface so we can await the QB instance.
*/
then<TResult1 = any, TResult2 = never>(onfulfilled?: ((value: any) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<T[] | number | QueryResult<T>> {
return this.getInnerPromise().then(onfulfilled, onrejected) as any;
}

private getInnerPromise() {
if (!this.innerPromise) {
this.innerPromise = (async () => {
switch (this.type) {
case QueryType.INSERT:
case QueryType.UPDATE:
case QueryType.DELETE:
case QueryType.TRUNCATE:
return this.execute('run');
case QueryType.SELECT:
return this.getResultList();
case QueryType.COUNT:
return this.getCount();
}
})();
}

return this.innerPromise!;
}

/**
* Returns knex instance with sub-query aliased with given alias.
* You can provide `EntityName.propName` as alias, then the field name will be used based on the metadata
Expand Down Expand Up @@ -838,22 +866,26 @@ export class QueryBuilder<T extends AnyEntity<T> = AnyEntity> {

}

export interface RunQueryBuilder<T> extends Omit<QueryBuilder<T>, 'getResult' | 'getSingleResult' | 'getResultList'> {
export interface RunQueryBuilder<T> extends Omit<QueryBuilder<T>, 'getResult' | 'getSingleResult' | 'getResultList' | 'where'> {
where(cond: QBFilterQuery<T> | string, params?: keyof typeof GroupOperator | any[], operator?: keyof typeof GroupOperator): this;
execute<U = QueryResult<T>>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;
then<TResult1 = QueryResult<T>, TResult2 = never>(onfulfilled?: ((value: QueryResult<T>) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<QueryResult<T>>;
}

export interface SelectQueryBuilder<T> extends QueryBuilder<T> {
execute<U = T[]>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;
execute<U = T[]>(method: 'all', mapResults?: boolean): Promise<U>;
execute<U = T>(method: 'get', mapResults?: boolean): Promise<U>;
execute<U = QueryResult<T>>(method: 'run', mapResults?: boolean): Promise<U>;
then<TResult1 = T[], TResult2 = never>(onfulfilled?: ((value: T[]) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<T[]>;
}

export interface CountQueryBuilder<T> extends SelectQueryBuilder<T> {
export interface CountQueryBuilder<T> extends QueryBuilder<T> {
execute<U = { count: number }[]>(method?: 'all' | 'get' | 'run', mapResults?: boolean): Promise<U>;
execute<U = { count: number }[]>(method: 'all', mapResults?: boolean): Promise<U>;
execute<U = { count: number }>(method: 'get', mapResults?: boolean): Promise<U>;
execute<U = QueryResult<{ count: number }>>(method: 'run', mapResults?: boolean): Promise<U>;
then<TResult1 = number, TResult2 = never>(onfulfilled?: ((value: number) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<number>;
}

export interface InsertQueryBuilder<T> extends RunQueryBuilder<T> {}
Expand Down
3 changes: 3 additions & 0 deletions tests/EntityRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const methods = {
persistAndFlush: jest.fn(),
persistLater: jest.fn(),
createQueryBuilder: jest.fn(),
qb: jest.fn(),
findOne: jest.fn(),
findOneOrFail: jest.fn(),
find: jest.fn(),
Expand Down Expand Up @@ -63,6 +64,8 @@ describe('EntityRepository', () => {
expect(methods.findOneOrFail.mock.calls[0]).toEqual([Publisher, 'bar', undefined]);
await repo.createQueryBuilder();
expect(methods.createQueryBuilder.mock.calls[0]).toEqual([Publisher, undefined]);
await repo.qb();
expect(methods.createQueryBuilder.mock.calls[0]).toEqual([Publisher, undefined]);
repo.remove(e);
expect(methods.remove.mock.calls[0]).toEqual([e]);
const entity = {} as AnyEntity;
Expand Down
46 changes: 37 additions & 9 deletions tests/QueryBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,34 @@ describe('QueryBuilder', () => {
expect(qb.getParams()).toEqual([2, 1]);
});

test('awaiting the QB instance', async () => {
const qb1 = orm.em.qb(Publisher2);
const res1 = await qb1.insert({ name: 'p1', type: PublisherType.GLOBAL });
expect(res1.insertId > 0).toBe(true); // test the type
expect(res1.insertId).toBeGreaterThanOrEqual(1);

const qb2 = orm.em.qb(Publisher2);
const res2 = await qb2.select('*').where({ name: 'p1' }).limit(5);
expect(res2.map(p => p.name)).toEqual(['p1']); // test the type
expect(res2).toHaveLength(1);
expect(res2[0]).toBeInstanceOf(Publisher2);

const qb3 = orm.em.qb(Publisher2);
const res3 = await qb3.count().where({ name: 'p1' });
expect(res3 > 0).toBe(true); // test the type
expect(res3).toBe(1);

const qb4 = orm.em.qb(Publisher2);
const res4 = await qb4.update({ type: PublisherType.LOCAL }).where({ name: 'p1' });
expect(res4.affectedRows > 0).toBe(true); // test the type
expect(res4.affectedRows).toBe(1);

const qb5 = orm.em.qb(Publisher2);
const res5 = await qb5.delete().where({ name: 'p1' });
expect(res5.affectedRows > 0).toBe(true); // test the type
expect(res5.affectedRows).toBe(1);
});

test('select query with order by variants', async () => {
const qb1 = orm.em.createQueryBuilder(Publisher2);
qb1.select('*').where({ name: 'test 123' }).orderBy({ name: QueryOrder.DESC, type: 'ASC' }).limit(2, 1);
Expand Down Expand Up @@ -1755,21 +1783,21 @@ describe('QueryBuilder', () => {
});

test('pivot joining of m:n when target entity is null (GH issue 548)', async () => {
const qb11 = await orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: null });
const qb11 = orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: null });
expect(qb11.getQuery()).toMatch('select `u`.* ' +
'from `user2` as `u` ' +
'left join `user2_cars` as `e1` on `u`.`first_name` = `e1`.`user2_first_name` and `u`.`last_name` = `e1`.`user2_last_name` ' +
'where (`e1`.`car2_name`, `e1`.`car2_year`) is null');
expect(qb11.getParams()).toEqual([]);

const qb2 = await orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ $or: [{ tags: null }, { tags: { $ne: 1 } }] });
const qb2 = orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ $or: [{ tags: null }, { tags: { $ne: 1 } }] });
expect(qb2.getQuery()).toMatch('select `b`.*, `b`.price * 1.19 as `price_taxed` ' +
'from `book2` as `b` ' +
'left join `book2_tags` as `e1` on `b`.`uuid_pk` = `e1`.`book2_uuid_pk` ' +
'where (`e1`.`book_tag2_id` is null or `e1`.`book_tag2_id` != ?)');
expect(qb2.getParams()).toEqual(['1']);

const qb3 = await orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: null }).orderBy({ friends: { name: QueryOrder.ASC } });
const qb3 = orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: null }).orderBy({ friends: { name: QueryOrder.ASC } });
expect(qb3.getQuery()).toMatch('select `a`.* ' +
'from `author2` as `a` ' +
'left join `author_to_friend` as `e1` on `a`.`id` = `e1`.`author2_1_id` ' +
Expand All @@ -1778,7 +1806,7 @@ describe('QueryBuilder', () => {
'order by `e2`.`name` asc');
expect(qb3.getParams()).toEqual([]);

const qb4 = await orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: null }).orderBy({ friends: QueryOrder.ASC });
const qb4 = orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: null }).orderBy({ friends: QueryOrder.ASC });
expect(qb4.getQuery()).toMatch('select `a`.* ' +
'from `author2` as `a` ' +
'left join `author_to_friend` as `e1` on `a`.`id` = `e1`.`author2_1_id` ' +
Expand All @@ -1788,35 +1816,35 @@ describe('QueryBuilder', () => {
});

test('pivot joining of m:n when no target entity needed directly (GH issue 549)', async () => {
const qb1 = await orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ tags: { id: 1 } });
const qb1 = orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ tags: { id: 1 } });
expect(qb1.getQuery()).toMatch('select `b`.*, `b`.price * 1.19 as `price_taxed` ' +
'from `book2` as `b` ' +
'left join `book2_tags` as `e1` on `b`.`uuid_pk` = `e1`.`book2_uuid_pk` ' +
'where `e1`.`book_tag2_id` = ?');
expect(qb1.getParams()).toEqual(['1']);

const qb11 = await orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: { name: 'n', year: 1 } });
const qb11 = orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: { name: 'n', year: 1 } });
expect(qb11.getQuery()).toMatch('select `u`.* ' +
'from `user2` as `u` ' +
'left join `user2_cars` as `e1` on `u`.`first_name` = `e1`.`user2_first_name` and `u`.`last_name` = `e1`.`user2_last_name` ' +
'where (`e1`.`car2_name`, `e1`.`car2_year`) = (?, ?)');
expect(qb11.getParams()).toEqual(['n', 1]);

const qb12 = await orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: { $in: [{ name: 'n', year: 1 }, { name: 'n', year: 2 }] } });
const qb12 = orm.em.createQueryBuilder(User2, 'u').select('u.*').where({ cars: { $in: [{ name: 'n', year: 1 }, { name: 'n', year: 2 }] } });
expect(qb12.getQuery()).toMatch('select `u`.* ' +
'from `user2` as `u` ' +
'left join `user2_cars` as `e1` on `u`.`first_name` = `e1`.`user2_first_name` and `u`.`last_name` = `e1`.`user2_last_name` ' +
'where (`e1`.`car2_name`, `e1`.`car2_year`) in ((?, ?), (?, ?))');
expect(qb12.getParams()).toEqual(['n', 1, 'n', 2]);

const qb2 = await orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ $or: [{ tags: { id: null } }, { tags: { $ne: 1 } }] });
const qb2 = orm.em.createQueryBuilder(Book2, 'b').select('b.*').where({ $or: [{ tags: { id: null } }, { tags: { $ne: 1 } }] });
expect(qb2.getQuery()).toMatch('select `b`.*, `b`.price * 1.19 as `price_taxed` ' +
'from `book2` as `b` ' +
'left join `book2_tags` as `e1` on `b`.`uuid_pk` = `e1`.`book2_uuid_pk` ' +
'where (`e1`.`book_tag2_id` is null or `e1`.`book_tag2_id` != ?)');
expect(qb2.getParams()).toEqual(['1']);

const qb4 = await orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: 1 }).orderBy({ friends: { id: QueryOrder.ASC } });
const qb4 = orm.em.createQueryBuilder(Author2, 'a').select('a.*').where({ friends: 1 }).orderBy({ friends: { id: QueryOrder.ASC } });
expect(qb4.getQuery()).toMatch('select `a`.* ' +
'from `author2` as `a` ' +
'left join `author_to_friend` as `e1` on `a`.`id` = `e1`.`author2_1_id` ' +
Expand Down
6 changes: 3 additions & 3 deletions tests/issues/GH572.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ describe('GH issue 572', () => {
});
expect(mock.mock.calls[0][0]).toMatch('select `a0`.*, `b1`.`id` as `b_id` from `a` as `a0` left join `b` as `b1` on `a0`.`id` = `b1`.`a_id` order by `b1`.`camel_case_field` asc');
expect(res1).toHaveLength(0);
const qb1 = await orm.em.createQueryBuilder(A, 'a').select('a.*').orderBy({ b: { camelCaseField: QueryOrder.ASC } });
const qb1 = orm.em.createQueryBuilder(A, 'a').select('a.*').orderBy({ b: { camelCaseField: QueryOrder.ASC } });
expect(qb1.getQuery()).toMatch('select `a`.* from `a` as `a` left join `b` as `b1` on `a`.`id` = `b1`.`a_id` order by `b1`.`camel_case_field` asc');
const qb2 = await orm.em.createQueryBuilder(B, 'b').select('b.*').orderBy({ 'b.camelCaseField': QueryOrder.ASC });
const qb2 = orm.em.createQueryBuilder(B, 'b').select('b.*').orderBy({ 'b.camelCaseField': QueryOrder.ASC });
expect(qb2.getQuery()).toMatch('select `b`.* from `b` as `b` order by `b`.`camel_case_field` asc');
const qb3 = await orm.em.createQueryBuilder(A, 'a').select('a.*').leftJoin('a.b', 'b_').orderBy({ 'b_.camelCaseField': QueryOrder.ASC });
const qb3 = orm.em.createQueryBuilder(A, 'a').select('a.*').leftJoin('a.b', 'b_').orderBy({ 'b_.camelCaseField': QueryOrder.ASC });
expect(qb3.getQuery()).toMatch('select `a`.* from `a` as `a` left join `b` as `b_` on `a`.`id` = `b_`.`a_id` order by `b_`.`camel_case_field` asc');
});
});

0 comments on commit c1c4d51

Please sign in to comment.