Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does DataAnnotationsModelBinder work with custom ViewModels?

I'm trying to use the DataAnnotationsModelBinder in order to use data annotations for server-side validation in ASP.NET MVC.

Everything works fine as long as my ViewModel is just a simple class with immediate properties such as

public class Foo
{
    public int Bar {get;set;}
}

However, the DataAnnotationsModelBinder causes a NullReferenceException when trying to use a complex ViewModel, such as

public class Foo
{
    public class Baz
    {
        public int Bar {get;set;}
    }

    public Baz MyBazProperty {get;set;}
}

This is a big problem for views that render more than one LINQ entity because I really prefer using custom ViewModels that include several LINQ entities instead of untyped ViewData arrays.

The DefaultModelBinder does not have this problem, so it seems like a bug in DataAnnotationsModelBinder. Is there any workaround to this?

Edit: A possible workaround is of course to expose the child object's properties in the ViewModel class like this:

public class Foo
{
    private Baz myBazInstance;

    [Required]
    public string ExposedBar
    {
        get { return MyBaz.Bar; }
        set { MyBaz.Bar = value; }
    }

    public Baz MyBaz
    {
        get { return myBazInstance ?? (myBazInstance = new Baz()); }
        set { myBazInstance = value; }
    }

    #region Nested type: Baz

    public class Baz
    {
        [Required]
        public string Bar { get; set; }
    }

    #endregion
}

#endregion

But I'd prefer not to have to write all this extra code. The DefaultModelBinder works fine with such hiearchies, so I suppose the DataAnnotationsModelBinder should as well.

Second Edit: It looks like this is indeed a bug in DataAnnotationsModelBinder. However, there is hope this might be fixed before the next ASP.NET MVC framework version ships. See this forum thread for more details.

like image 816
Adrian Grigore Avatar asked May 04 '09 14:05

Adrian Grigore


2 Answers

I faced the exact same issue today. Like yourself I don't tie my View directly to my Model but use an intermediate ViewDataModel class that holds an instance of the Model and any parameters / configurations I'd like to sent of to the view.

I ended up modifying BindProperty on the DataAnnotationsModelBinder to circumvent the NullReferenceException, and I personally didn't like properties only being bound if they were valid (see reasons below).

protected override void BindProperty(ControllerContext controllerContext,
                                         ModelBindingContext bindingContext,
                                         PropertyDescriptor propertyDescriptor) {
    string fullPropertyKey = CreateSubPropertyName(bindingContext.ModelName, propertyDescriptor.Name);

    // Only bind properties that are part of the request
    if (bindingContext.ValueProvider.DoesAnyKeyHavePrefix(fullPropertyKey)) {
        var innerContext = new ModelBindingContext() {
            Model = propertyDescriptor.GetValue(bindingContext.Model),
            ModelName = fullPropertyKey,
            ModelState = bindingContext.ModelState,
            ModelType = propertyDescriptor.PropertyType,
            ValueProvider = bindingContext.ValueProvider
        };

        IModelBinder binder = Binders.GetBinder(propertyDescriptor.PropertyType);
        object newPropertyValue = ConvertValue(propertyDescriptor, binder.BindModel(controllerContext, innerContext));
        ModelState modelState = bindingContext.ModelState[fullPropertyKey];
        if (modelState == null)
        {
            var keys = bindingContext.ValueProvider.FindKeysWithPrefix(fullPropertyKey);
            if (keys != null && keys.Count() > 0)
                modelState = bindingContext.ModelState[keys.First().Key];
        }
        // Only validate and bind if the property itself has no errors
        //if (modelState.Errors.Count == 0) {
            SetProperty(controllerContext, bindingContext, propertyDescriptor, newPropertyValue);
            if (OnPropertyValidating(controllerContext, bindingContext, propertyDescriptor, newPropertyValue)) {

                OnPropertyValidated(controllerContext, bindingContext, propertyDescriptor, newPropertyValue);
            }
        //}

        // There was an error getting the value from the binder, which was probably a format
        // exception (meaning, the data wasn't appropriate for the field)
        if (modelState.Errors.Count != 0) {
            foreach (var error in modelState.Errors.Where(err => err.ErrorMessage == "" && err.Exception != null).ToList()) {
                for (var exception = error.Exception; exception != null; exception = exception.InnerException) {
                    if (exception is FormatException) {
                        string displayName = GetDisplayName(propertyDescriptor);
                        string errorMessage = InvalidValueFormatter(propertyDescriptor, modelState.Value.AttemptedValue, displayName);
                        modelState.Errors.Remove(error);
                        modelState.Errors.Add(errorMessage);
                        break;
                    }
                }
            }
        }
    }
}

I also modified it so that it always binds the data on the property no matter if it's valid or not. This way I can just pass the model back to the view withouth invalid properties being reset to null.

Controller Excerpt

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(ProfileViewDataModel model)
{
    FormCollection form = new FormCollection(this.Request.Form);
    wsPerson service = new wsPerson();
    Person newPerson = service.Select(1, -1);
    if (ModelState.IsValid && TryUpdateModel<IPersonBindable>(newPerson, "Person", form.ToValueProvider()))
    {
        //call wsPerson.save(newPerson);
    }
    return View(model); //model.Person is always bound no null properties (unless they were null to begin with)
}

My Model class (Person) comes from a webservice so I can't put attributes on them directly, the way I solved this is as follows:

Example with nested DataAnnotations

[Validation.MetadataType(typeof(PersonValidation))]
public partial class Person : IPersonBindable { } //force partial.

public class PersonValidation
{
    [Validation.Immutable]
    public int Id { get; set; }
    [Validation.Required]
    public string FirstName { get; set; }
    [Validation.StringLength(35)]
    [Validation.Required]
    public string LastName { get; set; }
    CategoryItemNullable NearestGeographicRegion { get; set; }
}

[Validation.MetadataType(typeof(CategoryItemNullableValidation))]
public partial class CategoryItemNullable { }

public class CategoryItemNullableValidation
{
    [Validation.Required]
    public string Text { get; set; }
    [Validation.Range(1,10)]
    public string Value { get; set; }
}

Now if I bind a form field to [ViewDataModel.]Person.NearestGeographicRegion.Text & [ViewDataModel.]Person.NearestGeographicRegion.Value the ModelState starts validating them correctly and DataAnnotationsModelBinder binds them correctly as well.

This answer is not definitive, it's the product of scratching my head this afternoon. It's not been properly tested, eventhough it passed the unit tests in the project Brian Wilson started and most of my own limited testing. For true closure on this matter I would love to hear Brad Wilson thoughts on this solution.

like image 115
Martijn Laarman Avatar answered Oct 21 '22 08:10

Martijn Laarman


The fix for this issue is simple, as Martijn has noted.

In the BindProperty method, you will find this line of code:

if (modelState.Errors.Count == 0) {

It should be changed to:

if (modelState == null || modelState.Errors.Count == 0) {

We are intending to include DataAnnotations support in MVC 2, which will include the DataAnnotationsModelBinder. This feature will be part of the first CTP.

like image 29
Brad Wilson Avatar answered Oct 21 '22 09:10

Brad Wilson