Jun
10

Conditional Validation in ASP.NET MVC3

posted on 10 June 2011 in programming with 0 Comments

Warning: Please consider that this post is over 6 years old and the content may no longer be relevant.

Need to perform validation on a model’s property based on some other state of the model? Here’s a way to achieve it using the IValidatableObject interface and data annotations.

In our example project we want to validate the state field is a valid Australian state if the country field is “Australia”.

Conditional validation of state field

IValidatableObject

Any model that implements IValidatableObject in MVC3 (note this doesn’t work in MVC2) will get it’s Validate method called when validating the object.

public abstract class ModelBase : IValidatableObject
...
public IEnumerable Validate(ValidationContext validationContext)

ConditionalValidationAttribute

To define the conditional validation we’ll use a ConditionalValidationAttribute which identifies a method to call to perform the validation. The method must be static, accept at least one parameter (the value of the property to be validated), optionally accept a second context parameter (this will be the object whose property is being validated) and return a ValidationResult.

[ConditionalValidation(typeof(User), "ValidateState")]
public string State { get; set; }
...
public static ValidationResult ValidateState(string state, object context)

ModelBase

The ModelBase class brings this all together. The constructor builds a dictionary of ConditionalValidationAttributes for each property and it implements the IValidatableObject.Validate method to loop through all the ConditionalValidationAttributes and validate them.

Note that the IValidatableObject.Validate method is called after all data annotations have been successfully validated. So if you have some properties with a RequiredAttribute, the required field error message will show and the user will need to fix this before our model’s Validate messages are displayed.

Here’s the full code for ModelBase.cs:

public abstract class ModelBase : IValidatableObject
{
  /// <summary>
  /// The list of validations for each property
  /// </summary>
  protected Dictionary<string, ConditionalValidationAttribute[]> Validations { get; private set; }

  protected ModelBase()
  {
    Validations = (from property in GetType().GetProperties()
             let validations = GetValidations(property)
             where validations.Length > 0
             select new {PropertyName = property.Name, Validations = validations})
      .ToDictionary(a => a.PropertyName, a => a.Validations);
  }

  /// <summary>
  /// Validates all ConditionalValidationAttributes
  /// </summary>
  /// <param name=&quot;validationContext&quot;></param>
  /// <returns></returns>
  public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
  {
    return from property in Validations
         from validator in property.Value
         select validator.IsValid(GetPropertyValue(property.Key), this)
         into validationResult
         where validationResult != ValidationResult.Success
         select validationResult;
  }

  #region Private & Protected Methods

  /// <summary>
  /// Gets the validation attributes attached to a property.
  /// </summary>
  /// <param name=&quot;property&quot;>The property.</param>
  /// <returns>An array of validation attributes.</returns>
  private static ConditionalValidationAttribute[] GetValidations(PropertyInfo property)
  {
    return property.GetCustomAttributes(typeof(ConditionalValidationAttribute), true)
      .Cast<ConditionalValidationAttribute>().ToArray();
  }

  /// <summary>
  /// Gets the value for a property.
  /// </summary>
  /// <param name=&quot;propertyName&quot;>The property name.</param>
  /// <returns>The value of the property as an object.</returns>
  protected object GetPropertyValue(string propertyName)
  {
    try
    {
      return GetType().GetProperty(propertyName).GetValue(this, null);
    }
    catch (TargetInvocationException exception)
    {
      if (exception.InnerException != null)
        throw exception.InnerException;
      throw;
    }
  }

  #endregion
}

Note that when we call the ConditionalValidationAttribute.IsValid method we pass in ‘this’ as the context so we have a reference to the object being validated.

ConditionalValidationAttribute.cs:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)]
public class ConditionalValidationAttribute : Attribute
{
  #region Fields

  private readonly MethodInfo _methodInfo;
  private readonly bool _isSingleArgumentMethod;
  private readonly Type _valuesType;

  #endregion

  #region Properties

  public string ErrorMessage { get; set; }
  public string Method { get; private set; }
  public Type ValidatorType { get; private set; }

  #endregion

  #region Constructor

  /// <summary>
  /// Performs validation by calling a specified static method with one of the following signatures:
  /// ValidationResult Method(object value)
  /// ValidationResult Method(object value, object context)
  /// The value parameter may be strongly typed, if a process passes a value of a different type, type conversion will be attempted.
  /// </summary>
  /// <param name=&quot;validatorType&quot;>The type that contains the method that performs custom validation.</param>
  /// <param name=&quot;method&quot;>The method that performs custom validation.</param>
  public ConditionalValidationAttribute(Type validatorType, string method)
  {
    if (string.IsNullOrEmpty(method))
      throw new ArgumentException(&quot;method&quot;);

    Method = method;
    ValidatorType = validatorType;
    // Find a matching method - if multiple matching methods, pick the one with 2 parameters.
    var methods = from m in validatorType.GetMethods(BindingFlags.Public | BindingFlags.Static)
            let p = m.GetParameters().Length
            where m.Name == method &&
              m.ReturnType == typeof(ValidationResult) &&
              (p == 1 || p == 2)
            orderby p descending
            select m;

    if (methods.Count() == 0)
      throw new InvalidOperationException(string.Format(&quot;No method &#39;{0}&#39; found in class &#39;{1}&#39; with a valid signature.&quot;, method, validatorType));
    _methodInfo = methods.First();
    var parameters = _methodInfo.GetParameters();
    _isSingleArgumentMethod = parameters.Length == 1;
    _valuesType = parameters[0].ParameterType;
  }

  #endregion

  #region Public Methods

  public ValidationResult IsValid(object value, object context)
  {
    object convertedValue;
    var info = _methodInfo;
    if (!TryConvertValue(value, out convertedValue))
    {
      throw new InvalidOperationException(
        string.Format(
          &quot;Unable to convert value from type {0} to type {1}&quot;,
          (value != null) ? value.GetType().ToString() : &quot;null&quot;,
          _valuesType));
    }
    try
    {
      var parameters = _isSingleArgumentMethod ? new[] { convertedValue } : new[] { convertedValue, context };
      var result = (ValidationResult)info.Invoke(null, parameters);
      if (result != ValidationResult.Success && result.ErrorMessage == null)
        result.ErrorMessage = ErrorMessage;
      return result;
    }
    catch (TargetInvocationException exception)
    {
      if (exception.InnerException != null)
      {
        throw exception.InnerException;
      }
      throw;
    }
  }

  #endregion

  #region Private Methods

  private bool TryConvertValue(object value, out object convertedValue)
  {
    convertedValue = null;
    Type conversionType = _valuesType;
    if (value == null)
    {
      return (!conversionType.IsValueType || (conversionType.IsGenericType && (conversionType.GetGenericTypeDefinition() == typeof(Nullable<>))));
    }
    if (conversionType.IsAssignableFrom(value.GetType()))
    {
      convertedValue = value;
      return true;
    }
    try
    {
      convertedValue = Convert.ChangeType(value, conversionType, CultureInfo.CurrentCulture);
      return true;
    }
    catch (FormatException)
    {
      return false;
    }
    catch (InvalidCastException)
    {
      return false;
    }
    catch (NotSupportedException)
    {
      return false;
    }
  }

  #endregion
}

A few notes here:

  • The constructor tries to find an appropriate method using reflection. If there are multiple overloads of the method then the one with 2 parameters will be chosen.
  • The first parameter of the validation method can be of any type. Type conversion is attempted on the value to validate to match the method signature. I.e. the first (value) parameter can be object, string, int, etc.
  • A generic error message can be supplied to the ConditionalValidationAttribute. If the validation method doesn’t set an error message when it returns a validation error, the ConditionalValidationAttribute.ErrorMessage property will be used.

And a sample model User.cs:

public class User : ModelBase
{
  [Required]
  public int UserId { get; set; }
  [Required]
  public string FirstName { get; set; }
  [Required]
  public string LastName { get; set; }
  [Required]
  public string Country { get; set; }
  [Required]
  [ConditionalValidation(typeof(User), &quot;ValidateState&quot;)]
  public string State { get; set; }

  public static ValidationResult ValidateState(string state, object context)
  {
    var user = context as User;
    if (user == null)
      throw new ArgumentException(&quot;context is not of type User&quot;, &quot;context&quot;);

    var australianStates = new[] {&quot;VIC&quot;, &quot;TAS&quot;, &quot;NSW&quot;, &quot;ACT&quot;, &quot;QLD&quot;, &quot;NT&quot;, &quot;WA&quot;, &quot;SA&quot;};
    if (user.Country.Equals(&quot;Australia&quot;, StringComparison.CurrentCultureIgnoreCase))
    {
      if (!australianStates.Contains(state, StringComparer.CurrentCultureIgnoreCase))
        return new ValidationResult(&quot;Not a valid Australian state&quot;, new[] {&quot;State&quot;});
    }
    return ValidationResult.Success;
  }
}

Download the example by clicking the button below.

Download source