Validating a view model after custom model binding

2020-03-06 03:54发布

问题:

I have a view model that implements IValidatableObject that contains a string and a collection of another view model, something like this:

public sealed class MainViewModel
{
    public string Name { get; set; }
    public ICollection<OtherViewModel> Others { get; set; }
}

My validation checks each object in Others against different rules using the contract provided by IValidatableObject:

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    foreach (var other in this.Others)
    {
        // validate or yield return new ValidationResult
    }
}

Because of the complex structure of the real MainViewModel I have had to create a custom model binder which re-builds the model and assigns POST data to the relevant components. The problem that I'm getting is that nothing is getting validated resulting in validation errors at the context level as it violates certain database constraints and I'm not sure what I'm doing wrong - I assumed that ModelState.IsValid would invoke the Validate method on my view model but it doesn't seem to go down that way.

My model binder looks like this:

public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
    int modelId = (int)controllerContext.RouteData.Values["id"];

    // query the database and re-build the components of the view model

    // iterate the POST data and assign to the model where necessary

    // should I be calling something here to validate the model before it's passed to the controller?

    return model;
}

Any help appreciated!

Validator.TryValidateObject

OK, seems I'm a little closer. I can now get my IValidatableObject method to run by adding the following to my custom model binder:

var validationResults = new HashSet<ValidationResult>();
var isValid = Validator.TryValidateObject(model, new ValidationContext(model, null, null), validationResults, true);

Seems that Validator.TryValidateObject invokes the validation method and setting the last parameter to true causes it to validate all properties. However, I'm now stuck with getting the validationResults to the controller so they can be used in a meaningful way.

回答1:

I should have realised that I could use the ModelState.AddModelError through a custom binder, I've managed to get this working correctly now by adding the following to my custom model binder before returning the model to the controller:

var validationResults = new HashSet<ValidationResult>();
var isValid = Validator.TryValidateObject(model, new ValidationContext(model, null, null), validationResults, true);
if (!isValid)
{
    foreach (var result in validationResults)
    {
        bindingContext.ModelState.AddModelError("", result.ErrorMessage);
    }
}

return model;

This now returns a list of all errors to my page and the ModelState.IsValid check on my controller action is now returning false.



回答2:

Paul's great answer can be refactored into a generic validate-and-convert to ModelState method as follows (e.g. in a helper or CustomModelBinder base). In addition, the bindings to the validated properties are retained.

public static void DoValidation(ModelBindingContext bindingContext, 
                                IValidatableObject model)
{
    var validationResults = new HashSet<ValidationResult>();
    var isValid = Validator.TryValidateObject(model, 
        new ValidationContext(model, null, null), validationResults, true);
    if (!isValid)
    {
        var resultsGroupedByMembers = validationResults
            .SelectMany(_ => _.MemberNames.Select(
                 x => new {MemberName = x ?? "", 
                           Error = _.ErrorMessage}))
            .GroupBy(_ => _.MemberName);

        foreach (var member in resultsGroupedByMembers)
        {
            bindingContext.ModelState.AddModelError(
                member.Key,
                string.Join(". ", member.Select(_ => _.Error)));
        }
    }
}