Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom filter support for typeorm and graphql #1397

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions documentation/docs/graphql/custom-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
---
title: Custom Filters
---

In addition to the built in filters, you can also define custom filtering operations.

There are 2 types of custom filters:

- Global type-based filters, that automatically work on all fields of a given GraphQL type.
- Custom entity-specific filters, that are custom-tailored for DTOs and do not require a specific backing field (more on
that below).

[//]: # (TODO Add link to page)
:::important

This page describes how to implement custom filters at the GraphQL Level. The persistence layer needs to support them as
well. For now, only TypeOrm is implemented. See here:

- [TypeOrm Custom Filters](/docs/persistence/typeorm/custom-filters)

:::

## Global type-based filters

Type based filters are applied globally to all DTOs, based only on the underlying GraphQL Type.

### Extending the existing filters

Let's assume our persistence layer exposes a `isMultipleOf` filter which allows us to filter numeric fields and choose
only multiples of a user-supplied value. In order to expose that filter on all numeric GraphQL fields, we can do the
following in any typescript file (ideally this should run before the app is initialized):

```ts
import { registerTypeComparison } from '@nestjs-query/query-graphql';
import { IsBoolean, IsInt } from 'class-validator';
import { Float, Int } from '@nestjs/graphql';

registerTypeComparison(Number, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
registerTypeComparison(Int, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
registerTypeComparison(Float, 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });

// Note, this also works
// registerTypeComparison([Number, Int, Float], 'isMultipleOf', { FilterType: Number, GqlType: Int, decorators: [IsInt()] });
```

Where:

- FilterType is the typescript type of the filter
- GqlType is the GraphQL type that will be used in the schema
- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the
operation, used e.g. for validation purposes

The above snippet patches the existing Number/Int/Float FieldComparisons so that they expose the new
field/operation `isMultipleOf`. Example:

```graphql
input NumberFieldComparison {
is: Boolean
isNot: Boolean
eq: Float
neq: Float
gt: Float
gte: Float
lt: Float
lte: Float
in: [Float!]
notIn: [Float!]
between: NumberFieldComparisonBetween
notBetween: NumberFieldComparisonBetween
isMultipleOf: Int
}
```

### Defining a filter on a custom scalar

Let's assume we have a custom scalar, to represent e.g. a geo point (i.e. {lat, lng}):

```ts
@Scalar('Point', (type) => Object)
export class PointScalar implements CustomScalar<any, any> {
description = 'Point custom scalar type';

parseValue(value: any): any {
return { lat: value.lat, lng: value.lng };
}

serialize(value: any): any {
return { lat: value.lat, lng: value.lng };
}

parseLiteral(ast: ValueNode): any {
if (ast.kind === Kind.OBJECT) {
return ast.fields;
}
return null;
}
}
```

Now, we want to add a radius filter to all Point scalars. A radius filter is a filter that returns all entities whose
location is within a given distance from another point.

First, we need to define the filter type:

```ts
@InputType('RadiusFilter')
export class RadiusFilter {
@Field(() => Number)
lat!: number;

@Field(() => Number)
lng!: number;

@Field(() => Number)
radius!: number;
}
```

Then, we need to register said filter:

```ts
registerTypeComparison(PointScalar, 'distanceFrom', {
FilterType: RadiusFilter,
GqlType: RadiusFilter,
});
```

The above snippet creates a new comparison type for the Point scalar and adds the distanceFrom operations to it.
Example:

```graphql
input RadiusFilter {
lat: Float!
lng: Float!
radius: Float!
}

input PointScalarFilterComparison {
distanceFrom: RadiusFilter
}
```

Now, our persistence layer will be able to receive this new `distanceFrom` key for every property that is represented as
a Point scalar.

:::important

If the shape of the filter at the GraphQL layer is different from what the persistence layer expects, remember to use
an [Assembler and its convertQuery method!](/docs/concepts/advanced/assemblers#converting-the-query)

:::

### Disabling a type-based custom filter on specific fields of a DTO

Global filters are fully compatible with the [allowedComparisons](/docs/graphql/dtos/#example---allowedcomparisons)
option of the `@FilterableField` decorator.

## DTO-based custom filters

These custom filters are explicitly registered on a single DTO field, rather than at the type level. This can be useful
if the persistence layer exposes some specific filters only on some entities (e.g. "Filter all projects who more than 5
pending tasks" where we need to compute the number of pending tasks using a SQL sub-query in the where clause, instead
of having a computed field in the project entity).

:::important

DTO-based custom filters cannot be registered on existing DTO filterable fields, use type-based filters for that!

:::

In order to register a "pending tasks count" filter on our ProjectDto, we can do as follows:

```ts
registerDTOFieldComparison(TestDto, 'pendingTaskCount', 'gt', {
FilterType: Number,
GqlType: Int,
decorators: [IsInt()],
});
```

Where:

- FilterType is the typescript type of the filter
- GqlType is the GraphQL type that will be used in the schema
- decorators represents a list of decorators that will be applied to the filter class at the specific field used for the
operation, used e.g. for validation purposes

This will add a new operation to the GraphQL `TestDto` input type

```graphql
input TestPendingTaskCountFilterComparison {
gt: Int
}

input TestDtoFilter {
"""
...Other fields defined in TestDTO
"""
pendingTaskCount: TestPendingTaskCountFilterComparison
}
```

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.
123 changes: 123 additions & 0 deletions documentation/docs/persistence/typeorm/custom-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
title: Custom Filters
---

In addition to the built in filters, which work for a lot of common scenarios, @nestjs-query/typeorm supports custom filters.

There are 2 types of custom filters:
- Global type-based filters, that automatically work on all fields of a given database type.
- Custom entity-specific filters, that are custom-tailored for entities and do not require a backing database field (more on that below).

[//]: # (TODO Add link to page)
:::important
This page describes how to implement custom filters. In order to expose them in Graphql see the [relevant page](/docs/graphql/custom-filters)!
:::

## Global custom filters

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

```ts title="is-multiple-of.filter.ts"
import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm';

@TypeOrmQueryFilter({
types: [Number, 'integer'],
operations: ['isMultipleOf'],
})
export class IsMultipleOfCustomFilter implements CustomFilter {
apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult {
alias = alias ? alias : '';
const pname = `param${randomString()}`;
return {
sql: `(${alias}.${field} % :${pname}) == 0`,
params: { [pname]: val },
};
}
}
```

Then, you need to register the filter in your NestjsQueryTypeOrmModule definition

```ts
NestjsQueryTypeOrmModule.forFeature(
[], // Entities
undefined, // Connection, undefined means "use the default one"
{
providers: [
IsMultipleOfCustomFilter,
],
},
);
```

That's it! Now the filter will be automatically used whenever a filter like `{<propertyName>: {isMultipleOf: <number>}}` is passed!

## Entity custom filters

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.

Our entities look like this:

```ts
@Entity()
// Note how the custom filter is registered here
@WithTypeormQueryFilter<TestEntity>({
filter: TestEntityTestRelationCountFilter,
fields: ['pendingTasks'],
operations: ['gt'],
})
export class Project {
@PrimaryColumn({ name: 'id' })
id!: string;

@OneToMany('TestRelation', 'testEntity')
tasks?: Task[];
}

@Entity()
export class Task {
@PrimaryColumn({ name: 'id' })
id!: string;

@Column({ name: 'status' })
status!: string;

@ManyToOne(() => TestEntity, (te) => te.tasks, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'project_id' })
project?: Project;

@Column({ name: 'project_id', nullable: true })
projectId?: string;
}
```

The custom filter, instead, looks like this:

```ts title="project-pending-tasks-count.filter.ts"
import { TypeOrmQueryFilter, CustomFilter, CustomFilterResult } from '@nestjs-query/query-typeorm';
import { EntityManager } from 'typeorm';

// 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.
@TypeOrmQueryFilter()
export class TestEntityTestRelationCountFilter implements CustomFilter {
// Since the filter is an Injectable, we can inject other services here, such as an entity manager to create the subquery
constructor(private em: EntityManager) {}

apply(field: string, cmp: string, val: unknown, alias?: string): CustomFilterResult {
alias = alias ? alias : '';
const pname = `param${randomString()}`;

const subQb = this.em
.createQueryBuilder(Task, 't')
.select('COUNT(*)')
.where(`t.status = 'pending' AND t.project_id = ${alias}.id`);

return {
sql: `(${subQb.getSql()}) > :${pname}`,
params: { [pname]: val },
};
}
}
```

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.
2 changes: 2 additions & 0 deletions documentation/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module.exports = {
TypeOrm: [
'persistence/typeorm/getting-started',
'persistence/typeorm/custom-service',
'persistence/typeorm/custom-filters',
'persistence/typeorm/multiple-databases',
'persistence/typeorm/soft-delete',
'persistence/typeorm/testing-services',
Expand Down Expand Up @@ -50,6 +51,7 @@ module.exports = {
'graphql/dtos',
'graphql/resolvers',
'graphql/queries',
'graphql/custom-filters',
'graphql/mutations',
'graphql/paging',
'graphql/hooks',
Expand Down
3 changes: 2 additions & 1 deletion examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@
"sequelize": "6.9.0",
"sequelize-typescript": "2.1.1",
"typeorm": "0.2.40",
"typeorm-seeding": "1.6.1"
"typeorm-seeding": "1.6.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"@nestjs/cli": "8.1.4",
Expand Down
1 change: 1 addition & 0 deletions examples/typeorm/e2e/graphql-fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const todoItemFields = `
title
completed
description
priority
age
`;

Expand Down
2 changes: 2 additions & 0 deletions examples/typeorm/e2e/sub-task.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => {
title: 'Create Nest App',
completed: true,
description: null,
priority: 0,
age: expect.any(Number),
},
},
Expand Down Expand Up @@ -830,6 +831,7 @@ describe('SubTaskResolver (typeorm - e2e)', () => {
title: 'Create Entity',
completed: false,
description: null,
priority: 1,
age: expect.any(Number),
},
},
Expand Down
Loading