Custom Validation Attributes: Comparing two properties in the same model

34,195

Solution 1

You can create a custom validation attribute for comparison two properties. It's a server side validation:

public class MyViewModel
{
    [DateLessThan("End", ErrorMessage = "Not valid")]
    public DateTime Begin { get; set; }

    public DateTime End { get; set; }
}

public class DateLessThanAttribute : ValidationAttribute
{
    private readonly string _comparisonProperty;

    public DateLessThanAttribute(string comparisonProperty)
    {
         _comparisonProperty = comparisonProperty;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        ErrorMessage = ErrorMessageString;
        var currentValue = (DateTime)value;

        var property = validationContext.ObjectType.GetProperty(_comparisonProperty);

        if (property == null)
            throw new ArgumentException("Property with this name not found");

        var comparisonValue = (DateTime)property.GetValue(validationContext.ObjectInstance);

        if (currentValue > comparisonValue)
            return new ValidationResult(ErrorMessage);

        return ValidationResult.Success;
    }
}

Update: If you need a client side validation for this attribute, you need implement an IClientModelValidator interface:

public class DateLessThanAttribute : ValidationAttribute, IClientModelValidator
{
    ...
    public void AddValidation(ClientModelValidationContext context)
    {
        var error = FormatErrorMessage(context.ModelMetadata.GetDisplayName());
        context.Attributes.Add("data-val", "true");
        context.Attributes.Add("data-val-error", error);
    }
}

The AddValidation method will add attributes to your inputs from context.Attributes.

enter image description here

You can read more here IClientModelValidator

Solution 2

As one possible option self-validation:

You just need to Implement an interface IValidatableObject with the method Validate(), where you can put your validation code.

public class MyViewModel : IValidatableObject
{
    [Required]
    public DateTime StartDate { get; set; }

    [Required]
    public DateTime EndDate { get; set; } = DateTime.Parse("3000-01-01");

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        int result = DateTime.Compare(StartDate , EndDate);
        if (result < 0)
        {
            yield return new ValidationResult("start date must be less than the end date!", new [] { "ConfirmEmail" });
        }
    }
}

Solution 3

I created a library with the most common custom validations in ASP.NET Core. The library also has the client-side validation for all the server-side custom validations. The library solves OP's problem with a single attribute as follows:

// If you want the StartDate to be smaller than the EndDate:
[CompareTo(nameof(EndDate), ComparisionType.SmallerThan)] 
public DateTime StartDate { get; set; }

Here is the GitHub link of the library: AspNetCore.CustomValidation

Currently, the library contains the following validation attributes:

1. FileAttribute - To validate file type, file max size, file min size;

2. MaxAgeAttribute - To validate maximum age against date of birth value of DateTime type;

3. MinAgeAttribute - To validate minimum required age against a date of birth value of DateTime type;

4. MaxDateAttribute -To set max value validation for a DateTime field;

5. MinDateAttribute - To set min value validation for a DateTime field;

6. CompareToAttibute – To compare one property value against another property value;

7. TinyMceRequiredAttribute -To enforce required validation attribute on the online text editors like TinyMCE, CkEditor, etc.

Solution 4

Based on the Alexander Gore response, I suggest a better and generic validation (and it is .Net core compatible). When you want to compare properties using GreatherThan or LessThan logic (whatever the types are), you could validate if they have implemented the IComparable interface. If both properties are valid you could use the CompareTo implementation. This rule applies for DateTime and number types as well

LessThan

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class LessThanAttribute : ValidationAttribute
{
    private readonly string _comparisonProperty;

    public LessThanAttribute(string comparisonProperty)
    {
        _comparisonProperty = comparisonProperty;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        ErrorMessage = ErrorMessageString;

        if (value.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("value has not implemented IComparable interface");
        }

        var currentValue = (IComparable)value;

        var property = validationContext.ObjectType.GetProperty(_comparisonProperty);

        if (property == null)
        {
            throw new ArgumentException("Comparison property with this name not found");
        }

        var comparisonValue = property.GetValue(validationContext.ObjectInstance);

        if (comparisonValue.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("Comparison property has not implemented IComparable interface");
        }

        if (!ReferenceEquals(value.GetType(), comparisonValue.GetType()))
        {
            throw new ArgumentException("The properties types must be the same");
        }

        if (currentValue.CompareTo((IComparable)comparisonValue) >= 0)
        {
            return new ValidationResult(ErrorMessage);
        }

        return ValidationResult.Success;
    }
}

GreaterThan

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class GreaterThanAttribute : ValidationAttribute
{
    private readonly string _comparisonProperty;

    public GreaterThanAttribute(string comparisonProperty)
    {
        _comparisonProperty = comparisonProperty;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        ErrorMessage = ErrorMessageString;

        if (value.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("value has not implemented IComparable interface");
        }

        var currentValue = (IComparable)value;

        var property = validationContext.ObjectType.GetProperty(_comparisonProperty);

        if (property == null)
        {
            throw new ArgumentException("Comparison property with this name not found");
        }

        var comparisonValue = property.GetValue(validationContext.ObjectInstance);

        if (comparisonValue.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("Comparison property has not implemented IComparable interface");
        }

        if (!ReferenceEquals(value.GetType(), comparisonValue.GetType()))
        {
            throw new ArgumentException("The properties types must be the same");
        }

        if (currentValue.CompareTo((IComparable)comparisonValue) < 0)
        {
            return new ValidationResult(ErrorMessage);
        }

        return ValidationResult.Success;
    }
}

In a booking context an example could be as follow:

public DateTime CheckInDate { get; set; }

[GreaterThan("CheckInDate", ErrorMessage = "CheckOutDate must be greater than CheckInDate")]
public DateTime CheckOutDate { get; set; }

Solution 5

Based on Jaime answer and Jeffrey's comment regarding needing a single attribute for Less Than, Less Than or Equal to, Equal To, Greater Than, Greater Than or Equal to.

The below code will handle all conditions with a single attribute.

public enum ComparisonType
{
    LessThan,
    LessThanOrEqualTo,
    EqualTo,
    GreaterThan,
    GreaterThanOrEqualTo
}

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public class ComparisonAttribute : ValidationAttribute
{
    private readonly string _comparisonProperty;
    private readonly ComparisonType _comparisonType;

    public ComparisonAttribute(string comparisonProperty, ComparisonType comparisonType)
    {
        _comparisonProperty = comparisonProperty;
        _comparisonType = comparisonType;
    }

    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        ErrorMessage = ErrorMessageString;

        if (value.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("value has not implemented IComparable interface");
        }

        var currentValue = (IComparable) value;

        var property = validationContext.ObjectType.GetProperty(_comparisonProperty);

        if (property == null)
        {
            throw new ArgumentException("Comparison property with this name not found");
        }

        var comparisonValue = property.GetValue(validationContext.ObjectInstance);

        if (comparisonValue.GetType() == typeof(IComparable))
        {
            throw new ArgumentException("Comparison property has not implemented IComparable interface");
        }

        if (!ReferenceEquals(value.GetType(), comparisonValue.GetType()))
        {
            throw new ArgumentException("The properties types must be the same");
        }

        bool compareToResult;

        switch (_comparisonType)
        {
            case ComparisonType.LessThan:
                compareToResult = currentValue.CompareTo((IComparable) comparisonValue) >= 0;
                break;
            case ComparisonType.LessThanOrEqualTo:
                compareToResult = currentValue.CompareTo((IComparable) comparisonValue) > 0;
                break;
            case ComparisonType.EqualTo:
                compareToResult = currentValue.CompareTo((IComparable) comparisonValue) != 0;
                break;
            case ComparisonType.GreaterThan:
                compareToResult = currentValue.CompareTo((IComparable) comparisonValue) <= 0;
                break;
            case ComparisonType.GreaterThanOrEqualTo:
                compareToResult = currentValue.CompareTo((IComparable) comparisonValue) < 0;
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }

        return compareToResult ? new ValidationResult(ErrorMessage) : ValidationResult.Success;
    }
}

In the booking context, an example would be as follows:

public DateTime CheckInDate { get; set; }

[Comparison("CheckInDate", ComparisonType.EqualTo, ErrorMessage = "CheckOutDate must be equal to CheckInDate")]
public DateTime CheckOutDate { get; set; }
Share:
34,195
Hussein Salman
Author by

Hussein Salman

Engineering Manager &amp; Cloud-Native Architect, focusing on Kubernetes, Containers &amp; Microservices. Check out my youtube channel: https://www.youtube.com/channel/UCoAh8g6dmwXQUwKhkggUFIA

Updated on July 09, 2022

Comments

  • Hussein Salman
    Hussein Salman almost 2 years

    Is there a way to create a custom attribute in ASP.NET Core to validate if one date property is less than other date property in a model using ValidationAttribute.

    Lets say I have this:

    public class MyViewModel 
    {
        [Required]
        [CompareDates]
        public DateTime StartDate { get; set; }
    
        [Required]
        public DateTime EndDate { get; set; } = DateTime.Parse("3000-01-01");
    }
    

    I am trying to use something like this:

        public class CompareDates : ValidationAttribute
    {
        public CompareDates()
            : base("") { }
    
        public override bool IsValid(object value)
        {
            return base.IsValid(value);
        }
    
    }
    

    I found other SO post that proposes to use another library, But I prefer to stick with ValidationAttribute if that was doable.

  • Hussein Salman
    Hussein Salman over 7 years
    Thanks, but I need to implement that as an attribute.
  • JsonStatham
    JsonStatham about 6 years
    I've done this and the server side validates, but the client side does nothing?
  • Fordy
    Fordy almost 6 years
    IClientModelValidator is for Asp.net Core. IClientValidatable or a RemoteAttribute (see SO here) might help for full framework client side validation.
  • Taraz
    Taraz over 5 years
    That works, but only for the server-side validation (after you submit).
  • Jeffrey Roughgarden
    Jeffrey Roughgarden over 5 years
    Jaime, I like your generalization of Alexander Gore's response. I suggest replacing the check 'if (comparisonValue.GetType() == typeof(IComparable))' with a check of 'if(!ReferenceEquals(value.GetType(), comparisonValue.GetType()))'. Two types could implement IComparable but not be themselves comparable, e.g. an int and a DateTime. Secondly, it would be nice if we could put make one comparison attribute for GT, GE, EQ, LE, and LT, but I don't know how.
  • rschoenbach
    rschoenbach almost 5 years
    Jaime, you have a typo in the above less than attribute. The code if (currentValue.CompareTo((IComparable)comparisonValue) >= 0) should be if (currentValue.CompareTo((IComparable)comparisonValue) > 0)
  • TanvirArjel
    TanvirArjel over 4 years
    I have created a library with most common custom validations in asp.net core. The library has client validation for all the server side custom validations. The library also solves OP's problem with single attribute as follows: [CompareTo(nameof(EndDate), ComparisionType.SmallerThan)] public DateTime StartDate { get; set; } . Here is the link of the library: github.com/TanvirArjel/AspNetCore.CustomValidation
  • M Fuat
    M Fuat over 3 years
    How to localize error string ? FormatErrorMessage(context.ModelMetadata.GetDisplayName()) localized?
  • fogbanksy
    fogbanksy about 3 years
    Would it be naughty to derive from CompareAttribute, just call the base constructor and use "OtherProperty" instead of "_comparisonProperty"?
  • Marc_Sei
    Marc_Sei over 2 years
    This works good for non-null values, but it would be better if you place the if(null != value)... and an additional if(null != comparisonValue) statement before calling .GetType() on the values to avoid Null reference exceptions.
  • user692942
    user692942 over 2 years
    This makes using an IStringlocalizer a real pain, do not recommend this approach.