diff --git a/src/metadata/MetadataStorage.ts b/src/metadata/MetadataStorage.ts index 286346ac90..093d2852a1 100644 --- a/src/metadata/MetadataStorage.ts +++ b/src/metadata/MetadataStorage.ts @@ -3,6 +3,7 @@ import { ConstraintMetadata } from './ConstraintMetadata'; import { ValidationSchema } from '../validation-schema/ValidationSchema'; import { ValidationSchemaToMetadataTransformer } from '../validation-schema/ValidationSchemaToMetadataTransformer'; import { getGlobal } from '../utils'; +import { ValidationTypes } from '../validation/ValidationTypes'; /** * Storage all metadatas. @@ -140,7 +141,11 @@ export class MetadataStorage { return !originalMetadatas.find(originalMetadata => { return ( originalMetadata.propertyName === inheritedMetadata.propertyName && - originalMetadata.type === inheritedMetadata.type + originalMetadata.type === inheritedMetadata.type && + (originalMetadata.type !== ValidationTypes.CUSTOM_VALIDATION || + (originalMetadata.name !== undefined || inheritedMetadata.name !== undefined + ? originalMetadata.name === inheritedMetadata.name + : originalMetadata.constraintCls === inheritedMetadata.constraintCls)) ); }); }); diff --git a/test/functional/inherited-validation.spec.ts b/test/functional/inherited-validation.spec.ts index 04254a9d3b..48a97b2d85 100644 --- a/test/functional/inherited-validation.spec.ts +++ b/test/functional/inherited-validation.spec.ts @@ -1,9 +1,144 @@ -import { Contains, MinLength } from '../../src/decorator/decorators'; +import { + Contains, + IsNotEmpty, + IsString, + MinLength, + Validate, + ValidatorConstraint, +} from '../../src/decorator/decorators'; import { Validator } from '../../src/validation/Validator'; +import { ValidationArguments } from '../../src/validation/ValidationArguments'; const validator = new Validator(); describe('inherited validation', () => { + it('should retain different validators on a redeclared property across inheritance levels', async () => { + class Base { + @IsString() + @MinLength(3) + title: string; + } + class Child extends Base { + @Contains('hello') + title: string; + } + class Grandchild extends Child { + @IsNotEmpty() + title: string; + } + + const model = new Grandchild(); + model.title = 'a'; + const errors = await validator.validate(model); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ + contains: 'title must contain a hello string', + minLength: 'title must be longer than or equal to 3 characters', + }); + model.title = ''; + const emptyErrors = await validator.validate(model); + expect(emptyErrors).toHaveLength(1); + expect(emptyErrors[0].constraints).toEqual({ + isNotEmpty: 'title should not be empty', + contains: 'title must contain a hello string', + minLength: 'title must be longer than or equal to 3 characters', + }); + model.title = 'hello'; + expect(await validator.validate(model)).toEqual([]); + }); + + it('should prefer the child constraints for the same named validator', async () => { + class Base { + @MinLength(10) + title: string; + } + class Child extends Base { + @MinLength(3) + title: string; + } + + const model = new Child(); + model.title = 'hello'; + expect(await validator.validate(model)).toEqual([]); + model.title = 'a'; + const errors = await validator.validate(model); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ minLength: 'title must be longer than or equal to 3 characters' }); + }); + + it('should retain different custom constraint classes and prefer child constraints for the same class', async () => { + @ValidatorConstraint({ name: 'startsWith' }) + class StartsWith { + validate(value: string, args: ValidationArguments): boolean { + return value.startsWith(args.constraints[0]); + } + } + @ValidatorConstraint({ name: 'endsWith' }) + class EndsWith { + validate(value: string, args: ValidationArguments): boolean { + return value.endsWith(args.constraints[0]); + } + } + class Base { + @Validate(StartsWith, ['base']) + @Validate(EndsWith, ['!']) + title: string; + } + class Child extends Base { + @Validate(StartsWith, ['child']) + title: string; + } + + const model = new Child(); + model.title = 'child'; + const errors = await validator.validate(model); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ endsWith: '' }); + model.title = 'child!'; + expect(await validator.validate(model)).toEqual([]); + }); + + it('should retain inherited custom constraints when the child adds a named validator', async () => { + @ValidatorConstraint({ name: 'hasHello' }) + class HasHello { + validate(value: string): boolean { + return value.includes('hello'); + } + } + class Base { + @Validate(HasHello) + title: string; + } + class Child extends Base { + @IsNotEmpty() + title: string; + } + + const model = new Child(); + model.title = 'world'; + const errors = await validator.validate(model); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ hasHello: '' }); + }); + + it('should only override inherited validators in the selected group', async () => { + class Base { + @MinLength(10, { groups: ['base'] }) + title: string; + } + class Child extends Base { + @MinLength(3, { groups: ['child'] }) + title: string; + } + + const model = new Child(); + model.title = 'hello'; + const errors = await validator.validate(model, { groups: ['base'] }); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toEqual({ minLength: 'title must be longer than or equal to 10 characters' }); + expect(await validator.validate(model, { groups: ['child'] })).toEqual([]); + }); + it('should validate inherited properties', () => { expect.assertions(9);