Skip to content

Commit c20dfbb

Browse files
committed
Add support for (operation) type filters
1 parent 5929f56 commit c20dfbb

File tree

4 files changed

+71
-38
lines changed

4 files changed

+71
-38
lines changed

packages/query-typeorm/__tests__/query/__snapshots__/where.builder.spec.ts.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,14 @@ exports[`WhereBuilder should accept a empty filter 1`] = `SELECT "TestEntity"."t
110110

111111
exports[`WhereBuilder should accept a empty filter 2`] = `Array []`;
112112

113-
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(?,?)) <= ?)`;
113+
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(?,?)) <= ?)`;
114114

115115
exports[`WhereBuilder should accept custom filters alongside regular filters 2`] = `
116116
Array [
117117
1,
118118
10,
119119
5,
120+
3,
120121
45.3,
121122
9.5,
122123
50000,

packages/query-typeorm/__tests__/query/where.builder.spec.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ describe('WhereBuilder', (): void => {
1313
const createWhereBuilder = () => new WhereBuilder<TestEntity>();
1414

1515
const customFilterRegistry = new CustomFilterRegistry();
16-
customFilterRegistry.setFilter<TestEntity>(TestEntity, 'numberType', 'isMultipleOf', {
16+
// Test for (operation) filter registration (this is valid for all fields of all entities)
17+
customFilterRegistry.setFilter('isMultipleOf', {
1718
apply(field, cmp, val: number, alias): CustomFilterResult {
1819
alias = alias ? alias : '';
1920
const pname = `param${randomString()}`;
@@ -23,19 +24,38 @@ describe('WhereBuilder', (): void => {
2324
};
2425
},
2526
});
26-
// This property does not actually exist in the entity, but since we are testing only the generated SQL it's ok.
27-
customFilterRegistry.setFilter<TestEntity>(TestEntity, 'fakePointType', 'distanceFrom', {
28-
apply(field, cmp, val: { point: { lat: number; lng: number }; radius: number }, alias): CustomFilterResult {
29-
alias = alias ? alias : '';
30-
const plat = `param${randomString()}`;
31-
const plng = `param${randomString()}`;
32-
const prad = `param${randomString()}`;
33-
return {
34-
sql: `ST_Distance("${alias}"."${field}", ST_MakePoint(:${plat},:${plng})) <= :${prad}`,
35-
params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius },
36-
};
27+
// Test for (class, field, operation) filter overriding the previous operation filter on a specific field
28+
customFilterRegistry.setFilter<TestEntity>(
29+
'isMultipleOf',
30+
{
31+
apply(field, cmp, val: number, alias): CustomFilterResult {
32+
alias = alias ? alias : '';
33+
const pname = `param${randomString()}`;
34+
return {
35+
sql: `(EXTRACT(EPOCH FROM "${alias}"."${field}") / 3600 / 24) % :${pname}) == 0`,
36+
params: { [pname]: val },
37+
};
38+
},
3739
},
38-
});
40+
{ klass: TestEntity, field: 'dateType' },
41+
);
42+
// Test for (class, field, operation) filter on a virtual property 'fakePointType' that does not really exist on the entity
43+
customFilterRegistry.setFilter<TestEntity>(
44+
'distanceFrom',
45+
{
46+
apply(field, cmp, val: { point: { lat: number; lng: number }; radius: number }, alias): CustomFilterResult {
47+
alias = alias ? alias : '';
48+
const plat = `param${randomString()}`;
49+
const plng = `param${randomString()}`;
50+
const prad = `param${randomString()}`;
51+
return {
52+
sql: `ST_Distance("${alias}"."${field}", ST_MakePoint(:${plat},:${plng})) <= :${prad}`,
53+
params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius },
54+
};
55+
},
56+
},
57+
{ klass: TestEntity, field: 'fakePointType' },
58+
);
3959

4060
const expectSQLSnapshot = (filter: Filter<TestEntity>): void => {
4161
const selectQueryBuilder = createWhereBuilder().build(
@@ -66,7 +86,11 @@ describe('WhereBuilder', (): void => {
6686
// TODO Fix typings to avoid usage of any
6787
it('should accept custom filters alongside regular filters', (): void => {
6888
expectSQLSnapshot({
89+
// This has the global isMultipleOf filter
6990
numberType: { gte: 1, lte: 10, isMultipleOf: 5 },
91+
// Here, the isMultipleOf filter was overridden for dateType only
92+
dateType: { isMultipleOf: 3 },
93+
// This is a more complex filter involving geospatial queries
7094
fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } },
7195
} as any);
7296
});

packages/query-typeorm/src/query/custom-filter.registry.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,43 +12,51 @@ export type CustomFilterResult = { sql: string; params: ObjectLiteral };
1212
* Used to create custom filters
1313
*/
1414
export interface CustomFilter<Entity = unknown, T = unknown> {
15-
apply(field: keyof Entity & string, cmp: string, val: T, alias?: string): CustomFilterResult;
15+
apply(field: keyof Entity | string, cmp: string, val: T, alias?: string): CustomFilterResult;
1616
}
1717

1818
type EntityCustomFilters = Record<string | symbol | number, Record<string, CustomFilter>>;
1919

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

23-
getEntityFilters<Entity = unknown>(klass: Class<Entity>): EntityCustomFilters {
24-
if (!this.registry.has(klass)) {
25-
this.registry.set(klass, {});
26-
}
27-
return this.registry.get(klass) as EntityCustomFilters;
28-
}
29-
30-
getFieldFilters<Entity = unknown>(klass: Class<Entity>, field: string | keyof Entity): Record<string, CustomFilter> {
31-
return this.getEntityFilters(klass)[field];
32-
}
24+
// Registry for (operation) filters
25+
private oRegistry: Record<string, CustomFilter> = {};
3326

3427
getFilter<Entity = unknown>(
35-
klass: Class<Entity>,
36-
field: string | keyof Entity,
3728
opName: string,
29+
opts?: { klass: Class<Entity>; field?: keyof Entity | string },
3830
): CustomFilter<Entity> | undefined {
39-
return this.getFieldFilters(klass, field)?.[opName];
31+
// Most specific: (class, field, operation) filters.
32+
if (opts && opts.klass && opts.field) {
33+
const flt = this.cfoRegistry.get(opts.klass)?.[opts.field]?.[opName];
34+
if (flt) {
35+
return flt;
36+
}
37+
}
38+
// Less specific: (operation) filters
39+
return this.oRegistry[opName];
4040
}
4141

42+
// (operation) filter overload
43+
setFilter<Entity>(opName: string, filter: CustomFilter, opts?: unknown): void;
44+
45+
// (class, field, operation) filter overload
4246
setFilter<Entity>(
43-
klass: Class<Entity>,
44-
field: keyof Entity | string,
4547
opName: string,
4648
filter: CustomFilter<Entity>,
49+
opts?: { klass: Class<Entity>; field?: keyof Entity | string },
4750
): void {
48-
if (!this.registry.has(klass)) {
49-
this.registry.set(klass, {});
51+
if (opts && opts.klass && opts.field) {
52+
const { klass, field } = opts;
53+
if (!this.cfoRegistry.has(klass)) {
54+
this.cfoRegistry.set(klass, {});
55+
}
56+
const klassFilters = this.cfoRegistry.get(klass) as EntityCustomFilters;
57+
klassFilters[field] = merge(klassFilters[field], { [opName]: filter });
58+
} else {
59+
this.oRegistry[opName] = filter;
5060
}
51-
const klassFilters = this.registry.get(klass) as EntityCustomFilters;
52-
klassFilters[field] = merge(klassFilters[field], { [opName]: filter });
5361
}
5462
}

packages/query-typeorm/src/query/where.builder.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export class WhereBuilder<Entity> {
156156
cmp: FilterFieldComparison<Entity[T]>,
157157
relationMeta: RelationsMeta,
158158
klass: Class<Entity>,
159-
customFilters?: CustomFilterRegistry,
159+
filterRegistry?: CustomFilterRegistry,
160160
alias?: string,
161161
): Where {
162162
if (relationMeta && relationMeta[field as string]) {
@@ -166,15 +166,15 @@ export class WhereBuilder<Entity> {
166166
cmp as Filter<Entity[T]>,
167167
relationMeta[field as string].relations,
168168
relationMeta[field as string].targetKlass,
169-
customFilters,
169+
filterRegistry,
170170
);
171171
}
172172
return where.andWhere(
173173
new Brackets((qb) => {
174174
// Fallback sqlComparisonBuilder
175175
const opts = Object.keys(cmp) as (keyof FilterFieldComparison<Entity[T]> & string)[];
176176
const sqlComparisons = opts.map((cmpType) => {
177-
const customFilter = customFilters?.getFilter(klass, field, cmpType);
177+
const customFilter = filterRegistry?.getFilter(cmpType, { klass, field });
178178
// If we have a registered customfilter for this cmpType, this has priority over the standard sqlComparisonBuilder
179179
if (customFilter) {
180180
return customFilter.apply(field, cmpType, cmp[cmpType], alias);

0 commit comments

Comments
 (0)