Skip to content

Commit 8733a1d

Browse files
committed
feat(graphql+typeorm): Custom filters
- Force UTC timezone to make some tests (e.g. typeorm-query-service.spec) deterministic - Allow to define and register custom filters on types and entities (virtual fields as well) at the Typeorm (persistence) layer - Allow extending filters on the built-in graphql types - Implement custom filters on custom graphql scalars - Implement allowedComparisons for extended filters and for custom scalar defined graphql filters - Implement custom graphql filters on virtual properties - Documentation - Tests
1 parent 098f83a commit 8733a1d

File tree

64 files changed

+2467
-250
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+2467
-250
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
title: Custom Filters
3+
---
4+
5+
In addition to the built in filters, you can also define custom filtering operations.
6+
7+
There are 2 types of custom filters:
8+
9+
- Global type-based filters, that automatically work on all fields of a given GraphQL type.
10+
- Custom entity-specific filters, that are custom-tailored for DTOs and do not require a specific backing field (more on
11+
that below).
12+
13+
[//]: # (TODO Add link to page)
14+
:::important
15+
16+
This page describes how to implement custom filters at the GraphQL Level. The persistence layer needs to support them as
17+
well. For now, only TypeOrm is implemented. See here:
18+
19+
- [TypeOrm Custom Filters](/docs/persistence/typeorm/custom-filters)
20+
21+
:::
22+
23+
## Global type-based filters
24+
25+
Type based filters are applied globally to all DTOs, based only on the underlying GraphQL Type.
26+
27+
### Extending the existing filters
28+
29+
Let's assume our persistence layer exposes a `isMultipleOf` filter which allows us to filter numeric fields and choose
30+
only multiples of a user-supplied value. In order to expose that filter on all numeric GraphQL fields, we can do the
31+
following in any typescript file (ideally this should run before the app is initialized):
32+
33+
```ts
34+
import { registerTypeComparison } from '@nestjs-query/query-graphql';
35+
import { IsBoolean, IsInt } from 'class-validator';
36+
import { Float, Int } from '@nestjs/graphql';
37+
38+
registerTypeComparison(Number, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
39+
registerTypeComparison(Int, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
40+
registerTypeComparison(Float, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
41+
42+
// Note, this also works
43+
// registerTypeComparison([Number, Int, Float], 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
44+
```
45+
46+
Where:
47+
48+
- FilterType is the typescript type of the filter
49+
- GqlType is the GraphQL type that will be used in the schema
50+
- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the
51+
operation, used e.g. for validation purposes
52+
53+
The above snippet patches the existing Number/Int/Float FieldComparisons so that they expose the new
54+
field/operation `isMultipleOf`. Example:
55+
56+
```graphql
57+
input NumberFieldComparison {
58+
is: Boolean
59+
isNot: Boolean
60+
eq: Float
61+
neq: Float
62+
gt: Float
63+
gte: Float
64+
lt: Float
65+
lte: Float
66+
in: [Float!]
67+
notIn: [Float!]
68+
between: NumberFieldComparisonBetween
69+
notBetween: NumberFieldComparisonBetween
70+
isMultipleOf: Int
71+
}
72+
```
73+
74+
### Defining a filter on a custom scalar
75+
76+
Let's assume we have a custom scalar, to represent e.g. a geo point (i.e. {lat, lng}):
77+
78+
```ts
79+
@Scalar('Point', (type) => Object)
80+
export class PointScalar implements CustomScalar<any, any> {
81+
description = 'Point custom scalar type';
82+
83+
parseValue(value: any): any {
84+
return { lat: value.lat, lng: value.lng };
85+
}
86+
87+
serialize(value: any): any {
88+
return { lat: value.lat, lng: value.lng };
89+
}
90+
91+
parseLiteral(ast: ValueNode): any {
92+
if (ast.kind === Kind.OBJECT) {
93+
return ast.fields;
94+
}
95+
return null;
96+
}
97+
}
98+
```
99+
100+
Now, we want to add a radius filter to all Point scalars. A radius filter is a filter that returns all entities whose
101+
location is within a given distance from another point.
102+
103+
First, we need to define the filter type:
104+
105+
```ts
106+
@InputType('RadiusFilter')
107+
export class RadiusFilter {
108+
@Field(() => Number)
109+
lat!: number;
110+
111+
@Field(() => Number)
112+
lng!: number;
113+
114+
@Field(() => Number)
115+
radius!: number;
116+
}
117+
```
118+
119+
Then, we need to register said filter:
120+
121+
```ts
122+
registerTypeComparison(PointScalar, 'distanceFrom', {
123+
FilterType: RadiusFilter,
124+
GqlType: RadiusFilter,
125+
});
126+
```
127+
128+
The above snippet creates a new comparison type for the Point scalar and adds the distanceFrom operations to it.
129+
Example:
130+
131+
```graphql
132+
input RadiusFilter {
133+
lat: Float!
134+
lng: Float!
135+
radius: Float!
136+
}
137+
138+
input PointScalarFilterComparison {
139+
distanceFrom: RadiusFilter
140+
}
141+
```
142+
143+
Now, our persistence layer will be able to receive this new `distanceFrom` key for every property that is represented as
144+
a Point scalar.
145+
146+
:::important
147+
148+
If the shape of the filter at the GraphQL layer is different from what the persistence layer expects, remember to use
149+
an [Assembler and its convertQuery method!](/docs/concepts/advanced/assemblers#converting-the-query)
150+
151+
:::
152+
153+
### Disabling a type-based custom filter on specific fields of a DTO
154+
155+
Global filters are fully compatible with the [allowedComparisons](/docs/graphql/dtos/#example---allowedcomparisons)
156+
option of the `@FilterableField` decorator.
157+
158+
## DTO-based custom filters
159+
160+
These custom filters are explicitly registered on a single DTO field, rather than at the type level. This can be useful
161+
if the persistence layer exposes some specific filters only on some entities (e.g. "Filter all projects who more than 5
162+
pending tasks" where we need to compute the number of pending tasks using a SQL sub-query in the where clause, instead
163+
of having a computed field in the project entity).
164+
165+
:::important
166+
167+
DTO-based custom filters cannot be registered on existing DTO filterable fields, use type-based filters for that!
168+
169+
:::
170+
171+
In order to register a "pending tasks count" filter on our ProjectDto, we can do as follows:
172+
173+
```ts
174+
registerDTOFieldComparison(TestDto, 'pendingTaskCount', 'gt', {
175+
FilterType: Number,
176+
GqlType: Int,
177+
decorators: [IsInt()],
178+
});
179+
```
180+
181+
Where:
182+
183+
- FilterType is the typescript type of the filter
184+
- GqlType is the GraphQL type that will be used in the schema
185+
- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the
186+
operation, used e.g. for validation purposes
187+
188+
This will add a new operation to the GraphQL `TestDto` input type
189+
190+
```graphql
191+
input TestPendingTaskCountFilterComparison {
192+
gt: Int
193+
}
194+
195+
input TestDtoFilter {
196+
"""
197+
...Other fields defined in TestDTO
198+
"""
199+
pendingTaskCount: TestPendingTaskCountFilterComparison
200+
}
201+
```
202+
203+
Now, graphQL will accept the new filter and our persistence layer will be able to receive the key `pendingTaskCount` for all filtering operations related to the "TestDto" DTO.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
---
2+
title: Custom Filters
3+
---
4+
5+
In addition to the built in filters, which work for a lot of common scenarios, @nestjs-query/typeorm supports custom filters.
6+
7+
There are 2 types of custom filters:
8+
- Global type-based filters, that automatically work on all fields of a given database type.
9+
- Custom entity-specific filters, that are custom-tailored for entities and do not require a backing database field (more on that below).
10+
11+
[//]: # (TODO Add link to page)
12+
:::important
13+
This page describes how to implement custom filters. In order to expose them in Graphql see the [relevant page](/docs/graphql/custom-filters)!
14+
:::
15+
16+
## Global custom filters
17+
18+
Let's assume we want to create a filter that allows us to filter for integer fields where the value is a multiple of a given number. The custom filter would look like this
19+
20+
```ts title="is-multiple-of.filter.ts"
21+
import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm';
22+
23+
@TypeOrmQueryFilter({
24+
types: [Number, 'integer'],
25+
operations: ['isMultipleOf'],
26+
})
27+
export class IsMultipleOfCustomFilter implements CustomFilter {
28+
apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult {
29+
alias = alias ? alias : '';
30+
const pname = `param${randomString()}`;
31+
return {
32+
sql: `(${alias}.${field} % :${pname}) == 0`,
33+
params: { [pname]: val },
34+
};
35+
}
36+
}
37+
```
38+
39+
Then, you need to register the filter in your NestjsQueryTypeOrmModule definition
40+
41+
```ts
42+
NestjsQueryTypeOrmModule.forFeature(
43+
[], // Entities
44+
undefined, // Connection, undefined means "use the default one"
45+
{
46+
providers: [
47+
IsMultipleOfCustomFilter,
48+
],
49+
},
50+
);
51+
```
52+
53+
That's it! Now the filter will be automatically used whenever a filter like `{<propertyName>: {isMultipleOf: <number>}}` is passed!
54+
55+
## Entity custom filters
56+
57+
Let's assume that we have a Project entity and a Task entity, where Project has many tasks and where tasks can be either complete or not. We want to create a filter on Project that returns only projects with X pending tasks.
58+
59+
Our entities look like this:
60+
61+
```ts
62+
@Entity()
63+
// Note how the custom filter is registered here
64+
@WithTypeormQueryFilter<TestEntity>({
65+
filter: TestEntityTestRelationCountFilter,
66+
fields: ['pendingTasks'],
67+
operations: ['gt'],
68+
})
69+
export class Project {
70+
@PrimaryColumn({ name: 'id' })
71+
id!: string;
72+
73+
@OneToMany('TestRelation', 'testEntity')
74+
tasks?: Task[];
75+
}
76+
77+
@Entity()
78+
export class Task {
79+
@PrimaryColumn({ name: 'id' })
80+
id!: string;
81+
82+
@Column({ name: 'status' })
83+
status!: string;
84+
85+
@ManyToOne(() => TestEntity, (te) => te.tasks, { onDelete: 'CASCADE' })
86+
@JoinColumn({ name: 'project_id' })
87+
project?: Project;
88+
89+
@Column({ name: 'project_id', nullable: true })
90+
projectId?: string;
91+
}
92+
```
93+
94+
The custom filter, instead, looks like this:
95+
96+
```ts title="project-pending-tasks-count.filter.ts"
97+
import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm';
98+
import { EntityManager } from 'typeorm';
99+
100+
// No operations or types here, which means that the filter is not registered globally on types. We will be registering the filter individually on the Project entity.
101+
@TypeOrmQueryFilter()
102+
export class TestEntityTestRelationCountFilter implements CustomFilter {
103+
// Since the filter is an Injectable, we can inject other services here, such as an entity manager to create the subquery
104+
constructor(private em: EntityManager) {}
105+
106+
apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult {
107+
alias = alias ? alias : '';
108+
const pname = `param${randomString()}`;
109+
110+
const subQb = this.em
111+
.createQueryBuilder(Task, 't')
112+
.select('COUNT(*)')
113+
.where(`t.status = 'pending' AND t.project_id = ${alias}.id`);
114+
115+
return {
116+
sql: `(${subQb.getSql()}) > :${pname}`,
117+
params: { [pname]: val },
118+
};
119+
}
120+
}
121+
```
122+
123+
That's it! Now the filter will be automatically used whenever a filter like `{pendingTasks: {gt: <number>}}` is used, but only when said filter refers to the Project entity.

documentation/sidebars.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module.exports = {
2222
TypeOrm: [
2323
'persistence/typeorm/getting-started',
2424
'persistence/typeorm/custom-service',
25+
'persistence/typeorm/custom-filters',
2526
'persistence/typeorm/multiple-databases',
2627
'persistence/typeorm/soft-delete',
2728
'persistence/typeorm/testing-services',
@@ -50,6 +51,7 @@ module.exports = {
5051
'graphql/dtos',
5152
'graphql/resolvers',
5253
'graphql/queries',
54+
'graphql/custom-filters',
5355
'graphql/mutations',
5456
'graphql/paging',
5557
'graphql/hooks',

examples/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"sequelize": "6.9.0",
5050
"sequelize-typescript": "2.1.1",
5151
"typeorm": "0.2.40",
52-
"typeorm-seeding": "1.6.1"
52+
"typeorm-seeding": "1.6.1",
53+
"uuid": "^8.3.2"
5354
},
5455
"devDependencies": {
5556
"@nestjs/cli": "8.1.4",

examples/typeorm/e2e/graphql-fragments.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const todoItemFields = `
33
title
44
completed
55
description
6+
priority
67
age
78
`;
89

examples/typeorm/e2e/sub-task.resolver.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => {
177177
title: 'Create Nest App',
178178
completed: true,
179179
description: null,
180+
priority: 0,
180181
age: expect.any(Number),
181182
},
182183
},
@@ -830,6 +831,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => {
830831
title: 'Create Entity',
831832
completed: false,
832833
description: null,
834+
priority: 1,
833835
age: expect.any(Number),
834836
},
835837
},

0 commit comments

Comments
 (0)