diff --git a/documentation/docs/graphql/dtos.mdx b/documentation/docs/graphql/dtos.mdx index 5edb4f6f39..0fcdbf1e25 100644 --- a/documentation/docs/graphql/dtos.mdx +++ b/documentation/docs/graphql/dtos.mdx @@ -19,11 +19,14 @@ If you use the @nestjs/graphql `Field` decorator it will not be exposed in the q ### Options In addition to the normal field options you can also specify the following options -* `allowedComparisons` - An array of allowed comparisons. You can use this option to allow a subset of filter comparisons when querying through graphql. - * This option is useful if the field is expensive to query on for certain operators, or your data source supports a limited set of comparisons. -* `filterRequired` - When set to `true` the field will be required whenever a `filter` is used. The `filter` requirement applies to all `read`, `update`, and `delete` endpoints that use a `filter`. - * The `filterRequired` option is useful when your entity has an index that requires a subset of fields to be used to provide certain level of query performance. - * **NOTE**: When a field is a required in a filter the default `filter` option is ignored. + +- `allowedComparisons` - An array of allowed comparisons. You can use this option to allow a subset of filter comparisons when querying through graphql. + - This option is useful if the field is expensive to query on for certain operators, or your data source supports a limited set of comparisons. +- `filterRequired` - When set to `true` the field will be required whenever a `filter` is used. The `filter` requirement applies to all `read`, `update`, and `delete` endpoints that use a `filter`. + - The `filterRequired` option is useful when your entity has an index that requires a subset of fields to be used to provide certain level of query performance. + - **NOTE**: When a field is a required in a filter the default `filter` option is ignored. +- `filterOnly`- When set to `true`, the field will only appear as `filter` but isn't included as field inside the `ObjectType`. + - This option is useful if you want to filter on foreign keys without resolving the relation but you don't want to have the foreign key show up as field in your query type for the DTO. This might be especially useful for [federated relations](./federation.mdx#reference-decorator) ### Example @@ -50,7 +53,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` ### Example - allowedComparisons @@ -82,7 +84,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` ### Example - filterRequired @@ -110,7 +111,37 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } +``` + +### Example - filterOnly + +In the following example the `filterOnly` option is applied to the `assigneeId` field, which makes a query filerable by the id of an assignd user but won't return the `assigneeId` as field. + +```ts title="todo-item.dto.ts" +import { FilterableField } from '@nestjs-query/query-graphql'; +import { ObjectType, ID, GraphQLISODateTime, Field } from '@nestjs/graphql'; + +@ObjectType('TodoItem') +@Relation('assignee', () => UserDTO) +export class TodoItemDTO { + @FilterableField(() => ID) + id!: string; + + @FilterableField() + title!: string; + + @FilterableField() + completed!: boolean; + + @FilterableField({ filterOnly: true }) + assigneeId!: string; + + @Field(() => GraphQLISODateTime) + created!: Date; + @Field(() => GraphQLISODateTime) + updated!: Date; +} ``` ## `@QueryOptions` @@ -151,7 +182,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` --- @@ -163,7 +193,7 @@ By default all results will be limited to 10 records. To override the default you can override the default page size by setting the `defaultResultSize` option. In this example we specify the `defaultResultSize` to 5 which means if a page size is not specified 5 results will be - returned. +returned. ```ts title="todo-item.dto.ts" {5} import { FilterableField, QueryOptions } from '@nestjs-query/query-graphql'; @@ -187,7 +217,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` ### Limiting Results Size @@ -224,7 +253,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` --- @@ -237,8 +265,8 @@ For a more in-depth overview of paging check out the [paging docs](./paging.mdx) You can override the default `pagingStrategy` to one of the following alternatives -* `OFFSET` - sets paging to allow `limit` and `offset` fields, and returns an `OffsetConnection`. -* `NONE` - turn off all paging and always return an `ArrayConnection`. +- `OFFSET` - sets paging to allow `limit` and `offset` fields, and returns an `OffsetConnection`. +- `NONE` - turn off all paging and always return an `ArrayConnection`. When using the `OFFSET` strategy your the paging arguments for a many query will accept a `limit` and/or `offset`. This will also change the return type from a `CursorConnection` to an `OffsetConnection`. @@ -265,7 +293,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` To disable paging entirely you can use the `NONE` `pagingStrategy`. When using `NONE` an `ArrayConnection` will be returned. @@ -292,7 +319,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` ### Paging with Total Count @@ -307,7 +333,7 @@ Enabling `totalCount` can be expensive. If your table is large the `totalCount` The `totalCount` field is not eagerly fetched. It will only be executed if the field is queried from the client. ::: -When using the `CURSOR` (the default) or `OFFSET` paging strategy you have the option to expose a `totalCount` field to +When using the `CURSOR` (the default) or `OFFSET` paging strategy you have the option to expose a `totalCount` field to allow clients to fetch a total count of records in a connection. To enable the `totalCount` field for connections set the `enableTotalCount` option to `true` using the @@ -335,7 +361,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` When setting `enableTotalCount` to `true` you will be able to query for `totalCount` on `cursor` or offset connections @@ -353,7 +378,7 @@ values={[ { todoItems { totalCount - pageInfo{ + pageInfo { hasNextPage hasPreviousPage startCursor @@ -368,7 +393,6 @@ values={[ } } } - ``` @@ -460,7 +484,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` ### Allowed Boolean Expressions @@ -492,7 +515,6 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` To turn off all boolean expressions you can set `allowedBooleanExpressions` to an empty array. This is useful if you @@ -522,5 +544,4 @@ export class TodoItemDTO { @Field(() => GraphQLISODateTime) updated!: Date; } - ``` diff --git a/examples/basic/e2e/todo-item.resolver.spec.ts b/examples/basic/e2e/todo-item.resolver.spec.ts index 8dc939aef2..a5e23046ed 100644 --- a/examples/basic/e2e/todo-item.resolver.spec.ts +++ b/examples/basic/e2e/todo-item.resolver.spec.ts @@ -75,6 +75,25 @@ describe('TodoItemResolver (basic - e2e)', () => { }, })); + it(`should not include filter-only fields`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: {}, + query: `{ + todoItem(id: 1) { + ${todoItemFields} + created + } + }`, + }) + .expect(400) + .then(({ body }) => { + expect(body.errors).toHaveLength(1); + expect(body.errors[0].message).toBe('Cannot query field "created" on type "TodoItem".'); + })); + it(`should return subTasks as a connection`, () => request(app.getHttpServer()) .post('/graphql') @@ -199,6 +218,41 @@ describe('TodoItemResolver (basic - e2e)', () => { ]); })); + it(`should allow querying by filter-only fields`, () => + request(app.getHttpServer()) + .post('/graphql') + .send({ + operationName: null, + variables: { + now: new Date().toISOString(), + }, + query: `query ($now: DateTime!) { + todoItems(filter: { created: { lt: $now } }) { + ${pageInfoField} + ${edgeNodes(todoItemFields)} + } + }`, + }) + .expect(200) + .then(({ body }) => { + const { edges, pageInfo }: CursorConnectionType = body.data.todoItems; + expect(pageInfo).toEqual({ + endCursor: 'YXJyYXljb25uZWN0aW9uOjQ=', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'YXJyYXljb25uZWN0aW9uOjA=', + }); + expect(edges).toHaveLength(5); + + expect(edges.map((e) => e.node)).toEqual([ + { id: '1', title: 'Create Nest App', completed: true, description: null }, + { id: '2', title: 'Create Entity', completed: false, description: null }, + { id: '3', title: 'Create Entity Service', completed: false, description: null }, + { id: '4', title: 'Add Todo Item Resolver', completed: false, description: null }, + { id: '5', title: 'How to create item With Sub Tasks', completed: false, description: null }, + ]); + })); + it(`should allow sorting`, () => request(app.getHttpServer()) .post('/graphql') diff --git a/examples/basic/src/todo-item/dto/todo-item.dto.ts b/examples/basic/src/todo-item/dto/todo-item.dto.ts index 6815b60cdc..97bd4a0d7b 100644 --- a/examples/basic/src/todo-item/dto/todo-item.dto.ts +++ b/examples/basic/src/todo-item/dto/todo-item.dto.ts @@ -19,9 +19,9 @@ export class TodoItemDTO { @FilterableField() completed!: boolean; - @FilterableField(() => GraphQLISODateTime) + @FilterableField(() => GraphQLISODateTime, { filterOnly: true }) created!: Date; - @FilterableField(() => GraphQLISODateTime) + @FilterableField(() => GraphQLISODateTime, { filterOnly: true }) updated!: Date; } diff --git a/packages/query-graphql/__tests__/decorators/fitlerable-fields.decorator.spec.ts b/packages/query-graphql/__tests__/decorators/filterable-fields.decorator.spec.ts similarity index 92% rename from packages/query-graphql/__tests__/decorators/fitlerable-fields.decorator.spec.ts rename to packages/query-graphql/__tests__/decorators/filterable-fields.decorator.spec.ts index a1ce7951a7..9e1d39fe3c 100644 --- a/packages/query-graphql/__tests__/decorators/fitlerable-fields.decorator.spec.ts +++ b/packages/query-graphql/__tests__/decorators/filterable-fields.decorator.spec.ts @@ -23,6 +23,9 @@ describe('FilterableField decorator', (): void => { @FilterableField(undefined, { nullable: true }) numberField?: number; + + @FilterableField({ filterOnly: true }) + filterOnlyField!: string; } const fields = getFilterableFields(TestDto); expect(fields).toMatchObject([ @@ -40,6 +43,12 @@ describe('FilterableField decorator', (): void => { returnTypeFunc: floatReturnFunc, }, { propertyName: 'numberField', target: Number, advancedOptions: { nullable: true }, returnTypeFunc: undefined }, + { + propertyName: 'filterOnlyField', + target: String, + advancedOptions: { filterOnly: true }, + returnTypeFunc: undefined, + }, ]); expect(fieldSpy).toHaveBeenCalledTimes(4); expect(fieldSpy).toHaveBeenNthCalledWith(1); diff --git a/packages/query-graphql/src/decorators/filterable-field.decorator.ts b/packages/query-graphql/src/decorators/filterable-field.decorator.ts index 1fff96fd38..25908dc18b 100644 --- a/packages/query-graphql/src/decorators/filterable-field.decorator.ts +++ b/packages/query-graphql/src/decorators/filterable-field.decorator.ts @@ -6,6 +6,7 @@ const reflector = new ArrayReflector(FILTERABLE_FIELD_KEY); export type FilterableFieldOptions = { allowedComparisons?: FilterComparisonOperators[]; filterRequired?: boolean; + filterOnly?: boolean; } & FieldOptions; export interface FilterableFieldDescriptor { @@ -78,6 +79,11 @@ export function FilterableField( returnTypeFunc, advancedOptions, }); + + if (advancedOptions?.filterOnly) { + return undefined; + } + if (returnTypeFunc) { return Field(returnTypeFunc, advancedOptions)(target, propertyName, descriptor); }