Skip to content

Commit 5929f56

Browse files
committed
Custom filter support for typeorm
1 parent 9af43aa commit 5929f56

File tree

10 files changed

+350
-104
lines changed

10 files changed

+350
-104
lines changed

packages/core/src/helpers/filter.builder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ export class FilterBuilder {
5858
throw new Error(`unknown comparison ${JSON.stringify(fieldOrNested)}`);
5959
}
6060
const nestedFilterFn = this.build(value);
61-
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : null);
61+
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : undefined);
6262
}
6363
}

packages/core/src/interfaces/filter.interface.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,6 @@ type FilterGrouping<T> = {
111111
* ```
112112
*
113113
* @typeparam T - the type of object to filter on.
114+
* @typeparam C - custom filters defined on the object.
114115
*/
115-
export type Filter<T> = FilterGrouping<T> & FilterComparisons<T>;
116+
export type Filter<T, C = Record<string, any>> = FilterGrouping<T> & FilterComparisons<T> & { [K in keyof C]: C[K] };

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,16 @@ Array [
109109
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"`;
110110

111111
exports[`WhereBuilder should accept a empty filter 2`] = `Array []`;
112+
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(?,?)) <= ?)`;
114+
115+
exports[`WhereBuilder should accept custom filters alongside regular filters 2`] = `
116+
Array [
117+
1,
118+
10,
119+
5,
120+
45.3,
121+
9.5,
122+
50000,
123+
]
124+
`;

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

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito';
2-
import { QueryBuilder, WhereExpression } from 'typeorm';
31
import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core';
2+
import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito';
3+
import { QueryBuilder, WhereExpression } from 'typeorm';
4+
import { CustomFilterRegistry, FilterQueryBuilder, WhereBuilder } from '../../src/query';
45
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture';
56
import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity';
67
import { TestEntity } from '../__fixtures__/test.entity';
7-
import { FilterQueryBuilder, WhereBuilder } from '../../src/query';
88

99
describe('FilterQueryBuilder', (): void => {
1010
beforeEach(createTestConnection);
@@ -98,15 +98,23 @@ describe('FilterQueryBuilder', (): void => {
9898
it('should not call whereBuilder#build', () => {
9999
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
100100
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
101-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
101+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
102102
});
103103

104104
it('should call whereBuilder#build if there is a filter', () => {
105105
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
106106
const query = { filter: { stringType: { eq: 'foo' } } };
107-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), 'TestEntity')).thenCall(
108-
(where: WhereExpression, field: Filter<TestEntity>, relationNames: string[], alias: string) =>
109-
where.andWhere(`${alias}.stringType = 'foo'`),
107+
when(
108+
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, 'TestEntity'),
109+
).thenCall(
110+
(
111+
where: WhereExpression,
112+
field: Filter<TestEntity>,
113+
relationNames: string[],
114+
klass: Class<any>,
115+
customFilters: CustomFilterRegistry,
116+
alias: string,
117+
) => where.andWhere(`${alias}.stringType = 'foo'`),
110118
);
111119
expectSelectSQLSnapshot(query, instance(mockWhereBuilder));
112120
});
@@ -116,19 +124,23 @@ describe('FilterQueryBuilder', (): void => {
116124
it('should apply empty paging args', () => {
117125
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
118126
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
119-
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
127+
verify(
128+
mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, undefined, 'TestEntity'),
129+
).never();
120130
});
121131

122132
it('should apply paging args going forward', () => {
123133
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
124134
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
125-
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
135+
verify(
136+
mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, undefined, 'TestEntity'),
137+
).never();
126138
});
127139

128140
it('should apply paging args going backward', () => {
129141
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
130142
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 10 } }, instance(mockWhereBuilder));
131-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
143+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
132144
});
133145
});
134146

@@ -139,7 +151,7 @@ describe('FilterQueryBuilder', (): void => {
139151
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC }] },
140152
instance(mockWhereBuilder),
141153
);
142-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
154+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
143155
});
144156

145157
it('should apply ASC NULLS_FIRST sorting', () => {
@@ -148,7 +160,7 @@ describe('FilterQueryBuilder', (): void => {
148160
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] },
149161
instance(mockWhereBuilder),
150162
);
151-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
163+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
152164
});
153165

154166
it('should apply ASC NULLS_LAST sorting', () => {
@@ -157,7 +169,7 @@ describe('FilterQueryBuilder', (): void => {
157169
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] },
158170
instance(mockWhereBuilder),
159171
);
160-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
172+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
161173
});
162174

163175
it('should apply DESC sorting', () => {
@@ -166,7 +178,7 @@ describe('FilterQueryBuilder', (): void => {
166178
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC }] },
167179
instance(mockWhereBuilder),
168180
);
169-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
181+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
170182
});
171183

172184
it('should apply DESC NULLS_FIRST sorting', () => {
@@ -183,7 +195,7 @@ describe('FilterQueryBuilder', (): void => {
183195
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] },
184196
instance(mockWhereBuilder),
185197
);
186-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
198+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
187199
});
188200

189201
it('should apply multiple sorts', () => {
@@ -199,7 +211,7 @@ describe('FilterQueryBuilder', (): void => {
199211
},
200212
instance(mockWhereBuilder),
201213
);
202-
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
214+
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
203215
});
204216
});
205217
});
@@ -214,17 +226,17 @@ describe('FilterQueryBuilder', (): void => {
214226
it('should call whereBuilder#build if there is a filter', () => {
215227
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
216228
const query = { filter: { stringType: { eq: 'foo' } } };
217-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
218-
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
219-
);
229+
when(
230+
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, undefined),
231+
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
220232
expectUpdateSQLSnapshot(query, instance(mockWhereBuilder));
221233
});
222234
});
223235
describe('with paging', () => {
224236
it('should ignore paging args', () => {
225237
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
226238
expectUpdateSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
227-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
239+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
228240
});
229241
});
230242

@@ -235,7 +247,7 @@ describe('FilterQueryBuilder', (): void => {
235247
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC }] },
236248
instance(mockWhereBuilder),
237249
);
238-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
250+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
239251
});
240252

241253
it('should apply ASC NULLS_FIRST sorting', () => {
@@ -244,7 +256,7 @@ describe('FilterQueryBuilder', (): void => {
244256
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] },
245257
instance(mockWhereBuilder),
246258
);
247-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
259+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
248260
});
249261

250262
it('should apply ASC NULLS_LAST sorting', () => {
@@ -253,7 +265,7 @@ describe('FilterQueryBuilder', (): void => {
253265
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] },
254266
instance(mockWhereBuilder),
255267
);
256-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
268+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
257269
});
258270

259271
it('should apply DESC sorting', () => {
@@ -262,7 +274,7 @@ describe('FilterQueryBuilder', (): void => {
262274
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC }] },
263275
instance(mockWhereBuilder),
264276
);
265-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
277+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
266278
});
267279

268280
it('should apply DESC NULLS_FIRST sorting', () => {
@@ -271,7 +283,7 @@ describe('FilterQueryBuilder', (): void => {
271283
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }] },
272284
instance(mockWhereBuilder),
273285
);
274-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
286+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
275287
});
276288

277289
it('should apply DESC NULLS_LAST sorting', () => {
@@ -280,7 +292,7 @@ describe('FilterQueryBuilder', (): void => {
280292
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] },
281293
instance(mockWhereBuilder),
282294
);
283-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
295+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
284296
});
285297

286298
it('should apply multiple sorts', () => {
@@ -296,7 +308,7 @@ describe('FilterQueryBuilder', (): void => {
296308
},
297309
instance(mockWhereBuilder),
298310
);
299-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
311+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
300312
});
301313
});
302314
});
@@ -311,17 +323,17 @@ describe('FilterQueryBuilder', (): void => {
311323
it('should call whereBuilder#build if there is a filter', () => {
312324
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
313325
const query = { filter: { stringType: { eq: 'foo' } } };
314-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
315-
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
316-
);
326+
when(
327+
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, undefined),
328+
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
317329
expectDeleteSQLSnapshot(query, instance(mockWhereBuilder));
318330
});
319331
});
320332
describe('with paging', () => {
321333
it('should ignore paging args', () => {
322334
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
323335
expectDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
324-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
336+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
325337
});
326338
});
327339

@@ -339,7 +351,7 @@ describe('FilterQueryBuilder', (): void => {
339351
},
340352
instance(mockWhereBuilder),
341353
);
342-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
354+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
343355
});
344356
});
345357
});
@@ -357,17 +369,17 @@ describe('FilterQueryBuilder', (): void => {
357369
it('should call whereBuilder#build if there is a filter', () => {
358370
const mockWhereBuilder = mock<WhereBuilder<TestSoftDeleteEntity>>(WhereBuilder);
359371
const query = { filter: { stringType: { eq: 'foo' } } };
360-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
361-
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
362-
);
372+
when(
373+
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestSoftDeleteEntity, undefined, undefined),
374+
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
363375
expectSoftDeleteSQLSnapshot(query, instance(mockWhereBuilder));
364376
});
365377
});
366378
describe('with paging', () => {
367379
it('should ignore paging args', () => {
368380
const mockWhereBuilder = mock<WhereBuilder<TestSoftDeleteEntity>>(WhereBuilder);
369381
expectSoftDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
370-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
382+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
371383
});
372384
});
373385

@@ -383,7 +395,7 @@ describe('FilterQueryBuilder', (): void => {
383395
},
384396
instance(mockWhereBuilder),
385397
);
386-
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
398+
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
387399
});
388400
});
389401
});

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

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Filter } from '@nestjs-query/core';
2+
import { randomString } from '../../src/common';
3+
import { CustomFilterRegistry, CustomFilterResult, WhereBuilder } from '../../src/query';
24
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture';
35
import { TestEntity } from '../__fixtures__/test.entity';
4-
import { WhereBuilder } from '../../src/query';
56

67
describe('WhereBuilder', (): void => {
78
beforeEach(createTestConnection);
@@ -11,8 +12,40 @@ describe('WhereBuilder', (): void => {
1112
const getQueryBuilder = () => getRepo().createQueryBuilder();
1213
const createWhereBuilder = () => new WhereBuilder<TestEntity>();
1314

15+
const customFilterRegistry = new CustomFilterRegistry();
16+
customFilterRegistry.setFilter<TestEntity>(TestEntity, 'numberType', 'isMultipleOf', {
17+
apply(field, cmp, val: number, alias): CustomFilterResult {
18+
alias = alias ? alias : '';
19+
const pname = `param${randomString()}`;
20+
return {
21+
sql: `("${alias}"."${field}" % :${pname}) == 0`,
22+
params: { [pname]: val },
23+
};
24+
},
25+
});
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+
};
37+
},
38+
});
39+
1440
const expectSQLSnapshot = (filter: Filter<TestEntity>): void => {
15-
const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, 'TestEntity');
41+
const selectQueryBuilder = createWhereBuilder().build(
42+
getQueryBuilder(),
43+
filter,
44+
{},
45+
TestEntity,
46+
customFilterRegistry,
47+
'TestEntity',
48+
);
1649
const [sql, params] = selectQueryBuilder.getQueryAndParameters();
1750
expect(sql).toMatchSnapshot();
1851
expect(params).toMatchSnapshot();
@@ -30,6 +63,14 @@ describe('WhereBuilder', (): void => {
3063
expectSQLSnapshot({ numberType: { eq: 1 }, stringType: { like: 'foo%' }, boolType: { is: true } });
3164
});
3265

66+
// TODO Fix typings to avoid usage of any
67+
it('should accept custom filters alongside regular filters', (): void => {
68+
expectSQLSnapshot({
69+
numberType: { gte: 1, lte: 10, isMultipleOf: 5 },
70+
fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } },
71+
} as any);
72+
});
73+
3374
describe('and', (): void => {
3475
it('and multiple expressions together', (): void => {
3576
expectSQLSnapshot({

0 commit comments

Comments
 (0)