diff --git a/packages/graphql/lib/type-helpers/partial-type.helper.ts b/packages/graphql/lib/type-helpers/partial-type.helper.ts index d4f290ad1..69a08edac 100644 --- a/packages/graphql/lib/type-helpers/partial-type.helper.ts +++ b/packages/graphql/lib/type-helpers/partial-type.helper.ts @@ -2,6 +2,7 @@ import { Type } from '@nestjs/common'; import { isFunction } from '@nestjs/common/utils/shared.utils'; import { applyIsOptionalDecorator, + applyValidateIfDefinedDecorator, inheritPropertyInitializers, inheritTransformationMetadata, inheritValidationMetadata, @@ -17,6 +18,7 @@ import { applyFieldDecorators } from './type-helpers.utils'; interface PartialTypeOptions { decorator?: ClassDecoratorFactory; omitDefaultValues?: boolean; + skipNullProperties?: boolean; } function isPartialTypeOptions( @@ -25,7 +27,8 @@ function isPartialTypeOptions( return ( optionsOrDecorator && ('decorator' in optionsOrDecorator || - 'omitDefaultValues' in optionsOrDecorator) + 'omitDefaultValues' in optionsOrDecorator || + 'skipNullProperties' in optionsOrDecorator) ); } @@ -42,13 +45,20 @@ export function PartialType( let decorator: ClassDecoratorFactory | undefined; let omitDefaultValues = false; + let skipNullProperties = true; if (isPartialTypeOptions(optionsOrDecorator)) { decorator = optionsOrDecorator.decorator; omitDefaultValues = optionsOrDecorator.omitDefaultValues; + skipNullProperties = optionsOrDecorator.skipNullProperties ?? true; } else { decorator = optionsOrDecorator; } + const applyPartialDecoratorFn = + skipNullProperties === false + ? applyValidateIfDefinedDecorator + : applyIsOptionalDecorator; + if (decorator) { decorator({ isAbstract: true })(PartialObjectType); } else { @@ -70,7 +80,7 @@ export function PartialType( nullable: true, defaultValue: omitDefaultValues ? undefined : item.options.defaultValue, })(PartialObjectType.prototype, item.name); - applyIsOptionalDecorator(PartialObjectType, item.name); + applyPartialDecoratorFn(PartialObjectType, item.name); applyFieldDecorators(PartialObjectType, item); }); } @@ -90,7 +100,7 @@ export function PartialType( PartialObjectType[METADATA_FACTORY_NAME](), ); pluginFields.forEach((key) => - applyIsOptionalDecorator(PartialObjectType, key), + applyPartialDecoratorFn(PartialObjectType, key), ); } diff --git a/packages/graphql/tests/plugin/type-helpers/partial-type.helper.spec.ts b/packages/graphql/tests/plugin/type-helpers/partial-type.helper.spec.ts index 2904eeb96..265b456f1 100644 --- a/packages/graphql/tests/plugin/type-helpers/partial-type.helper.spec.ts +++ b/packages/graphql/tests/plugin/type-helpers/partial-type.helper.spec.ts @@ -10,6 +10,10 @@ import { getValidationMetadataByTarget } from './type-helpers.test-utils'; @ObjectType() class CreateUserDto extends BaseType { + @IsString() + @Field() + firstName: string; + @Field({ nullable: true }) login: string; @@ -31,7 +35,7 @@ describe('PartialType', () => { const prototype = Object.getPrototypeOf(UpdateUserDto); const { fields } = getFieldsAndDecoratorForType(prototype); - expect(fields.length).toEqual(6); + expect(fields.length).toEqual(7); expect(fields).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -54,6 +58,10 @@ describe('PartialType', () => { name: 'password', options: { nullable: true }, }), + expect.objectContaining({ + name: 'firstName', + options: { nullable: true }, + }), expect.objectContaining({ name: 'meta', options: { nullable: true }, @@ -79,6 +87,7 @@ describe('PartialType', () => { ), ); expect(Array.from(validationKeys)).toEqual([ + 'firstName', 'password', 'id', 'createdAt', @@ -87,6 +96,12 @@ describe('PartialType', () => { 'meta', ]); }); + it('should apply @IsOptional to properties reflected by the plugin', async () => { + const updateDto = new UpdateUserDto(); + updateDto.firstName = null; + const validationErrors = await validate(updateDto); + expect(validationErrors).toHaveLength(0); + }); describe('when object does not fulfil validation rules', () => { it('"validate" should return validation errors', async () => { const updateDto = new UpdateUserDto(); @@ -125,4 +140,39 @@ describe('PartialType', () => { expect(fields[0].options.defaultValue).toEqual(undefined); }); }); + + describe('skipNullProperties', () => { + it('should apply @IsOptional to properties reflected by the plugin if option `skipNullProperties` is true', async () => { + class UpdateUserWithNullableDto extends PartialType(CreateUserDto, { + skipNullProperties: true, + }) {} + const updateDto = new UpdateUserWithNullableDto(); + updateDto.firstName = null; + const validationErrors = await validate(updateDto); + expect(validationErrors).toHaveLength(0); + }); + + it('should apply @IsOptional to properties reflected by the plugin if option `skipNullProperties` is undefined', async () => { + class UpdateUserWithoutNullableDto extends PartialType(CreateUserDto, { + skipNullProperties: undefined, + }) {} + const updateDto = new UpdateUserWithoutNullableDto(); + updateDto.firstName = null; + const validationErrors = await validate(updateDto); + expect(validationErrors).toHaveLength(0); + }); + + it('should apply @ValidateIf to properties reflected by the plugin if option `skipNullProperties` is false', async () => { + class UpdateUserWithoutNullableDto extends PartialType(CreateUserDto, { + skipNullProperties: false, + }) {} + const updateDto = new UpdateUserWithoutNullableDto(); + updateDto.firstName = null; + const validationErrors = await validate(updateDto); + expect(validationErrors).toHaveLength(1); + expect(validationErrors[0].constraints).toEqual({ + isString: 'firstName must be a string', + }); + }); + }); });