46

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
  • 7,806
  • 15
  • 60
  • 98

7 Answers7

75

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

Alexander Surkov
  • 1,613
  • 2
  • 18
  • 22
  • I've done this and the server side validates, but the client side does nothing? – JsonStatham Feb 20 '18 at 14:12
  • IClientModelValidator is for Asp.net Core. IClientValidatable or a RemoteAttribute [(see SO here)](https://stackoverflow.com/questions/27022380/mvc4-iclientvalidatable-automatic-ajax-calls-to-server-side-validation) might help for full framework client side validation. – Fordy Jun 18 '18 at 10:35
  • 2
    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: https://github.com/TanvirArjel/AspNetCore.CustomValidation – TanvirArjel Nov 28 '19 at 14:18
  • How to localize error string ? `FormatErrorMessage(context.ModelMetadata.GetDisplayName())` localized? – M Fuat Aug 06 '20 at 11:15
  • Would it be naughty to derive from CompareAttribute, just call the base constructor and use "OtherProperty" instead of "_comparisonProperty"? – fogbanksy Mar 06 '21 at 08:08
  • I would suggest to use `[DateLessThan(nameof(End), ErrorMessage = "Not valid")]` instead of `[DateLessThan("End", ErrorMessage = "Not valid")]`. So, if you rename the End property, you do not need to change the reference too. – Benjamin Lichtenstein Aug 08 '22 at 14:37
19

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" });
        }
    }
}
Massimiliano Kraus
  • 3,638
  • 5
  • 27
  • 47
Curiousdev
  • 5,668
  • 3
  • 24
  • 38
6

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; }
rschoenbach
  • 496
  • 4
  • 6
  • 2
    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. – Marc_Sei Aug 31 '21 at 06:40
5

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; }
Jaime Marín
  • 578
  • 6
  • 11
  • 1
    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. – Jeffrey Roughgarden Nov 01 '18 at 00:00
  • 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)` – rschoenbach Jun 27 '19 at 02:40
5

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.

TanvirArjel
  • 30,049
  • 14
  • 78
  • 114
2

You could compare the two dates in the IsValid() method.

public class CompareDates : ValidationAttribute
{
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        // get your StartDate and EndDate from model and value

        // perform comparison
        if (StartDate < EndDate)
        {
            return new ValidationResult("start date must be less than the end date");
        }
        else
        {
            return ValidationResult.Success;
        }
    }
}
Massimiliano Kraus
  • 3,638
  • 5
  • 27
  • 47
Balaji Marimuthu
  • 1,940
  • 13
  • 13
1

Here is my take on this. My version ignores properties which are null (optional). It fits very well when applied to web APIs.

[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;

        var property = validationContext.ObjectType.GetProperty(_comparisonProperty);
        if (property == null)
            throw new ArgumentException($"Property {_comparisonProperty} not found");

        var right = property.GetValue(validationContext.ObjectInstance);
        if (value is null || right is null)
            return ValidationResult.Success;

        if (value.GetType() == typeof(IComparable))
            throw new ArgumentException($"The property {validationContext.MemberName} does not implement {typeof(IComparable).Name} interface");

        if (right.GetType() == typeof(IComparable))
            throw new ArgumentException($"The property {_comparisonProperty} does not implement {typeof(IComparable).Name} interface");

        if (!ReferenceEquals(value.GetType(), right.GetType()))
            throw new ArgumentException("The property types must be the same");

        var left = (IComparable)value;
        bool isValid;

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

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

    public enum ComparisonType
    {
        LessThan,
        LessThanOrEqualTo,
        EqualTo,
        GreaterThan,
        GreaterThanOrEqualTo
    }
}
mynkow
  • 4,408
  • 4
  • 38
  • 65