diff --git a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts index 32680e5d3..ec6c5dd7f 100644 --- a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts @@ -1,4 +1,4 @@ -import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito'; import { QueryBuilder, WhereExpression } from 'typeorm'; import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; @@ -82,14 +82,14 @@ describe('FilterQueryBuilder', (): void => { it('should not call whereBuilder#build', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); assertSelectSQL({}, instance(mockWhereBuilder), '', []); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, 'TestEntity')).thenCall( - (where: WhereExpression, field: Filter, alias: string) => { + when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), 'TestEntity')).thenCall( + (where: WhereExpression, field: Filter, relationNames: string[], alias: string) => { return where.andWhere(`${alias}.stringType = 'foo'`); }, ); @@ -101,7 +101,7 @@ describe('FilterQueryBuilder', (): void => { it('should apply empty paging args', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); assertSelectSQL({}, instance(mockWhereBuilder), '', []); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual([]), 'TestEntity')).never(); }); it('should apply paging args going forward', () => { @@ -117,7 +117,7 @@ describe('FilterQueryBuilder', (): void => { ' LIMIT 10 OFFSET 11', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual([]), 'TestEntity')).never(); }); it('should apply paging args going backward', () => { @@ -133,7 +133,7 @@ describe('FilterQueryBuilder', (): void => { ' LIMIT 10 OFFSET 10', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); }); @@ -148,7 +148,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" ASC', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -161,7 +161,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" ASC NULLS FIRST', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -174,7 +174,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" ASC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should apply DESC sorting', () => { @@ -187,7 +187,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" DESC', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -212,7 +212,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" DESC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); it('should apply multiple sorts', () => { @@ -230,7 +230,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "TestEntity"."number_type" ASC, "TestEntity"."bool_type" DESC, "TestEntity"."string_type" ASC NULLS FIRST, "TestEntity"."date_type" DESC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything(), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never(); }); }); }); @@ -240,9 +240,11 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, undefined)).thenCall((where: WhereExpression) => { - return where.andWhere(`stringType = 'foo'`); - }); + when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall( + (where: WhereExpression) => { + return where.andWhere(`stringType = 'foo'`); + }, + ); assertUpdateSQL(query, instance(mockWhereBuilder), ` WHERE "string_type" = 'foo'`, []); }); }); @@ -260,7 +262,7 @@ describe('FilterQueryBuilder', (): void => { '', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); @@ -275,7 +277,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" ASC', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -288,7 +290,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" ASC NULLS FIRST', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -301,7 +303,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" ASC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply DESC sorting', () => { @@ -314,7 +316,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" DESC', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -327,7 +329,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" DESC NULLS FIRST', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_LAST sorting', () => { @@ -340,7 +342,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" DESC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); it('should apply multiple sorts', () => { @@ -358,7 +360,7 @@ describe('FilterQueryBuilder', (): void => { ' ORDER BY "number_type" ASC, "bool_type" DESC, "string_type" ASC NULLS FIRST, "date_type" DESC NULLS LAST', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); }); @@ -368,9 +370,11 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, undefined)).thenCall((where: WhereExpression) => { - return where.andWhere(`stringType = 'foo'`); - }); + when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall( + (where: WhereExpression) => { + return where.andWhere(`stringType = 'foo'`); + }, + ); assertDeleteSQL(query, instance(mockWhereBuilder), ` WHERE "string_type" = 'foo'`, []); }); }); @@ -388,7 +392,7 @@ describe('FilterQueryBuilder', (): void => { '', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); @@ -408,7 +412,7 @@ describe('FilterQueryBuilder', (): void => { '', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); }); @@ -418,9 +422,11 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder: WhereBuilder = mock(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, undefined)).thenCall((where: WhereExpression) => { - return where.andWhere(`stringType = 'foo'`); - }); + when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall( + (where: WhereExpression) => { + return where.andWhere(`stringType = 'foo'`); + }, + ); assertSoftDeleteSQL(query, instance(mockWhereBuilder), ` WHERE "string_type" = 'foo'`, []); }); }); @@ -438,7 +444,7 @@ describe('FilterQueryBuilder', (): void => { '', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); @@ -456,7 +462,7 @@ describe('FilterQueryBuilder', (): void => { '', [], ); - verify(mockWhereBuilder.build(anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); }); }); }); diff --git a/packages/query-typeorm/__tests__/query/where.builder.spec.ts b/packages/query-typeorm/__tests__/query/where.builder.spec.ts index 14f919169..8aafb8c85 100644 --- a/packages/query-typeorm/__tests__/query/where.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/where.builder.spec.ts @@ -22,7 +22,7 @@ describe('WhereBuilder', (): void => { const createWhereBuilder = () => new WhereBuilder(); const assertSQL = (filter: Filter, expectedSql: string, expectedArgs: any[]): void => { - const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, 'TestEntity'); + const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, [], 'TestEntity'); const [sql, params] = selectQueryBuilder.getQueryAndParameters(); expect(sql).toEqual(`${baseQuery}${expectedSql}`); expect(params).toEqual(expectedArgs); diff --git a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts index 0ca3f7f90..dcc5082a0 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -64,6 +64,58 @@ describe('TypeOrmQueryService', (): void => { const queryResult = await queryService.query({ filter: { stringType: { eq: 'foo1' } } }); return expect(queryResult).toEqual([TEST_ENTITIES[0]]); }); + + describe('filter on relations', () => { + describe('oneToOne', () => { + it('should allow filtering on a one to one relation', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.query({ + filter: { + oneTestRelation: { + testRelationPk: { + in: [`test-relations-${entity.testEntityPk}-1`, `test-relations-${entity.testEntityPk}-3`], + }, + }, + }, + }); + expect(queryResult).toEqual([entity]); + }); + }); + + describe('manyToOne', () => { + it('should allow filtering on a many to one relation', async () => { + const queryService = moduleRef.get(TestRelationService); + const queryResults = await queryService.query({ + filter: { + testEntity: { + testEntityPk: { + in: [TEST_ENTITIES[0].testEntityPk, TEST_ENTITIES[1].testEntityPk], + }, + }, + }, + }); + expect(queryResults).toEqual(TEST_RELATIONS.slice(0, 6)); + }); + }); + + describe('oneToMany', () => { + it('should allow filtering on a many to one relation', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + const queryResult = await queryService.query({ + filter: { + testRelations: { + relationName: { + in: [TEST_RELATIONS[0].relationName, TEST_RELATIONS[1].relationName], + }, + }, + }, + }); + expect(queryResult).toEqual([entity]); + }); + }); + }); }); describe('#count', () => { @@ -72,6 +124,52 @@ describe('TypeOrmQueryService', (): void => { const queryResult = await queryService.count({ stringType: { like: 'foo%' } }); return expect(queryResult).toBe(10); }); + + describe('with relations', () => { + describe('oneToOne', () => { + it('should properly count the number pf records with the associated relations', async () => { + const entity = TEST_ENTITIES[0]; + const queryService = moduleRef.get(TestEntityService); + const count = await queryService.count({ + oneTestRelation: { + testRelationPk: { + in: [`test-relations-${entity.testEntityPk}-1`, `test-relations-${entity.testEntityPk}-3`], + }, + }, + }); + expect(count).toEqual(1); + }); + }); + + describe('manyToOne', () => { + it('set the relation to null', async () => { + const queryService = moduleRef.get(TestRelationService); + const count = await queryService.count({ + testEntity: { + testEntityPk: { + in: [TEST_ENTITIES[0].testEntityPk, TEST_ENTITIES[2].testEntityPk], + }, + }, + }); + expect(count).toEqual(6); + }); + }); + + describe('oneToMany', () => { + it('set the relation to null', async () => { + const relation = TEST_RELATIONS[0]; + const queryService = moduleRef.get(TestEntityService); + const count = await queryService.count({ + testRelations: { + testEntityId: { + in: [relation.testEntityId as string], + }, + }, + }); + expect(count).toEqual(1); + }); + }); + }); }); describe('#queryRelations', () => { diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index 8bed2a266..34dd92c8e 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -1,4 +1,4 @@ -import { Filter, Paging, Query, SortField } from '@nestjs-query/core'; +import { Filter, Paging, Query, SortField, getFilterFields } from '@nestjs-query/core'; import { DeleteQueryBuilder, QueryBuilder, @@ -47,6 +47,7 @@ export class FilterQueryBuilder { */ select(query: Query): SelectQueryBuilder { let qb = this.createQueryBuilder(); + qb = this.applyRelationJoins(qb, query.filter); qb = this.applyFilter(qb, query.filter, qb.alias); qb = this.applySorting(qb, query.sorting, qb.alias); qb = this.applyPaging(qb, query.paging); @@ -107,7 +108,7 @@ export class FilterQueryBuilder { if (!filter) { return qb; } - return this.whereBuilder.build(qb, filter, alias); + return this.whereBuilder.build(qb, filter, this.getReferencedRelations(filter), alias); } /** @@ -133,4 +134,24 @@ export class FilterQueryBuilder { private createQueryBuilder(): SelectQueryBuilder { return this.repo.createQueryBuilder(); } + + private applyRelationJoins(qb: SelectQueryBuilder, filter?: Filter): SelectQueryBuilder { + if (!filter) { + return qb; + } + const referencedRelations = this.getReferencedRelations(filter); + return referencedRelations.reduce((rqb, relation) => { + return rqb.leftJoin(`${rqb.alias}.${relation}`, relation); + }, qb); + } + + private getReferencedRelations(filter: Filter): string[] { + const { relationNames } = this; + const referencedFields = getFilterFields(filter); + return referencedFields.filter((f) => relationNames.includes(f)); + } + + private get relationNames(): string[] { + return this.repo.metadata.relations.map((r) => r.propertyName); + } } diff --git a/packages/query-typeorm/src/query/where.builder.ts b/packages/query-typeorm/src/query/where.builder.ts index ad36bd53c..6c8be116d 100644 --- a/packages/query-typeorm/src/query/where.builder.ts +++ b/packages/query-typeorm/src/query/where.builder.ts @@ -15,15 +15,20 @@ export class WhereBuilder { * @param filter - the filter to build the WHERE clause from. * @param alias - optional alias to use to qualify an identifier */ - build(where: Where, filter: Filter, alias?: string): Where { + build( + where: Where, + filter: Filter, + relationNames: string[], + alias?: string, + ): Where { const { and, or } = filter; if (and && and.length) { - this.filterAnd(where, and, alias); + this.filterAnd(where, and, relationNames, alias); } if (or && or.length) { - this.filterOr(where, or, alias); + this.filterOr(where, or, relationNames, alias); } - return this.filterFields(where, filter, alias); + return this.filterFields(where, filter, relationNames, alias); } /** @@ -33,19 +38,29 @@ export class WhereBuilder { * @param filters - the array of filters to AND together * @param alias - optional alias to use to qualify an identifier */ - private filterAnd(where: Where, filters: Filter[], alias?: string): Where { - return filters.reduce((w, f) => w.andWhere(this.createBrackets(f, alias)), where); + private filterAnd( + where: Where, + filters: Filter[], + relationNames: string[], + alias?: string, + ): Where { + return filters.reduce((w, f) => w.andWhere(this.createBrackets(f, relationNames, alias)), where); } /** * ORs multiple filters together. This will properly group every clause to ensure proper precedence. * * @param where - the `typeorm` WhereExpression - * @param filters - the array of filters to OR together + * @param filter - the array of filters to OR together * @param alias - optional alias to use to qualify an identifier */ - private filterOr(where: Where, filter: Filter[], alias?: string): Where { - return filter.reduce((w, f) => where.orWhere(this.createBrackets(f, alias)), where); + private filterOr( + where: Where, + filter: Filter[], + relationNames: string[], + alias?: string, + ): Where { + return filter.reduce((w, f) => where.orWhere(this.createBrackets(f, relationNames, alias)), where); } /** @@ -57,8 +72,8 @@ export class WhereBuilder { * @param filter - the filter to wrap in brackets. * @param alias - optional alias to use to qualify an identifier */ - private createBrackets(filter: Filter, alias?: string): Brackets { - return new Brackets((qb) => this.build(qb, filter, alias)); + private createBrackets(filter: Filter, relationNames: string[], alias?: string): Brackets { + return new Brackets((qb) => this.build(qb, filter, relationNames, alias)); } /** @@ -67,13 +82,19 @@ export class WhereBuilder { * @param filter - the filter with fields to create comparisons for. * @param alias - optional alias to use to qualify an identifier */ - private filterFields(where: Where, filter: Filter, alias?: string): Where { + private filterFields( + where: Where, + filter: Filter, + relationNames: string[], + alias?: string, + ): Where { return Object.keys(filter).reduce((w, field) => { if (field !== 'and' && field !== 'or') { return this.withFilterComparison( where, field as keyof Entity, this.getField(filter, field as keyof Entity), + relationNames, alias, ); } @@ -92,8 +113,12 @@ export class WhereBuilder { where: Where, field: T, cmp: FilterFieldComparison, + relationNames: string[], alias?: string, ): Where { + if (relationNames.includes(field as string)) { + return this.withRelationFilter(where, field, cmp as Filter); + } return where.andWhere( new Brackets((qb) => { const opts = Object.keys(cmp) as FilterComparisonOperators[]; @@ -104,4 +129,18 @@ export class WhereBuilder { }), ); } + + private withRelationFilter( + where: Where, + field: T, + cmp: Filter, + ): Where { + return where.andWhere( + new Brackets((qb) => { + const relationWhere = new WhereBuilder(); + // for now ignore relations of relations. + return relationWhere.build(qb, cmp, [], field as string); + }), + ); + } }