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