diff --git a/packages/core/src/EntityManager.ts b/packages/core/src/EntityManager.ts index 79709b8dab76..8bac4759bd3e 100644 --- a/packages/core/src/EntityManager.ts +++ b/packages/core/src/EntityManager.ts @@ -79,7 +79,7 @@ export class EntityManager { */ async find(entityName: EntityName, where: FilterQuery, populate?: Populate | FindOptions, orderBy?: QueryOrderMap, limit?: number, offset?: number): Promise { entityName = Utils.className(entityName); - where = SmartQueryHelper.processWhere(where, entityName, this.metadata.get(entityName)); + where = SmartQueryHelper.processWhere(where, entityName, this.metadata); this.validator.validateParams(where); const options = Utils.isObject>(populate) ? populate : { populate, orderBy, limit, offset }; options.orderBy = options.orderBy || {}; @@ -146,7 +146,7 @@ export class EntityManager { const options = Utils.isObject>(populate) ? populate : { populate, orderBy }; const meta = this.metadata.get(entityName); this.validator.validateEmptyWhere(where); - where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, meta); + where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, this.metadata); this.checkLockRequirements(options.lockMode, meta); let entity = this.getUnitOfWork().tryGetById(entityName, where); const isOptimisticLocking = !Utils.isDefined(options.lockMode) || options.lockMode === LockMode.OPTIMISTIC; @@ -240,7 +240,7 @@ export class EntityManager { async nativeUpdate(entityName: EntityName, where: FilterQuery, data: EntityData): Promise { entityName = Utils.className(entityName); data = SmartQueryHelper.processParams(data); - where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, this.metadata.get(entityName, false, false)); + where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, this.metadata); this.validator.validateParams(data, 'update data'); this.validator.validateParams(where, 'update condition'); const res = await this.driver.nativeUpdate(entityName, where, data, this.transactionContext); @@ -253,7 +253,7 @@ export class EntityManager { */ async nativeDelete(entityName: EntityName, where: FilterQuery): Promise { entityName = Utils.className(entityName); - where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, this.metadata.get(entityName, false, false)); + where = SmartQueryHelper.processWhere(where as FilterQuery, entityName, this.metadata); this.validator.validateParams(where, 'delete condition'); const res = await this.driver.nativeDelete(entityName, where, this.transactionContext); @@ -366,7 +366,7 @@ export class EntityManager { */ async count(entityName: EntityName, where: FilterQuery = {}): Promise { entityName = Utils.className(entityName); - where = SmartQueryHelper.processWhere(where, entityName, this.metadata.get(entityName)); + where = SmartQueryHelper.processWhere(where, entityName, this.metadata); this.validator.validateParams(where); return this.driver.count(entityName, where, this.transactionContext); diff --git a/packages/core/src/utils/SmartQueryHelper.ts b/packages/core/src/utils/SmartQueryHelper.ts index ba6b66c7e285..18d40f9ffa78 100644 --- a/packages/core/src/utils/SmartQueryHelper.ts +++ b/packages/core/src/utils/SmartQueryHelper.ts @@ -2,6 +2,7 @@ import { Reference, wrap } from '../entity'; import { Utils } from './Utils'; import { AnyEntity, Dictionary, EntityMetadata, FilterQuery } from '../typings'; import { GroupOperator } from '../enums'; +import { MetadataStorage } from '../metadata'; export class SmartQueryHelper { @@ -33,12 +34,47 @@ export class SmartQueryHelper { return params; } - static processWhere>(where: FilterQuery, entityName: string, meta?: EntityMetadata): FilterQuery { + static inlinePrimaryKeyObjects>(where: Dictionary, meta: EntityMetadata, metadata: MetadataStorage, key?: string): boolean { + if (Array.isArray(where)) { + where.forEach((item, i) => { + if (this.inlinePrimaryKeyObjects(item, meta, metadata, key)) { + where[i] = Utils.getPrimaryKeyValues(item, meta.primaryKeys, false); + } + }); + } + + if (!Utils.isPlainObject(where)) { + return false; + } + + if (meta.primaryKeys.every(pk => pk in where) && Object.keys(where).length === meta.primaryKeys.length) { + return !GroupOperator[key as string] && Object.keys(where).every(k => !Utils.isPlainObject(where[k]) || Object.keys(where[k]).every(v => !Utils.isOperator(v, false))); + } + + Object.keys(where).forEach(k => { + const meta2 = metadata.get(meta.properties[k]?.type, false, false) || meta; + + if (this.inlinePrimaryKeyObjects(where[k], meta2, metadata, k)) { + where[k] = Utils.getPrimaryKeyValues(where[k], meta2.primaryKeys, true); + } + }); + + return false; + } + + static processWhere>(where: FilterQuery, entityName: string, metadata: MetadataStorage): FilterQuery { + const meta = metadata.get(entityName, false, false); + + // inline PK-only objects in M:N queries so we don't join the target entity when not needed + if (meta) { + SmartQueryHelper.inlinePrimaryKeyObjects(where as Dictionary, meta, metadata); + } + where = SmartQueryHelper.processParams(where, true) || {}; if (Array.isArray(where)) { const rootPrimaryKey = meta ? Utils.getPrimaryKeyHash(meta.primaryKeys) : entityName; - return { [rootPrimaryKey]: { $in: (where as FilterQuery[]).map(sub => SmartQueryHelper.processWhere(sub, entityName, meta)) } } as FilterQuery; + return { [rootPrimaryKey]: { $in: (where as FilterQuery[]).map(sub => SmartQueryHelper.processWhere(sub, entityName, metadata)) } } as FilterQuery; } if (!Utils.isPlainObject(where) || Utils.isPrimaryKey(where)) { @@ -50,7 +86,7 @@ export class SmartQueryHelper { const composite = meta?.properties[key]?.joinColumns?.length > 1; if (key in GroupOperator) { - o[key] = value.map((sub: any) => SmartQueryHelper.processWhere(sub, entityName, meta)); + o[key] = value.map((sub: any) => SmartQueryHelper.processWhere(sub, entityName, metadata)); return o; } diff --git a/packages/knex/src/query/QueryBuilder.ts b/packages/knex/src/query/QueryBuilder.ts index 6bda5dc14608..e22f9777dc14 100644 --- a/packages/knex/src/query/QueryBuilder.ts +++ b/packages/knex/src/query/QueryBuilder.ts @@ -104,7 +104,7 @@ export class QueryBuilder = AnyEntity> { where(cond: QBFilterQuery, operator?: keyof typeof GroupOperator): this; where(cond: string, params?: any[], operator?: keyof typeof GroupOperator): this; where(cond: QBFilterQuery | string, params?: keyof typeof GroupOperator | any[], operator?: keyof typeof GroupOperator): this { - cond = SmartQueryHelper.processWhere(cond as Dictionary, this.entityName, this.metadata.get(this.entityName, false, false))!; + cond = SmartQueryHelper.processWhere(cond as Dictionary, this.entityName, this.metadata)!; if (Utils.isString(cond)) { cond = { [`(${cond})`]: Utils.asArray(params) }; @@ -146,6 +146,7 @@ export class QueryBuilder = AnyEntity> { } orderBy(orderBy: QueryOrderMap): this { + orderBy = SmartQueryHelper.processWhere(orderBy as Dictionary, this.entityName, this.metadata) as QueryOrderMap; this._orderBy = CriteriaNode.create(this.metadata, this.entityName, orderBy).process(this); return this; } @@ -336,7 +337,7 @@ export class QueryBuilder = AnyEntity> { const entityName = this._aliasMap[fromAlias]; const prop = this.metadata.get(entityName).properties[fromField]; this._aliasMap[alias] = prop.type; - cond = SmartQueryHelper.processWhere(cond, this.entityName, this.metadata.get(this.entityName))!; + cond = SmartQueryHelper.processWhere(cond, this.entityName, this.metadata)!; const aliasedName = `${fromAlias}.${prop.name}`; if (prop.reference === ReferenceType.ONE_TO_MANY) { diff --git a/tests/QueryBuilder.test.ts b/tests/QueryBuilder.test.ts index 6b18c4d0613f..4f98dd9a3d48 100644 --- a/tests/QueryBuilder.test.ts +++ b/tests/QueryBuilder.test.ts @@ -77,7 +77,7 @@ describe('QueryBuilder', () => { test('select query with auto-joined composite key entity', async () => { const qb1 = orm.em.createQueryBuilder(CarOwner2); qb1.select('*').where({ car: { name: 'Audi A8', year: 2010 } }); - expect(qb1.getQuery()).toEqual('select `e0`.* from `car_owner2` as `e0` left join `car2` as `e1` on `e0`.`car_name` = `e1`.`name` and `e0`.`car_year` = `e1`.`year` where `e1`.`name` = ? and `e1`.`year` = ?'); + expect(qb1.getQuery()).toEqual('select `e0`.* from `car_owner2` as `e0` where (`e0`.`car_name`, `e0`.`car_year`) = (?, ?)'); expect(qb1.getParams()).toEqual(['Audi A8', 2010]); const qb2 = orm.em.createQueryBuilder(CarOwner2); @@ -1328,6 +1328,44 @@ describe('QueryBuilder', () => { expect(qb4.getParams()).toEqual([]); }); + 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 } }); + 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 } }); + 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 }] } }); + 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 } }] }); + 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 } }); + expect(qb4.getQuery()).toMatch('select `a`.* ' + + 'from `author2` as `a` ' + + 'left join `author_to_friend` as `e1` on `a`.`id` = `e1`.`author2_1_id` ' + + 'where `e1`.`author2_2_id` = ? ' + + 'order by `e1`.`author2_2_id` asc'); + expect(qb4.getParams()).toEqual([1]); + }); + afterAll(async () => orm.close(true)); }); diff --git a/tests/SmartQueryHelper.test.ts b/tests/SmartQueryHelper.test.ts index 43f29d771e47..61143c0f4dfe 100644 --- a/tests/SmartQueryHelper.test.ts +++ b/tests/SmartQueryHelper.test.ts @@ -18,7 +18,7 @@ describe('SmartQueryHelper', () => { 'key4<=': 123, 'key5!=': 123, 'key6!': 123, - }, 'id')).toEqual({ + }, 'id', orm.getMetadata())).toEqual({ key1: { $gt: 123 }, key2: { $lt: 123 }, key3: { $gte: 123 }, @@ -33,7 +33,7 @@ describe('SmartQueryHelper', () => { 'key4 <=': 123, 'key5 !=': 123, 'key6 !': 123, - }, 'id')).toEqual({ + }, 'id', orm.getMetadata())).toEqual({ key1: { $gt: 123 }, key2: { $lt: 123 }, key3: { $gte: 123 }, @@ -53,7 +53,7 @@ describe('SmartQueryHelper', () => { 'key6:not': 123, 'key7:in': [123], 'key8:nin': [123], - }, 'id')).toEqual({ + }, 'id', orm.getMetadata())).toEqual({ key1: { $gt: 123 }, key2: { $lt: 123 }, key3: { $gte: 123 }, @@ -66,7 +66,7 @@ describe('SmartQueryHelper', () => { }); test('processWhere returns empty object for undefined condition', async () => { - expect(SmartQueryHelper.processWhere(undefined as any, 'id')).toEqual({}); + expect(SmartQueryHelper.processWhere(undefined as any, 'id', orm.getMetadata())).toEqual({}); }); test('test entity conversion to PK', async () => { @@ -102,10 +102,10 @@ describe('SmartQueryHelper', () => { const book1 = new Book2('b1', author); const book2 = new Book2('b2', author); const book3 = new Book2('b3', author); - expect(SmartQueryHelper.processWhere([1, 2, 3], 'uuid')).toEqual({ uuid: { $in: [1, 2, 3] } }); - expect(SmartQueryHelper.processWhere([book1, book2, book3], 'uuid')).toEqual({ uuid: { $in: [book1.uuid, book2.uuid, book3.uuid] } }); - expect(SmartQueryHelper.processWhere({ favouriteBook: ['1', '2', '3'] }, 'id')).toEqual({ favouriteBook: { $in: ['1', '2', '3'] } }); - expect(SmartQueryHelper.processWhere({ $or: [{ author: [1, 2, 3] }, { author: [7, 8, 9] }] }, 'id')).toEqual({ + expect(SmartQueryHelper.processWhere([1, 2, 3], 'uuid', orm.getMetadata())).toEqual({ uuid: { $in: [1, 2, 3] } }); + expect(SmartQueryHelper.processWhere([book1, book2, book3], 'uuid', orm.getMetadata())).toEqual({ uuid: { $in: [book1.uuid, book2.uuid, book3.uuid] } }); + expect(SmartQueryHelper.processWhere({ favouriteBook: ['1', '2', '3'] }, 'id', orm.getMetadata())).toEqual({ favouriteBook: { $in: ['1', '2', '3'] } }); + expect(SmartQueryHelper.processWhere({ $or: [{ author: [1, 2, 3] }, { author: [7, 8, 9] }] }, 'id', orm.getMetadata())).toEqual({ $or: [{ author: { $in: [1, 2, 3] } }, { author: { $in: [7, 8, 9] } }], }); });