From 60229b8fe981a863e8f31f1734c0b9a1aa001cf2 Mon Sep 17 00:00:00 2001 From: doug-martin Date: Thu, 2 Jul 2020 00:25:09 -0500 Subject: [PATCH] feat(graphql): Enable filtering on ORM relations --- .../sequelize/e2e/sub-task.resolver.spec.ts | 29 ++ examples/sequelize/e2e/tag.resolver.spec.ts | 29 ++ .../sequelize/e2e/todo-item.resolver.spec.ts | 66 ++++ .../src/sub-task/dto/sub-task.dto.ts | 4 +- examples/sequelize/src/tag/dto/tag.dto.ts | 4 +- .../src/todo-item/dto/todo-item.dto.ts | 6 +- .../typeorm/e2e/sub-task.resolver.spec.ts | 29 ++ examples/typeorm/e2e/tag.resolver.spec.ts | 29 ++ .../typeorm/e2e/todo-item.resolver.spec.ts | 66 ++++ .../typeorm/src/sub-task/dto/sub-task.dto.ts | 4 +- examples/typeorm/src/tag/dto/tag.dto.ts | 4 +- .../src/todo-item/dto/todo-item.dto.ts | 6 +- .../delete-filter-input-type.graphql | 213 +++++++++++ .../__fixtures__/filter-input-type.graphql | 14 + .../__tests__/__fixtures__/index.ts | 5 + .../subscription-filter-input-type.graphql | 213 +++++++++++ .../update-filter-input-type.graphql | 213 +++++++++++ .../decorators/relation.decorator.spec.ts | 73 +++- .../__tests__/types/query/filter.type.spec.ts | 336 +++++++++++++++--- .../query-graphql/src/decorators/index.ts | 9 +- .../src/decorators/relation.decorator.ts | 38 ++ packages/query-graphql/src/index.ts | 2 + .../src/metadata/metadata-storage.ts | 21 +- .../src/resolvers/relations/helpers.ts | 14 +- .../relations/relations.interface.ts | 7 + packages/query-graphql/src/types/index.ts | 3 + .../src/types/query/filter.type.ts | 34 +- 27 files changed, 1368 insertions(+), 103 deletions(-) create mode 100644 packages/query-graphql/__tests__/__fixtures__/delete-filter-input-type.graphql create mode 100644 packages/query-graphql/__tests__/__fixtures__/subscription-filter-input-type.graphql create mode 100644 packages/query-graphql/__tests__/__fixtures__/update-filter-input-type.graphql diff --git a/examples/sequelize/e2e/sub-task.resolver.spec.ts b/examples/sequelize/e2e/sub-task.resolver.spec.ts index d460d1ad0..06744401f 100644 --- a/examples/sequelize/e2e/sub-task.resolver.spec.ts +++ b/examples/sequelize/e2e/sub-task.resolver.spec.ts @@ -245,6 +245,35 @@ describe('SubTaskResolver (sequelize - e2e)', () => { }); }); + it(`should allow querying on todoItem`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + subTasks(filter: { todoItem: { title: { like: "Create Entity%" } } }) { + ${pageInfoField} + ${edgeNodes(subTaskFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjU=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(6); + expect(edges).toHaveLength(6); + expect(edges.map((e) => e.node)).toEqual(subTasks.slice(3, 9)); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/sequelize/e2e/tag.resolver.spec.ts b/examples/sequelize/e2e/tag.resolver.spec.ts index 47f002b0f..49a463f41 100644 --- a/examples/sequelize/e2e/tag.resolver.spec.ts +++ b/examples/sequelize/e2e/tag.resolver.spec.ts @@ -168,6 +168,35 @@ describe('TagResolver (sequelize - e2e)', () => { }); }); + it(`should allow querying on todoItems`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + tags(filter: { todoItems: { title: { like: "Create Entity%" } } }, sorting: [{field: id, direction: ASC}]) { + ${pageInfoField} + ${edgeNodes(tagFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(3); + expect(edges).toHaveLength(3); + expect(edges.map((e) => e.node)).toEqual([tags[0], tags[2], tags[4]]); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/sequelize/e2e/todo-item.resolver.spec.ts b/examples/sequelize/e2e/todo-item.resolver.spec.ts index 3354fd039..d3f6a4e50 100644 --- a/examples/sequelize/e2e/todo-item.resolver.spec.ts +++ b/examples/sequelize/e2e/todo-item.resolver.spec.ts @@ -223,6 +223,72 @@ describe('TodoItemResolver (sequelize - e2e)', () => { }); }); + it(`should allow querying on subTasks`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { subTasks: { title: { in: ["Create Nest App - Sub Task 1", "Create Entity - Sub Task 1"] } } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, totalCount, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, + { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, + ]); + }); + }); + + it(`should allow querying on tags`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { tags: { name: { eq: "Home" } } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, totalCount, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, + { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, age: expect.any(Number) }, + ]); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/sequelize/src/sub-task/dto/sub-task.dto.ts b/examples/sequelize/src/sub-task/dto/sub-task.dto.ts index 09b806086..1b082acbc 100644 --- a/examples/sequelize/src/sub-task/dto/sub-task.dto.ts +++ b/examples/sequelize/src/sub-task/dto/sub-task.dto.ts @@ -1,9 +1,9 @@ -import { Relation, FilterableField } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableRelation } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto'; @ObjectType('SubTask') -@Relation('todoItem', () => TodoItemDTO, { disableRemove: true }) +@FilterableRelation('todoItem', () => TodoItemDTO, { disableRemove: true }) export class SubTaskDTO { @FilterableField(() => ID) id!: number; diff --git a/examples/sequelize/src/tag/dto/tag.dto.ts b/examples/sequelize/src/tag/dto/tag.dto.ts index c45cbd342..027123687 100644 --- a/examples/sequelize/src/tag/dto/tag.dto.ts +++ b/examples/sequelize/src/tag/dto/tag.dto.ts @@ -1,9 +1,9 @@ -import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableConnection } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto'; @ObjectType('Tag') -@Connection('todoItems', () => TodoItemDTO) +@FilterableConnection('todoItems', () => TodoItemDTO) export class TagDTO { @FilterableField(() => ID) id!: number; diff --git a/examples/sequelize/src/todo-item/dto/todo-item.dto.ts b/examples/sequelize/src/todo-item/dto/todo-item.dto.ts index e1ca82ccc..9d0bc48cc 100644 --- a/examples/sequelize/src/todo-item/dto/todo-item.dto.ts +++ b/examples/sequelize/src/todo-item/dto/todo-item.dto.ts @@ -1,12 +1,12 @@ -import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableConnection } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; import { AuthGuard } from '../../auth.guard'; import { SubTaskDTO } from '../../sub-task/dto/sub-task.dto'; import { TagDTO } from '../../tag/dto/tag.dto'; @ObjectType('TodoItem') -@Connection('subTasks', () => SubTaskDTO, { guards: [AuthGuard] }) -@Connection('tags', () => TagDTO, { guards: [AuthGuard] }) +@FilterableConnection('subTasks', () => SubTaskDTO, { guards: [AuthGuard] }) +@FilterableConnection('tags', () => TagDTO, { guards: [AuthGuard] }) export class TodoItemDTO { @FilterableField(() => ID) id!: number; diff --git a/examples/typeorm/e2e/sub-task.resolver.spec.ts b/examples/typeorm/e2e/sub-task.resolver.spec.ts index 536854847..b18c84077 100644 --- a/examples/typeorm/e2e/sub-task.resolver.spec.ts +++ b/examples/typeorm/e2e/sub-task.resolver.spec.ts @@ -245,6 +245,35 @@ describe('SubTaskResolver (typeorm - e2e)', () => { }); }); + it(`should allow querying on todoItem`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + subTasks(filter: { todoItem: { title: { like: "Create Entity%" } } }) { + ${pageInfoField} + ${edgeNodes(subTaskFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.subTasks; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjU=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(6); + expect(edges).toHaveLength(6); + expect(edges.map((e) => e.node)).toEqual(subTasks.slice(3, 9)); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/typeorm/e2e/tag.resolver.spec.ts b/examples/typeorm/e2e/tag.resolver.spec.ts index 34f726617..dc5a09b42 100644 --- a/examples/typeorm/e2e/tag.resolver.spec.ts +++ b/examples/typeorm/e2e/tag.resolver.spec.ts @@ -168,6 +168,35 @@ describe('TagResolver (typeorm - e2e)', () => { }); }); + it(`should allow querying on todoItems`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + tags(filter: { todoItems: { title: { like: "Create Entity%" } } }, sorting: [{field: id, direction: ASC}]) { + ${pageInfoField} + ${edgeNodes(tagFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo, totalCount }: CursorConnectionType = body.data.tags; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjI=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(3); + expect(edges).toHaveLength(3); + expect(edges.map((e) => e.node)).toEqual([tags[0], tags[2], tags[4]]); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/typeorm/e2e/todo-item.resolver.spec.ts b/examples/typeorm/e2e/todo-item.resolver.spec.ts index e952fbb6e..1b578c2ce 100644 --- a/examples/typeorm/e2e/todo-item.resolver.spec.ts +++ b/examples/typeorm/e2e/todo-item.resolver.spec.ts @@ -223,6 +223,72 @@ describe('TodoItemResolver (typeorm - e2e)', () => { }); }); + it(`should allow querying on subTasks`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { subTasks: { title: { in: ["Create Nest App - Sub Task 1", "Create Entity - Sub Task 1"] } } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, totalCount, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, + { id: '2', title: 'Create Entity', completed: false, description: null, age: expect.any(Number) }, + ]); + }); + }); + + it(`should allow querying on tags`, () => { + return request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItems(filter: { tags: { name: { eq: "Home" } } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + totalCount + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, totalCount, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjE=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(totalCount).toBe(2); + expect(edges).toHaveLength(2); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null, age: expect.any(Number) }, + { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null, age: expect.any(Number) }, + ]); + }); + }); + it(`should allow sorting`, () => { return request(app.getHttpServer()) .post('/graphql') diff --git a/examples/typeorm/src/sub-task/dto/sub-task.dto.ts b/examples/typeorm/src/sub-task/dto/sub-task.dto.ts index 0d635f8f7..8dea1708a 100644 --- a/examples/typeorm/src/sub-task/dto/sub-task.dto.ts +++ b/examples/typeorm/src/sub-task/dto/sub-task.dto.ts @@ -1,9 +1,9 @@ -import { FilterableField, Relation } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableRelation } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto'; @ObjectType('SubTask') -@Relation('todoItem', () => TodoItemDTO, { disableRemove: true }) +@FilterableRelation('todoItem', () => TodoItemDTO, { disableRemove: true }) export class SubTaskDTO { @FilterableField(() => ID) id!: number; diff --git a/examples/typeorm/src/tag/dto/tag.dto.ts b/examples/typeorm/src/tag/dto/tag.dto.ts index c45cbd342..027123687 100644 --- a/examples/typeorm/src/tag/dto/tag.dto.ts +++ b/examples/typeorm/src/tag/dto/tag.dto.ts @@ -1,9 +1,9 @@ -import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableConnection } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime } from '@nestjs/graphql'; import { TodoItemDTO } from '../../todo-item/dto/todo-item.dto'; @ObjectType('Tag') -@Connection('todoItems', () => TodoItemDTO) +@FilterableConnection('todoItems', () => TodoItemDTO) export class TagDTO { @FilterableField(() => ID) id!: number; diff --git a/examples/typeorm/src/todo-item/dto/todo-item.dto.ts b/examples/typeorm/src/todo-item/dto/todo-item.dto.ts index 976239a8e..5608a8487 100644 --- a/examples/typeorm/src/todo-item/dto/todo-item.dto.ts +++ b/examples/typeorm/src/todo-item/dto/todo-item.dto.ts @@ -1,12 +1,12 @@ -import { FilterableField, Connection } from '@nestjs-query/query-graphql'; +import { FilterableField, FilterableConnection } from '@nestjs-query/query-graphql'; import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; import { AuthGuard } from '../../auth.guard'; import { SubTaskDTO } from '../../sub-task/dto/sub-task.dto'; import { TagDTO } from '../../tag/dto/tag.dto'; @ObjectType('TodoItem') -@Connection('subTasks', () => SubTaskDTO, { disableRemove: true, guards: [AuthGuard] }) -@Connection('tags', () => TagDTO, { guards: [AuthGuard] }) +@FilterableConnection('subTasks', () => SubTaskDTO, { disableRemove: true, guards: [AuthGuard] }) +@FilterableConnection('tags', () => TagDTO, { guards: [AuthGuard] }) export class TodoItemDTO { @FilterableField(() => ID) id!: number; diff --git a/packages/query-graphql/__tests__/__fixtures__/delete-filter-input-type.graphql b/packages/query-graphql/__tests__/__fixtures__/delete-filter-input-type.graphql new file mode 100644 index 000000000..93c11d927 --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/delete-filter-input-type.graphql @@ -0,0 +1,213 @@ +type Query { + test(input: TestDtoFilter!): Int! +} + +input TestDtoFilter { + and: [TestFilterDtoDeleteFilter!] + or: [TestFilterDtoDeleteFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +input TestFilterDtoDeleteFilter { + and: [TestFilterDtoDeleteFilter!] + or: [TestFilterDtoDeleteFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +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 +} + +input NumberFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input BooleanFieldComparison { + is: Boolean + isNot: Boolean +} + +input DateFieldComparison { + is: Boolean + isNot: Boolean + eq: DateTime + neq: DateTime + gt: DateTime + gte: DateTime + lt: DateTime + lte: DateTime + in: [DateTime!] + notIn: [DateTime!] + between: DateFieldComparisonBetween + notBetween: DateFieldComparisonBetween +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +input DateFieldComparisonBetween { + lower: DateTime! + upper: DateTime! +} + +input FloatFieldComparison { + is: Boolean + isNot: Boolean + eq: Float + neq: Float + gt: Float + gte: Float + lt: Float + lte: Float + in: [Float!] + notIn: [Float!] + between: FloatFieldComparisonBetween + notBetween: FloatFieldComparisonBetween +} + +input FloatFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input IntFieldComparison { + is: Boolean + isNot: Boolean + eq: Int + neq: Int + gt: Int + gte: Int + lt: Int + lte: Int + in: [Int!] + notIn: [Int!] + between: IntFieldComparisonBetween + notBetween: IntFieldComparisonBetween +} + +input IntFieldComparisonBetween { + lower: Int! + upper: Int! +} + +input StringFieldComparison { + is: Boolean + isNot: Boolean + eq: String + neq: String + gt: String + gte: String + lt: String + lte: String + like: String + notLike: String + iLike: String + notILike: String + in: [String!] + notIn: [String!] +} + +input StringEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: StringEnum + neq: StringEnum + gt: StringEnum + gte: StringEnum + lt: StringEnum + lte: StringEnum + like: StringEnum + notLike: StringEnum + iLike: StringEnum + notILike: StringEnum + in: [StringEnum!] + notIn: [StringEnum!] +} + +enum StringEnum { + ONE_STR + TWO_STR + THREE_STR + FOUR_STR +} + +input NumberEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: NumberEnum + neq: NumberEnum + gt: NumberEnum + gte: NumberEnum + lt: NumberEnum + lte: NumberEnum + like: NumberEnum + notLike: NumberEnum + iLike: NumberEnum + notILike: NumberEnum + in: [NumberEnum!] + notIn: [NumberEnum!] +} + +enum NumberEnum { + ONE + TWO + THREE + FOUR +} + +input TimestampFieldComparison { + is: Boolean + isNot: Boolean + eq: Timestamp + neq: Timestamp + gt: Timestamp + gte: Timestamp + lt: Timestamp + lte: Timestamp + in: [Timestamp!] + notIn: [Timestamp!] + between: TimestampFieldComparisonBetween + notBetween: TimestampFieldComparisonBetween +} + +""" +`Date` type as integer. Type represents date and time as number of milliseconds from start of UNIX epoch. +""" +scalar Timestamp + +input TimestampFieldComparisonBetween { + lower: Timestamp! + upper: Timestamp! +} diff --git a/packages/query-graphql/__tests__/__fixtures__/filter-input-type.graphql b/packages/query-graphql/__tests__/__fixtures__/filter-input-type.graphql index 47b3bc2bb..899614467 100644 --- a/packages/query-graphql/__tests__/__fixtures__/filter-input-type.graphql +++ b/packages/query-graphql/__tests__/__fixtures__/filter-input-type.graphql @@ -15,6 +15,9 @@ input TestDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + filterableRelation: TestFilterDtoFilterTestRelationDtoFilter + filterableConnection: TestFilterDtoFilterTestRelationDtoFilter + filterableRelations: TestFilterDtoFilterTestRelationDtoFilter } input TestFilterDtoFilter { @@ -30,6 +33,9 @@ input TestFilterDtoFilter { stringEnumField: StringEnumFilterComparison numberEnumField: NumberEnumFilterComparison timestampField: TimestampFieldComparison + filterableRelation: TestFilterDtoFilterTestRelationDtoFilter + filterableConnection: TestFilterDtoFilterTestRelationDtoFilter + filterableRelations: TestFilterDtoFilterTestRelationDtoFilter } input NumberFieldComparison { @@ -211,3 +217,11 @@ input TimestampFieldComparisonBetween { lower: Timestamp! upper: Timestamp! } + +input TestFilterDtoFilterTestRelationDtoFilter { + and: [TestFilterDtoFilterTestRelationDtoFilter!] + or: [TestFilterDtoFilterTestRelationDtoFilter!] + id: NumberFieldComparison + relationName: StringFieldComparison + relationAge: NumberFieldComparison +} diff --git a/packages/query-graphql/__tests__/__fixtures__/index.ts b/packages/query-graphql/__tests__/__fixtures__/index.ts index f88f9ac81..7eb3f2d3a 100644 --- a/packages/query-graphql/__tests__/__fixtures__/index.ts +++ b/packages/query-graphql/__tests__/__fixtures__/index.ts @@ -34,6 +34,11 @@ export const mutationArgsTypeSDL = readGraphql(resolve(__dirname, './mutation-ar export const relationInputTypeSDL = readGraphql(resolve(__dirname, './relation-input-type.graphql')); export const relationsInputTypeSDL = readGraphql(resolve(__dirname, './relations-input-type.graphql')); export const filterInputTypeSDL = readGraphql(resolve(__dirname, './filter-input-type.graphql')); +export const updateFilterInputTypeSDL = readGraphql(resolve(__dirname, './update-filter-input-type.graphql')); +export const deleteFilterInputTypeSDL = readGraphql(resolve(__dirname, './delete-filter-input-type.graphql')); +export const subscriptionFilterInputTypeSDL = readGraphql( + resolve(__dirname, './subscription-filter-input-type.graphql'), +); export const pagingInputTypeSDL = readGraphql(resolve(__dirname, './paging-input-type.graphql')); export const pageInfoObjectTypeSDL = readGraphql(resolve(__dirname, './page-info-object-type.graphql')); export const sortingInputTypeSDL = readGraphql(resolve(__dirname, './sorting-input-type.graphql')); diff --git a/packages/query-graphql/__tests__/__fixtures__/subscription-filter-input-type.graphql b/packages/query-graphql/__tests__/__fixtures__/subscription-filter-input-type.graphql new file mode 100644 index 000000000..f4e2df6be --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/subscription-filter-input-type.graphql @@ -0,0 +1,213 @@ +type Query { + test(input: TestDtoFilter!): Int! +} + +input TestDtoFilter { + and: [TestFilterDtoSubscriptionFilter!] + or: [TestFilterDtoSubscriptionFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +input TestFilterDtoSubscriptionFilter { + and: [TestFilterDtoSubscriptionFilter!] + or: [TestFilterDtoSubscriptionFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +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 +} + +input NumberFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input BooleanFieldComparison { + is: Boolean + isNot: Boolean +} + +input DateFieldComparison { + is: Boolean + isNot: Boolean + eq: DateTime + neq: DateTime + gt: DateTime + gte: DateTime + lt: DateTime + lte: DateTime + in: [DateTime!] + notIn: [DateTime!] + between: DateFieldComparisonBetween + notBetween: DateFieldComparisonBetween +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +input DateFieldComparisonBetween { + lower: DateTime! + upper: DateTime! +} + +input FloatFieldComparison { + is: Boolean + isNot: Boolean + eq: Float + neq: Float + gt: Float + gte: Float + lt: Float + lte: Float + in: [Float!] + notIn: [Float!] + between: FloatFieldComparisonBetween + notBetween: FloatFieldComparisonBetween +} + +input FloatFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input IntFieldComparison { + is: Boolean + isNot: Boolean + eq: Int + neq: Int + gt: Int + gte: Int + lt: Int + lte: Int + in: [Int!] + notIn: [Int!] + between: IntFieldComparisonBetween + notBetween: IntFieldComparisonBetween +} + +input IntFieldComparisonBetween { + lower: Int! + upper: Int! +} + +input StringFieldComparison { + is: Boolean + isNot: Boolean + eq: String + neq: String + gt: String + gte: String + lt: String + lte: String + like: String + notLike: String + iLike: String + notILike: String + in: [String!] + notIn: [String!] +} + +input StringEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: StringEnum + neq: StringEnum + gt: StringEnum + gte: StringEnum + lt: StringEnum + lte: StringEnum + like: StringEnum + notLike: StringEnum + iLike: StringEnum + notILike: StringEnum + in: [StringEnum!] + notIn: [StringEnum!] +} + +enum StringEnum { + ONE_STR + TWO_STR + THREE_STR + FOUR_STR +} + +input NumberEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: NumberEnum + neq: NumberEnum + gt: NumberEnum + gte: NumberEnum + lt: NumberEnum + lte: NumberEnum + like: NumberEnum + notLike: NumberEnum + iLike: NumberEnum + notILike: NumberEnum + in: [NumberEnum!] + notIn: [NumberEnum!] +} + +enum NumberEnum { + ONE + TWO + THREE + FOUR +} + +input TimestampFieldComparison { + is: Boolean + isNot: Boolean + eq: Timestamp + neq: Timestamp + gt: Timestamp + gte: Timestamp + lt: Timestamp + lte: Timestamp + in: [Timestamp!] + notIn: [Timestamp!] + between: TimestampFieldComparisonBetween + notBetween: TimestampFieldComparisonBetween +} + +""" +`Date` type as integer. Type represents date and time as number of milliseconds from start of UNIX epoch. +""" +scalar Timestamp + +input TimestampFieldComparisonBetween { + lower: Timestamp! + upper: Timestamp! +} diff --git a/packages/query-graphql/__tests__/__fixtures__/update-filter-input-type.graphql b/packages/query-graphql/__tests__/__fixtures__/update-filter-input-type.graphql new file mode 100644 index 000000000..f5751d285 --- /dev/null +++ b/packages/query-graphql/__tests__/__fixtures__/update-filter-input-type.graphql @@ -0,0 +1,213 @@ +type Query { + test(input: TestDtoFilter!): Int! +} + +input TestDtoFilter { + and: [TestFilterDtoUpdateFilter!] + or: [TestFilterDtoUpdateFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +input TestFilterDtoUpdateFilter { + and: [TestFilterDtoUpdateFilter!] + or: [TestFilterDtoUpdateFilter!] + id: NumberFieldComparison + boolField: BooleanFieldComparison + dateField: DateFieldComparison + floatField: FloatFieldComparison + intField: IntFieldComparison + numberField: NumberFieldComparison + stringField: StringFieldComparison + stringEnumField: StringEnumFilterComparison + numberEnumField: NumberEnumFilterComparison + timestampField: TimestampFieldComparison +} + +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 +} + +input NumberFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input BooleanFieldComparison { + is: Boolean + isNot: Boolean +} + +input DateFieldComparison { + is: Boolean + isNot: Boolean + eq: DateTime + neq: DateTime + gt: DateTime + gte: DateTime + lt: DateTime + lte: DateTime + in: [DateTime!] + notIn: [DateTime!] + between: DateFieldComparisonBetween + notBetween: DateFieldComparisonBetween +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +input DateFieldComparisonBetween { + lower: DateTime! + upper: DateTime! +} + +input FloatFieldComparison { + is: Boolean + isNot: Boolean + eq: Float + neq: Float + gt: Float + gte: Float + lt: Float + lte: Float + in: [Float!] + notIn: [Float!] + between: FloatFieldComparisonBetween + notBetween: FloatFieldComparisonBetween +} + +input FloatFieldComparisonBetween { + lower: Float! + upper: Float! +} + +input IntFieldComparison { + is: Boolean + isNot: Boolean + eq: Int + neq: Int + gt: Int + gte: Int + lt: Int + lte: Int + in: [Int!] + notIn: [Int!] + between: IntFieldComparisonBetween + notBetween: IntFieldComparisonBetween +} + +input IntFieldComparisonBetween { + lower: Int! + upper: Int! +} + +input StringFieldComparison { + is: Boolean + isNot: Boolean + eq: String + neq: String + gt: String + gte: String + lt: String + lte: String + like: String + notLike: String + iLike: String + notILike: String + in: [String!] + notIn: [String!] +} + +input StringEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: StringEnum + neq: StringEnum + gt: StringEnum + gte: StringEnum + lt: StringEnum + lte: StringEnum + like: StringEnum + notLike: StringEnum + iLike: StringEnum + notILike: StringEnum + in: [StringEnum!] + notIn: [StringEnum!] +} + +enum StringEnum { + ONE_STR + TWO_STR + THREE_STR + FOUR_STR +} + +input NumberEnumFilterComparison { + is: Boolean + isNot: Boolean + eq: NumberEnum + neq: NumberEnum + gt: NumberEnum + gte: NumberEnum + lt: NumberEnum + lte: NumberEnum + like: NumberEnum + notLike: NumberEnum + iLike: NumberEnum + notILike: NumberEnum + in: [NumberEnum!] + notIn: [NumberEnum!] +} + +enum NumberEnum { + ONE + TWO + THREE + FOUR +} + +input TimestampFieldComparison { + is: Boolean + isNot: Boolean + eq: Timestamp + neq: Timestamp + gt: Timestamp + gte: Timestamp + lt: Timestamp + lte: Timestamp + in: [Timestamp!] + notIn: [Timestamp!] + between: TimestampFieldComparisonBetween + notBetween: TimestampFieldComparisonBetween +} + +""" +`Date` type as integer. Type represents date and time as number of milliseconds from start of UNIX epoch. +""" +scalar Timestamp + +input TimestampFieldComparisonBetween { + lower: Timestamp! + upper: Timestamp! +} diff --git a/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts b/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts index ec1185b00..7f0dd7c4f 100644 --- a/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts +++ b/packages/query-graphql/__tests__/decorators/relation.decorator.spec.ts @@ -1,5 +1,6 @@ +// eslint-disable-next-line max-classes-per-file import { ObjectType } from '@nestjs/graphql'; -import { Relation, Connection, PagingStrategies } from '../../src'; +import { Relation, Connection, PagingStrategies, FilterableRelation, FilterableConnection } from '../../src'; import { getMetadataStorage } from '../../src/metadata'; @ObjectType() @@ -14,12 +15,7 @@ describe('@Relation', () => { class TestDTO {} const relations = getMetadataStorage().getRelations(TestDTO); - expect(relations).toHaveLength(1); - const relation = relations![0]; - expect(relation.name).toBe('test'); - expect(relation.relationTypeFunc).toBe(relationFn); - expect(relation.isMany).toBe(false); - expect(relation.relationOpts).toBe(relationOpts); + expect(relations).toEqual({ one: { test: { DTO: TestRelation, ...relationOpts } } }); }); it('should set the isMany flag if the relationFn returns an array', () => { @@ -30,12 +26,37 @@ describe('@Relation', () => { class TestDTO {} const relations = getMetadataStorage().getRelations(TestDTO); - expect(relations).toHaveLength(1); - const relation = relations![0]; - expect(relation.name).toBe('tests'); - expect(relation.relationTypeFunc).toBe(relationFn); - expect(relation.isMany).toBe(true); - expect(relation.relationOpts).toEqual({ ...relationOpts, pagingStrategy: PagingStrategies.OFFSET }); + expect(relations).toEqual({ + many: { tests: { DTO: TestRelation, ...relationOpts, pagingStrategy: PagingStrategies.OFFSET } }, + }); + }); +}); + +describe('@FilterableRelation', () => { + it('should add the relation metadata to the metadata storage', () => { + const relationFn = () => TestRelation; + const relationOpts = { disableRead: true }; + @ObjectType() + @FilterableRelation('test', relationFn, relationOpts) + class TestDTO {} + + const relations = getMetadataStorage().getRelations(TestDTO); + expect(relations).toEqual({ one: { test: { DTO: TestRelation, ...relationOpts, allowFiltering: true } } }); + }); + + it('should set the isMany flag if the relationFn returns an array', () => { + const relationFn = () => [TestRelation]; + const relationOpts = { disableRead: true }; + @ObjectType() + @FilterableRelation('tests', relationFn, relationOpts) + class TestDTO {} + + const relations = getMetadataStorage().getRelations(TestDTO); + expect(relations).toEqual({ + many: { + tests: { DTO: TestRelation, ...relationOpts, pagingStrategy: PagingStrategies.OFFSET, allowFiltering: true }, + }, + }); }); }); @@ -48,11 +69,25 @@ describe('@Connection', () => { class TestDTO {} const relations = getMetadataStorage().getRelations(TestDTO); - expect(relations).toHaveLength(1); - const relation = relations![0]; - expect(relation.name).toBe('test'); - expect(relation.relationTypeFunc).toBe(relationFn); - expect(relation.isMany).toBe(true); - expect(relation.relationOpts).toEqual({ pagingStrategy: PagingStrategies.CURSOR, ...relationOpts }); + expect(relations).toEqual({ + many: { test: { DTO: TestRelation, ...relationOpts, pagingStrategy: PagingStrategies.CURSOR } }, + }); + }); +}); + +describe('@FilterableConnection', () => { + it('should add the relation metadata to the metadata storage', () => { + const relationFn = () => TestRelation; + const relationOpts = { disableRead: true }; + @ObjectType() + @FilterableConnection('test', relationFn, relationOpts) + class TestDTO {} + + const relations = getMetadataStorage().getRelations(TestDTO); + expect(relations).toEqual({ + many: { + test: { DTO: TestRelation, ...relationOpts, pagingStrategy: PagingStrategies.CURSOR, allowFiltering: true }, + }, + }); }); }); diff --git a/packages/query-graphql/__tests__/types/query/filter.type.spec.ts b/packages/query-graphql/__tests__/types/query/filter.type.spec.ts index 152415c89..76b620792 100644 --- a/packages/query-graphql/__tests__/types/query/filter.type.spec.ts +++ b/packages/query-graphql/__tests__/types/query/filter.type.spec.ts @@ -13,10 +13,26 @@ import { InputType, registerEnumType, } from '@nestjs/graphql'; -import { FilterableField, FilterType } from '../../../src'; -import { expectSDL, filterInputTypeSDL } from '../../__fixtures__'; +import { + FilterableField, + FilterType, + Relation, + FilterableRelation, + Connection, + FilterableConnection, + UpdateFilterType, + DeleteFilterType, + SubscriptionFilterType, +} from '../../../src'; +import { + expectSDL, + filterInputTypeSDL, + updateFilterInputTypeSDL, + deleteFilterInputTypeSDL, + subscriptionFilterInputTypeSDL, +} from '../../__fixtures__'; -describe('GraphQLFilterType', (): void => { +describe('filter types', (): void => { enum NumberEnum { ONE, TWO, @@ -45,7 +61,21 @@ describe('GraphQLFilterType', (): void => { id!: number; } + @ObjectType('TestRelationDto') + class TestRelation extends BaseType { + @FilterableField() + relationName!: string; + + @FilterableField() + relationAge!: number; + } + @ObjectType('TestFilterDto') + @Relation('unfilterableRelation', () => TestRelation) + @FilterableRelation('filterableRelation', () => TestRelation) + @FilterableRelation('filterableRelations', () => [TestRelation]) + @Connection('unfilterableConnection', () => TestRelation) + @FilterableConnection('filterableConnection', () => TestRelation) class TestDto extends BaseType { @FilterableField() boolField!: boolean; @@ -77,65 +107,269 @@ describe('GraphQLFilterType', (): void => { @Field() nonFilterField!: number; } - const TestGraphQLFilter: Class> = FilterType(TestDto); - @InputType() - class TestDtoFilter extends TestGraphQLFilter {} - it('should throw an error if the class is not annotated with @ObjectType', () => { - class TestInvalidFilter {} + describe('FilterType', () => { + const TestGraphQLFilter: Class> = FilterType(TestDto); + @InputType() + class TestDtoFilter extends TestGraphQLFilter {} - expect(() => FilterType(TestInvalidFilter)).toThrow( - 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', - ); - }); + it('should throw an error if the class is not annotated with @ObjectType', () => { + class TestInvalidFilter {} + + expect(() => FilterType(TestInvalidFilter)).toThrow( + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + ); + }); - it('should create the correct filter graphql schema', () => { - @Resolver() - class FilterTypeSpec { - @Query(() => Int) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - test(@Args('input') input: TestDtoFilter): number { - return 1; + it('should create the correct filter graphql schema', () => { + @Resolver() + class FilterTypeSpec { + @Query(() => Int) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test(@Args('input') input: TestDtoFilter): number { + return 1; + } } - } - return expectSDL([FilterTypeSpec], filterInputTypeSDL); - }); + return expectSDL([FilterTypeSpec], filterInputTypeSDL); + }); + + it('should throw an error if no fields are found', () => { + @ObjectType('TestNoFields') + class TestInvalidFilter {} + + expect(() => FilterType(TestInvalidFilter)).toThrow( + 'No fields found to create GraphQLFilter for TestInvalidFilter', + ); + }); + + it('should throw an error when the field type is unknown', () => { + enum EnumField { + ONE = 'one', + } + @ObjectType('TestBadField') + class TestInvalidFilter { + @FilterableField(() => EnumField) + fakeType!: EnumField; + } + + expect(() => FilterType(TestInvalidFilter)).toThrow('Unable to create filter comparison for {"ONE":"one"}.'); + }); - it('should throw an error if no fields are found', () => { - @ObjectType('TestNoFields') - class TestInvalidFilter {} + it('should convert and filters to filter class', () => { + const filterObject: Filter = { + and: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); + }); - expect(() => FilterType(TestInvalidFilter)).toThrow( - 'No fields found to create GraphQLFilter for TestInvalidFilter', - ); + it('should convert or filters to filter class', () => { + const filterObject: Filter = { + or: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); + }); }); - it('should throw an error when the field type is unknown', () => { - enum EnumField { - ONE = 'one', - } - @ObjectType('TestBadField') - class TestInvalidFilter { - @FilterableField(() => EnumField) - fakeType!: EnumField; - } - - expect(() => FilterType(TestInvalidFilter)).toThrow('Unable to create filter comparison for {"ONE":"one"}.'); + describe('UpdateFilterType', () => { + const TestGraphQLFilter: Class> = UpdateFilterType(TestDto); + + @InputType() + class TestDtoFilter extends TestGraphQLFilter {} + + it('should throw an error if the class is not annotated with @ObjectType', () => { + class TestInvalidFilter {} + + expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + ); + }); + + it('should create the correct filter graphql schema', () => { + @Resolver() + class FilterTypeSpec { + @Query(() => Int) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test(@Args('input') input: TestDtoFilter): number { + return 1; + } + } + return expectSDL([FilterTypeSpec], updateFilterInputTypeSDL); + }); + + it('should throw an error if no fields are found', () => { + @ObjectType('TestNoFields') + class TestInvalidFilter {} + + expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create GraphQLFilter for TestInvalidFilter', + ); + }); + + it('should throw an error when the field type is unknown', () => { + enum EnumField { + ONE = 'one', + } + @ObjectType('TestBadField') + class TestInvalidFilter { + @FilterableField(() => EnumField) + fakeType!: EnumField; + } + + expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( + 'Unable to create filter comparison for {"ONE":"one"}.', + ); + }); + + it('should convert and filters to filter class', () => { + const filterObject: Filter = { + and: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); + }); + + it('should convert or filters to filter class', () => { + const filterObject: Filter = { + or: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); + }); }); - it('should convert and filters to filter class', () => { - const filterObject: Filter = { - and: [{ stringField: { eq: 'foo' } }], - }; - const filterInstance = plainToClass(TestDtoFilter, filterObject); - expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); + describe('DeleteFilterType', () => { + const TestGraphQLFilter: Class> = DeleteFilterType(TestDto); + + @InputType() + class TestDtoFilter extends TestGraphQLFilter {} + + it('should throw an error if the class is not annotated with @ObjectType', () => { + class TestInvalidFilter {} + + expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + ); + }); + + it('should create the correct filter graphql schema', () => { + @Resolver() + class FilterTypeSpec { + @Query(() => Int) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test(@Args('input') input: TestDtoFilter): number { + return 1; + } + } + return expectSDL([FilterTypeSpec], deleteFilterInputTypeSDL); + }); + + it('should throw an error if no fields are found', () => { + @ObjectType('TestNoFields') + class TestInvalidFilter {} + + expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create GraphQLFilter for TestInvalidFilter', + ); + }); + + it('should throw an error when the field type is unknown', () => { + enum EnumField { + ONE = 'one', + } + @ObjectType('TestBadField') + class TestInvalidFilter { + @FilterableField(() => EnumField) + fakeType!: EnumField; + } + + expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( + 'Unable to create filter comparison for {"ONE":"one"}.', + ); + }); + + it('should convert and filters to filter class', () => { + const filterObject: Filter = { + and: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); + }); + + it('should convert or filters to filter class', () => { + const filterObject: Filter = { + or: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); + }); }); - it('should convert or filters to filter class', () => { - const filterObject: Filter = { - or: [{ stringField: { eq: 'foo' } }], - }; - const filterInstance = plainToClass(TestDtoFilter, filterObject); - expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); + describe('SubscriptionFilterType', () => { + const TestGraphQLFilter: Class> = SubscriptionFilterType(TestDto); + + @InputType() + class TestDtoFilter extends TestGraphQLFilter {} + + it('should throw an error if the class is not annotated with @ObjectType', () => { + class TestInvalidFilter {} + + expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + ); + }); + + it('should create the correct filter graphql schema', () => { + @Resolver() + class FilterTypeSpec { + @Query(() => Int) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test(@Args('input') input: TestDtoFilter): number { + return 1; + } + } + return expectSDL([FilterTypeSpec], subscriptionFilterInputTypeSDL); + }); + + it('should throw an error if no fields are found', () => { + @ObjectType('TestNoFields') + class TestInvalidFilter {} + + expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( + 'No fields found to create GraphQLFilter for TestInvalidFilter', + ); + }); + + it('should throw an error when the field type is unknown', () => { + enum EnumField { + ONE = 'one', + } + @ObjectType('TestBadField') + class TestInvalidFilter { + @FilterableField(() => EnumField) + fakeType!: EnumField; + } + + expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( + 'Unable to create filter comparison for {"ONE":"one"}.', + ); + }); + + it('should convert and filters to filter class', () => { + const filterObject: Filter = { + and: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); + }); + + it('should convert or filters to filter class', () => { + const filterObject: Filter = { + or: [{ stringField: { eq: 'foo' } }], + }; + const filterInstance = plainToClass(TestDtoFilter, filterObject); + expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); + }); }); }); diff --git a/packages/query-graphql/src/decorators/index.ts b/packages/query-graphql/src/decorators/index.ts index 8c99d1fc3..85c46c925 100644 --- a/packages/query-graphql/src/decorators/index.ts +++ b/packages/query-graphql/src/decorators/index.ts @@ -1,6 +1,13 @@ export { FilterableField } from './filterable-field.decorator'; export { ResolverMethodOpts } from './resolver-method.decorator'; -export { Connection, Relation, RelationDecoratorOpts, RelationTypeFunc } from './relation.decorator'; +export { + Connection, + Relation, + RelationDecoratorOpts, + RelationTypeFunc, + FilterableConnection, + FilterableRelation, +} from './relation.decorator'; export * from './resolver-mutation.decorator'; export * from './resolver-query.decorator'; export * from './resolver-field.decorator'; diff --git a/packages/query-graphql/src/decorators/relation.decorator.ts b/packages/query-graphql/src/decorators/relation.decorator.ts index 6eef542fd..9c3c7fdca 100644 --- a/packages/query-graphql/src/decorators/relation.decorator.ts +++ b/packages/query-graphql/src/decorators/relation.decorator.ts @@ -24,6 +24,27 @@ export function Relation( }; } +export function FilterableRelation( + name: string, + relationTypeFunction: RelationTypeFunc, + options?: RelationDecoratorOpts, +) { + return >(DTOClass: Cls): Cls | void => { + const isMany = Array.isArray(relationTypeFunction()); + const relationOpts = { + ...(isMany ? { pagingStrategy: PagingStrategies.OFFSET, ...options } : options), + allowFiltering: true, + }; + getMetadataStorage().addRelation(DTOClass, name, { + name, + isMany, + relationOpts, + relationTypeFunc: relationTypeFunction, + }); + return DTOClass; + }; +} + export function Connection( name: string, relationTypeFunction: ConnectionTypeFunc, @@ -39,3 +60,20 @@ export function Connection( return DTOClass; }; } + +export function FilterableConnection( + name: string, + relationTypeFunction: ConnectionTypeFunc, + options?: RelationDecoratorOpts, +) { + const relationOpts = { pagingStrategy: PagingStrategies.CURSOR, ...options, allowFiltering: true }; + return >(DTOClass: Cls): Cls | void => { + getMetadataStorage().addRelation(DTOClass, name, { + name, + isMany: true, + relationOpts, + relationTypeFunc: relationTypeFunction, + }); + return DTOClass; + }; +} diff --git a/packages/query-graphql/src/index.ts b/packages/query-graphql/src/index.ts index d72b44473..58cb5eca1 100644 --- a/packages/query-graphql/src/index.ts +++ b/packages/query-graphql/src/index.ts @@ -3,7 +3,9 @@ export { FilterableField, ResolverMethodOpts, Relation, + FilterableRelation, Connection, + FilterableConnection, RelationTypeFunc, RelationDecoratorOpts, Reference, diff --git a/packages/query-graphql/src/metadata/metadata-storage.ts b/packages/query-graphql/src/metadata/metadata-storage.ts index e491c93fb..d3199c3cc 100644 --- a/packages/query-graphql/src/metadata/metadata-storage.ts +++ b/packages/query-graphql/src/metadata/metadata-storage.ts @@ -4,7 +4,7 @@ import { Class, Filter, SortField } from '@nestjs-query/core'; import { ObjectTypeMetadata } from '@nestjs/graphql/dist/schema-builder/metadata/object-type.metadata'; import { ReturnTypeFunc, FieldOptions } from '@nestjs/graphql'; import { EnumMetadata } from '@nestjs/graphql/dist/schema-builder/metadata'; -import { ResolverRelation, ResolverRelationReference } from '../resolvers/relations'; +import { RelationsOpts, ResolverRelation, ResolverRelationReference } from '../resolvers/relations'; import { ReferencesKeys } from '../resolvers/relations/relations.interface'; import { EdgeType, StaticConnectionType } from '../types/connection'; @@ -139,8 +139,23 @@ export class GraphQLQueryMetadataStorage { relations.push(relation); } - getRelations(type: Class): RelationDescriptor[] | undefined { - return this.relationStorage.get(type); + getRelations(type: Class): RelationsOpts { + const relations: RelationsOpts = {}; + const metaRelations = this.relationStorage.get(type); + if (!metaRelations) { + return relations; + } + metaRelations.forEach((r) => { + const relationType = r.relationTypeFunc(); + const DTO = Array.isArray(relationType) ? relationType[0] : relationType; + const opts = { ...r.relationOpts, DTO }; + if (r.isMany) { + relations.many = { ...relations.many, [r.name]: opts }; + } else { + relations.one = { ...relations.one, [r.name]: opts }; + } + }); + return relations; } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/query-graphql/src/resolvers/relations/helpers.ts b/packages/query-graphql/src/resolvers/relations/helpers.ts index 11744b381..cb95434b8 100644 --- a/packages/query-graphql/src/resolvers/relations/helpers.ts +++ b/packages/query-graphql/src/resolvers/relations/helpers.ts @@ -33,19 +33,7 @@ export const removeRelationOpts = ( }; export const getRelationsFromMetadata = (DTOClass: Class): RelationsOpts => { - const relations: RelationsOpts = {}; - const metaRelations = getMetadataStorage().getRelations(DTOClass) ?? []; - metaRelations.forEach((r) => { - const relationType = r.relationTypeFunc(); - const DTO = Array.isArray(relationType) ? relationType[0] : relationType; - const opts = { ...r.relationOpts, DTO }; - if (r.isMany) { - relations.many = { ...relations.many, [r.name]: opts }; - } else { - relations.one = { ...relations.one, [r.name]: opts }; - } - }); - return relations; + return getMetadataStorage().getRelations(DTOClass); }; export const mergeRelations = (into: RelationsOpts, from: RelationsOpts): RelationsOpts => { diff --git a/packages/query-graphql/src/resolvers/relations/relations.interface.ts b/packages/query-graphql/src/resolvers/relations/relations.interface.ts index a3f5cbe33..81fe5d0fa 100644 --- a/packages/query-graphql/src/resolvers/relations/relations.interface.ts +++ b/packages/query-graphql/src/resolvers/relations/relations.interface.ts @@ -51,6 +51,13 @@ export type ResolverRelation = { * Disable remove relation graphql endpoints */ disableRemove?: boolean; + + /** + * Set to true if you should be able to filter on this relation. + * + * This will only work with relations defined through an ORM (typeorm or sequelize). + */ + allowFiltering?: boolean; } & DTONamesOpts & ResolverMethodOpts & QueryArgsTypeOpts & diff --git a/packages/query-graphql/src/types/index.ts b/packages/query-graphql/src/types/index.ts index d4dcc7b88..1b346d583 100644 --- a/packages/query-graphql/src/types/index.ts +++ b/packages/query-graphql/src/types/index.ts @@ -10,6 +10,9 @@ export { MutationArgsType } from './mutation-args.type'; export { CursorPagingType, FilterType, + UpdateFilterType, + DeleteFilterType, + SubscriptionFilterType, QueryArgsType, SortType, StaticPagingTypes, diff --git a/packages/query-graphql/src/types/query/filter.type.ts b/packages/query-graphql/src/types/query/filter.type.ts index 01c60b086..165129d11 100644 --- a/packages/query-graphql/src/types/query/filter.type.ts +++ b/packages/query-graphql/src/types/query/filter.type.ts @@ -3,10 +3,17 @@ import { InputType, Field } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; import { getMetadataStorage } from '../../metadata'; +import { ResolverRelation } from '../../resolvers/relations'; import { createFilterComparisonType } from './field-comparison'; import { UnregisteredObjectType } from '../type.errors'; -function getOrCreateFilterType(TClass: Class, name: string): Class> { +export type FilterableRelations = Record>; + +function getOrCreateFilterType( + TClass: Class, + name: string, + filterableRelations: FilterableRelations = {}, +): Class> { const metadataStorage = getMetadataStorage(); const existing = metadataStorage.getFilterType(name); if (existing) { @@ -36,6 +43,16 @@ function getOrCreateFilterType(TClass: Class, name: string): Class FC, { nullable: true })(GraphQLFilter.prototype, propertyName); Type(() => FC)(GraphQLFilter.prototype, propertyName); }); + Object.keys(filterableRelations).forEach((field) => { + const FieldType = filterableRelations[field]; + if (FieldType) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const FC = getOrCreateFilterType(FieldType, `${name}${getObjectTypeName(FieldType)}Filter`); + ValidateNested()(GraphQLFilter.prototype, field); + Field(() => FC, { nullable: true })(GraphQLFilter.prototype, field); + Type(() => FC)(GraphQLFilter.prototype, field); + } + }); metadataStorage.addFilterType(name, GraphQLFilter as Class>); return GraphQLFilter as Class>; } @@ -48,8 +65,21 @@ function getObjectTypeName(DTOClass: Class): string { return objMetadata.name; } +function getFilterableRelations(relations: Record>): FilterableRelations { + const filterableRelations: FilterableRelations = {}; + Object.keys(relations).forEach((r) => { + const opts = relations[r]; + if (opts && opts.allowFiltering) { + filterableRelations[r] = opts.DTO; + } + }); + return filterableRelations; +} + export function FilterType(TClass: Class): Class> { - return getOrCreateFilterType(TClass, `${getObjectTypeName(TClass)}Filter`); + const { one = {}, many = {} } = getMetadataStorage().getRelations(TClass); + const filterableRelations: FilterableRelations = { ...getFilterableRelations(one), ...getFilterableRelations(many) }; + return getOrCreateFilterType(TClass, `${getObjectTypeName(TClass)}Filter`, filterableRelations); } export function DeleteFilterType(TClass: Class): Class> {