From 88f0244208271215b89ee4dcc41a2d71a089631a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Dubigny?= Date: Fri, 7 Nov 2025 17:14:46 +0100 Subject: [PATCH] fix: overwrite validation decorators from parent class when defined in child --- README.md | 3 +- src/decorator/common/Allow.ts | 3 + src/decorator/common/Validate.ts | 3 + src/decorator/common/ValidateIf.ts | 3 + src/decorator/common/ValidateNested.ts | 2 + src/decorator/common/ValidatePromise.ts | 3 + src/metadata/MetadataStorage.ts | 13 +- src/metadata/ValidationMetadata.ts | 6 +- src/metadata/ValidationMetadataArgs.ts | 6 +- src/validation-schema/ValidationSchema.ts | 6 +- test/functional/inherited-validation.spec.ts | 174 ++++++++++++++++++- 11 files changed, 207 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 886712dd76..18d0095ac0 100644 --- a/README.md +++ b/README.md @@ -371,7 +371,7 @@ export class Post { ## Inheriting Validation decorators -When you define a subclass which extends from another one, the subclass will automatically inherit the parent's decorators. If a property is redefined in the descendant, class decorators will be applied on it from both its own class and the base class. +When you define a subclass which extends from another one, the subclass will automatically inherit the parent's decorators. If a property is redefined in the descendant, class decorators will be applied on it from both its own class and the base class. If a property-decorator pair is defined in both the subclass and parent-class, the decorator from the subclass will be used instead of the parent-class. For this purpose, "@IsDefined()" and "@IsOptional()" are considered the same. ```typescript import { validate } from 'class-validator'; @@ -733,6 +733,7 @@ Lets create another custom validation decorator called `IsUserAlreadyExist`: export function IsUserAlreadyExist(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { registerDecorator({ + name: 'isUserAlreadyExist', target: object.constructor, propertyName: propertyName, options: validationOptions, diff --git a/src/decorator/common/Allow.ts b/src/decorator/common/Allow.ts index 943722ec8c..33258ad124 100644 --- a/src/decorator/common/Allow.ts +++ b/src/decorator/common/Allow.ts @@ -4,12 +4,15 @@ import { ValidationTypes } from '../../validation/ValidationTypes'; import { ValidationMetadata } from '../../metadata/ValidationMetadata'; import { getMetadataStorage } from '../../metadata/MetadataStorage'; +export const ALLOW = 'allow'; + /** * If object has both allowed and not allowed properties a validation error will be thrown. */ export function Allow(validationOptions?: ValidationOptions): PropertyDecorator { return function (object: object, propertyName: string): void { const args: ValidationMetadataArgs = { + name: ALLOW, type: ValidationTypes.WHITELIST, target: object.constructor, propertyName: propertyName, diff --git a/src/decorator/common/Validate.ts b/src/decorator/common/Validate.ts index 59c80cdd08..e825f8e8bb 100644 --- a/src/decorator/common/Validate.ts +++ b/src/decorator/common/Validate.ts @@ -5,6 +5,8 @@ import { getMetadataStorage } from '../../metadata/MetadataStorage'; import { ValidationTypes } from '../../validation/ValidationTypes'; import { ConstraintMetadata } from '../../metadata/ConstraintMetadata'; +export const VALIDATE = 'validate'; + /** * Registers custom validator class. */ @@ -40,6 +42,7 @@ export function Validate( ): PropertyDecorator { return function (object: object, propertyName: string): void { const args: ValidationMetadataArgs = { + name: VALIDATE, type: ValidationTypes.CUSTOM_VALIDATION, target: object.constructor, propertyName: propertyName, diff --git a/src/decorator/common/ValidateIf.ts b/src/decorator/common/ValidateIf.ts index 14f1deeb77..787e6ba78a 100644 --- a/src/decorator/common/ValidateIf.ts +++ b/src/decorator/common/ValidateIf.ts @@ -4,6 +4,8 @@ import { ValidationTypes } from '../../validation/ValidationTypes'; import { ValidationMetadata } from '../../metadata/ValidationMetadata'; import { getMetadataStorage } from '../../metadata/MetadataStorage'; +const VALIDATE_IF = 'validateIf'; + /** * Ignores the other validators on a property when the provided condition function returns false. */ @@ -13,6 +15,7 @@ export function ValidateIf( ): PropertyDecorator { return function (object: object, propertyName: string): void { const args: ValidationMetadataArgs = { + name: VALIDATE_IF, type: ValidationTypes.CONDITIONAL_VALIDATION, target: object.constructor, propertyName: propertyName, diff --git a/src/decorator/common/ValidateNested.ts b/src/decorator/common/ValidateNested.ts index da56eaefa4..f69db47184 100644 --- a/src/decorator/common/ValidateNested.ts +++ b/src/decorator/common/ValidateNested.ts @@ -4,6 +4,7 @@ import { ValidationTypes } from '../../validation/ValidationTypes'; import { ValidationMetadata } from '../../metadata/ValidationMetadata'; import { getMetadataStorage } from '../../metadata/MetadataStorage'; +const VALIDATE_NESTED = 'validateNested'; /** * Objects / object arrays marked with this decorator will also be validated. */ @@ -14,6 +15,7 @@ export function ValidateNested(validationOptions?: ValidationOptions): PropertyD return function (object: object, propertyName: string): void { const args: ValidationMetadataArgs = { + name: VALIDATE_NESTED, type: ValidationTypes.NESTED_VALIDATION, target: object.constructor, propertyName: propertyName, diff --git a/src/decorator/common/ValidatePromise.ts b/src/decorator/common/ValidatePromise.ts index bd90519e86..adf54a3e86 100644 --- a/src/decorator/common/ValidatePromise.ts +++ b/src/decorator/common/ValidatePromise.ts @@ -4,12 +4,15 @@ import { ValidationTypes } from '../../validation/ValidationTypes'; import { ValidationMetadata } from '../../metadata/ValidationMetadata'; import { getMetadataStorage } from '../../metadata/MetadataStorage'; +export const VALIDATE_PROMISE = 'validatePromise'; + /** * Resolve promise before validation */ export function ValidatePromise(validationOptions?: ValidationOptions): PropertyDecorator { return function (object: object, propertyName: string): void { const args: ValidationMetadataArgs = { + name: VALIDATE_PROMISE, type: ValidationTypes.PROMISE_VALIDATION, target: object.constructor, propertyName: propertyName, diff --git a/src/metadata/MetadataStorage.ts b/src/metadata/MetadataStorage.ts index 286346ac90..0bf288513b 100644 --- a/src/metadata/MetadataStorage.ts +++ b/src/metadata/MetadataStorage.ts @@ -3,6 +3,8 @@ import { ConstraintMetadata } from './ConstraintMetadata'; import { ValidationSchema } from '../validation-schema/ValidationSchema'; import { ValidationSchemaToMetadataTransformer } from '../validation-schema/ValidationSchemaToMetadataTransformer'; import { getGlobal } from '../utils'; +import { IS_DEFINED } from '../decorator/common/IsDefined'; +import { IS_OPTIONAL } from '../decorator/common/IsOptional'; /** * Storage all metadatas. @@ -138,10 +140,13 @@ export class MetadataStorage { // filter out duplicate metadatas, prefer original metadatas instead of inherited metadatas const uniqueInheritedMetadatas = inheritedMetadatas.filter(inheritedMetadata => { return !originalMetadatas.find(originalMetadata => { - return ( - originalMetadata.propertyName === inheritedMetadata.propertyName && - originalMetadata.type === inheritedMetadata.type - ); + const isSameProperty = originalMetadata.propertyName === inheritedMetadata.propertyName; + const isSameValidator = + originalMetadata.name === inheritedMetadata.name || + (originalMetadata.name === IS_DEFINED && inheritedMetadata.name === IS_OPTIONAL) || + (originalMetadata.name === IS_OPTIONAL && inheritedMetadata.name === IS_DEFINED); + + return isSameProperty && isSameValidator; }); }); diff --git a/src/metadata/ValidationMetadata.ts b/src/metadata/ValidationMetadata.ts index c1b1acce82..0d518f495d 100644 --- a/src/metadata/ValidationMetadata.ts +++ b/src/metadata/ValidationMetadata.ts @@ -10,14 +10,14 @@ export class ValidationMetadata { // ------------------------------------------------------------------------- /** - * Validation type. + * Validation type. Should be one of the ValidationTypes values. */ type: string; /** - * Validator name. + * Validation name. Used to uniquely identify this validator. */ - name?: string; + name: string; /** * Target class to which this validation is applied. diff --git a/src/metadata/ValidationMetadataArgs.ts b/src/metadata/ValidationMetadataArgs.ts index ff28b3e0af..8790449c92 100644 --- a/src/metadata/ValidationMetadataArgs.ts +++ b/src/metadata/ValidationMetadataArgs.ts @@ -5,14 +5,14 @@ import { ValidationOptions } from '../decorator/ValidationOptions'; */ export interface ValidationMetadataArgs { /** - * Validation type. + * Validation type. Should be one of the ValidationTypes values. */ type: string; /** - * Validator name. + * Validation name. Used to uniquely identify this validator. */ - name?: string; + name: string; /** * Object that is used to be validated. diff --git a/src/validation-schema/ValidationSchema.ts b/src/validation-schema/ValidationSchema.ts index f76fec6807..92ddc6499f 100644 --- a/src/validation-schema/ValidationSchema.ts +++ b/src/validation-schema/ValidationSchema.ts @@ -17,14 +17,14 @@ export interface ValidationSchema { */ [propertyName: string]: { /** - * Validation type. Should be one of the ValidationTypes value. + * Validation type. Should be one of the ValidationTypes values. */ type: string; /** - * Validator name. + * Validation name. Used to uniquely identify this validator. */ - name?: string; + name: string; /** * Constraints set by validation type. diff --git a/test/functional/inherited-validation.spec.ts b/test/functional/inherited-validation.spec.ts index 04254a9d3b..85dfbe6ead 100644 --- a/test/functional/inherited-validation.spec.ts +++ b/test/functional/inherited-validation.spec.ts @@ -1,4 +1,4 @@ -import { Contains, MinLength } from '../../src/decorator/decorators'; +import { Contains, MinLength, Equals, Min, IsOptional, IsDefined } from '../../src/decorator/decorators'; import { Validator } from '../../src/validation/Validator'; const validator = new Validator(); @@ -34,4 +34,176 @@ describe('inherited validation', () => { expect(errors[1].value).toEqual('helo world'); }); }); + + it('should use validators from parent and child classes', () => { + expect.assertions(5); + + class MyClass { + @Contains('hello') + title: string; + } + + class MySubClass extends MyClass { + @MinLength(5) + title: string; + } + + const model = new MySubClass(); + model.title = 'helo'; + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(1); + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('title'); + expect(errors[0].constraints).toEqual({ + minLength: 'title must be longer than or equal to 5 characters', + contains: 'title must contain a hello string', + }); + expect(errors[0].value).toEqual('helo'); + }); + }); + + it('should override inherited validators in sub classes', () => { + expect.assertions(9); + + class MyClass { + @Min(30) + age: number; + + @Equals('validator') + first_name: string; + + @Equals('class') + last_name: string; + } + + class MySubClass extends MyClass { + @Min(40) + age: number; + + @Equals('class') + first_name: string; + + @Equals('validator') + last_name: string; + } + + const model = new MySubClass(); + model.age = 20; // fail validation (using sub classes constraint) + model.first_name = 'class'; // pass validation (overriding fail from parent) + model.last_name = 'class'; // fail validation (overriding pass from parent) + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(2); + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('age'); + expect(errors[0].constraints).toEqual({ + min: 'age must not be less than 40', + }); + expect(errors[0].value).toEqual(20); + + expect(errors[1].target).toEqual(model); + expect(errors[1].property).toEqual('last_name'); + expect(errors[1].constraints).toEqual({ + equals: 'last_name must be equal to validator', + }); + expect(errors[1].value).toEqual('class'); + }); + }); + + it('should not override different validators of inherited properties in the parent class', () => { + expect.assertions(4); + + class MyClass { + @Contains('parent-class') + title: string; + } + + class MySubClass extends MyClass { + @Equals('sub-class') + title: string; + } + + const model = new MySubClass(); + model.title = 'sub-class'; + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(1); + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('title'); + expect(errors[0].constraints).toEqual({ + contains: 'title must contain a parent-class string', + }); + }); + }); + + it('should not override different validators of inherited properties in the sub class', () => { + expect.assertions(4); + + class MyClass { + @Contains('parent-class') + title: string; + } + + class MySubClass extends MyClass { + @Equals('sub-class') + title: string; + } + + const model = new MySubClass(); + model.title = 'parent-class'; + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(1); + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('title'); + expect(errors[0].constraints).toEqual({ + equals: 'title must be equal to sub-class', + }); + }); + }); + + it('should override isOptional validator in the parent class', () => { + expect.assertions(4); + + class MyClass { + @IsOptional() + title?: string; + } + + class MySubClass extends MyClass { + @IsDefined() + title: string; + } + + const model = new MySubClass(); + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(1); + expect(errors[0].target).toEqual(model); + expect(errors[0].property).toEqual('title'); + expect(errors[0].constraints).toEqual({ + isDefined: 'title should not be null or undefined', + }); + }); + }); + + it('should override isDefined validator in the parent class', () => { + expect.assertions(1); + + class MyClass { + @IsDefined() + title?: string; + } + + class MySubClass extends MyClass { + @IsOptional() + title: string; + } + + const model = new MySubClass(); + + return validator.validate(model).then(errors => { + expect(errors.length).toEqual(0); + }); + }); });