diff --git a/packages/query-graphql/__tests__/__fixtures__/test-resolver.dto.ts b/packages/query-graphql/__tests__/__fixtures__/test-resolver.dto.ts index 0119ba0d4..a20472340 100644 --- a/packages/query-graphql/__tests__/__fixtures__/test-resolver.dto.ts +++ b/packages/query-graphql/__tests__/__fixtures__/test-resolver.dto.ts @@ -5,6 +5,7 @@ import { TestResolverAuthorizer } from './test-resolver.authorizer'; @ObjectType() @Authorize(TestResolverAuthorizer) export class TestResolverDTO { + @FilterableField(() => ID) id!: string; diff --git a/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap b/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap index 1236a709d..dc6eea3b2 100644 --- a/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap +++ b/packages/query-graphql/__tests__/types/query/__snapshots__/filter.type.spec.ts.snap @@ -416,6 +416,110 @@ input TestAllowedComparisonStringFieldFilterComparison { `; +exports[`filter types FilterType allowedComparisons option should only expose between/not between comparisons for allowed types 1`] = ` +type Query { + test(input: TestBetweenComparisonDtoFilter!): Int! +} + +input TestBetweenComparisonDtoFilter { + and: [TestBetweenComparisonFilter!] + or: [TestBetweenComparisonFilter!] + id: NumberFieldComparison + boolField: TestBetweenComparisonBoolFieldFilterComparison + dateField: TestBetweenComparisonDateFieldFilterComparison + floatField: TestBetweenComparisonFloatFieldFilterComparison + intField: TestBetweenComparisonIntFieldFilterComparison + numberField: TestBetweenComparisonNumberFieldFilterComparison + stringField: TestBetweenComparisonStringFieldFilterComparison +} + +input TestBetweenComparisonFilter { + and: [TestBetweenComparisonFilter!] + or: [TestBetweenComparisonFilter!] + id: NumberFieldComparison + boolField: TestBetweenComparisonBoolFieldFilterComparison + dateField: TestBetweenComparisonDateFieldFilterComparison + floatField: TestBetweenComparisonFloatFieldFilterComparison + intField: TestBetweenComparisonIntFieldFilterComparison + numberField: TestBetweenComparisonNumberFieldFilterComparison + stringField: TestBetweenComparisonStringFieldFilterComparison +} + +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 TestBetweenComparisonBoolFieldFilterComparison { + eq: Boolean +} + +input TestBetweenComparisonDateFieldFilterComparison { + between: TestBetweenComparisonDateFieldFilterComparisonBetween + notBetween: TestBetweenComparisonDateFieldFilterComparisonBetween +} + +input TestBetweenComparisonDateFieldFilterComparisonBetween { + lower: DateTime! + upper: DateTime! +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +input TestBetweenComparisonFloatFieldFilterComparison { + between: TestBetweenComparisonFloatFieldFilterComparisonBetween + notBetween: TestBetweenComparisonFloatFieldFilterComparisonBetween +} + +input TestBetweenComparisonFloatFieldFilterComparisonBetween { + lower: Float! + upper: Float! +} + +input TestBetweenComparisonIntFieldFilterComparison { + between: TestBetweenComparisonIntFieldFilterComparisonBetween + notBetween: TestBetweenComparisonIntFieldFilterComparisonBetween +} + +input TestBetweenComparisonIntFieldFilterComparisonBetween { + lower: Int! + upper: Int! +} + +input TestBetweenComparisonNumberFieldFilterComparison { + between: TestBetweenComparisonNumberFieldFilterComparisonBetween + notBetween: TestBetweenComparisonNumberFieldFilterComparisonBetween +} + +input TestBetweenComparisonNumberFieldFilterComparisonBetween { + lower: Float! + upper: Float! +} + +input TestBetweenComparisonStringFieldFilterComparison { + eq: String +} + +`; + exports[`filter types FilterType filterRequired option should only expose allowed comparisons 1`] = ` type Query { test(input: TestComparisonDtoFilter!): Int! 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 f7157f5ca..852b011ad 100644 --- a/packages/query-graphql/__tests__/types/query/filter.type.spec.ts +++ b/packages/query-graphql/__tests__/types/query/filter.type.spec.ts @@ -11,7 +11,7 @@ import { GraphQLTimestamp, Field, InputType, - registerEnumType, + registerEnumType } from '@nestjs/graphql'; import { FilterableField, @@ -27,7 +27,7 @@ import { FilterableOffsetConnection, UnPagedRelation, FilterableUnPagedRelation, - QueryOptions, + QueryOptions } from '@ptc-org/nestjs-query-graphql'; import { generateSchema } from '../../__fixtures__'; @@ -47,11 +47,11 @@ describe('filter types', (): void => { } registerEnumType(StringEnum, { - name: 'StringEnum', + name: 'StringEnum' }); registerEnumType(NumberEnum, { - name: 'NumberEnum', + name: 'NumberEnum' }); @ObjectType({ isAbstract: true }) @@ -112,14 +112,17 @@ describe('filter types', (): void => { describe('FilterType', () => { const TestGraphQLFilter: Class> = FilterType(TestDto); + @InputType() - class TestDtoFilter extends TestGraphQLFilter {} + class TestDtoFilter extends TestGraphQLFilter { + } it('should throw an error if the class is not annotated with @ObjectType', () => { - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => FilterType(TestInvalidFilter)).toThrow( - 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType' ); }); @@ -132,16 +135,18 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); it('should throw an error if no fields are found', () => { @ObjectType('TestNoFields') - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => FilterType(TestInvalidFilter)).toThrow( - 'No fields found to create GraphQLFilter for TestInvalidFilter', + 'No fields found to create GraphQLFilter for TestInvalidFilter' ); }); @@ -149,6 +154,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -160,7 +166,7 @@ describe('filter types', (): void => { it('should convert and filters to filter class', () => { const filterObject: Filter = { - and: [{ stringField: { eq: 'foo' } }], + and: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); @@ -168,7 +174,7 @@ describe('filter types', (): void => { it('should convert or filters to filter class', () => { const filterObject: Filter = { - or: [{ stringField: { eq: 'foo' } }], + or: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); @@ -197,8 +203,10 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestAllowedComparisonsDto); + @InputType() - class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} + class TestComparisonDtoFilter extends TestGraphQLComparisonFilter { + } it('should only expose allowed comparisons', async () => { @Resolver() @@ -209,9 +217,51 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); + + it('should only expose between/not between comparisons for allowed types', async () => { + @ObjectType('TestBetweenComparison') + class TestBetweenComparisonsDto extends BaseType { + @FilterableField({ allowedComparisons: ['eq', 'between', 'notBetween'] }) + boolField!: boolean; + + @FilterableField({ allowedComparisons: ['between', 'notBetween'] }) + dateField!: Date; + + @FilterableField(() => Float, { allowedComparisons: ['between', 'notBetween'] }) + floatField!: number; + + @FilterableField(() => Int, { allowedComparisons: ['between', 'notBetween'] }) + intField!: number; + + @FilterableField({ allowedComparisons: ['between', 'notBetween'] }) + numberField!: number; + + @FilterableField({ allowedComparisons: ['eq', 'between', 'notBetween'] }) + stringField!: string; + } + + const TestGraphQLBetweenComparisonFilter: Class> = FilterType(TestBetweenComparisonsDto); + + @InputType() + class TestBetweenComparisonDtoFilter extends TestGraphQLBetweenComparisonFilter { + } + + @Resolver() + class FilterBetweenTypeSpec { + @Query(() => Int) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + test(@Args('input') input: TestBetweenComparisonDtoFilter): number { + return 1; + } + } + + const schema = await generateSchema([FilterBetweenTypeSpec]); + expect(schema).toMatchSnapshot(); + }); }); describe('allowedBooleanExpressions option', () => { @@ -224,8 +274,10 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestOnlyAndBooleanExpressionsDto); + @InputType() - class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} + class TestComparisonDtoFilter extends TestGraphQLComparisonFilter { + } it('should only expose allowed comparisons', async () => { @Resolver() @@ -236,6 +288,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -250,8 +303,10 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestOnlyOrBooleanExpressionsDto); + @InputType() - class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} + class TestComparisonDtoFilter extends TestGraphQLComparisonFilter { + } it('should only expose allowed comparisons', async () => { @Resolver() @@ -262,6 +317,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -276,8 +332,10 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestNoBooleanExpressionsDto); + @InputType() - class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} + class TestComparisonDtoFilter extends TestGraphQLComparisonFilter { + } it('should only expose allowed comparisons', async () => { @Resolver() @@ -288,6 +346,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -308,8 +367,10 @@ describe('filter types', (): void => { } const TestGraphQLComparisonFilter: Class> = FilterType(TestFilterRequiredDto); + @InputType() - class TestComparisonDtoFilter extends TestGraphQLComparisonFilter {} + class TestComparisonDtoFilter extends TestGraphQLComparisonFilter { + } it('should only expose allowed comparisons', async () => { @Resolver() @@ -320,6 +381,7 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); @@ -330,13 +392,15 @@ describe('filter types', (): void => { const TestGraphQLFilter: Class> = UpdateFilterType(TestDto); @InputType() - class TestDtoFilter extends TestGraphQLFilter {} + class TestDtoFilter extends TestGraphQLFilter { + } it('should throw an error if the class is not annotated with @ObjectType', () => { - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType' ); }); @@ -349,16 +413,18 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); it('should throw an error if no fields are found', () => { @ObjectType('TestNoFields') - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create GraphQLFilter for TestInvalidFilter', + 'No fields found to create GraphQLFilter for TestInvalidFilter' ); }); @@ -366,6 +432,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -373,13 +440,13 @@ describe('filter types', (): void => { } expect(() => UpdateFilterType(TestInvalidFilter)).toThrow( - 'Unable to create filter comparison for {"ONE":"one"}.', + 'Unable to create filter comparison for {"ONE":"one"}.' ); }); it('should convert and filters to filter class', () => { const filterObject: Filter = { - and: [{ stringField: { eq: 'foo' } }], + and: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); @@ -387,7 +454,7 @@ describe('filter types', (): void => { it('should convert or filters to filter class', () => { const filterObject: Filter = { - or: [{ stringField: { eq: 'foo' } }], + or: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); @@ -398,13 +465,15 @@ describe('filter types', (): void => { const TestGraphQLFilter: Class> = DeleteFilterType(TestDto); @InputType() - class TestDtoFilter extends TestGraphQLFilter {} + class TestDtoFilter extends TestGraphQLFilter { + } it('should throw an error if the class is not annotated with @ObjectType', () => { - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType' ); }); @@ -417,16 +486,18 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); it('should throw an error if no fields are found', () => { @ObjectType('TestNoFields') - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create GraphQLFilter for TestInvalidFilter', + 'No fields found to create GraphQLFilter for TestInvalidFilter' ); }); @@ -434,6 +505,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -441,13 +513,13 @@ describe('filter types', (): void => { } expect(() => DeleteFilterType(TestInvalidFilter)).toThrow( - 'Unable to create filter comparison for {"ONE":"one"}.', + 'Unable to create filter comparison for {"ONE":"one"}.' ); }); it('should convert and filters to filter class', () => { const filterObject: Filter = { - and: [{ stringField: { eq: 'foo' } }], + and: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); @@ -455,7 +527,7 @@ describe('filter types', (): void => { it('should convert or filters to filter class', () => { const filterObject: Filter = { - or: [{ stringField: { eq: 'foo' } }], + or: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); @@ -466,13 +538,15 @@ describe('filter types', (): void => { const TestGraphQLFilter: Class> = SubscriptionFilterType(TestDto); @InputType() - class TestDtoFilter extends TestGraphQLFilter {} + class TestDtoFilter extends TestGraphQLFilter { + } it('should throw an error if the class is not annotated with @ObjectType', () => { - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType', + 'No fields found to create FilterType. Ensure TestInvalidFilter is annotated with @nestjs/graphql @ObjectType' ); }); @@ -485,16 +559,18 @@ describe('filter types', (): void => { return 1; } } + const schema = await generateSchema([FilterTypeSpec]); expect(schema).toMatchSnapshot(); }); it('should throw an error if no fields are found', () => { @ObjectType('TestNoFields') - class TestInvalidFilter {} + class TestInvalidFilter { + } expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( - 'No fields found to create GraphQLFilter for TestInvalidFilter', + 'No fields found to create GraphQLFilter for TestInvalidFilter' ); }); @@ -502,6 +578,7 @@ describe('filter types', (): void => { enum EnumField { ONE = 'one', } + @ObjectType('TestBadField') class TestInvalidFilter { @FilterableField(() => EnumField) @@ -509,13 +586,13 @@ describe('filter types', (): void => { } expect(() => SubscriptionFilterType(TestInvalidFilter)).toThrow( - 'Unable to create filter comparison for {"ONE":"one"}.', + 'Unable to create filter comparison for {"ONE":"one"}.' ); }); it('should convert and filters to filter class', () => { const filterObject: Filter = { - and: [{ stringField: { eq: 'foo' } }], + and: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.and![0]).toBeInstanceOf(TestGraphQLFilter); @@ -523,7 +600,7 @@ describe('filter types', (): void => { it('should convert or filters to filter class', () => { const filterObject: Filter = { - or: [{ stringField: { eq: 'foo' } }], + or: [{ stringField: { eq: 'foo' } }] }; const filterInstance = plainToClass(TestDtoFilter, filterObject); expect(filterInstance.or![0]).toBeInstanceOf(TestGraphQLFilter); diff --git a/packages/query-graphql/package.json b/packages/query-graphql/package.json index c7b5d2e75..f160370f3 100644 --- a/packages/query-graphql/package.json +++ b/packages/query-graphql/package.json @@ -1,6 +1,6 @@ { "name": "@ptc-org/nestjs-query-graphql", - "version": "1.0.0-alpha.1", + "version": "1.0.0-alpha.2", "description": "Nestjs graphql query adapter", "author": "doug-martin ", "homepage": "https://github.com/tripss/nestjs-query#readme", diff --git a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts index 4732e3710..731f6cc3d 100644 --- a/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts +++ b/packages/query-graphql/src/types/query/field-comparison/field-comparison.factory.ts @@ -1,5 +1,5 @@ import { Class, FilterFieldComparison, FilterComparisonOperators, isNamed } from '@ptc-org/nestjs-query-core'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean, IsDate, IsOptional, ValidateNested } from 'class-validator'; import { upperCaseFirst } from 'upper-case-first'; import { Field, @@ -48,6 +48,15 @@ const knownTypes: Set = new Set([ GraphQLTimestamp ]); +const allowedBetweenTypes: Set = new Set([ + Number, + Int, + Float, + Date, + GraphQLISODateTime, + GraphQLTimestamp +]); + /** @internal */ const getTypeName = (SomeType: ReturnTypeFuncValue): string => { if (knownTypes.has(SomeType) || isNamed(SomeType)) { @@ -90,7 +99,26 @@ export function createFilterComparisonType(options: FilterComparisonOptions>; } - const isNotAllowed = (val: FilterComparisonOperators) => () => !isInAllowedList(options.allowedComparisons, val as unknown); + const isNotAllowed = (val: FilterComparisonOperators, mustBeType?: Set) => () => { + const comparisonAllowed = isInAllowedList(options.allowedComparisons, val as unknown); + + if (comparisonAllowed) { + return mustBeType && !mustBeType.has(fieldType); + } + + return true; + }; + + @InputType(`${inputName}Between`) + class FcBetween { + @Field(() => fieldType, { nullable: false }) + @IsDate() + lower!: T; + + @Field(() => fieldType, { nullable: false }) + @IsDate() + upper!: T; + } @InputType(inputName) class Fc { @@ -163,6 +191,16 @@ export function createFilterComparisonType(options: FilterComparisonOptions FieldType) notIn?: T[]; + + @SkipIf(isNotAllowed('between', allowedBetweenTypes), Field(() => FcBetween, { nullable: true })) + @ValidateNested() + @Type(() => FcBetween) + between?: T; + + @SkipIf(isNotAllowed('notBetween', allowedBetweenTypes), Field(() => FcBetween, { nullable: true })) + @ValidateNested() + @Type(() => FcBetween) + notBetween?: T; } filterComparisonMap.set(inputName, () => Fc);