diff --git a/package.json b/package.json index 4ad9318d4f..84482b8692 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "lint": "eslint --cache --ext=.ts .", "lint:no-cache": "eslint --ext=.ts .", "lint:fix": "eslint --fix --ext=.ts .", - "jest": "jest --runInBand --coverage", - "jest:e2e": "jest --runInBand --config=./jest.e2e.config.js", - "jest:unit": "jest --coverage --config=./jest.unit.config.js", + "jest": "TZ=UTC jest --runInBand --coverage", + "jest:e2e": "TZ=UTC jest --runInBand --config=./jest.e2e.config.js", + "jest:unit": "TZ=UTC jest --coverage --config=./jest.unit.config.js", "coverage": "cat ./coverage/lcov.info | coveralls", "prepare": "husky install" }, diff --git a/packages/core/src/helpers/filter.builder.ts b/packages/core/src/helpers/filter.builder.ts index 7b4f7e9a43..e2383f4f98 100644 --- a/packages/core/src/helpers/filter.builder.ts +++ b/packages/core/src/helpers/filter.builder.ts @@ -58,6 +58,6 @@ export class FilterBuilder { throw new Error(`unknown comparison ${JSON.stringify(fieldOrNested)}`); } const nestedFilterFn = this.build(value); - return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : null); + return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : undefined); } } diff --git a/packages/core/src/interfaces/filter.interface.ts b/packages/core/src/interfaces/filter.interface.ts index 1b63d95ecd..263d3f15d9 100644 --- a/packages/core/src/interfaces/filter.interface.ts +++ b/packages/core/src/interfaces/filter.interface.ts @@ -111,5 +111,6 @@ type FilterGrouping = { * ``` * * @typeparam T - the type of object to filter on. + * @typeparam C - custom filters defined on the object. */ -export type Filter = FilterGrouping & FilterComparisons; +export type Filter> = FilterGrouping & FilterComparisons & { [K in keyof C]: C[K] }; diff --git a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts index e17750ecc7..338158070a 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts @@ -16,8 +16,13 @@ export const CONNECTION_OPTIONS: ConnectionOptions = { logging: false, }; -export function createTestConnection(): Promise { - return createConnection(CONNECTION_OPTIONS); +// eslint-disable-next-line @typescript-eslint/ban-types +export function createTestConnection(opts?: { extraEntities: (string | Function)[] }): Promise { + const connOpts: ConnectionOptions = { + ...CONNECTION_OPTIONS, + entities: [...(CONNECTION_OPTIONS.entities || []), ...(opts?.extraEntities ?? [])], + }; + return createConnection(connOpts); } export function closeTestConnection(): Promise { diff --git a/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts b/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts new file mode 100644 index 0000000000..0064df3e26 --- /dev/null +++ b/packages/query-typeorm/__tests__/__fixtures__/custom-filters.services.ts @@ -0,0 +1,63 @@ +import { ColumnType } from 'typeorm'; +import { randomString } from '../../src/common'; +import { CustomFilter, CustomFilterResult } from '../../src/query/custom-filter.registry'; +import { TypeOrmQueryFilter } from '../../src/decorators/typeorm-query-filter.decorator'; + +type IsMultipleOfOpType = 'isMultipleOf'; + +@TypeOrmQueryFilter() +export class IsMultipleOfCustomFilter implements CustomFilter { + readonly operations: IsMultipleOfOpType[] = ['isMultipleOf']; + + readonly types: ColumnType[] = [Number, 'integer']; + + apply(field: string, cmp: IsMultipleOfOpType, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(${alias}.${field} % :${pname}) == 0`, + params: { [pname]: val }, + }; + } +} + +@TypeOrmQueryFilter() +export class IsMultipleOfDateCustomFilter implements CustomFilter { + readonly operations: IsMultipleOfOpType[] = ['isMultipleOf']; + + readonly types: ColumnType[] = [Date, 'date', 'datetime']; + + apply(field: string, cmp: IsMultipleOfOpType, val: unknown, alias?: string): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(EXTRACT(EPOCH FROM ${alias}.${field}) / 3600 / 24) % :${pname}) == 0`, + params: { [pname]: val }, + }; + } +} + +type RadiusCustomFilterOp = 'distanceFrom'; + +@TypeOrmQueryFilter({ + autoRegister: false, +}) +export class RadiusCustomFilter implements CustomFilter { + readonly operations: RadiusCustomFilterOp[] = ['distanceFrom']; + + apply( + field: string, + cmp: RadiusCustomFilterOp, + val: { point: { lat: number; lng: number }; radius: number }, + alias?: string, + ): CustomFilterResult { + alias = alias ? alias : ''; + const plat = `param${randomString()}`; + const plng = `param${randomString()}`; + const prad = `param${randomString()}`; + return { + sql: `ST_Distance(${alias}.${field}, ST_MakePoint(:${plat},:${plng})) <= :${prad}`, + params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius }, + }; + } +} diff --git a/packages/query-typeorm/__tests__/__fixtures__/seeds.ts b/packages/query-typeorm/__tests__/__fixtures__/seeds.ts index 1876c6fa7f..17ffb60e19 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/seeds.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/seeds.ts @@ -23,30 +23,33 @@ export const TEST_SOFT_DELETE_ENTITIES: TestSoftDeleteEntity[] = [1, 2, 3, 4, 5, }; }); -export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce( - (relations, te) => [ +// Generate different numberTypes so we can use them for filters later on +export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce((relations, te) => { + return [ ...relations, { testRelationPk: `test-relations-${te.testEntityPk}-1`, relationName: `${te.stringType}-test-relation-one`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 1, }, { testRelationPk: `test-relations-${te.testEntityPk}-2`, relationName: `${te.stringType}-test-relation-two`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 2, }, { testRelationPk: `test-relations-${te.testEntityPk}-3`, relationName: `${te.stringType}-test-relation-three`, testEntityId: te.testEntityPk, uniDirectionalTestEntityId: te.testEntityPk, + numberType: te.numberType * 10 + 3, }, - ], - [] as TestRelation[], -); + ]; +}, [] as TestRelation[]); export const TEST_RELATIONS_OF_RELATION = TEST_RELATIONS.map>((testRelation) => ({ relationName: `test-relation-of-${testRelation.relationName}`, diff --git a/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts b/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts index 0e40cf7d41..8009feea39 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts @@ -1,4 +1,4 @@ -import { ManyToOne, Column, Entity, JoinColumn, ManyToMany, OneToOne, OneToMany, PrimaryColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; import { TestEntityRelationEntity } from './test-entity-relation.entity'; import { TestEntity } from './test.entity'; import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity'; @@ -17,6 +17,9 @@ export class TestRelation { @Column({ name: 'uni_directional_test_entity_id', nullable: true }) uniDirectionalTestEntityId?: string; + @Column({ name: 'number_type' }) + numberType?: number; + @ManyToOne(() => TestEntity, (te) => te.testRelations, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'test_entity_id' }) testEntity?: TestEntity; diff --git a/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts b/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts index 632d466936..da1408ac59 100644 --- a/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts +++ b/packages/query-typeorm/__tests__/__fixtures__/test.entity.ts @@ -1,8 +1,14 @@ -import { Column, Entity, OneToMany, ManyToMany, JoinTable, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne, PrimaryColumn } from 'typeorm'; +import { WithTypeormQueryFilter } from '../../src/decorators/with-typeorm-entity-query-filter.decorator'; +import { RadiusCustomFilter } from './custom-filters.services'; import { TestEntityRelationEntity } from './test-entity-relation.entity'; import { TestRelation } from './test-relation.entity'; @Entity() +@WithTypeormQueryFilter({ + filter: RadiusCustomFilter, + fields: ['fakePointType'], +}) export class TestEntity { @PrimaryColumn({ name: 'test_entity_pk' }) testEntityPk!: string; diff --git a/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts b/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts new file mode 100644 index 0000000000..9566b4b136 --- /dev/null +++ b/packages/query-typeorm/__tests__/metadata/typeorm-metadata.spec.ts @@ -0,0 +1,73 @@ +import { Column, Entity, PrimaryColumn } from 'typeorm'; +import { getQueryTypeormMetadata, QueryTypeormEntityMetadata } from '../../src/common'; +import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; +import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; +import { TestRelation } from '../__fixtures__/test-relation.entity'; +import { TestEntity } from '../__fixtures__/test.entity'; + +describe('TypeormMetadata', (): void => { + class TestEmbedded { + @Column({ type: 'text' }) + stringType!: string; + + @Column({ type: 'boolean' }) + boolType!: boolean; + } + + @Entity() + class TestMetadataEntity { + @PrimaryColumn({ type: 'text' }) + pk!: string; + + @Column({ type: 'text' }) + stringType!: string; + + @Column({ type: 'boolean' }) + boolType!: boolean; + + @Column({ type: 'integer' }) + numberType!: number; + + @Column({ type: 'date' }) + dateType!: Date; + + @Column({ type: 'datetime' }) + datetimeType!: Date; + + @Column({ type: 'simple-json' }) + jsonType!: any; + + @Column(() => TestEmbedded) + embeddedType!: TestEmbedded; + } + + beforeEach(() => createTestConnection({ extraEntities: [TestMetadataEntity] })); + afterEach(() => closeTestConnection()); + + it('Test metadata', (): void => { + const meta = getQueryTypeormMetadata(getTestConnection()); + console.log(meta); + // Implicit column types + expect(meta.get(TestEntity)).toMatchObject({ + testEntityPk: { metaType: 'property', type: String }, + stringType: { metaType: 'property', type: String }, + dateType: { metaType: 'property', type: Date }, + boolType: { metaType: 'property', type: Boolean }, + oneTestRelation: { metaType: 'relation', type: TestRelation }, + testRelations: { metaType: 'relation', type: TestRelation }, + manyTestRelations: { metaType: 'relation', type: TestRelation }, + manyToManyUniDirectional: { metaType: 'relation', type: TestRelation }, + testEntityRelation: { metaType: 'relation', type: TestEntityRelationEntity }, + } as QueryTypeormEntityMetadata); + // Explicit column types + expect(meta.get(TestMetadataEntity)).toMatchObject({ + pk: { metaType: 'property', type: 'text' }, + stringType: { metaType: 'property', type: 'text' }, + boolType: { metaType: 'property', type: 'boolean' }, + numberType: { metaType: 'property', type: 'integer' }, + dateType: { metaType: 'property', type: 'date' }, + datetimeType: { metaType: 'property', type: 'datetime' }, + jsonType: { metaType: 'property', type: 'simple-json' }, + } as QueryTypeormEntityMetadata); + }); +}); diff --git a/packages/query-typeorm/__tests__/module.spec.ts b/packages/query-typeorm/__tests__/module.spec.ts index 5575ce4138..59888e9bfc 100644 --- a/packages/query-typeorm/__tests__/module.spec.ts +++ b/packages/query-typeorm/__tests__/module.spec.ts @@ -3,10 +3,11 @@ import { NestjsQueryTypeOrmModule } from '../src'; describe('NestjsQueryTypeOrmModule', () => { it('should create a module', () => { class TestEntity {} + const typeOrmModule = NestjsQueryTypeOrmModule.forFeature([TestEntity]); expect(typeOrmModule.imports).toHaveLength(1); expect(typeOrmModule.module).toBe(NestjsQueryTypeOrmModule); - expect(typeOrmModule.providers).toHaveLength(1); + expect(typeOrmModule.providers).toHaveLength(3); expect(typeOrmModule.exports).toHaveLength(2); }); }); diff --git a/packages/query-typeorm/__tests__/providers.spec.ts b/packages/query-typeorm/__tests__/providers.spec.ts index a82289a5ca..96b942dc1f 100644 --- a/packages/query-typeorm/__tests__/providers.spec.ts +++ b/packages/query-typeorm/__tests__/providers.spec.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { mock, instance } from 'ts-mockito'; import { createTypeOrmQueryServiceProviders } from '../src/providers'; import { TypeOrmQueryService } from '../src/services'; +import { CustomFilterRegistry } from '../src/query'; describe('createTypeOrmQueryServiceProviders', () => { it('should create a provider for the entity', () => { @@ -12,7 +13,7 @@ describe('createTypeOrmQueryServiceProviders', () => { const providers = createTypeOrmQueryServiceProviders([TestEntity]); expect(providers).toHaveLength(1); expect(providers[0].provide).toBe(getQueryServiceToken(TestEntity)); - expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity)]); + expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity), CustomFilterRegistry]); expect(providers[0].useFactory(instance(mockRepo))).toBeInstanceOf(TypeOrmQueryService); }); }); diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap index e3e27b0f5e..298eb88d41 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap @@ -12,6 +12,34 @@ exports[`FilterQueryBuilder #delete with sorting should ignore sorting 1`] = `DE exports[`FilterQueryBuilder #delete with sorting should ignore sorting 2`] = `Array []`; +exports[`FilterQueryBuilder #select with custom filter should add custom filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 1`] = `SELECT MAX("TestEntity"."number_type") AS "MAX_numberType" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE "TestEntity"."string_type" = 'foo'`; exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a filter 2`] = `Array []`; diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap index 85b0f109a7..b5b9fbaeb3 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/relation-query.builder.spec.ts.snap @@ -16,7 +16,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 1`] = `SELECT "manyTestRelations"."test_relation_pk" AS "manyTestRelations_test_relation_pk", "manyTestRelations"."relation_name" AS "manyTestRelations_relation_name", "manyTestRelations"."test_entity_id" AS "manyTestRelations_test_entity_id", "manyTestRelations"."uni_directional_test_entity_id" AS "manyTestRelations_uni_directional_test_entity_id", "manyTestRelations"."uni_directional_relation_test_entity_id" AS "manyTestRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "manyTestRelations" INNER JOIN "test_entity_many_test_relations_test_relation" "test_entity_many_test_relations_test_relation" ON "test_entity_many_test_relations_test_relation"."testRelationTestRelationPk" = "manyTestRelations"."test_relation_pk" WHERE ("test_entity_many_test_relations_test_relation"."testEntityTestEntityPk" = ?)`; +exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 1`] = `SELECT "manyTestRelations"."test_relation_pk" AS "manyTestRelations_test_relation_pk", "manyTestRelations"."relation_name" AS "manyTestRelations_relation_name", "manyTestRelations"."test_entity_id" AS "manyTestRelations_test_entity_id", "manyTestRelations"."uni_directional_test_entity_id" AS "manyTestRelations_uni_directional_test_entity_id", "manyTestRelations"."number_type" AS "manyTestRelations_number_type", "manyTestRelations"."uni_directional_relation_test_entity_id" AS "manyTestRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "manyTestRelations" INNER JOIN "test_entity_many_test_relations_test_relation" "test_entity_many_test_relations_test_relation" ON "test_entity_many_test_relations_test_relation"."testRelationTestRelationPk" = "manyTestRelations"."test_relation_pk" WHERE ("test_entity_many_test_relations_test_relation"."testEntityTestEntityPk" = ?)`; exports[`RelationQueryBuilder #select many to many on owning side should work with one entity 2`] = ` Array [ @@ -24,7 +24,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 1`] = `SELECT "manyToManyUniDirectional"."test_relation_pk" AS "manyToManyUniDirectional_test_relation_pk", "manyToManyUniDirectional"."relation_name" AS "manyToManyUniDirectional_relation_name", "manyToManyUniDirectional"."test_entity_id" AS "manyToManyUniDirectional_test_entity_id", "manyToManyUniDirectional"."uni_directional_test_entity_id" AS "manyToManyUniDirectional_uni_directional_test_entity_id", "manyToManyUniDirectional"."uni_directional_relation_test_entity_id" AS "manyToManyUniDirectional_uni_directional_relation_test_entity_id" FROM "test_relation" "manyToManyUniDirectional" INNER JOIN "test_entity_many_to_many_uni_directional_test_relation" "test_entity_many_to_many_uni_directional_test_relation" ON "test_entity_many_to_many_uni_directional_test_relation"."testRelationTestRelationPk" = "manyToManyUniDirectional"."test_relation_pk" WHERE ("test_entity_many_to_many_uni_directional_test_relation"."testEntityTestEntityPk" = ?)`; +exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 1`] = `SELECT "manyToManyUniDirectional"."test_relation_pk" AS "manyToManyUniDirectional_test_relation_pk", "manyToManyUniDirectional"."relation_name" AS "manyToManyUniDirectional_relation_name", "manyToManyUniDirectional"."test_entity_id" AS "manyToManyUniDirectional_test_entity_id", "manyToManyUniDirectional"."uni_directional_test_entity_id" AS "manyToManyUniDirectional_uni_directional_test_entity_id", "manyToManyUniDirectional"."number_type" AS "manyToManyUniDirectional_number_type", "manyToManyUniDirectional"."uni_directional_relation_test_entity_id" AS "manyToManyUniDirectional_uni_directional_relation_test_entity_id" FROM "test_relation" "manyToManyUniDirectional" INNER JOIN "test_entity_many_to_many_uni_directional_test_relation" "test_entity_many_to_many_uni_directional_test_relation" ON "test_entity_many_to_many_uni_directional_test_relation"."testRelationTestRelationPk" = "manyToManyUniDirectional"."test_relation_pk" WHERE ("test_entity_many_to_many_uni_directional_test_relation"."testEntityTestEntityPk" = ?)`; exports[`RelationQueryBuilder #select many to many uni-directional many to many should create the correct sql 2`] = ` Array [ @@ -48,7 +48,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select one to many should query with a single entity 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?)`; +exports[`RelationQueryBuilder #select one to many should query with a single entity 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?)`; exports[`RelationQueryBuilder #select one to many should query with a single entity 2`] = ` Array [ @@ -64,7 +64,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select one to one on owning side 1`] = `SELECT "oneTestRelation"."test_relation_pk" AS "oneTestRelation_test_relation_pk", "oneTestRelation"."relation_name" AS "oneTestRelation_relation_name", "oneTestRelation"."test_entity_id" AS "oneTestRelation_test_entity_id", "oneTestRelation"."uni_directional_test_entity_id" AS "oneTestRelation_uni_directional_test_entity_id", "oneTestRelation"."uni_directional_relation_test_entity_id" AS "oneTestRelation_uni_directional_relation_test_entity_id" FROM "test_relation" "oneTestRelation" INNER JOIN "test_entity" "TestEntity" ON "TestEntity"."oneTestRelationTestRelationPk" = "oneTestRelation"."test_relation_pk" WHERE ("TestEntity"."test_entity_pk" = ?)`; +exports[`RelationQueryBuilder #select one to one on owning side 1`] = `SELECT "oneTestRelation"."test_relation_pk" AS "oneTestRelation_test_relation_pk", "oneTestRelation"."relation_name" AS "oneTestRelation_relation_name", "oneTestRelation"."test_entity_id" AS "oneTestRelation_test_entity_id", "oneTestRelation"."uni_directional_test_entity_id" AS "oneTestRelation_uni_directional_test_entity_id", "oneTestRelation"."number_type" AS "oneTestRelation_number_type", "oneTestRelation"."uni_directional_relation_test_entity_id" AS "oneTestRelation_uni_directional_relation_test_entity_id" FROM "test_relation" "oneTestRelation" INNER JOIN "test_entity" "TestEntity" ON "TestEntity"."oneTestRelationTestRelationPk" = "oneTestRelation"."test_relation_pk" WHERE ("TestEntity"."test_entity_pk" = ?)`; exports[`RelationQueryBuilder #select one to one on owning side 2`] = ` Array [ @@ -72,7 +72,22 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."relation_name" = ?)`; +exports[`RelationQueryBuilder #select with custom filters should accept custom filters 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."number_type" >= ? OR "testRelations"."number_type" <= ? OR ("testRelations"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM testRelations.dateType) / 3600 / 24) % ?) == 0) AND (ST_Distance(testRelations.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`RelationQueryBuilder #select with custom filters should accept custom filters 2`] = ` +Array [ + test-entity-id-1, + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + +exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) AND ("testRelations"."relation_name" = ?)`; exports[`RelationQueryBuilder #select with filter should call whereBuilder#build if there is a filter 2`] = ` Array [ @@ -81,7 +96,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with paging should apply paging args going backward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 10`; +exports[`RelationQueryBuilder #select with paging should apply paging args going backward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 10`; exports[`RelationQueryBuilder #select with paging should apply paging args going backward 2`] = ` Array [ @@ -89,7 +104,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with paging should apply paging args going forward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 11`; +exports[`RelationQueryBuilder #select with paging should apply paging args going forward 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) LIMIT 10 OFFSET 11`; exports[`RelationQueryBuilder #select with paging should apply paging args going forward 2`] = ` Array [ @@ -97,7 +112,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS FIRST`; +exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS FIRST`; exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_FIRST sorting 2`] = ` Array [ @@ -105,7 +120,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS LAST`; +exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC NULLS LAST`; exports[`RelationQueryBuilder #select with sorting should apply ASC NULLS_LAST sorting 2`] = ` Array [ @@ -113,7 +128,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC`; +exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC`; exports[`RelationQueryBuilder #select with sorting should apply ASC sorting 2`] = ` Array [ @@ -121,7 +136,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS FIRST`; +exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS FIRST`; exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_FIRST sorting 2`] = ` Array [ @@ -129,7 +144,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS LAST`; +exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC NULLS LAST`; exports[`RelationQueryBuilder #select with sorting should apply DESC NULLS_LAST sorting 2`] = ` Array [ @@ -137,7 +152,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC`; +exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" DESC`; exports[`RelationQueryBuilder #select with sorting should apply DESC sorting 2`] = ` Array [ @@ -145,7 +160,7 @@ Array [ ] `; -exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC, "testRelations"."test_relation_pk" DESC`; +exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 1`] = `SELECT "testRelations"."test_relation_pk" AS "testRelations_test_relation_pk", "testRelations"."relation_name" AS "testRelations_relation_name", "testRelations"."test_entity_id" AS "testRelations_test_entity_id", "testRelations"."uni_directional_test_entity_id" AS "testRelations_uni_directional_test_entity_id", "testRelations"."number_type" AS "testRelations_number_type", "testRelations"."uni_directional_relation_test_entity_id" AS "testRelations_uni_directional_relation_test_entity_id" FROM "test_relation" "testRelations" WHERE ("testRelations"."test_entity_id" = ?) ORDER BY "testRelations"."relation_name" ASC, "testRelations"."test_relation_pk" DESC`; exports[`RelationQueryBuilder #select with sorting should apply multiple sorts 2`] = ` Array [ diff --git a/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap b/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap index a0618ae704..c3bd81bfe4 100644 --- a/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap +++ b/packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap @@ -11,6 +11,22 @@ Array [ ] `; +exports[`WhereBuilder and and multiple expressions together with custom filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?)) AND (("TestEntity"."number_type" < ?)) AND (("TestEntity"."number_type" >= ?)) AND (("TestEntity"."number_type" <= ?)) AND ((("TestEntity"."number_type" % ?) == 0)) AND (((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0)) AND ((ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)))`; + +exports[`WhereBuilder and and multiple expressions together with custom filters 2`] = ` +Array [ + 10, + 20, + 30, + 40, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`WhereBuilder and and multiple filters together with multiple fields 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?) AND ("TestEntity"."string_type" LIKE ?)) AND (("TestEntity"."number_type" < ?) AND ("TestEntity"."string_type" LIKE ?)))`; exports[`WhereBuilder and and multiple filters together with multiple fields 2`] = ` @@ -85,6 +101,22 @@ Array [ ] `; +exports[`WhereBuilder or or multiple expressions together with custom filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" > ?)) OR (("TestEntity"."number_type" < ?)) OR (("TestEntity"."number_type" >= ?)) OR (("TestEntity"."number_type" <= ?)) OR ((("TestEntity"."number_type" % ?) == 0)) OR (((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0)) OR ((ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)))`; + +exports[`WhereBuilder or or multiple expressions together with custom filter 2`] = ` +Array [ + 10, + 20, + 30, + 40, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; + exports[`WhereBuilder or should properly group OR with a sibling field comparison 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ((("TestEntity"."number_type" = ?)) OR (("TestEntity"."number_type" > ?))) AND ("TestEntity"."string_type" = ?)`; exports[`WhereBuilder or should properly group OR with a sibling field comparison 2`] = ` @@ -109,3 +141,17 @@ Array [ exports[`WhereBuilder should accept a empty filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity"`; exports[`WhereBuilder should accept a empty filter 2`] = `Array []`; + +exports[`WhereBuilder should accept custom filters alongside regular filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`; + +exports[`WhereBuilder should accept custom filters alongside regular filters 2`] = ` +Array [ + 1, + 10, + 5, + 3, + 45.3, + 9.5, + 50000, +] +`; diff --git a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts index b4d335764d..955d58fb31 100644 --- a/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/aggregate.builder.spec.ts @@ -5,8 +5,8 @@ import { TestEntity } from '../__fixtures__/test.entity'; import { AggregateBuilder } from '../../src/query'; describe('AggregateBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getRepo = () => getTestConnection().getRepository(TestEntity); const getQueryBuilder = () => getRepo().createQueryBuilder(); 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 5bda27a779..e0266434cf 100644 --- a/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts @@ -1,14 +1,16 @@ -import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito'; +import { AggregateQuery, Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito'; import { QueryBuilder, WhereExpression } from 'typeorm'; -import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { FilterQueryBuilder, WhereBuilder } from '../../src/query'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; import { TestEntity } from '../__fixtures__/test.entity'; -import { FilterQueryBuilder, WhereBuilder } from '../../src/query'; +import { getCustomFilterRegistry } from '../utils'; +import { getQueryTypeormMetadata } from '../../src/common'; describe('FilterQueryBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getEntityQueryBuilder = ( entity: Class, @@ -94,41 +96,97 @@ describe('FilterQueryBuilder', (): void => { expectSQLSnapshot(selectQueryBuilder); }; + const expectAggregateSQLSnapshot = ( + query: Query, + aggregate: AggregateQuery, + whereBuilder: WhereBuilder, + ): void => { + const aggregateQueryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).aggregate(query, aggregate); + expectSQLSnapshot(aggregateQueryBuilder); + }; + describe('with filter', () => { it('should not call whereBuilder#build', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({}, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), 'TestEntity')).thenCall( - (where: WhereExpression, field: Filter, relationNames: string[], alias: string) => - where.andWhere(`${alias}.stringType = 'foo'`), + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, 'TestEntity')).thenCall( + ( + where: WhereExpression, + field: Filter, + relationNames: string[], + klass: Class, + alias: string, + ) => where.andWhere(`${alias}.stringType = 'foo'`), ); expectSelectSQLSnapshot(query, instance(mockWhereBuilder)); }); }); + describe('with custom filter', () => { + it('should add custom filters', () => { + expectSelectSQLSnapshot( + { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }, + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(), + }), + ); + }); + + // eslint-disable-next-line jest/expect-expect + it('should add custom filters with aggregate', () => { + expectAggregateSQLSnapshot( + { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }, + { + max: ['numberType'], + }, + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(), + }), + ); + }); + }); + describe('with paging', () => { it('should apply empty paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({}, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, 'TestEntity')).never(); }); it('should apply paging args going forward', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, 'TestEntity')).never(); }); it('should apply paging args going backward', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSelectSQLSnapshot({ paging: { limit: 10, offset: 10 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); }); @@ -139,7 +197,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -148,7 +206,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -157,7 +215,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply DESC sorting', () => { @@ -166,7 +224,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -183,7 +241,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); it('should apply multiple sorts', () => { @@ -199,7 +257,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never(); + verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, 'TestEntity')).never(); }); }); }); @@ -214,7 +272,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectUpdateSQLSnapshot(query, instance(mockWhereBuilder)); @@ -224,7 +282,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectUpdateSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -235,7 +293,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_FIRST sorting', () => { @@ -244,7 +302,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply ASC NULLS_LAST sorting', () => { @@ -253,7 +311,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC sorting', () => { @@ -262,7 +320,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_FIRST sorting', () => { @@ -271,7 +329,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply DESC NULLS_LAST sorting', () => { @@ -280,7 +338,7 @@ describe('FilterQueryBuilder', (): void => { { sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); it('should apply multiple sorts', () => { @@ -296,7 +354,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); @@ -311,7 +369,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectDeleteSQLSnapshot(query, instance(mockWhereBuilder)); @@ -321,7 +379,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -339,7 +397,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); @@ -357,7 +415,7 @@ describe('FilterQueryBuilder', (): void => { it('should call whereBuilder#build if there is a filter', () => { const mockWhereBuilder = mock>(WhereBuilder); const query = { filter: { stringType: { eq: 'foo' } } }; - when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall( + when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestSoftDeleteEntity, undefined)).thenCall( (where: WhereExpression) => where.andWhere(`stringType = 'foo'`), ); expectSoftDeleteSQLSnapshot(query, instance(mockWhereBuilder)); @@ -367,7 +425,7 @@ describe('FilterQueryBuilder', (): void => { it('should ignore paging args', () => { const mockWhereBuilder = mock>(WhereBuilder); expectSoftDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder)); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); @@ -383,7 +441,7 @@ describe('FilterQueryBuilder', (): void => { }, instance(mockWhereBuilder), ); - verify(mockWhereBuilder.build(anything(), anything(), anything())).never(); + verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never(); }); }); }); diff --git a/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts b/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts index 26d73cfb88..41326f862f 100644 --- a/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/relation-query.builder.spec.ts @@ -1,18 +1,21 @@ import { Class, Query, SortDirection, SortNulls } from '@nestjs-query/core'; +import { RelationQueryBuilder } from '../../src/query'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; import { TestRelation } from '../__fixtures__/test-relation.entity'; import { TestEntity } from '../__fixtures__/test.entity'; -import { RelationQueryBuilder } from '../../src/query'; +import { getCustomFilterRegistry } from '../utils'; describe('RelationQueryBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); + + const customFilterRegistry = getCustomFilterRegistry(); const getRelationQueryBuilder = ( EntityClass: Class, relationName: string, ): RelationQueryBuilder => - new RelationQueryBuilder(getTestConnection().getRepository(EntityClass), relationName); + new RelationQueryBuilder(getTestConnection().getRepository(EntityClass), relationName, { customFilterRegistry }); const expectSQLSnapshot = ( EntityClass: Class, @@ -161,5 +164,21 @@ describe('RelationQueryBuilder', (): void => { }); }); }); + describe('with custom filters', () => { + // TODO Fix typings to avoid usage of any + it('should accept custom filters', (): void => { + const query: Query = { + filter: { + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any, // TODO Fix any typing + }; + expectSQLSnapshot(TestEntity, testEntity, 'testRelations', query); + }); + }); }); }); diff --git a/packages/query-typeorm/__tests__/query/where.builder.spec.ts b/packages/query-typeorm/__tests__/query/where.builder.spec.ts index 2e386092e9..60ed7f1df0 100644 --- a/packages/query-typeorm/__tests__/query/where.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/where.builder.spec.ts @@ -1,18 +1,23 @@ import { Filter } from '@nestjs-query/core'; +import { getQueryTypeormMetadata } from '../../src/common'; +import { WhereBuilder } from '../../src/query'; import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture'; import { TestEntity } from '../__fixtures__/test.entity'; -import { WhereBuilder } from '../../src/query'; +import { getCustomFilterRegistry } from '../utils'; describe('WhereBuilder', (): void => { - beforeEach(createTestConnection); - afterEach(closeTestConnection); + beforeEach(() => createTestConnection()); + afterEach(() => closeTestConnection()); const getRepo = () => getTestConnection().getRepository(TestEntity); const getQueryBuilder = () => getRepo().createQueryBuilder(); - const createWhereBuilder = () => new WhereBuilder(); + const createWhereBuilder = () => + new WhereBuilder(getQueryTypeormMetadata(getTestConnection()), { + customFilterRegistry: getCustomFilterRegistry(), + }); const expectSQLSnapshot = (filter: Filter): void => { - const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, 'TestEntity'); + const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, TestEntity, 'TestEntity'); const [sql, params] = selectQueryBuilder.getQueryAndParameters(); expect(sql).toMatchSnapshot(); expect(params).toMatchSnapshot(); @@ -30,6 +35,18 @@ describe('WhereBuilder', (): void => { expectSQLSnapshot({ numberType: { eq: 1 }, stringType: { like: 'foo%' }, boolType: { is: true } }); }); + // TODO Fix typings to avoid usage of any + it('should accept custom filters alongside regular filters', (): void => { + expectSQLSnapshot({ + // This has the global isMultipleOf filter + numberType: { gte: 1, lte: 10, isMultipleOf: 5 }, + // Here, the isMultipleOf filter was overridden for dateType only + dateType: { isMultipleOf: 3 }, + // This is a more complex filter involving geospatial queries + fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } }, + } as any); + }); + describe('and', (): void => { it('and multiple expressions together', (): void => { expectSQLSnapshot({ @@ -42,6 +59,20 @@ describe('WhereBuilder', (): void => { }); }); + it('and multiple expressions together with custom filters', (): void => { + expectSQLSnapshot({ + and: [ + { numberType: { gt: 10 } }, + { numberType: { lt: 20 } }, + { numberType: { gte: 30 } }, + { numberType: { lte: 40 } }, + { numberType: { isMultipleOf: 5 } }, + { dateType: { isMultipleOf: 3 } }, + { fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } } }, + ], + } as any); // TODO Remove any typing + }); + it('and multiple filters together with multiple fields', (): void => { expectSQLSnapshot({ and: [ @@ -77,6 +108,20 @@ describe('WhereBuilder', (): void => { }); }); + it('or multiple expressions together with custom filter', (): void => { + expectSQLSnapshot({ + or: [ + { numberType: { gt: 10 } }, + { numberType: { lt: 20 } }, + { numberType: { gte: 30 } }, + { numberType: { lte: 40 } }, + { numberType: { isMultipleOf: 5 } }, + { dateType: { isMultipleOf: 3 } }, + { fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } } }, + ], + } as any); // TODO Remove any typing + }); + it('and multiple and filters together', (): void => { expectSQLSnapshot({ or: [ diff --git a/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts b/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts new file mode 100644 index 0000000000..d0314808d1 --- /dev/null +++ b/packages/query-typeorm/__tests__/services/custom-filter-registry.spec.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ColumnType } from 'typeorm'; +import { NestjsQueryTypeOrmModule } from '../../src'; +import { CustomFilterRegistry } from '../../src/query'; +import { closeTestConnection, CONNECTION_OPTIONS, refresh } from '../__fixtures__/connection.fixture'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, +} from '../__fixtures__/custom-filters.services'; +import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; +import { TestRelation } from '../__fixtures__/test-relation.entity'; +import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity'; +import { TestEntity } from '../__fixtures__/test.entity'; + +describe('CustomFilterRegistry', (): void => { + let moduleRef: TestingModule; + + afterEach(() => closeTestConnection()); + + beforeEach(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(CONNECTION_OPTIONS), + // Use the full module so we can test custom filters well + NestjsQueryTypeOrmModule.forFeature( + [TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity], + undefined, + { + providers: [ + // Custom filters + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + ], + }, + ), + ], + providers: [], + }).compile(); + // Trigger onModuleInit() + await moduleRef.init(); + await refresh(); + }); + + describe('#custom-filters', () => { + it('Verify that custom filters are registered', () => { + const customFilterRegistry = moduleRef.get(CustomFilterRegistry); + expect(customFilterRegistry).toBeDefined(); + expect(customFilterRegistry.getFilter('isMultipleOf')).toBeUndefined(); + for (const type of ['integer', Number] as ColumnType[]) { + expect(customFilterRegistry.getFilter('isMultipleOf', type)).toBeInstanceOf(IsMultipleOfCustomFilter); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity)).toBeInstanceOf( + IsMultipleOfCustomFilter, + ); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity, 'numberType')).toBeInstanceOf( + IsMultipleOfCustomFilter, + ); + } + + for (const type of ['date', 'datetime', Date] as ColumnType[]) { + expect(customFilterRegistry.getFilter('isMultipleOf', type)).toBeInstanceOf(IsMultipleOfDateCustomFilter); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity)).toBeInstanceOf( + IsMultipleOfDateCustomFilter, + ); + expect(customFilterRegistry.getFilter('isMultipleOf', type, TestEntity, 'dateType')).toBeInstanceOf( + IsMultipleOfDateCustomFilter, + ); + } + + // Test for (class, field, entity) filter + expect(customFilterRegistry.getFilter('distanceFrom', undefined, TestEntity, 'fakePointType')).toBeInstanceOf( + RadiusCustomFilter, + ); + }); + }); +}); 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 351c6a269a..3244c9b3c4 100644 --- a/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts +++ b/packages/query-typeorm/__tests__/services/typeorm-query.service.spec.ts @@ -1,9 +1,9 @@ -import { Filter, SortDirection } from '@nestjs-query/core'; +import { Filter, getQueryServiceToken, QueryService, SortDirection } from '@nestjs-query/core'; import { Test, TestingModule } from '@nestjs/testing'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; import { plainToClass } from 'class-transformer'; import { Repository } from 'typeorm'; -import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; -import { TypeOrmQueryService } from '../../src'; +import { NestjsQueryTypeOrmModule, TypeOrmQueryService } from '../../src'; import { FilterQueryBuilder } from '../../src/query'; import { closeTestConnection, @@ -12,6 +12,11 @@ import { refresh, truncate, } from '../__fixtures__/connection.fixture'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, +} from '../__fixtures__/custom-filters.services'; import { TEST_ENTITIES, TEST_RELATIONS, TEST_SOFT_DELETE_ENTITIES } from '../__fixtures__/seeds'; import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity'; import { TestRelation } from '../__fixtures__/test-relation.entity'; @@ -39,16 +44,31 @@ describe('TypeOrmQueryService', (): void => { } } - afterEach(closeTestConnection); + afterEach(() => closeTestConnection()); beforeEach(async () => { moduleRef = await Test.createTestingModule({ imports: [ TypeOrmModule.forRoot(CONNECTION_OPTIONS), - TypeOrmModule.forFeature([TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity]), + // Use the full module so we can test custom filters well + NestjsQueryTypeOrmModule.forFeature( + [TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity], + undefined, + { + providers: [ + // Custom filters + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, + ], + }, + ), + // TypeOrmModule.forFeature([TestEntity, TestRelation, TestEntityRelationEntity, TestSoftDeleteEntity]), ], providers: [TestEntityService, TestRelationService, TestSoftDeleteEntityService], }).compile(); + // Trigger onModuleInit() + await moduleRef.init(); await refresh(); }); @@ -1864,4 +1884,48 @@ describe('TypeOrmQueryService', (): void => { }); }); }); + + describe('#custom-filters', () => { + it('Simple query without relations', async () => { + const queryService: QueryService = moduleRef.get(getQueryServiceToken(TestEntity)); + expect(queryService).toBeDefined(); + // // TODO Remove any typing + // prettier-ignore + const queryResult = await queryService.query({ + filter: { + and: [ + { numberType: { gte: 6 } }, + { numberType: { isMultipleOf: 3 } }, + ], + } as any, + }); + expect(queryResult).toHaveLength(2); + // prettier-ignore + expect(queryResult).toMatchObject([ + { testEntityPk: 'test-entity-6' }, + { testEntityPk: 'test-entity-9' }, + ]); + }); + + it('Query relations', async () => { + const queryService: QueryService = moduleRef.get(getQueryServiceToken(TestEntity)); + expect(queryService).toBeDefined(); + // // TODO Remove any typing + // prettier-ignore + const queryResult = await queryService.query({ + filter: { + and: [ + { testRelations: { numberType: { gte: 30 } } }, + { testRelations: { numberType: { isMultipleOf: 21 } } }, + ], + } as any, + }); + expect(queryResult).toHaveLength(2); + // prettier-ignore + expect(queryResult).toMatchObject([ + { testEntityPk: 'test-entity-4' }, + { testEntityPk: 'test-entity-6' }, + ]); + }); + }); }); diff --git a/packages/query-typeorm/__tests__/utils.ts b/packages/query-typeorm/__tests__/utils.ts new file mode 100644 index 0000000000..646f5dd315 --- /dev/null +++ b/packages/query-typeorm/__tests__/utils.ts @@ -0,0 +1,18 @@ +import { CustomFilterRegistry } from '../src/query'; +import { + IsMultipleOfCustomFilter, + IsMultipleOfDateCustomFilter, + RadiusCustomFilter, +} from './__fixtures__/custom-filters.services'; +import { TestEntity } from './__fixtures__/test.entity'; + +export function getCustomFilterRegistry(): CustomFilterRegistry { + const customFilterRegistry = new CustomFilterRegistry(); + // Test for (type, operation) filter registration (this is valid for all fields of all entities) + customFilterRegistry.setFilter(new IsMultipleOfCustomFilter()); + // Test for (type, operation) filter on another type + customFilterRegistry.setFilter(new IsMultipleOfDateCustomFilter()); + // Test for (class, field, operation) filter on a virtual property 'fakePointType' that does not really exist on the entity + customFilterRegistry.setFilter(new RadiusCustomFilter(), TestEntity, 'fakePointType'); + return customFilterRegistry; +} diff --git a/packages/query-typeorm/package.json b/packages/query-typeorm/package.json index 85b6fb8ad4..7fc7e2d7a3 100644 --- a/packages/query-typeorm/package.json +++ b/packages/query-typeorm/package.json @@ -28,6 +28,7 @@ }, "peerDependencies": { "@nestjs/common": "^8.0.4", + "@nestjs/core": "^8.0.0", "@nestjs/typeorm": "^8.0.0", "class-transformer": "^0.2.3 || 0.3.1 || 0.4", "typeorm": "^0.2.25" diff --git a/packages/query-typeorm/src/common/index.ts b/packages/query-typeorm/src/common/index.ts index 02e438c208..d967a84a4a 100644 --- a/packages/query-typeorm/src/common/index.ts +++ b/packages/query-typeorm/src/common/index.ts @@ -1 +1,2 @@ export * from './randomString'; +export * from './typeorm'; diff --git a/packages/query-typeorm/src/common/typeorm.ts b/packages/query-typeorm/src/common/typeorm.ts new file mode 100644 index 0000000000..34ed55a8f7 --- /dev/null +++ b/packages/query-typeorm/src/common/typeorm.ts @@ -0,0 +1,57 @@ +import { Class } from '@nestjs-query/core'; +import { ColumnType, Connection } from 'typeorm'; + +interface QueryTypeormPropertyMetadata { + metaType: 'property'; + type: ColumnType; +} +interface QueryTypeormRelationMetadata { + metaType: 'relation'; + type: Class; +} + +export type QueryTypeormEntityMetadata = Record< + keyof T | string, + QueryTypeormPropertyMetadata | QueryTypeormRelationMetadata +>; +export type QueryTypeormMetadata = Map, QueryTypeormEntityMetadata>; + +export function buildQueryTypeormMetadata(connection: Connection): QueryTypeormMetadata { + const meta: QueryTypeormMetadata = new Map(); + for (const entity of connection.entityMetadatas) { + const entityMeta: QueryTypeormEntityMetadata = {}; + for (const field of [...entity.ownColumns]) { + entityMeta[field.propertyName] = { + metaType: 'property', + type: field.type, + }; + } + for (const field of [...entity.ownRelations]) { + // Skip strings + if (typeof field.inverseEntityMetadata.target === 'function') { + entityMeta[field.propertyName] = { + metaType: 'relation', + type: field.inverseEntityMetadata.target as Class, + }; + } + } + + // Ignore things like junction tables + if (typeof entity.target === 'string') { + continue; + } + meta.set(entity.target as Class, entityMeta); + } + return meta; +} + +const cache = new Map(); + +export function getQueryTypeormMetadata(connection: Connection): QueryTypeormMetadata { + let meta = cache.get(connection); + if (!meta) { + meta = buildQueryTypeormMetadata(connection); + cache.set(connection, meta); + } + return meta; +} diff --git a/packages/query-typeorm/src/decorators/constants.ts b/packages/query-typeorm/src/decorators/constants.ts new file mode 100644 index 0000000000..7155179958 --- /dev/null +++ b/packages/query-typeorm/src/decorators/constants.ts @@ -0,0 +1,2 @@ +export const TYPEORM_QUERY_FILTER_KEY = 'nestjs-query:typeorm:query-filter'; +export const TYPEORM_ENTITY_QUERY_FILTER_KEY = 'nestjs-query:typeorm:entity-query-filter'; diff --git a/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts b/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts new file mode 100644 index 0000000000..16454b1d1b --- /dev/null +++ b/packages/query-typeorm/src/decorators/typeorm-query-filter.decorator.ts @@ -0,0 +1,46 @@ +import { Class, ValueReflector } from '@nestjs-query/core'; +import { Injectable } from '@nestjs/common'; +import { TYPEORM_QUERY_FILTER_KEY } from './constants'; +import { TypedClassDecorator } from './utils'; +import { CustomFilter } from '../query'; + +const reflector = new ValueReflector(TYPEORM_QUERY_FILTER_KEY); + +export interface TypeOrmQueryFilterOpts { + /** + * Automatically register this filter on all available entities. + * Default: true + */ + autoRegister?: boolean; +} + +/** + * @internal + */ +export interface TypeOrmQueryFilterMetadata { + filter: Class; + autoRegister: boolean; +} + +const FilterList: Class[] = []; +const FilterMeta: TypeOrmQueryFilterMetadata[] = []; + +export function TypeOrmQueryFilter(opts: TypeOrmQueryFilterOpts = {}): TypedClassDecorator { + return >(FilterClass: Cls): Cls | void => { + FilterList.push(FilterClass); + const meta: TypeOrmQueryFilterMetadata = { + filter: FilterClass, + autoRegister: opts.autoRegister ?? true, + }; + reflector.set(FilterClass, meta); + FilterMeta.push(meta); + return Injectable()(FilterClass); + }; +} + +/** + * @internal + */ +export function getTypeOrmQueryFilters(): TypeOrmQueryFilterMetadata[] { + return FilterMeta; +} diff --git a/packages/query-typeorm/src/decorators/utils.ts b/packages/query-typeorm/src/decorators/utils.ts new file mode 100644 index 0000000000..26ad5a3a8e --- /dev/null +++ b/packages/query-typeorm/src/decorators/utils.ts @@ -0,0 +1,3 @@ +import { Class } from '@nestjs-query/core'; + +export type TypedClassDecorator = >(DTOClass: Cls) => Cls | void; diff --git a/packages/query-typeorm/src/decorators/with-typeorm-entity-query-filter.decorator.ts b/packages/query-typeorm/src/decorators/with-typeorm-entity-query-filter.decorator.ts new file mode 100644 index 0000000000..48f52b0d1b --- /dev/null +++ b/packages/query-typeorm/src/decorators/with-typeorm-entity-query-filter.decorator.ts @@ -0,0 +1,33 @@ +import { ArrayReflector, Class } from '@nestjs-query/core'; +import { CustomFilter } from '../query'; +import { TYPEORM_ENTITY_QUERY_FILTER_KEY } from './constants'; +import { TypedClassDecorator } from './utils'; + +const reflector = new ArrayReflector(TYPEORM_ENTITY_QUERY_FILTER_KEY); + +export interface EntityQueryFilterOpts { + /** + * Filter class (injection token) + */ + filter: Class; + /** + * Used to register a filter on specific fields instead of types. + * Note that arbitrary field names can be used, to support filters that are not mapped to real entity fields + */ + fields?: (string | keyof Entity)[]; +} + +export function WithTypeormQueryFilter( + opts: EntityQueryFilterOpts, +): TypedClassDecorator> { + return >>(EntityClass: Cls): Cls | void => { + reflector.append(EntityClass, opts); + }; +} + +/** + * @internal + */ +export function getTypeormEntityQueryFilters(EntityClass: Class): EntityQueryFilterOpts[] { + return reflector.get(EntityClass) ?? []; +} diff --git a/packages/query-typeorm/src/module.ts b/packages/query-typeorm/src/module.ts index 77a342b0fc..e7a371cd70 100644 --- a/packages/query-typeorm/src/module.ts +++ b/packages/query-typeorm/src/module.ts @@ -1,18 +1,80 @@ import { Class } from '@nestjs-query/core'; +import { DynamicModule, Inject, OnModuleInit, Provider } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DynamicModule } from '@nestjs/common'; import { Connection, ConnectionOptions } from 'typeorm'; +import { getTypeormEntityQueryFilters } from './decorators/with-typeorm-entity-query-filter.decorator'; import { createTypeOrmQueryServiceProviders } from './providers'; +import { CustomFilterRegistry } from './query'; +import { getTypeOrmQueryFilters } from './decorators/typeorm-query-filter.decorator'; -export class NestjsQueryTypeOrmModule { - static forFeature(entities: Class[], connection?: Connection | ConnectionOptions | string): DynamicModule { +export const CONFIG_KEY = 'nestjs-query:typeorm:config'; + +interface NestjsQueryTypeOrmModuleOpts { + providers?: Provider[]; +} + +interface NestjsQueryTypeOrmModuleConfig { + entities: Class[]; +} + +export class NestjsQueryTypeOrmModule implements OnModuleInit { + constructor( + @Inject(CONFIG_KEY) private readonly config: NestjsQueryTypeOrmModuleConfig, + private readonly ref: ModuleRef, + private readonly customFilterRegistry: CustomFilterRegistry, + ) {} + + static forFeature( + entities: Class[], + connection?: Connection | ConnectionOptions | string, + opts?: NestjsQueryTypeOrmModuleOpts, + ): DynamicModule { const queryServiceProviders = createTypeOrmQueryServiceProviders(entities, connection); const typeOrmModule = TypeOrmModule.forFeature(entities, connection); return { imports: [typeOrmModule], module: NestjsQueryTypeOrmModule, - providers: [...queryServiceProviders], + providers: [ + ...queryServiceProviders, + ...(opts?.providers ?? []), + { + provide: CONFIG_KEY, + useValue: { entities }, + }, + { + provide: CustomFilterRegistry, + useFactory: () => new CustomFilterRegistry(), + }, + ], exports: [...queryServiceProviders, typeOrmModule], }; } + + onModuleInit(): void { + for (const entity of this.config.entities) { + const globalCustomFilters = getTypeOrmQueryFilters(); + // Register global (type) custom filters + for (const cf of globalCustomFilters) { + if (cf.autoRegister) { + const instance = this.ref.get(cf.filter); + this.customFilterRegistry.setFilter(instance); + } + } + // Register entity specific custom filters + const customFilters = getTypeormEntityQueryFilters(entity); + if (customFilters.length > 0) { + for (const filterSpec of customFilters) { + const instance = this.ref.get(filterSpec.filter); + if (filterSpec.fields) { + for (const field of filterSpec.fields) { + this.customFilterRegistry.setFilter(instance, entity, field); + } + } else { + this.customFilterRegistry.setFilter(instance, entity); + } + } + } + } + } } diff --git a/packages/query-typeorm/src/providers.ts b/packages/query-typeorm/src/providers.ts index d00542d7be..a834af2eab 100644 --- a/packages/query-typeorm/src/providers.ts +++ b/packages/query-typeorm/src/providers.ts @@ -3,6 +3,8 @@ import { FactoryProvider } from '@nestjs/common'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository, Connection, ConnectionOptions } from 'typeorm'; import { TypeOrmQueryService } from './services'; +import { CustomFilterRegistry, FilterQueryBuilder, WhereBuilder } from './query'; +import { getQueryTypeormMetadata } from './common'; function createTypeOrmQueryServiceProvider( EntityClass: Class, @@ -10,10 +12,17 @@ function createTypeOrmQueryServiceProvider( ): FactoryProvider { return { provide: getQueryServiceToken(EntityClass), - useFactory(repo: Repository) { - return new TypeOrmQueryService(repo); + useFactory(repo: Repository, customFilterRegistry: CustomFilterRegistry) { + return new TypeOrmQueryService(repo, { + filterQueryBuilder: new FilterQueryBuilder( + repo, + new WhereBuilder(getQueryTypeormMetadata(repo.manager.connection), { + customFilterRegistry, + }), + ), + }); }, - inject: [getRepositoryToken(EntityClass, connection)], + inject: [getRepositoryToken(EntityClass, connection), CustomFilterRegistry], }; } diff --git a/packages/query-typeorm/src/query/custom-filter.registry.ts b/packages/query-typeorm/src/query/custom-filter.registry.ts new file mode 100644 index 0000000000..225fb22210 --- /dev/null +++ b/packages/query-typeorm/src/query/custom-filter.registry.ts @@ -0,0 +1,93 @@ +import { Class } from '@nestjs-query/core'; +import { ColumnType, ObjectLiteral } from 'typeorm'; + +export type CustomFilterResult = { sql: string; params: ObjectLiteral }; + +export interface CustomFilter { + readonly operations: OperationType[]; + // If defined, the filter is restricted to a specific set of database types + readonly types?: ColumnType[]; + + apply(field: keyof Entity | string, cmp: OperationType, val: unknown, alias?: string): CustomFilterResult; +} + +type OperationCustomFilters = Record; + +export class CustomFilterRegistry { + // Registry for (class, field, operation) filters + private cfoRegistry: Map, Record> = new Map(); + + // Registry for (class,type) filters + private ctRegistry: Map, Map> = new Map(); + + // Registry for (type) filters + private tRegistry: Map = new Map(); + + getFilter( + operation: string, + type?: ColumnType, + klass?: Class, + field?: keyof Entity | string, + ): CustomFilter | undefined { + // Most specific: (class, field, operation) filters. + if (klass && field) { + const flt = this.cfoRegistry.get(klass)?.[field]?.[operation]; + if (flt) { + return flt; + } + } + // 2nd: (class, type) filters + if (klass && type) { + const flt = this.ctRegistry.get(klass)?.get(type)?.[operation]; + if (flt) { + return flt; + } + } + // 3rd (type) filter + if (type) { + return this.tRegistry.get(type)?.[operation]; + } + // There's no (operation) only filter + return undefined; + } + + /** + * We have 3 types of filters: + * - (type, operation) filters + * - (class, type, operation) filters + * - (class, field, operation) filters + * type is the database column type (TypeORM's ColumnType) + * Specificity of the filters increases from top to bottom + */ + setFilter(filter: CustomFilter, klass?: Class, field?: keyof Entity | string): void { + if (klass && field) { + const entityFilters = this.cfoRegistry.get(klass) || {}; + entityFilters[field] = this.createCustomFilterOperationMap(filter); + this.cfoRegistry.set(klass, entityFilters); + } else if (klass) { + if (!filter.types) { + throw new Error('Cannot register an (Entity, type) filter without types, please define the types array'); + } + const tRegistry: Map = new Map(); + for (const type of filter.types) { + tRegistry.set(type, this.createCustomFilterOperationMap(filter)); + } + this.ctRegistry.set(klass, tRegistry); + } else { + if (!filter.types) { + throw new Error('Cannot register a (type) filter without types, please define the types array'); + } + for (const type of filter.types) { + this.tRegistry.set(type, this.createCustomFilterOperationMap(filter)); + } + } + } + + private createCustomFilterOperationMap(cf: CustomFilter): OperationCustomFilters { + const ocf: OperationCustomFilters = {}; + for (const op of cf.operations) { + ocf[op] = cf; + } + return ocf; + } +} diff --git a/packages/query-typeorm/src/query/filter-query.builder.ts b/packages/query-typeorm/src/query/filter-query.builder.ts index 13d1383f6f..730546a86d 100644 --- a/packages/query-typeorm/src/query/filter-query.builder.ts +++ b/packages/query-typeorm/src/query/filter-query.builder.ts @@ -1,17 +1,18 @@ -import { Filter, Paging, Query, SortField, getFilterFields, AggregateQuery } from '@nestjs-query/core'; +import { AggregateQuery, Class, Filter, getFilterFields, Paging, Query, SortField } from '@nestjs-query/core'; +import merge from 'lodash.merge'; import { DeleteQueryBuilder, + EntityMetadata, QueryBuilder, Repository, SelectQueryBuilder, UpdateQueryBuilder, WhereExpression, - EntityMetadata, } from 'typeorm'; import { SoftDeleteQueryBuilder } from 'typeorm/query-builder/SoftDeleteQueryBuilder'; +import { getQueryTypeormMetadata } from '../common'; import { AggregateBuilder } from './aggregate.builder'; import { WhereBuilder } from './where.builder'; -import merge from 'lodash.merge'; /** * @internal @@ -33,8 +34,11 @@ interface Groupable extends QueryBuilder { */ interface Pageable extends QueryBuilder { limit(limit?: number): this; + offset(offset?: number): this; + skip(skip?: number): this; + take(take?: number): this; } @@ -55,6 +59,13 @@ interface R { */ export type NestedRecord = R; +export interface RelationMeta { + targetKlass: Class; + relations: Record; +} + +export type RelationsMeta = Record; + /** * @internal * @@ -63,10 +74,16 @@ export type NestedRecord = R; export class FilterQueryBuilder { constructor( readonly repo: Repository, - readonly whereBuilder: WhereBuilder = new WhereBuilder(), + readonly whereBuilder: WhereBuilder = new WhereBuilder( + getQueryTypeormMetadata(repo.manager.connection), + ), readonly aggregateBuilder: AggregateBuilder = new AggregateBuilder(), ) {} + private get relationNames(): string[] { + return this.repo.metadata.relations.map((r) => r.propertyName); + } + /** * Create a `typeorm` SelectQueryBuilder with `WHERE`, `ORDER BY` and `LIMIT/OFFSET` clauses. * @@ -75,10 +92,11 @@ export class FilterQueryBuilder { select(query: Query): SelectQueryBuilder { const hasRelations = this.filterHasRelations(query.filter); let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = hasRelations ? this.applyRelationJoinsRecursive(qb, this.getReferencedRelationsRecursive(this.repo.metadata, query.filter)) : qb; - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applySorting(qb, query.sorting, qb.alias); qb = this.applyPaging(qb, query.paging, hasRelations); return qb; @@ -87,11 +105,12 @@ export class FilterQueryBuilder { selectById(id: string | number | (string | number)[], query: Query): SelectQueryBuilder { const hasRelations = this.filterHasRelations(query.filter); let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = hasRelations ? this.applyRelationJoinsRecursive(qb, this.getReferencedRelationsRecursive(this.repo.metadata, query.filter)) : qb; qb = qb.andWhereInIds(id); - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applySorting(qb, query.sorting, qb.alias); qb = this.applyPaging(qb, query.paging, hasRelations); return qb; @@ -99,8 +118,9 @@ export class FilterQueryBuilder { aggregate(query: Query, aggregate: AggregateQuery): SelectQueryBuilder { let qb = this.createQueryBuilder(); + const klass = this.repo.metadata.target as Class; qb = this.applyAggregate(qb, aggregate, qb.alias); - qb = this.applyFilter(qb, query.filter, qb.alias); + qb = this.applyFilter(qb, klass, query.filter, qb.alias); qb = this.applyAggregateSorting(qb, aggregate.groupBy, qb.alias); qb = this.applyGroupBy(qb, aggregate.groupBy, qb.alias); return qb; @@ -112,7 +132,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ delete(query: Query): DeleteQueryBuilder { - return this.applyFilter(this.repo.createQueryBuilder().delete(), query.filter); + const qb = this.repo.createQueryBuilder().delete(); + const klass = this.repo.metadata.target as Class; + return this.applyFilter(qb, klass, query.filter); } /** @@ -121,10 +143,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ softDelete(query: Query): SoftDeleteQueryBuilder { - return this.applyFilter( - this.repo.createQueryBuilder().softDelete() as SoftDeleteQueryBuilder, - query.filter, - ); + const qb = this.repo.createQueryBuilder().softDelete() as SoftDeleteQueryBuilder; + const klass = this.repo.metadata.target as Class; + return this.applyFilter(qb, klass, query.filter); } /** @@ -133,7 +154,9 @@ export class FilterQueryBuilder { * @param query - the query to apply. */ update(query: Query): UpdateQueryBuilder { - const qb = this.applyFilter(this.repo.createQueryBuilder().update(), query.filter); + const qb = this.repo.createQueryBuilder().update(); + const klass = this.repo.metadata.target as Class; + this.applyFilter(qb, klass, query.filter); return this.applySorting(qb, query.sorting); } @@ -170,14 +193,26 @@ export class FilterQueryBuilder { * Applies the filter from a Query to a `typeorm` QueryBuilder. * * @param qb - the `typeorm` QueryBuilder. + * @param klass - the class currently being processed * @param filter - the filter. * @param alias - optional alias to use to qualify an identifier */ - applyFilter(qb: Where, filter?: Filter, alias?: string): Where { + applyFilter( + qb: Where, + klass: Class, + filter?: Filter, + alias?: string, + ): Where { if (!filter) { return qb; } - return this.whereBuilder.build(qb, filter, this.getReferencedRelationsRecursive(this.repo.metadata, filter), alias); + return this.whereBuilder.build( + qb, + filter, + this.getReferencedRelationsMetaRecursive(this.repo.metadata, filter), + klass, + alias, + ); } /** @@ -216,14 +251,6 @@ export class FilterQueryBuilder { }, qb); } - /** - * Create a `typeorm` SelectQueryBuilder which can be used as an entry point to create update, delete or insert - * QueryBuilders. - */ - private createQueryBuilder(): SelectQueryBuilder { - return this.repo.createQueryBuilder(); - } - /** * Gets relations referenced in the filter and adds joins for them to the query builder * @param qb - the `typeorm` QueryBuilder. @@ -263,15 +290,10 @@ export class FilterQueryBuilder { return this.getReferencedRelations(filter).length > 0; } - private getReferencedRelations(filter: Filter): string[] { - const { relationNames } = this; - const referencedFields = getFilterFields(filter); - return referencedFields.filter((f) => relationNames.includes(f)); - } - getReferencedRelationsRecursive(metadata: EntityMetadata, filter: Filter = {}): NestedRecord { - const referencedFields = Array.from(new Set(Object.keys(filter) as (keyof Filter)[])); + const referencedFields = Array.from(new Set(Object.keys(filter))); return referencedFields.reduce((prev, curr) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const currFilterValue = filter[curr]; if ((curr === 'and' || curr === 'or') && currFilterValue) { for (const subFilter of currFilterValue) { @@ -290,7 +312,41 @@ export class FilterQueryBuilder { }, {}); } - private get relationNames(): string[] { - return this.repo.metadata.relations.map((r) => r.propertyName); + getReferencedRelationsMetaRecursive(metadata: EntityMetadata, filter: Filter = {}): RelationsMeta { + const referencedFields = Array.from(new Set(Object.keys(filter))); + let meta: RelationsMeta = {}; + for (const referencedField of referencedFields) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const currFilterValue = filter[referencedField]; + if ((referencedField === 'and' || referencedField === 'or') && currFilterValue) { + for (const subFilter of currFilterValue) { + meta = merge(meta, this.getReferencedRelationsMetaRecursive(metadata, subFilter)); + } + } + const referencedRelation = metadata.relations.find((r) => r.propertyName === referencedField); + if (!referencedRelation) continue; + meta[referencedField] = { + targetKlass: referencedRelation.inverseEntityMetadata.target as Class, + relations: merge( + meta?.[referencedField]?.relations, + this.getReferencedRelationsMetaRecursive(referencedRelation.inverseEntityMetadata, currFilterValue), + ), + }; + } + return meta; + } + + /** + * Create a `typeorm` SelectQueryBuilder which can be used as an entry point to create update, delete or insert + * QueryBuilders. + */ + private createQueryBuilder(): SelectQueryBuilder { + return this.repo.createQueryBuilder(); + } + + private getReferencedRelations(filter: Filter): string[] { + const { relationNames } = this; + const referencedFields = getFilterFields(filter); + return referencedFields.filter((f) => relationNames.includes(f)); } } diff --git a/packages/query-typeorm/src/query/index.ts b/packages/query-typeorm/src/query/index.ts index 852faaf423..37a3c7968c 100644 --- a/packages/query-typeorm/src/query/index.ts +++ b/packages/query-typeorm/src/query/index.ts @@ -3,3 +3,4 @@ export * from './where.builder'; export * from './sql-comparison.builder'; export * from './relation-query.builder'; export * from './aggregate.builder'; +export * from './custom-filter.registry'; diff --git a/packages/query-typeorm/src/query/relation-query.builder.ts b/packages/query-typeorm/src/query/relation-query.builder.ts index 7bdd8d5b38..190ce5a1fd 100644 --- a/packages/query-typeorm/src/query/relation-query.builder.ts +++ b/packages/query-typeorm/src/query/relation-query.builder.ts @@ -3,8 +3,11 @@ import { AggregateQuery, Class, Query } from '@nestjs-query/core'; import { Repository, SelectQueryBuilder, ObjectLiteral, Brackets } from 'typeorm'; import { RelationMetadata } from 'typeorm/metadata/RelationMetadata'; import { DriverUtils } from 'typeorm/driver/DriverUtils'; +import { getQueryTypeormMetadata } from '../common'; +import { CustomFilterRegistry } from './custom-filter.registry'; import { FilterQueryBuilder } from './filter-query.builder'; import { AggregateBuilder } from './aggregate.builder'; +import { WhereBuilder } from './where.builder'; interface JoinCondition { leftHand: string; @@ -64,9 +67,18 @@ export class RelationQueryBuilder { private paramCount: number; - constructor(readonly repo: Repository, readonly relation: string) { + constructor( + readonly repo: Repository, + readonly relation: string, + readonly opts?: { customFilterRegistry?: CustomFilterRegistry }, + ) { this.relationRepo = this.repo.manager.getRepository(this.relationMeta.from); - this.filterQueryBuilder = new FilterQueryBuilder(this.relationRepo); + this.filterQueryBuilder = new FilterQueryBuilder( + this.relationRepo, + new WhereBuilder(getQueryTypeormMetadata(repo.manager.connection), { + customFilterRegistry: opts?.customFilterRegistry, + }), + ); this.paramCount = 0; } @@ -79,7 +91,8 @@ export class RelationQueryBuilder { this.filterQueryBuilder.getReferencedRelationsRecursive(this.relationRepo.metadata, query.filter), ) : relationBuilder; - relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + const klass = this.repo.metadata.target as Class; + relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, klass, query.filter, relationBuilder.alias); relationBuilder = this.filterQueryBuilder.applyPaging(relationBuilder, query.paging); return this.filterQueryBuilder.applySorting(relationBuilder, query.sorting, relationBuilder.alias); } @@ -120,8 +133,9 @@ export class RelationQueryBuilder { aggregateQuery: AggregateQuery, ): SelectQueryBuilder { let relationBuilder = this.createRelationQueryBuilder(entity); + const klass = this.repo.metadata.target as Class; relationBuilder = this.filterQueryBuilder.applyAggregate(relationBuilder, aggregateQuery, relationBuilder.alias); - relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, query.filter, relationBuilder.alias); + relationBuilder = this.filterQueryBuilder.applyFilter(relationBuilder, klass, query.filter, relationBuilder.alias); relationBuilder = this.filterQueryBuilder.applyAggregateSorting( relationBuilder, aggregateQuery.groupBy, diff --git a/packages/query-typeorm/src/query/sql-comparison.builder.ts b/packages/query-typeorm/src/query/sql-comparison.builder.ts index 8e3e854c7d..c886411e06 100644 --- a/packages/query-typeorm/src/query/sql-comparison.builder.ts +++ b/packages/query-typeorm/src/query/sql-comparison.builder.ts @@ -89,7 +89,7 @@ export class SQLComparisonBuilder { // notBetween comparison (field NOT BETWEEN x AND y) return this.notBetweenComparisonSQL(col, val); } - throw new Error(`unknown operator ${JSON.stringify(cmp)}`); + throw new Error(`unknown operator ${JSON.stringify(cmp)} for field ${JSON.stringify(field)}`); } private createComparisonSQL( diff --git a/packages/query-typeorm/src/query/where.builder.ts b/packages/query-typeorm/src/query/where.builder.ts index 79c47f3174..2c6aff067f 100644 --- a/packages/query-typeorm/src/query/where.builder.ts +++ b/packages/query-typeorm/src/query/where.builder.ts @@ -1,36 +1,57 @@ +import { Class, Filter, FilterComparisons, FilterFieldComparison } from '@nestjs-query/core'; import { Brackets, WhereExpression } from 'typeorm'; -import { Filter, FilterComparisons, FilterFieldComparison } from '@nestjs-query/core'; +import { QueryTypeormMetadata } from '../common'; +import { CustomFilterRegistry } from './custom-filter.registry'; +import { RelationsMeta } from './filter-query.builder'; import { EntityComparisonField, SQLComparisonBuilder } from './sql-comparison.builder'; -import { NestedRecord } from './filter-query.builder'; + +interface WhereBuilderOpts { + sqlComparisonBuilder?: SQLComparisonBuilder; + customFilterRegistry?: CustomFilterRegistry; + queryTypeormMetadata?: QueryTypeormMetadata; +} /** * @internal * Builds a WHERE clause from a Filter. */ export class WhereBuilder { - constructor(readonly sqlComparisonBuilder: SQLComparisonBuilder = new SQLComparisonBuilder()) {} + private sqlComparisonBuilder: SQLComparisonBuilder; + + private customFilterRegistry: CustomFilterRegistry; + + // prettier-ignore + constructor( + private queryTypeormMetadata: QueryTypeormMetadata, + opts?: WhereBuilderOpts + ) { + this.sqlComparisonBuilder = opts?.sqlComparisonBuilder ?? new SQLComparisonBuilder(); + this.customFilterRegistry = opts?.customFilterRegistry ?? new CustomFilterRegistry(); + } /** * Builds a WHERE clause from a Filter. * @param where - the `typeorm` WhereExpression * @param filter - the filter to build the WHERE clause from. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ build( where: Where, filter: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { const { and, or } = filter; if (and && and.length) { - this.filterAnd(where, and, relationNames, alias); + this.filterAnd(where, and, relationMeta, klass, alias); } if (or && or.length) { - this.filterOr(where, or, relationNames, alias); + this.filterOr(where, or, relationMeta, klass, alias); } - return this.filterFields(where, filter, relationNames, alias); + return this.filterFields(where, filter, relationMeta, klass, alias); } /** @@ -38,17 +59,21 @@ export class WhereBuilder { * * @param where - the `typeorm` WhereExpression * @param filters - the array of filters to AND together - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterAnd( where: Where, filters: Filter[], - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return where.andWhere( - new Brackets((qb) => filters.reduce((w, f) => qb.andWhere(this.createBrackets(f, relationNames, alias)), qb)), + new Brackets((qb) => + filters.reduce((w, f) => qb.andWhere(this.createBrackets(f, relationMeta, klass, alias)), qb), + ), ); } @@ -57,17 +82,19 @@ export class WhereBuilder { * * @param where - the `typeorm` WhereExpression * @param filter - the array of filters to OR together - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterOr( where: Where, filter: Filter[], - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return where.andWhere( - new Brackets((qb) => filter.reduce((w, f) => qb.orWhere(this.createBrackets(f, relationNames, alias)), qb)), + new Brackets((qb) => filter.reduce((w, f) => qb.orWhere(this.createBrackets(f, relationMeta, klass, alias)), qb)), ); } @@ -78,33 +105,42 @@ export class WhereBuilder { * {a: { eq: 1 }, b: { gt: 2 } } // "((a = 1) AND (b > 2))" * ``` * @param filter - the filter to wrap in brackets. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ - private createBrackets(filter: Filter, relationNames: NestedRecord, alias?: string): Brackets { - return new Brackets((qb) => this.build(qb, filter, relationNames, alias)); + private createBrackets( + filter: Filter, + relationMeta: RelationsMeta, + klass: Class, + alias?: string, + ): Brackets { + return new Brackets((qb) => this.build(qb, filter, relationMeta, klass, alias)); } /** * Creates field comparisons from a filter. This method will ignore and/or properties. * @param where - the `typeorm` WhereExpression * @param filter - the filter with fields to create comparisons for. - * @param relationNames - the relations tree. + * @param relationMeta - the relations tree. + * @param klass - the class currently being processed * @param alias - optional alias to use to qualify an identifier */ private filterFields( where: Where, filter: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { return Object.keys(filter).reduce((w, field) => { if (field !== 'and' && field !== 'or') { return this.withFilterComparison( where, - field as keyof Entity, + field as keyof Entity & string, this.getField(filter, field as keyof Entity), - relationNames, + relationMeta, + klass, alias, ); } @@ -121,21 +157,45 @@ export class WhereBuilder { private withFilterComparison( where: Where, - field: T, + field: T & string, cmp: FilterFieldComparison, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, alias?: string, ): Where { - if (relationNames[field as string]) { - return this.withRelationFilter(where, field, cmp as Filter, relationNames[field as string]); + if (relationMeta && relationMeta[field as string]) { + return this.withRelationFilter( + where, + field, + cmp as Filter, + relationMeta[field as string].relations, + relationMeta[field as string].targetKlass, + ); } + // This could be null if we are targeting a virtual field for special (class, field, operation) filters + const fieldMeta = this.queryTypeormMetadata.get(klass)?.[field]; return where.andWhere( new Brackets((qb) => { - const opts = Object.keys(cmp) as (keyof FilterFieldComparison)[]; - const sqlComparisons = opts.map((cmpType) => - this.sqlComparisonBuilder.build(field, cmpType, cmp[cmpType] as EntityComparisonField, alias), - ); - sqlComparisons.map(({ sql, params }) => qb.orWhere(sql, params)); + const opts = Object.keys(cmp) as (keyof FilterFieldComparison & string)[]; + const sqlComparisons = opts.map((cmpType) => { + // If we have a registered customfilter, this has priority over the standard sqlComparisonBuilder + if (!fieldMeta || fieldMeta.metaType === 'property') { + const customFilter = this.customFilterRegistry?.getFilter(cmpType, fieldMeta?.type, klass, field); + if (customFilter) { + return customFilter.apply(field, cmpType, cmp[cmpType], alias); + } + } + // Fallback to sqlComparisonBuilder + return this.sqlComparisonBuilder.build( + field, + cmpType, + cmp[cmpType] as EntityComparisonField, + alias, + ); + }); + sqlComparisons.map(({ sql, params }) => { + qb.orWhere(sql, params); + }); }), ); } @@ -144,12 +204,18 @@ export class WhereBuilder { where: Where, field: T, cmp: Filter, - relationNames: NestedRecord, + relationMeta: RelationsMeta, + klass: Class, ): Where { return where.andWhere( new Brackets((qb) => { - const relationWhere = new WhereBuilder(); - return relationWhere.build(qb, cmp, relationNames, field as string); + // const relationWhere = new WhereBuilder({ + // customFilterRegistry: this.customFilterRegistry, + // sqlComparisonBuilder: this.sqlComparisonBuilder, + // }); + // return relationWhere.build(qb, cmp, relationMeta, klass, field as string); + // No need to create a new builder since we are stateless and we can reuse the same instance + return (this as unknown as WhereBuilder).build(qb, cmp, relationMeta, klass, field as string); }), ); }