I frequently hear questions about how to perform cross-field validation in RIA Services. Before thoroughly covering this topic*, I wanted to be sure to go through some simple scenarios, show how to use CustomValidationAttribute, how to derive from ValidationAttribute, explain how validation rules are propagated to the client, and what triggers the validation. Hopefully by now, you’re getting pretty comfortable with the validation framework and you’re ready to explore some more examples.
* Perhaps you noticed that I snuck a reusable cross-field validator into the Custom Reusable Validators post—it’s a rather useful ConditionallyRequiredAttribute.
In this post, I’m going to assume you now understand how to write cross-tier validation methods and attributes. The code shown should be saved in a .shared.cs file so that it is automatically compiled into your Silverlight project.
Custom Validation Method
These requirements surface often: the validation rules for one field depend on a value in another field. Continuing to use my Meeting entity, we can implement a validator that ensures that the End time for a meeting cannot be before the Start time for the meeting. Let’s implement this using a [CustomValidation] attribute on the End time property. Here’s a NoTimeTravel method that performs this cross-field validation.
/// <summary>
/// Validate that the end time for a meeting is not before the
/// start time for the meeting.
/// </summary>
/// <param name="end">The end time being validated.</param>
/// <param name="validationContext">
/// The validation context, which includes the meeting instance.
/// </param>
/// <returns>
/// A <see cref="ValidationResult"/> with an error or <see cref="ValidationResult.Success"/>.
/// </returns>
public static ValidationResult NoTimeTravel(DateTime end, ValidationContext validationContext)
{
Meeting meeting = (Meeting)validationContext.ObjectInstance;
if (meeting.Start > end)
{
return new ValidationResult(
"Meetings cannot result in time travel.",
new[] { validationContext.MemberName });
}
return ValidationResult.Success;
}
The logic is pretty straight-forward. Here are three tips that helped:
- When using [CustomValidation], your validation method can accept a strongly-typed and well-named value parameter—in this case DateTime end;
- ValidationContext has the ObjectInstance which is the entity being validated and this can be cast to the expected entity type;
- For the End time value that is being validated, it’s important to validate the value specified as a parameter rather than getting the value from the ObjectInstance.
For tip #3, the Validation Triggers post explains why this is so important. To reiterate though, property validation occurs BEFORE the value is set on the property.
Here’s how the NoTimeTravel validator is applied to our Meeting entity type:
using System;
using System.ComponentModel.DataAnnotations;
using RudeValidation.Web.Resources;
using RudeValidation.Web.Validators;
namespace RudeValidation.Web.Models
{
public partial class Meeting
{
[Key]
public int MeetingId { get; set; }
[Required]
[CustomValidation(typeof(MeetingValidators), "NoEarlyMeetings")]
[DateValidator(DateValidatorType.Future)]
public DateTime Start { get; set; }
[Required]
[CustomValidation(typeof(MeetingValidators), "NoTimeTravel")]
public DateTime End { get; set; }
[Required]
[StringLength(80, MinimumLength = 5,
ErrorMessageResourceType = typeof(ValidationErrorResources),
ErrorMessageResourceName = "TitleStringLengthErrorMessage")]
// {0} must be at least {2} characters and no more than {1}.
public string Title { get; set; }
[ConditionallyRequired("IsLongMeeting",
ErrorMembers = "Start, End",
ErrorMessage = "If you're asking for more than an hour of time, provide an agenda.")]
[ConditionallyRequired("Location", "18/3367",
ErrorMessage = "No one can ever find this room; please be sure to include directions.")]
public string Details { get; set; }
[Required]
[RegularExpression(@"\d{1,3}/\d{4}",
ErrorMessage = "{0} must be in the format of 'Building/Room'")]
public string Location { get; set; }
[Range(2, 100)]
[Display(Name = "Minimum Attendees")]
public int MinimumAttendees { get; set; }
[Range(2, 100)]
[Display(Name = "Maximum Attendees")]
public int MaximumAttendees { get; set; }
}
}
As applied, the following sequence of events will occur:
- User enters a start time;
- User enters an end time that is before the start time;
- A validation error is displayed;
- Changing the end time to a valid value will clear the error;
- Changing the start time to make the end time valid will not clear the error.
A start time adjustment would not clear the error on the end time field, because the error does not indicate that the Start field was involved in the validation. Furthermore, the NoTimeTravel validation would not be triggered from the start time change even if the error was cleared out, since the validation is only applied to the End property. This behavior can often be acceptable or even desired. But if you would like the cross-field validation to respond immediately to both fields, then we can refactor this validation method to accommodate that.
/// <summary>
/// Validate that the end time for a meeting is not before the
/// start time for the meeting.
/// </summary>
/// <param name="time">The start or end time being validated.</param>
/// <param name="validationContext">
/// The validation context, which includes the meeting instance.
/// </param>
/// <returns>
/// A <see cref="ValidationResult"/> with an error or <see cref="ValidationResult.Success"/>.
/// </returns>
public static ValidationResult NoTimeTravel(DateTime time, ValidationContext validationContext)
{
Meeting meeting = (Meeting)validationContext.ObjectInstance;
DateTime start = validationContext.MemberName == "Start" ? time : meeting.Start;
DateTime end = validationContext.MemberName == "End" ? time : meeting.End;
if (start > end)
{
return new ValidationResult(
"Meetings cannot result in time travel.",
new[] { "Start", "End" });
}
return ValidationResult.Success;
}
- The value parameter is now just called “time” as it can represent either the start time or the end time;
- We conditionally grab the start/end time values from either the time parameter or from the Meeting instance;
Remember, for the property being validated, we must use the value parameter since the property still holds the old value - The ValidationResult returned has both the Start and End member names specified, so that both fields are highlighted and changes to either field would clear the error.
In addition to these changes, we now apply the [CustomValidation] attribute to both the Start and End properties.
using System;
using System.ComponentModel.DataAnnotations;
using RudeValidation.Web.Resources;
using RudeValidation.Web.Validators;
namespace RudeValidation.Web.Models
{
public partial class Meeting
{
[Key]
public int MeetingId { get; set; }
[Required]
[CustomValidation(typeof(MeetingValidators), "NoEarlyMeetings")]
[CustomValidation(typeof(MeetingValidators), "NoTimeTravel")]
[DateValidator(DateValidatorType.Future)]
public DateTime Start { get; set; }
[Required]
[CustomValidation(typeof(MeetingValidators), "NoTimeTravel")]
public DateTime End { get; set; }
[Required]
[StringLength(80, MinimumLength = 5,
ErrorMessageResourceType = typeof(ValidationErrorResources),
ErrorMessageResourceName = "TitleStringLengthErrorMessage")]
// {0} must be at least {2} characters and no more than {1}.
public string Title { get; set; }
[ConditionallyRequired("IsLongMeeting",
ErrorMembers = "Start, End",
ErrorMessage = "If you're asking for more than an hour of time, provide an agenda.")]
[ConditionallyRequired("Location", "18/3367",
ErrorMessage = "No one can ever find this room; please be sure to include directions.")]
public string Details { get; set; }
[Required]
[RegularExpression(@"\d{1,3}/\d{4}",
ErrorMessage = "{0} must be in the format of 'Building/Room'")]
public string Location { get; set; }
[Range(2, 100)]
[Display(Name = "Minimum Attendees")]
public int MinimumAttendees { get; set; }
[Range(2, 100)]
[Display(Name = "Maximum Attendees")]
public int MaximumAttendees { get; set; }
}
}
Reusable Cross-Field Validators (CompareValidator)
I already included a “Conditionally Required” validator in my Custom Reusable Validators post. That’s certainly a very common scenario, but value comparisons like the Start/End time example above is another widely found validation requirement. Because I wouldn’t want to have to write validation methods like NoTimeTravel above for every one of these scenarios, I have created a comparison validator that can be reused for these scenarios.
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
namespace RudeValidation.Web.Validators
{
/// <summary>
/// Define the comparison operators for
/// the <see cref="CompareValidatorAttribute"/>.
/// </summary>
public enum CompareOperator
{
[Display(Name = "must be less than")]
LessThan,
[Display(Name = "cannot be more than")]
LessThanEqual,
[Display(Name = "must be the same as")]
Equal,
[Display(Name = "must be different from")]
NotEqual,
[Display(Name = "cannot be less than")]
GreaterThanEqual,
[Display(Name = "must be more than")]
GreaterThan
}
/// <summary>
/// A comparison validator that will compare a value to another property
/// and validate that the comparison is valid.
/// </summary>
public class CompareValidatorAttribute : ValidationAttribute
{
public CompareValidatorAttribute(CompareOperator compareOperator, string compareToProperty)
: base("{0} {1} {2}")
{
this.CompareOperator = compareOperator;
this.CompareToProperty = compareToProperty;
}
public CompareOperator CompareOperator { get; private set; }
public string CompareToProperty { get; private set; }
/// <summary>
/// Cache the property info for the compare to property.
/// </summary>
private PropertyInfo _compareToPropertyInfo;
/// <summary>
/// Validate the value against the <see cref="CompareProperty"/>
/// using the <see cref="CompareOperator"/>.
/// </summary>
/// <param name="value">The value being validated.</param>
/// <param name="validationContext">The validation context.</param>
/// <returns>
/// A <see cref="ValidationResult"/> if invalid or <see cref="ValidationResult.Success"/>.
/// </returns>
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
// Get the property that we need to compare to.
this._compareToPropertyInfo = validationContext.ObjectType
.GetProperty(this.CompareToProperty);
object compareToValue = this._compareToPropertyInfo
.GetValue(validationContext.ObjectInstance, null);
int comparison = ((IComparable)value).CompareTo(compareToValue);
bool isValid;
if (comparison < 0)
{
isValid = this.CompareOperator == CompareOperator.LessThan
|| this.CompareOperator == CompareOperator.LessThanEqual
|| this.CompareOperator == CompareOperator.NotEqual;
}
else if (comparison > 0)
{
isValid = this.CompareOperator == CompareOperator.GreaterThan
|| this.CompareOperator == CompareOperator.GreaterThanEqual
|| this.CompareOperator == CompareOperator.NotEqual;
}
else
{
isValid = this.CompareOperator == CompareOperator.LessThanEqual
|| this.CompareOperator == CompareOperator.Equal
|| this.CompareOperator == CompareOperator.GreaterThanEqual;
}
if (!isValid)
{
return new ValidationResult(
this.FormatErrorMessage(validationContext.DisplayName),
new[] { validationContext.MemberName, this.CompareToProperty });
}
return ValidationResult.Success;
}
/// <summary>
/// Format the error message string using the property's
/// name, the compare operator, and the comparison property's
/// display name.
/// </summary>
/// <param name="name">The display name of the property validated.</param>
/// <returns>The formatted error message.</returns>
public override string FormatErrorMessage(string name)
{
return string.Format(this.ErrorMessageString,
name,
GetOperatorDisplay(this.CompareOperator),
GetPropertyDisplay(this._compareToPropertyInfo));
}
/// <summary>
/// Get the display name for the specified compare operator.
/// </summary>
/// <param name="compareOperator">The operator.</param>
/// <returns>The display name for the operator.</returns>
private static string GetOperatorDisplay(CompareOperator compareOperator)
{
return typeof(CompareOperator)
.GetField(compareOperator.ToString())
.GetCustomAttributes(typeof(DisplayAttribute), false)
.Cast<DisplayAttribute>()
.Single()
.GetName();
}
/// <summary>
/// Get the display name for the specified property.
/// </summary>
/// <param name="property">The property.</param>
/// <returns>The display name of the property.</returns>
private static string GetPropertyDisplay(PropertyInfo property)
{
DisplayAttribute attribute = property
.GetCustomAttributes(typeof(DisplayAttribute), false)
.Cast<DisplayAttribute>()
.SingleOrDefault();
return attribute != null ? attribute.GetName() : property.Name;
}
}
}
Ignoring the implementation of the actual comparison, there are some details I’d like to point out about this custom validation attribute implementation.
- The CompareOperator enum is not nested within the CompareValidatorAttribute class – RIA Services does not support nested types;
- The CompareValidatorAttribute constructor calls the base constructor specifying an error message string format, using placeholders;
- The IsValid method uses ValidationContext.ObjectType to easily get the type of the object being validated;
- The ValidationResult instance returned specifies both the MemberName being validated as well as the CompareToProperty, so that both fields are highlighted and changes to either field would clear this error;
- The ValidationResult instance returned uses FormatErrorMessage, using the ErrorMessageString property from the base class;
- FormatErrorMessage is overridden to format the string using the 3 placeholders specified in the default error message format on the constructor;
- Consumption of DisplayAttribute uses the GetName() method rather than the Name property—this is critical to localization; I’ll explain why in a future post.
You’ll notice that a couple of these points were related to the error message string. These details are necessary to allow declarations of this attribute to specify custom error messages.
Let’s take a look at how the CompareValidatorAttribute can be applied to our Meeting model. We’ll apply it a couple of times; first, we’ll replace the NoTimeTravel validation; second, we’ll validate the min/max attendees properties.
using System;
using System.ComponentModel.DataAnnotations;
using RudeValidation.Web.Resources;
using RudeValidation.Web.Validators;
namespace RudeValidation.Web.Models
{
public partial class Meeting
{
[Key]
public int MeetingId { get; set; }
[Required]
[CustomValidation(typeof(MeetingValidators), "NoEarlyMeetings")]
//[CustomValidation(typeof(MeetingValidators), "NoTimeTravel")]
[CompareValidator(CompareOperator.LessThan, "End",
ErrorMessage = "Meetings cannot result in time travel.")]
[DateValidator(DateValidatorType.Future)]
public DateTime Start { get; set; }
[Required]
//[CustomValidation(typeof(MeetingValidators), "NoTimeTravel")]
[CompareValidator(CompareOperator.GreaterThan, "Start",
ErrorMessage = "Meetings cannot result in time travel.")]
public DateTime End { get; set; }
[Required]
[StringLength(80, MinimumLength = 5,
ErrorMessageResourceType = typeof(ValidationErrorResources),
ErrorMessageResourceName = "TitleStringLengthErrorMessage")]
// {0} must be at least {2} characters and no more than {1}.
public string Title { get; set; }
[ConditionallyRequired("IsLongMeeting",
ErrorMembers = "Start, End",
ErrorMessage = "If you're asking for more than an hour of time, provide an agenda.")]
[ConditionallyRequired("Location", "18/3367",
ErrorMessage = "No one can ever find this room; please be sure to include directions.")]
public string Details { get; set; }
[Required]
[RegularExpression(@"\d{1,3}/\d{4}",
ErrorMessage = "{0} must be in the format of 'Building/Room'")]
public string Location { get; set; }
[Range(2, 100)]
[CompareValidator(CompareOperator.LessThanEqual, "MaximumAttendees")]
[Display(Name = "Minimum Attendees")]
public int MinimumAttendees { get; set; }
[Range(2, 100)]
[CompareValidator(CompareOperator.GreaterThanEqual, "MinimumAttendees")]
[Display(Name = "Maximum Attendees")]
public int MaximumAttendees { get; set; }
}
}
Cross-Field Validation Approaches
We have now seen how cross-field validation can be applied using [CustomValidation] as well as with a derived ValidationAttribute. I recommend starting out with [CustomValidation] when implementing cross-field validation rules, but as you start to recognize patterns of similar validation, that’s when you can extract custom ValidationAttribute classes to replace your custom validation methods. That is precisely what we’ve done in this blog post and I have had success with this mindset.
RIA Services Validation Recap
This post was dedicated to cross-field validation, showing patterns for validating fields that are related to each other. I had actually intended to cover entity-level validation in this post as well, but I decided to hold that for its own post (which will be next).
Here’s the list of blog posts in this RIA Services Validation series:
[9/6/2011] The source code for everything shown during the series is available on GitHub.
Digging Deeper
Yes, we will continue to dig deeper into RIA Services Validation. As mentioned, the next installment will be dedicated to entity-level validation. Still in this series, we’ll explore the power of ValidationContext and ViewModel validation.