views:

2082

answers:

2

Hi,

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. Does anyone know 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.

+6  A: 

I faced the exact same issue today. Like yourself i dont 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 its 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 followed:

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 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 its 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.

Martijn Laarman
Seems to work great for me as well, at least judging by my first quick test. Thanks a lot! I'll let Brad know of your bugfix, perhaps he'll have some time to have a look.
Adrian Grigore
Cool nice to know its working for you as well. One last hint if you bind to custom object arrays (see: http://haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx) Unlike that article mentions with this modelbinder you don't need the index field in fact it will error with but works fine without.
Martijn Laarman
+3  A: 

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.

Brad Wilson
Thanks again, Brad! :)
Adrian Grigore