by Jeff Handley via Jeff Handley on 10/10/2010 8:29:55 AM
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.
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:
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; } }}
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;}
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; } }}
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; } }}
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.
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; } }}
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.
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:
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, we’ll demonstrate validation localization, ViewModel validation, and more.
Original Post: RIA Services Validation: Cross-Field Validation
The content of the postings is owned by the respective author. Silverlight Feeds is not responsible for the contents of the postings. This site is automatically generated and cannot be reviewed for abusive content. If you find abusive content on Silverlight Feeds, please contact us. Designated trademarks and brands are the property of their respective owners. All rights reserved.