Search

Thursday 1 December 2011

Partial Validation with Data Annotations in ASP.NET MVC

Introduction

This article is a follow-up to Andy West's blog post about performing a conditional validation when using .NET data annotations on a model in MVC.

Now I am not going to go into the arguments about the use of DTOs vs 'real' model objects; or using separate vs shared model objects across different views. As many others have noted (from what I've seen on the Web), if you are working with 'real' model objects using data annotations, there is a clear need to be able to exclude certain validations depending on the specific scenario.

The Scenario

Let's look at a simple product/category model:

// C#
public class Category
{
    [Required]
    public int Id { get; set; }
    [Required]
    public string Name { get; set; }
    [Required]
    public string Description { get; set; }
}

public class Product
{
    [Required]
    public int Id { get; set; }
    [Required]
    public string Name { get; set; }
    [Required]
    public string Description { get; set; }
    [Required]
    public Category Category { get; set; }
    [Required]
    public decimal Price { get; set; }
}
' Visual Basic
Public Class Category

    <Required()>
    Public Property Id As Integer
    <Required()>
    Public Property Name As String
    <Required()>
    Public Property Description As String

End Class

Public Class Product

    <Required()>
    Public Property Id As Integer
    <Required()>
    Public Property Name As String
    <Required()>
    Public Property Description As String
    <Required()>
    Public Property Category As Category
    <Required()>
    Public Property Price As Decimal
End Class

As you can see, this is a very simple model where all properties on the two classes are decorated with the Required attribute.

Now let's take a simple action to create a new product:

// C#
[HttpPost]
public ActionResult Create(Product product)
{
    if (ModelState.IsValid)
    {
        // Do something here, probably put the product in some database.
        return View("SuccessPage");
    }
    else
    {
        // Do something else here, probably return to the view showing the errors.
        return View();
    }
}
' Visual Basic
<HttpPost()>
Public Function Create(ByVal product As Product) As ActionResult
    If ModelState.IsValid
        ' Do something here, probably put the product in some database.
        Return View("SuccessPage")
    Else
        ' Do something else here, probably return to the view showing the errors.
        Return View
    End If
End Function

Now our data annotations specify that a Product must have a Category that, in turn, must have values for its Id, Name, and Description properties. However, when we post back to the above action, do we really need to specify the name and description for the product's category? The answer is probably not. After all it is likely that at the time of product creation, the category already exists in our data store and that the user picked the category from a drop-down list (or similar) of current categories. In that case we are not really interested in the category's name and description. We are only really interested in the category's ID, so we can assigned it to the product and thus satisfy any data integrity constraints (e.g. database foreign keys) we have on our data.

However if we just post back the category ID, the model-state validation will fail because of the Required attributes on the Name and Description properties. However, we do not want to get rid of these attributes because elsewhere on the system, on the category creation view for example, we want to make sure the user specifies a name and description for any new categories they create.

So, what are we to do?

The Solution

This is where the IgnoreModelErrors attribute comes in. It allows us to specify a comma-separated string of model-state keys for which we wish to ignore any validation errors. So, in our example, we could decorate our action like this:

// C#
[HttpPost]
[IgnoreModelErrors("Category.Name, Category.Description")]
public ActionResult Create(Product product)
{
    // Code omitted for brevity.
}
' Visual Basic
<HttpPost()>
<IgnoreModelErrors("Category.Name, Category.Description")>
Public Function Create(ByVal product As Product) As ActionResult
    ' Code omitted for brevity.
End Function

Additional Options

The IgnoreModelErrors attribute has a couple of additional options worth mentioning:

Firstly, the attribute supports the '*' wildcard when specifying model-state keys. So if, for example, we used "Category.*", validation errors for any sub-property of the Category property will be ignored. However, if instead we used "*.Description", validation errors for the Description sub-property of any property will be ignored.

Secondly, the attribute also supports collections: For example, if the Product contained a property Categories which returned a IList<Category>, we could use "Categories[0].Description" to specify validation errors for the Description property of the first Category object in the list. We can use 1, 2, 3 etc. as the indexer to specify the second, third, fourth etc. Category as required. Omitting the indexer, e.g.: "Categories[].Description specifies all validation errors for the Description property of any Category object in the list.

The Code

The code for the IgnoreModelErrors attribute is shown below:

// C#
public class IgnoreModelErrorsAttribute : ActionFilterAttribute
{
    private string keysString;

    public IgnoreModelErrorsAttribute(string keys)
        : base()
    {
        this.keysString = keys;
    }

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        ModelStateDictionary modelState = filterContext.Controller.ViewData.ModelState;
        string[] keyPatterns = keysString.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
        for (int i = 0; i < keyPatterns.Length; i++)
        {
            string keyPattern = keyPatterns[i]
                .Trim()
                .Replace(@".", @"\.")
                .Replace(@"[", @"\[")
                .Replace(@"]", @"\]")
                .Replace(@"\[\]", @"\[[0-9]+\]")
                .Replace(@"*", @"[A-Za-z0-9]+");
            IEnumerable<string> matchingKeys = modelState.Keys.Where(x => Regex.IsMatch(x, keyPattern));
            foreach (string matchingKey in matchingKeys)
                modelState[matchingKey].Errors.Clear();
        }
    }
}
' Visual Basic
Public Class IgnoreModelErrorsAttribute
    Inherits ActionFilterAttribute

    Private keysString As String

    Public Sub New(ByVal keys As String)
        MyBase.New()
        Me.keysString = keys
    End Sub

    Public Overrides Sub OnActionExecuting(ByVal filterContext As ActionExecutingContext)
        Dim modelState As ModelStateDictionary = filterContext.Controller.ViewData.ModelState
        Dim keyPatterns As String() = keysString.Split(New Char() {","}, StringSplitOptions.RemoveEmptyEntries)
        For i As Integer = 0 To keyPatterns.Length - 1 Step 1
            Dim keyPattern As String = keyPatterns(i) _
                                       .Replace(".", "\.") _
                                       .Replace("[", "\[") _
                                       .Replace("]", "\]") _
                                       .Replace("\[\]", "\[[0-9]+\]") _
                                       .Replace("*", "[A-Za-z0-9]+")
            Dim matchingKeys As IEnumerable(Of String) = modelState.Keys.Where(Function(x) Regex.IsMatch(x, keyPattern))
            For Each matchingKey As String In matchingKeys
                modelState(matchingKey).Errors.Clear()
            Next
        Next
    End Sub

End Class

As you can see the code is very straightforward. Firstly we split the comma-separated string into its component keys. We then transform each key into a regular expression which we then use to query the model-state for any keys which match. For any matches which are found, we clear any validation errors which may have been raised.

Summary

The IgnoreModelErrors attribute provides another alternative, and more declarative, method for performing partial or selective validation when posting model data back to an action in MVC. At present it provides only a basic syntax for matching keys in the model-state dictionary, but it could easily be expanded upon to handle more complex queries.

No comments:

Post a Comment