Home > Programming > Custom model binding

Custom model binding

The default model binder in ASP.NET MVC does a good job of binding items that follow a certain naming convention. But if you’re using third party controls, you typically do not have control over the HTML they generate. One of the third party controls I’m using in my project allows a user to select a date range. At a minimum, the generated HTML looks something like this:

<p>
    Start date:
    <input type="text" name="StartMonth" />
    <input type="text" name="StartDay" />
    <input type="text" name="StartYear" />
</p>
<p>
    End date:
    <input type="text" name="EndMonth" />
    <input type="text" name="EndDay" />
    <input type="text" name="EndYear" />
</p>

I want to bind the results to my DateRange model:

public class DateRange
{
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
}

ASP.NET MVC provides an extension point to do custom model binding. We simply need to implement IModelBinder and register it in global.asax.cs in the Application_Start method:

public class DateRangeModelBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        int startMonth = this.GetValue(bindingContext, "StartMonth");
        int startDay = this.GetValue(bindingContext, "StartDay");
        int startYear = this.GetValue(bindingContext, "StartYear");

        int endMonth = this.GetValue(bindingContext, "EndMonth");
        int endDay = this.GetValue(bindingContext, "EndDay");
        int endYear = this.GetValue(bindingContext, "EndYear");

        return new DateRange
        {
            Start = new DateTime(startYear, startMonth, startDay),
            End = new DateTime(endYear, endMonth, endDay)
        };
    }

    private int GetValue(ModelBindingContext context, string name)
    {
        return (int)context
            .ValueProvider
            .GetValue(name)
            .ConvertTo(typeof(int));
    }
}
ModelBinders.Binders.Add(typeof(DateRange), new DateRangeModelBinder());

I’m omitting error checking in the custom model binder for this post, but normally you’ll want to catch format exceptions, add model state errors, etc…. At this point, I can add a DateRange as the action method parameter and get the expected result.

[HttpPost]
public ActionResult Index(DateRange input)
{
    return View();
}

If I want to keep my form submission as simple as this, everything works perfectly. However, the model binding above does not work if I create a new model with DateRange as a property.

public class Incoming
{
    public string Name { get; set; }
    public DateRange DateRange { get; set; }
}

In the Incoming model, I’ve added a Name property and a DateRange property. My form submission now includes other information in addition to a date range. When I swap out the DateRange parameter in my action method with the Incoming class, the model binding does not work as expected. If I put a breakpoint inside the action method and inspect the input, I’ll see that the date range is null.

The problem seems to indicate that the DefaultModelBinder in ASP.NET MVC does not check the type, but rather matches on names. In this case, my property is named “DateRange” and since my HTML is generated by a third party control, I can’t change the names. What I need to do is override the DefaultModelBinder and replace it with a custom one that checks property types.

public class CustomModelBinder : DefaultModelBinder
{
    protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
    {
        if (propertyDescriptor.PropertyType != typeof(DateRange))
        {
            base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
            return;
        }

        DateRangeModelBinder modelBinder = new DateRangeModelBinder();
        object dateProperty = modelBinder.BindModel(controllerContext, bindingContext);
        propertyDescriptor.SetValue(bindingContext.Model, dateProperty);
    }
}

I’ve created a custom model binder and have overridden the BindProperty method. If the property is a type of DateRange, I’ll use the DateRangeModelBinder and manually map the property on the model. I also need to register it in global.asax.cs in the Application_Start method:

ModelBinders.Binders.DefaultBinder = new CustomModelBinder();

At this point, I’ve set the default model binder to the one I’ve created and everything works as expected. But this only works for the date range control. If I had multiple third party controls, I don’t want to pollute the CustomModelBinder with multiple if statements. To make it easier to add additional model binders in the future, I’ve created an interface:

public interface IPropertyBinder
{
    bool ShouldHandle(Type propertyType);
    void Bind(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor);
}

The implementation for the date range control looks like this:

public class DateRangePropertyBinder : IPropertyBinder
{
    public bool ShouldHandle(Type propertyType)
    {
        return propertyType == typeof(DateRange);
    }

    public void Bind(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
    {
        DateRangeModelBinder modelBinder = new DateRangeModelBinder();
        object dateProperty = modelBinder.BindModel(controllerContext, bindingContext);
        propertyDescriptor.SetValue(bindingContext.Model, dateProperty);
    }
}

The ShouldHandle method will check to see if it’s the type expected. The Bind method will use DateRangeModelBinder and manually set the property. To use this new interface, I’ll need to update the CustomModelBinder.

public class CustomModelBinder : DefaultModelBinder
{
    private readonly IPropertyBinder[] propertyBinders;

    public CustomModelBinder(IPropertyBinder[] propertyBinders)
    {
        this.propertyBinders = propertyBinders;
    }

    protected override void BindProperty(ControllerContext controllerContext, ModelBindingContext bindingContext, PropertyDescriptor propertyDescriptor)
    {
        foreach (IPropertyBinder binder in propertyBinders)
        {
            if (binder.ShouldHandle(propertyDescriptor.PropertyType))
            {
                binder.Bind(controllerContext, bindingContext, propertyDescriptor);
                return;
            }
        }

        base.BindProperty(controllerContext, bindingContext, propertyDescriptor);
    }
}

I’ve updated the BindProperty method to loop over the list of property binders. The list of property binders is typically supplied by an inversion of control container. Again, I’ll need to update the Application_Start method in global.asax.cs.

CustomModelBinder modelBinder = container.Resolve<CustomModelBinder>();
ModelBinders.Binders.DefaultBinder = modelBinder;

To add additional model binders in the future, I simply need to implement the IPropertyBinder interface and make sure that it’s registered with my inversion of control container.

  1. spiko
    May 10, 2012 at 1:30 pm

    When you want to only do some custom binding on specific properties then one of the possible solutions is to decorate the properties with an attribute saying which custom binder should be used.

    We would have to create such attribute. The attribute could return the binder object implementing some (our new, general) IPropertyBinding interface. In that way various attributes (but still using the same base attr, abstract class) could provide various binders to be used.

    Then the default model binder class should just be extended and should just look for the attribute describing the custom binder to be used for a property. For example:

    IPropertyBinding customBinder = propertyDescriptor.Attributes.OfType().FirstOrDefault()

    then if we have one
    customBinder.BindProperty(…)

  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: