using IDataErrorInfo in asp.net mvc

2019-01-23 21:01发布

问题:

I've got a simple address entry app that I'm trying to use the IDataErrorInfo interface as explained on the asp.net site.

It works great for items that can be validated independently, but not so well when some items depend on others. For example, validating the postal code depends on the country:

    private string _PostalCode;
    public string PostalCode
    {
        get
        {
            return _PostalCode;
        }
        set
        {
            switch (_Country)
            {
                case Countries.USA:
                    if (!Regex.IsMatch(value, @"^[0-9]{5}$"))
                        _errors.Add("PostalCode", "Invalid Zip Code");
                    break;
                case Countries.Canada:
                    if (!Regex.IsMatch(value, @"^([a-z][0-9][a-z]) ?([0-9][a-z][0-9])$", RegexOptions.IgnoreCase))
                        _errors.Add("PostalCode", "Invalid postal Code");
                    break;
                default:
                    throw new ArgumentException("Unknown Country");
            }
            _PostalCode = value;
        }
    }

So you can only validate the postal code after the country has been set, but there seems to be no way of controlling that order.

I could use the Error string from IDataErrorInfo, but that doesn't show up in the Html.ValidationMessage next to the field.

回答1:

For more complex business rule validation, rather than type validation it is maybe better to implement design patterns such as a service layer. You can check the ModelState and add errors based on your logic.

You can view Rob Conroys example of patterns here

http://www.asp.net/learn/mvc/tutorial-29-cs.aspx

This article on Data Annotations ay also be useful.

http://www.asp.net/learn/mvc/tutorial-39-cs.aspx

Hope this helps.



回答2:

Here's the best solution I've found for more complex validation beyond the simple data annotations model.

I'm sure I'm not alone in trying to implement IDataErrorInfo and seeing that it has only created for me two methods to implement. I'm thinking wait a minute - do i have to go in there and write my own custom routines for everything now from scratch? And also - what if I have model level things to validate. It seems like you're on your own when you decide to use it unless you want to do something like this or this from within your IDataErrorInfo implementation.

I happened to have the exact same problem as the questioner. I wanted to validate US Zip but only if country was selected as US. Obviously a model-level data annotation wouldn't be any good because that wouldn't cause zipcode to be highlighted in red as an error. [good example of a class level data annotation can be found in the MVC 2 sample project in the PropertiesMustMatchAttribute class].

The solution is quite simple :

First you need to register a modelbinder in global.asax. You can do this as an class level [attribute] if you want but I find registering in global.asax to be more flexible.

private void RegisterModelBinders()
{
     ModelBinders.Binders[typeof(UI.Address)] = new AddressModelBinder();
}

Then create the modelbinder class, and write your complex validation. You have full access to all properties on the object. This will run after any data annotations have run so you can always clear model state if you want to reverse the default behavior of any validation attributes.

public class AddressModelBinder : DefaultModelBinder
{
    protected override void OnModelUpdated(ControllerContext controllerContext, 
        ModelBindingContext bindingContext)
    {
        base.OnModelUpdated(controllerContext, bindingContext);

        // get the address to validate
        var address = (Address)bindingContext.Model;

        // validate US zipcode
        if (address.CountryCode == "US")
        {
            if (new Regex(@"^\d{5}([\-]\d{4})?$", RegexOptions.Compiled).
                Match(address.ZipOrPostal ?? "").Success == false)
            {
                // not a valid zipcode so highlight the zipcode field
                var ms = bindingContext.ModelState;                    
                ms.AddModelError(bindingContext.ModelName + ".ZipOrPostal", 
                "The value " + address.ZipOrPostal + " is not a valid zipcode");
            }
        }
        else {
            // we don't care about the rest of the world right now
            // so just rely on a [Required] attribute on ZipOrPostal
        }

        // all other modelbinding attributes such as [Required] 
        // will be processed as normal
    }
}

The beauty of this is that all your existing validation attributes will still work - [Required], [EmailValidator], [MyCustomValidator] - whatever you have.

You can just add in any extra code into the model binder and set field, or model level ModelState errors as you wish.

Please note that for me an Address is a child of the main model - in this case CheckoutModel which looks like this :

public class CheckoutModel
{
    // uses AddressModelBinder
    public Address BillingAddress { get; set; }
    public Address ShippingAddress { get; set; }

    // etc.
}

That's why I have to do bindingContext.ModelName+ ".ZipOrPostal" so that the model error will be set for 'BillingAddress.ZipOrPostal' and 'ShippingAddress.ZipOrPostal'.

PS. Any comments from 'unit testing types' appreciated. I'm not sure about the impact of this for unit testing.



回答3:

Regarding the comment on Error string, IDataErrorInfo and the Html.ValidationMessage, you can display object level vs. field level error messages using:

Html.ValidationMessage("address", "Error")

Html.ValidationMessage("address.PostalCode", "Error")

In your controller decorate the post method handler parameter for the object with [Bind(Prefix = "address")]. In the HTML, name the input fields as such...

<input id="address_PostalCode" name="address.PostalCode" ... />

I don't generally use the Html helpers. Note the naming convention between id and name.