From 60dd2d8e951dd94946888765a5e81f4f16c3e7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ad=C3=A1mek?= Date: Mon, 26 Oct 2020 17:09:26 +0100 Subject: [PATCH] feat(query-builder): allow mapping of complex joined results (#988) To select multiple entities and map them from `QueryBuilder`, we can use `joinAndSelect` or `leftJoinAndSelect` method: ```ts // `res` will contain array of authors, with books and their tags populated const res = await orm.em.createQueryBuilder(Author, 'a') .select('*') .leftJoinAndSelect('a.books', 'b') .leftJoinAndSelect('b.tags', 't') .where({ 't.name': ['sick', 'sexy'] }) .getResultList(); ``` Closes #932 --- docs/docs/query-builder.md | 15 +++++ packages/core/src/drivers/DatabaseDriver.ts | 5 +- packages/knex/src/AbstractSqlDriver.ts | 10 ++- packages/knex/src/query/QueryBuilder.ts | 63 ++++++++++++++++--- packages/knex/src/query/QueryBuilderHelper.ts | 11 +--- packages/mongodb/src/MongoDriver.ts | 2 +- tests/EntityManager.mysql.test.ts | 2 +- tests/EntityManager.sqlite2.test.ts | 38 +++++++++++ tests/QueryBuilder.test.ts | 22 +++++++ 9 files changed, 147 insertions(+), 21 deletions(-) diff --git a/docs/docs/query-builder.md b/docs/docs/query-builder.md index a7c046c3e93a..3a9b3534da4c 100644 --- a/docs/docs/query-builder.md +++ b/docs/docs/query-builder.md @@ -177,6 +177,21 @@ console.log(qb.getQuery()); // limit ? offset ? ``` +## Mapping joined results + +To select multiple entities and map them from `QueryBuilder`, we can use +`joinAndSelect` or `leftJoinAndSelect` method: + +```ts +// `res` will contain array of authors, with books and their tags populated +const res = await orm.em.createQueryBuilder(Author, 'a') + .select('*') + .leftJoinAndSelect('a.books', 'b') + .leftJoinAndSelect('b.tags', 't') + .where({ 't.name': ['sick', 'sexy'] }) + .getResultList(); +``` + ## Complex Where Conditions There are multiple ways to construct complex query conditions. You can either write parts of SQL diff --git a/packages/core/src/drivers/DatabaseDriver.ts b/packages/core/src/drivers/DatabaseDriver.ts index 0cbd0dc979fc..56e9d145516b 100644 --- a/packages/core/src/drivers/DatabaseDriver.ts +++ b/packages/core/src/drivers/DatabaseDriver.ts @@ -204,7 +204,10 @@ export abstract class DatabaseDriver implements IDatabaseD throw new Error(`Pessimistic locks are not supported by ${this.constructor.name} driver`); } - protected shouldHaveColumn>(prop: EntityProperty, populate: PopulateOptions[], includeFormulas = true): boolean { + /** + * @internal + */ + shouldHaveColumn>(prop: EntityProperty, populate: PopulateOptions[], includeFormulas = true): boolean { if (prop.formula) { return includeFormulas; } diff --git a/packages/knex/src/AbstractSqlDriver.ts b/packages/knex/src/AbstractSqlDriver.ts index 9247b3a220be..461931d34e65 100644 --- a/packages/knex/src/AbstractSqlDriver.ts +++ b/packages/knex/src/AbstractSqlDriver.ts @@ -434,7 +434,10 @@ export abstract class AbstractSqlDriver>(rawResults: Dictionary[], meta: EntityMetadata): EntityData[] { + /** + * @internal + */ + mergeJoinedResult>(rawResults: Dictionary[], meta: EntityMetadata): EntityData[] { // group by the root entity primary key first const res = rawResults.reduce((result, item) => { const pk = Utils.getCompositeKeyHash(item as T, meta); @@ -469,7 +472,10 @@ export abstract class AbstractSqlDriver>(qb: QueryBuilder, prop: EntityProperty, tableAlias?: string): Field[] { + /** + * @internal + */ + mapPropToFieldNames>(qb: QueryBuilder, prop: EntityProperty, tableAlias?: string): Field[] { if (prop.formula) { const alias = qb.ref(tableAlias ?? qb.alias).toString(); const aliased = qb.ref(tableAlias ? `${tableAlias}_${prop.fieldNames[0]}` : prop.fieldNames[0]).toString(); diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index 936ad03f8272..4b5bdb59a393 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -1,7 +1,7 @@ import { QueryBuilder as KnexQueryBuilder, Raw, Transaction, Value } from 'knex'; import { - AnyEntity, Dictionary, EntityMetadata, FlatQueryOrderMap, GroupOperator, LockMode, MetadataStorage, - PopulateOptions, QBFilterQuery, QueryFlag, QueryHelper, QueryOrderMap, ReferenceType, Utils, ValidationError, + AnyEntity, Dictionary, EntityMetadata, EntityProperty, FlatQueryOrderMap, GroupOperator, LockMode, MetadataStorage, EntityData, + PopulateOptions, QBFilterQuery, QueryFlag, QueryHelper, QueryOrderMap, ReferenceType, Utils, ValidationError, LoadStrategy, } from '@mikro-orm/core'; import { QueryType } from './enums'; import { AbstractSqlDriver } from '../AbstractSqlDriver'; @@ -33,6 +33,7 @@ export class QueryBuilder = AnyEntity> { private _having: Dictionary = {}; private _limit?: number; private _offset?: number; + private _joinedProps = new Map>(); private _cache?: boolean | number | [string, number]; private lockMode?: LockMode; private subQueries: Dictionary = {}; @@ -60,7 +61,7 @@ export class QueryBuilder = AnyEntity> { return this.init(QueryType.SELECT); } - addSelect(fields: string | string[]): this { + addSelect(fields: Field | Field[]): this { if (this.type && this.type !== QueryType.SELECT) { return this; } @@ -103,6 +104,38 @@ export class QueryBuilder = AnyEntity> { return this.join(field, alias, cond, 'leftJoin'); } + joinAndSelect(field: string, alias: string, cond: QBFilterQuery = {}, type: 'leftJoin' | 'innerJoin' | 'pivotJoin' = 'innerJoin', path?: string): this { + const prop = this.joinReference(field, alias, cond, type, path); + this.addSelect(this.getFieldsForJoinedLoad(prop, alias)); + const [fromAlias] = this.helper.splitField(field); + const populate = this._joinedProps.get(fromAlias); + const item = { field: prop.name, strategy: LoadStrategy.JOINED, children: [] }; + + if (populate) { + populate.children!.push(item); + } else { // root entity + this._populate.push(item); + } + + this._joinedProps.set(alias, item); + + return this; + } + + leftJoinAndSelect(field: string, alias: string, cond: QBFilterQuery = {}): this { + return this.joinAndSelect(field, alias, cond, 'leftJoin'); + } + + protected getFieldsForJoinedLoad>(prop: EntityProperty, alias: string): Field[] { + const fields: Field[] = []; + const meta2 = this.metadata.find(prop.type)!; + meta2.props + .filter(prop => this.driver.shouldHaveColumn(prop, this._populate)) + .forEach(prop => fields.push(...this.driver.mapPropToFieldNames(this as unknown as QueryBuilder, prop, alias))); + + return fields; + } + withSubQuery(subQuery: KnexQueryBuilder, alias: string): this { this.subQueries[alias] = subQuery.toString(); return this; @@ -347,7 +380,12 @@ export class QueryBuilder = AnyEntity> { * Executes the query, returning array of results */ async getResultList(): Promise { - const res = await this.execute('all', true); + let res = await this.execute[]>('all', true); + + if (this._joinedProps.size > 0) { + res = this.driver.mergeJoinedResult(res, this.metadata.find(this.entityName)!); + } + return res.map(r => this.em!.map(this.entityName, r)); } @@ -355,7 +393,7 @@ export class QueryBuilder = AnyEntity> { * Executes the query, returning the first result or null */ async getSingleResult(): Promise { - const res = await this.getResult(); + const res = await this.getResultList(); return res[0] || null; } @@ -381,7 +419,7 @@ export class QueryBuilder = AnyEntity> { Object.assign(qb, this); // clone array/object properties - const properties = ['flags', '_fields', '_populate', '_populateMap', '_joins', '_aliasMap', '_cond', '_data', '_orderBy', '_schema', '_cache', 'subQueries']; + const properties = ['flags', '_fields', '_populate', '_populateMap', '_joins', '_joinedProps', '_aliasMap', '_cond', '_data', '_orderBy', '_schema', '_cache', 'subQueries']; properties.forEach(prop => (qb as any)[prop] = Utils.copy(this[prop as keyof this])); qb.finalized = false; @@ -399,13 +437,20 @@ export class QueryBuilder = AnyEntity> { return qb; } - private joinReference(field: string, alias: string, cond: Dictionary, type: 'leftJoin' | 'innerJoin' | 'pivotJoin', path?: string): void { + private joinReference(field: string, alias: string, cond: Dictionary, type: 'leftJoin' | 'innerJoin' | 'pivotJoin', path?: string): EntityProperty { const [fromAlias, fromField] = this.helper.splitField(field); const entityName = this._aliasMap[fromAlias]; - const prop = this.metadata.get(entityName).properties[fromField]; + const meta = this.metadata.get(entityName); + const prop = meta.properties[fromField]; + + if (!prop) { + throw new Error(`Trying to join ${field}, but ${fromField} is not a defined relation on ${meta.className}`); + } + this._aliasMap[alias] = prop.type; cond = QueryHelper.processWhere(cond, this.entityName, this.metadata, this.platform)!; const aliasedName = `${fromAlias}.${prop.name}`; + path = path ?? `${(Object.values(this._joins).find(j => j.alias === fromAlias)?.path ?? entityName)}.${prop.name}`; if (prop.reference === ReferenceType.ONE_TO_MANY) { this._joins[aliasedName] = this.helper.joinOneToReference(prop, fromAlias, alias, type, cond); @@ -429,6 +474,8 @@ export class QueryBuilder = AnyEntity> { if (!this._joins[aliasedName].path && path) { this._joins[aliasedName].path = path; } + + return prop; } private prepareFields, U extends string | Raw = string | Raw>(fields: Field[], type: 'where' | 'groupBy' | 'sub-query' = 'where'): U[] { diff --git a/packages/knex/src/query/QueryBuilderHelper.ts b/packages/knex/src/query/QueryBuilderHelper.ts index dbce097913f2..ab707ec7160a 100644 --- a/packages/knex/src/query/QueryBuilderHelper.ts +++ b/packages/knex/src/query/QueryBuilderHelper.ts @@ -117,7 +117,7 @@ export class QueryBuilderHelper { }; } - joinManyToManyReference(prop: EntityProperty, ownerAlias: string, alias: string, pivotAlias: string, type: 'leftJoin' | 'innerJoin' | 'pivotJoin', cond: Dictionary, path?: string): Dictionary { + joinManyToManyReference(prop: EntityProperty, ownerAlias: string, alias: string, pivotAlias: string, type: 'leftJoin' | 'innerJoin' | 'pivotJoin', cond: Dictionary, path: string): Dictionary { const ret = { [`${ownerAlias}.${prop.name}`]: { prop, type, cond, ownerAlias, @@ -127,12 +127,10 @@ export class QueryBuilderHelper { inverseJoinColumns: prop.inverseJoinColumns, primaryKeys: prop.referencedColumnNames, table: prop.pivotTable, + path: path.endsWith('[pivot]') ? path : `${path}[pivot]`, } as JoinOptions, }; - if (path) { - ret[`${ownerAlias}.${prop.name}`].path = path.endsWith('[pivot]') ? path : `${path}[pivot]`; - } if (type === 'pivotJoin') { return ret; @@ -140,10 +138,7 @@ export class QueryBuilderHelper { const prop2 = this.metadata.find(prop.pivotTable)!.properties[prop.type + (prop.owner ? '_inverse' : '_owner')]; ret[`${pivotAlias}.${prop2.name}`] = this.joinManyToOneReference(prop2, pivotAlias, alias, type); - - if (path) { - ret[`${pivotAlias}.${prop2.name}`].path = path; - } + ret[`${pivotAlias}.${prop2.name}`].path = path; return ret; } diff --git a/packages/mongodb/src/MongoDriver.ts b/packages/mongodb/src/MongoDriver.ts index b1e06071cd40..f4aebeead68d 100644 --- a/packages/mongodb/src/MongoDriver.ts +++ b/packages/mongodb/src/MongoDriver.ts @@ -280,7 +280,7 @@ export class MongoDriver extends DatabaseDriver { return fields; } - protected shouldHaveColumn(prop: EntityProperty, populate: PopulateOptions[]): boolean { + shouldHaveColumn(prop: EntityProperty, populate: PopulateOptions[]): boolean { if (super.shouldHaveColumn(prop, populate)) { return true; } diff --git a/tests/EntityManager.mysql.test.ts b/tests/EntityManager.mysql.test.ts index 2b7261abbe90..5020787dbd54 100644 --- a/tests/EntityManager.mysql.test.ts +++ b/tests/EntityManager.mysql.test.ts @@ -755,7 +755,7 @@ describe('EntityManagerMySql', () => { const qb2 = orm.em.createQueryBuilder(Book2); const res2 = await qb2.select('*').where({ title: 'not exists' }).getSingleResult(); expect(res2).toBeNull(); - const res3 = await qb1.select('*').getResultList(); + const res3 = await qb1.select('*').getResult(); expect(res3).toHaveLength(1); }); diff --git a/tests/EntityManager.sqlite2.test.ts b/tests/EntityManager.sqlite2.test.ts index dc6841ae80ed..387fda791d98 100644 --- a/tests/EntityManager.sqlite2.test.ts +++ b/tests/EntityManager.sqlite2.test.ts @@ -854,6 +854,44 @@ describe('EntityManagerSqlite2', () => { expect(b4.object).toBe(123); }); + test('mapping joined results from query builder', async () => { + const author = orm.em.create(Author4, { name: 'Jon Snow', email: 'snow@wall.st' }); + const book1 = orm.em.create(Book4, { title: 'My Life on the Wall, part 1', author }); + const book2 = orm.em.create(Book4, { title: 'My Life on the Wall, part 2', author }); + const book3 = orm.em.create(Book4, { title: 'My Life on the Wall, part 3', author }); + const tag1 = orm.em.create(BookTag4, { name: 'silly' }); + const tag2 = orm.em.create(BookTag4, { name: 'funny' }); + const tag3 = orm.em.create(BookTag4, { name: 'sick' }); + const tag4 = orm.em.create(BookTag4, { name: 'strange' }); + const tag5 = orm.em.create(BookTag4, { name: 'sexy' }); + book1.tags.add(tag1, tag3); + book2.tags.add(tag1, tag2, tag5); + book3.tags.add(tag2, tag4, tag5); + + await orm.em.persist([book1, book2, book3]).flush(); + orm.em.clear(); + + const qb = orm.em.createQueryBuilder(Author4, 'a'); + qb.select('*') + .leftJoinAndSelect('a.books', 'b') + .leftJoinAndSelect('b.tags', 't') + .where({ 't.name': ['sick', 'sexy'] }); + const sql = 'select `a`.*, ' + + '`b`.`id` as `b_id`, `b`.`created_at` as `b_created_at`, `b`.`updated_at` as `b_updated_at`, `b`.`title` as `b_title`, `b`.`author_id` as `b_author_id`, `b`.`publisher_id` as `b_publisher_id`, `b`.`meta` as `b_meta`, ' + + '`t`.`id` as `t_id`, `t`.`created_at` as `t_created_at`, `t`.`updated_at` as `t_updated_at`, `t`.`name` as `t_name`, `t`.`version` as `t_version` ' + + 'from `author4` as `a` ' + + 'left join `book4` as `b` on `a`.`id` = `b`.`author_id` ' + + 'left join `tags_ordered` as `e1` on `b`.`id` = `e1`.`book4_id` ' + + 'left join `book_tag4` as `t` on `e1`.`book_tag4_id` = `t`.`id` ' + + 'where `t`.`name` in (\'sick\', \'sexy\')'; + expect(qb.getFormattedQuery()).toEqual(sql); + const res = await qb.getSingleResult(); + expect(res).not.toBeNull(); + expect(res!.books[0]).not.toBeNull(); + expect(res!.books[0].title).toBe('My Life on the Wall, part 1'); + expect(res!.books[0].tags[0].name).toBe('sick'); + }); + test('question marks and parameter interpolation (GH issue #920)', async () => { const e = orm.em.create(Author4, { name: `?baz? uh \\? ? wut? \\\\ wut`, email: '123' }); await orm.em.persistAndFlush(e); diff --git a/tests/QueryBuilder.test.ts b/tests/QueryBuilder.test.ts index a476e672a2ae..fcb84996eaad 100644 --- a/tests/QueryBuilder.test.ts +++ b/tests/QueryBuilder.test.ts @@ -181,6 +181,28 @@ describe('QueryBuilder', () => { expect(qb.getParams()).toEqual(['test 123', 2, 1]); }); + test('complex select with mapping of joined results', async () => { + const qb = orm.em.createQueryBuilder(FooBar2, 'fb1'); + qb.select('*').leftJoinAndSelect('fb1.baz', 'fz'); + + const err = `Trying to join fz.fooBar, but fooBar is not a defined relation on FooBaz2`; + expect(() => qb.leftJoinAndSelect('fz.fooBar', 'fb2')).toThrowError(err); + + qb.leftJoinAndSelect('fz.bar', 'fb2') + .where({ 'fz.name': 'baz' }) + .limit(1); + const sql = 'select `fb1`.*, ' + + '`fz`.`id` as `fz_id`, `fz`.`name` as `fz_name`, `fz`.`version` as `fz_version`, ' + + '`fb2`.`id` as `fb2_id`, `fb2`.`name` as `fb2_name`, `fb2`.`baz_id` as `fb2_baz_id`, `fb2`.`foo_bar_id` as `fb2_foo_bar_id`, `fb2`.`version` as `fb2_version`, `fb2`.`blob` as `fb2_blob`, `fb2`.`array` as `fb2_array`, `fb2`.`object` as `fb2_object`, (select 123) as `fb2_random`, ' + + '(select 123) as `random` from `foo_bar2` as `fb1` ' + + 'left join `foo_baz2` as `fz` on `fb1`.`baz_id` = `fz`.`id` ' + + 'left join `foo_bar2` as `fb2` on `fz`.`id` = `fb2`.`baz_id` ' + + 'where `fz`.`name` = ? ' + + 'limit ?'; + expect(qb.getQuery()).toEqual(sql); + expect(qb.getParams()).toEqual(['baz', 1]); + }); + test('select leftJoin 1:1 inverse', async () => { const qb = orm.em.createQueryBuilder(FooBaz2, 'fz'); qb.select(['fb.*', 'fz.*'])