diff --git a/examples/filters/e2e/fixtures.ts b/examples/filters/e2e/fixtures.ts new file mode 100644 index 000000000..c7c555b8f --- /dev/null +++ b/examples/filters/e2e/fixtures.ts @@ -0,0 +1,23 @@ +import { Connection } from 'typeorm'; +import { TodoItemEntity } from '../src/todo-item/todo-item.entity'; +import { executeTruncate } from '../../helpers'; + +const tables = ['todo_item']; +export const truncate = async (connection: Connection): Promise => executeTruncate(connection, tables); + +export const refresh = async (connection: Connection): Promise => { + await truncate(connection); + + const todoRepo = connection.getRepository(TodoItemEntity); + + await todoRepo.save([ + { title: 'Create Nest App', completed: true }, + { title: 'Create Entity', completed: false }, + { title: 'Create Entity Service', completed: false }, + { title: 'Add Todo Item Resolver', completed: false }, + { + title: 'How to create item With Sub Tasks', + completed: false + } + ]); +}; diff --git a/examples/filters/e2e/graphql-fragments.ts b/examples/filters/e2e/graphql-fragments.ts new file mode 100644 index 000000000..7757620e6 --- /dev/null +++ b/examples/filters/e2e/graphql-fragments.ts @@ -0,0 +1,24 @@ +export const todoItemFields = ` + id + title + completed + description + `; + +export const pageInfoField = ` +pageInfo{ + hasNextPage + hasPreviousPage + startCursor + endCursor +} +`; + +export const edgeNodes = (fields: string): string => ` + edges { + node{ + ${fields} + } + cursor + } + `; diff --git a/examples/filters/e2e/todo-item.resolver.spec.ts b/examples/filters/e2e/todo-item.resolver.spec.ts new file mode 100644 index 000000000..5caf033fa --- /dev/null +++ b/examples/filters/e2e/todo-item.resolver.spec.ts @@ -0,0 +1,110 @@ +import { CursorConnectionType } from '@ptc-org/nestjs-query-graphql'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Connection } from 'typeorm'; +import { AppModule } from '../src/app.module'; +import { TodoItemDTO } from '../src/todo-item/dto/todo-item.dto'; +import { refresh } from './fixtures'; +import { edgeNodes, pageInfoField, todoItemFields } from './graphql-fragments'; + +describe('TodoItemResolver (filters - e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule] + }).compile(); + + app = moduleRef.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + forbidUnknownValues: true + }) + ); + + await app.init(); + await refresh(app.get(Connection)); + }); + + afterAll(() => refresh(app.get(Connection))); + + describe('query', () => { + it(`should require "completed" filter`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + } + }` + }) + .expect(400) + .then(({ body }) => { + expect(body.errors[0].message).toBe( + 'Field "todoItems" argument "filter" of type "TodoItemFilter!" is required, but it was not provided.' + ); + })); + + it(`should accepted "completed" filter`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems (filter: { completed: { is: true } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + } + }` + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=' + }); + expect(edges).toHaveLength(1); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null } + ]); + })); + + it(`should not accepted empty "completed" filter`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems (filter: { completed: { } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + } + }` + }) + .expect(200) + .then(({ body }) => { + expect(body.errors[0].extensions.response.message[0]).toBe( + 'filter.There was no filter provided for "completed"!' + ); + })); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/examples/filters/src/app.module.ts b/examples/filters/src/app.module.ts new file mode 100644 index 000000000..62ba48205 --- /dev/null +++ b/examples/filters/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TodoItemModule } from './todo-item/todo-item.module'; +import { typeormOrmConfig, formatGraphqlError } from '../../helpers'; + +@Module({ + imports: [ + TypeOrmModule.forRoot(typeormOrmConfig('basic')), + GraphQLModule.forRoot({ + autoSchemaFile: 'schema.gql', + formatError: formatGraphqlError + }), + TodoItemModule + ] +}) +export class AppModule { +} diff --git a/examples/filters/src/todo-item/dto/todo-item.dto.ts b/examples/filters/src/todo-item/dto/todo-item.dto.ts new file mode 100644 index 000000000..0463da72c --- /dev/null +++ b/examples/filters/src/todo-item/dto/todo-item.dto.ts @@ -0,0 +1,25 @@ +import { FilterableField } from '@ptc-org/nestjs-query-graphql'; +import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; + +@ObjectType('TodoItem') +export class TodoItemDTO { + @FilterableField(() => ID) + id!: number; + + @FilterableField() + title!: string; + + @FilterableField({ nullable: true }) + description?: string; + + @FilterableField({ + filterRequired: true + }) + completed!: boolean; + + @FilterableField(() => GraphQLISODateTime, { filterOnly: true }) + created!: Date; + + @FilterableField(() => GraphQLISODateTime, { filterOnly: true }) + updated!: Date; +} diff --git a/examples/filters/src/todo-item/todo-item.entity.ts b/examples/filters/src/todo-item/todo-item.entity.ts new file mode 100644 index 000000000..752940cf2 --- /dev/null +++ b/examples/filters/src/todo-item/todo-item.entity.ts @@ -0,0 +1,28 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'todo_item' }) +export class TodoItemEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column() + title!: string; + + @Column({ nullable: true }) + description?: string; + + @Column() + completed!: boolean; + + @CreateDateColumn() + created!: Date; + + @UpdateDateColumn() + updated!: Date; +} diff --git a/examples/filters/src/todo-item/todo-item.module.ts b/examples/filters/src/todo-item/todo-item.module.ts new file mode 100644 index 000000000..f9dcfc8be --- /dev/null +++ b/examples/filters/src/todo-item/todo-item.module.ts @@ -0,0 +1,21 @@ +import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql'; +import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; +import { Module } from '@nestjs/common'; +import { TodoItemDTO } from './dto/todo-item.dto'; +import { TodoItemEntity } from './todo-item.entity'; + +@Module({ + imports: [ + NestjsQueryGraphQLModule.forFeature({ + imports: [NestjsQueryTypeOrmModule.forFeature([TodoItemEntity])], + resolvers: [ + { + DTOClass: TodoItemDTO, + EntityClass: TodoItemEntity + } + ] + }) + ] +}) +export class TodoItemModule { +} diff --git a/packages/query-graphql/src/decorators/has-required.filter.ts b/packages/query-graphql/src/decorators/has-required.filter.ts new file mode 100644 index 000000000..34ea41779 --- /dev/null +++ b/packages/query-graphql/src/decorators/has-required.filter.ts @@ -0,0 +1,27 @@ +import { FilterFieldComparison } from '@ptc-org/nestjs-query-core'; +import { registerDecorator } from 'class-validator'; + +/** + * @internal + * Wraps Args to allow skipping decorating + * @param check - checker to run. + * @param decorators - The decorators to apply + */ +export function HasRequiredFilter(): PropertyDecorator { + return function (object: object, propertyName: string): void { + registerDecorator({ + name: 'hasRequiredFilter', + target: object.constructor, + propertyName: propertyName, + // constraints: [property], + options: { + message: 'There was no filter provided for "$property"!' + }, + validator: { + validate(value: FilterFieldComparison) { + return Object.keys(value).length > 0; + } + } + }); + }; +} diff --git a/packages/query-graphql/src/types/query/field-comparison/boolean-field-comparison.type.ts b/packages/query-graphql/src/types/query/field-comparison/boolean-field-comparison.type.ts index 126c1acd3..716d2096f 100644 --- a/packages/query-graphql/src/types/query/field-comparison/boolean-field-comparison.type.ts +++ b/packages/query-graphql/src/types/query/field-comparison/boolean-field-comparison.type.ts @@ -10,6 +10,7 @@ export function getOrCreateBooleanFieldComparison(): Class { @Field(() => Boolean, { nullable: true }) @@ -22,6 +23,8 @@ export function getOrCreateBooleanFieldComparison(): Class DateFieldComparisonBetween) notBetween?: DateFieldComparisonBetween; } + dateFieldComparison = DateFieldComparison; + return dateFieldComparison; } diff --git a/packages/query-graphql/src/types/query/filter.type.ts b/packages/query-graphql/src/types/query/filter.type.ts index b8e5d8785..9ad082b5f 100644 --- a/packages/query-graphql/src/types/query/filter.type.ts +++ b/packages/query-graphql/src/types/query/filter.type.ts @@ -8,6 +8,7 @@ import { createFilterComparisonType } from './field-comparison'; import { getDTONames, getGraphqlObjectName } from '../../common'; import { getFilterableFields, getQueryOptions, getRelations, SkipIf } from '../../decorators'; import { isInAllowedList } from './helpers'; +import { HasRequiredFilter } from '../../decorators/has-required.filter'; const reflector = new MapReflector('nestjs-query:filter-type'); @@ -47,8 +48,6 @@ function getOrCreateFilterType( class GraphQLFilter { static hasRequiredFilters: boolean = hasRequiredFilters; - // TODO:: Add the required fields also in here and validate they are set! - @ValidateNested() @SkipIf(() => isNotAllowedComparison('and'), Field(() => [GraphQLFilter], { nullable: true })) @Type(() => GraphQLFilter) @@ -70,6 +69,9 @@ function getOrCreateFilterType( }); const nullable = advancedOptions?.filterRequired !== true; ValidateNested()(GraphQLFilter.prototype, propertyName); + if (advancedOptions?.filterRequired) { + HasRequiredFilter()(GraphQLFilter.prototype, propertyName); + } Field(() => FC, { nullable })(GraphQLFilter.prototype, propertyName); Type(() => FC)(GraphQLFilter.prototype, propertyName); });