Skip to content

Commit

Permalink
Add support for (operation) type filters
Browse files Browse the repository at this point in the history
  • Loading branch information
luca-nardelli committed Oct 30, 2021
1 parent 5929f56 commit c20dfbb
Show file tree
Hide file tree
Showing 4 changed files with 71 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
50 changes: 37 additions & 13 deletions packages/query-typeorm/__tests__/query/where.builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ describe('WhereBuilder', (): void => {
const createWhereBuilder = () => new WhereBuilder<TestEntity>();

const customFilterRegistry = new CustomFilterRegistry();
customFilterRegistry.setFilter<TestEntity>(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()}`;
Expand All @@ -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>(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<TestEntity>(
'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<TestEntity>(
'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<TestEntity>): void => {
const selectQueryBuilder = createWhereBuilder().build(
Expand Down Expand Up @@ -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);
});
Expand Down
50 changes: 29 additions & 21 deletions packages/query-typeorm/src/query/custom-filter.registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,51 @@ export type CustomFilterResult = { sql: string; params: ObjectLiteral };
* Used to create custom filters
*/
export interface CustomFilter<Entity = unknown, T = unknown> {
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<string | symbol | number, Record<string, CustomFilter>>;

export class CustomFilterRegistry {
private registry: Map<Class<unknown>, EntityCustomFilters> = new Map();
// Registry for (class, field, operation) filters
private cfoRegistry: Map<Class<unknown>, EntityCustomFilters> = new Map();

getEntityFilters<Entity = unknown>(klass: Class<Entity>): EntityCustomFilters {
if (!this.registry.has(klass)) {
this.registry.set(klass, {});
}
return this.registry.get(klass) as EntityCustomFilters;
}

getFieldFilters<Entity = unknown>(klass: Class<Entity>, field: string | keyof Entity): Record<string, CustomFilter> {
return this.getEntityFilters(klass)[field];
}
// Registry for (operation) filters
private oRegistry: Record<string, CustomFilter> = {};

getFilter<Entity = unknown>(
klass: Class<Entity>,
field: string | keyof Entity,
opName: string,
opts?: { klass: Class<Entity>; field?: keyof Entity | string },
): CustomFilter<Entity> | 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<Entity>(opName: string, filter: CustomFilter, opts?: unknown): void;

// (class, field, operation) filter overload
setFilter<Entity>(
klass: Class<Entity>,
field: keyof Entity | string,
opName: string,
filter: CustomFilter<Entity>,
opts?: { klass: Class<Entity>; 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 });
}
}
6 changes: 3 additions & 3 deletions packages/query-typeorm/src/query/where.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class WhereBuilder<Entity> {
cmp: FilterFieldComparison<Entity[T]>,
relationMeta: RelationsMeta,
klass: Class<Entity>,
customFilters?: CustomFilterRegistry,
filterRegistry?: CustomFilterRegistry,
alias?: string,
): Where {
if (relationMeta && relationMeta[field as string]) {
Expand All @@ -166,15 +166,15 @@ export class WhereBuilder<Entity> {
cmp as Filter<Entity[T]>,
relationMeta[field as string].relations,
relationMeta[field as string].targetKlass,
customFilters,
filterRegistry,
);
}
return where.andWhere(
new Brackets((qb) => {
// Fallback sqlComparisonBuilder
const opts = Object.keys(cmp) as (keyof FilterFieldComparison<Entity[T]> & 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);
Expand Down

0 comments on commit c20dfbb

Please sign in to comment.