From c20dfbb859eebf084f53694df4e0ad4e7cfb15d0 Mon Sep 17 00:00:00 2001 From: Luca Nardelli Date: Sat, 30 Oct 2021 23:09:54 +0200 Subject: [PATCH] Add support for (operation) type filters --- .../__snapshots__/where.builder.spec.ts.snap | 3 +- .../__tests__/query/where.builder.spec.ts | 50 ++++++++++++++----- .../src/query/custom-filter.registry.ts | 50 +++++++++++-------- .../query-typeorm/src/query/where.builder.ts | 6 +-- 4 files changed, 71 insertions(+), 38 deletions(-) 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 a125772d0b..b66708b95e 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 @@ -110,13 +110,14 @@ exports[`WhereBuilder should accept a empty filter 1`] = `SELECT "TestEntity"."t 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"."numberType" % ?) == 0) AND (ST_Distance("TestEntity"."fakePointType", ST_MakePoint(?,?)) <= ?)`; +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"."numberType" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."dateType") / 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/where.builder.spec.ts b/packages/query-typeorm/__tests__/query/where.builder.spec.ts index 65e690c843..d784013fb1 100644 --- a/packages/query-typeorm/__tests__/query/where.builder.spec.ts +++ b/packages/query-typeorm/__tests__/query/where.builder.spec.ts @@ -13,7 +13,8 @@ describe('WhereBuilder', (): void => { const createWhereBuilder = () => new WhereBuilder(); const customFilterRegistry = new CustomFilterRegistry(); - customFilterRegistry.setFilter(TestEntity, 'numberType', 'isMultipleOf', { + // Test for (operation) filter registration (this is valid for all fields of all entities) + customFilterRegistry.setFilter('isMultipleOf', { apply(field, cmp, val: number, alias): CustomFilterResult { alias = alias ? alias : ''; const pname = `param${randomString()}`; @@ -23,19 +24,38 @@ describe('WhereBuilder', (): void => { }; }, }); - // This property does not actually exist in the entity, but since we are testing only the generated SQL it's ok. - customFilterRegistry.setFilter(TestEntity, 'fakePointType', 'distanceFrom', { - apply(field, cmp, val: { point: { lat: number; lng: number }; radius: number }, alias): 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 }, - }; + // Test for (class, field, operation) filter overriding the previous operation filter on a specific field + customFilterRegistry.setFilter( + 'isMultipleOf', + { + apply(field, cmp, val: number, alias): CustomFilterResult { + alias = alias ? alias : ''; + const pname = `param${randomString()}`; + return { + sql: `(EXTRACT(EPOCH FROM "${alias}"."${field}") / 3600 / 24) % :${pname}) == 0`, + params: { [pname]: val }, + }; + }, }, - }); + { klass: TestEntity, field: 'dateType' }, + ); + // Test for (class, field, operation) filter on a virtual property 'fakePointType' that does not really exist on the entity + customFilterRegistry.setFilter( + 'distanceFrom', + { + apply(field, cmp, val: { point: { lat: number; lng: number }; radius: number }, alias): 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 }, + }; + }, + }, + { klass: TestEntity, field: 'fakePointType' }, + ); const expectSQLSnapshot = (filter: Filter): void => { const selectQueryBuilder = createWhereBuilder().build( @@ -66,7 +86,11 @@ describe('WhereBuilder', (): void => { // 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); }); diff --git a/packages/query-typeorm/src/query/custom-filter.registry.ts b/packages/query-typeorm/src/query/custom-filter.registry.ts index 4ac7f71610..08b0f596d0 100644 --- a/packages/query-typeorm/src/query/custom-filter.registry.ts +++ b/packages/query-typeorm/src/query/custom-filter.registry.ts @@ -12,43 +12,51 @@ export type CustomFilterResult = { sql: string; params: ObjectLiteral }; * Used to create custom filters */ export interface CustomFilter { - apply(field: keyof Entity & string, cmp: string, val: T, alias?: string): CustomFilterResult; + apply(field: keyof Entity | string, cmp: string, val: T, alias?: string): CustomFilterResult; } type EntityCustomFilters = Record>; export class CustomFilterRegistry { - private registry: Map, EntityCustomFilters> = new Map(); + // Registry for (class, field, operation) filters + private cfoRegistry: Map, EntityCustomFilters> = new Map(); - getEntityFilters(klass: Class): EntityCustomFilters { - if (!this.registry.has(klass)) { - this.registry.set(klass, {}); - } - return this.registry.get(klass) as EntityCustomFilters; - } - - getFieldFilters(klass: Class, field: string | keyof Entity): Record { - return this.getEntityFilters(klass)[field]; - } + // Registry for (operation) filters + private oRegistry: Record = {}; getFilter( - klass: Class, - field: string | keyof Entity, opName: string, + opts?: { klass: Class; field?: keyof Entity | string }, ): CustomFilter | undefined { - return this.getFieldFilters(klass, field)?.[opName]; + // Most specific: (class, field, operation) filters. + if (opts && opts.klass && opts.field) { + const flt = this.cfoRegistry.get(opts.klass)?.[opts.field]?.[opName]; + if (flt) { + return flt; + } + } + // Less specific: (operation) filters + return this.oRegistry[opName]; } + // (operation) filter overload + setFilter(opName: string, filter: CustomFilter, opts?: unknown): void; + + // (class, field, operation) filter overload setFilter( - klass: Class, - field: keyof Entity | string, opName: string, filter: CustomFilter, + opts?: { klass: Class; field?: keyof Entity | string }, ): void { - if (!this.registry.has(klass)) { - this.registry.set(klass, {}); + if (opts && opts.klass && opts.field) { + const { klass, field } = opts; + if (!this.cfoRegistry.has(klass)) { + this.cfoRegistry.set(klass, {}); + } + const klassFilters = this.cfoRegistry.get(klass) as EntityCustomFilters; + klassFilters[field] = merge(klassFilters[field], { [opName]: filter }); + } else { + this.oRegistry[opName] = filter; } - const klassFilters = this.registry.get(klass) as EntityCustomFilters; - klassFilters[field] = merge(klassFilters[field], { [opName]: filter }); } } diff --git a/packages/query-typeorm/src/query/where.builder.ts b/packages/query-typeorm/src/query/where.builder.ts index 43d18619ad..c7131d4355 100644 --- a/packages/query-typeorm/src/query/where.builder.ts +++ b/packages/query-typeorm/src/query/where.builder.ts @@ -156,7 +156,7 @@ export class WhereBuilder { cmp: FilterFieldComparison, relationMeta: RelationsMeta, klass: Class, - customFilters?: CustomFilterRegistry, + filterRegistry?: CustomFilterRegistry, alias?: string, ): Where { if (relationMeta && relationMeta[field as string]) { @@ -166,7 +166,7 @@ export class WhereBuilder { cmp as Filter, relationMeta[field as string].relations, relationMeta[field as string].targetKlass, - customFilters, + filterRegistry, ); } return where.andWhere( @@ -174,7 +174,7 @@ export class WhereBuilder { // Fallback sqlComparisonBuilder const opts = Object.keys(cmp) as (keyof FilterFieldComparison & string)[]; const sqlComparisons = opts.map((cmpType) => { - const customFilter = customFilters?.getFilter(klass, field, cmpType); + const customFilter = filterRegistry?.getFilter(cmpType, { klass, field }); // If we have a registered customfilter for this cmpType, this has priority over the standard sqlComparisonBuilder if (customFilter) { return customFilter.apply(field, cmpType, cmp[cmpType], alias);