Custom Model Binding of IEnumerable Properties in ASP.Net MVC 2
- by Doug Lampe
MVC 2 provides a GREAT feature for dealing with enumerable types. Let's say you have an object with a parent/child relationship and you want to allow users to modify multiple children at the same time. You can simply use the following syntax for any indexed enumerables (arrays, generic lists, etc.) and then your values will bind to your enumerable model properties.
1:
<%
using (Html.BeginForm("TestModelParameter", "Home"))
2: { %>
3:
<
table
>
4:
<
tr
><
th
>ID</th><th>Name</th><th>Description</th></tr>
5:
<%
for (int i = 0; i < Model.Items.Count; i++)
6: { %>
7:
<
tr
>
8:
<
td
>
9:
<%= i %>
10:
</
td
>
11:
<
td
>
12:
<%= Html.TextBoxFor(m => m.Items[i].Name) %>
13:
</
td
>
14:
<
td
>
15:
<%= Model.Items[i].Description %>
16:
</
td
>
17:
</
tr
>
18:
<% } %>
19:
</
table
>
20:
<
input
type
="submit"
/>
21:
<% } %>
Then just update your model either by passing it into your action method as a parameter or explicitly with UpdateModel/TryUpdateModel.
1:
public ActionResult TestTryUpdate()
2: {
3: ContainerModel model = new ContainerModel();
4: TryUpdateModel(model);
5:
6:
return View("Test", model);
7: }
8:
9:
public ActionResult TestModelParameter(ContainerModel model)
10: {
11:
return View("Test", model);
12: }
Simple right? Well, not quite. The problem is the DefaultModelBinder and how it sets properties. In this case our model has a property that is a generic list (Items). The first bad thing the model binder does is create a new instance of the list. This can be fixed by making the property truly read-only by removing the set accessor. However this won't help because this behaviour continues. As the model binder iterates through the items to "set" their values, it creates new instances of them as well. This means you lose any information not passed via the UI to your controller so in the examplel above the "Description" property would be blank for each item after the form posts.
One solution for this is custom model binding. I have put together a solution which allows you to retain the structure of your model. Model binding is a somewhat advanced concept so you may need to do some additional research to really understand what is going on here, but the code is fairly simple. First we will create a binder for the parent object which will retain the state of the parent as well as some information on which children have already been bound.
1:
public
class ContainerModelBinder : DefaultModelBinder
2: {
3:
/// <summary>
4:
/// Gets an instance of the model to be used to bind child objects.
5:
/// </summary>
6:
public ContainerModel Model { get; private set; }
7:
8:
/// <summary>
9:
/// Gets a list which will be used to track which items have been bound.
10:
/// </summary>
11:
public List<ItemModel> BoundItems { get; private set; }
12:
13:
public ContainerModelBinder()
14: {
15: BoundItems = new List<ItemModel>();
16: }
17:
18:
protected
override
object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
19: {
20:
// Set the Model property so child binders can find children.
21: Model = base.CreateModel(controllerContext, bindingContext, modelType) as ContainerModel;
22:
23:
return Model;
24: }
25: }
Next we will create the child binder and have it point to the parent binder to get instances of the child objects. Note that this only works if there is only one property of type ItemModel in the parent class since the property to find the item in the parent is hard coded.
1:
public
class ItemModelBinder : DefaultModelBinder
2: {
3:
/// <summary>
4:
/// Gets the parent binder so we can find objects in the parent's collection
5:
/// </summary>
6:
public ContainerModelBinder ParentBinder { get; private set; }
7:
8:
public ItemModelBinder(ContainerModelBinder containerModelBinder)
9: {
10: ParentBinder = containerModelBinder;
11: }
12:
13:
protected
override
object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
14: {
15:
// Find the item in the parent collection and add it to the bound items list.
16: ItemModel item = ParentBinder.Model.Items.FirstOrDefault(i => !ParentBinder.BoundItems.Contains(i));
17: ParentBinder.BoundItems.Add(item);
18:
19:
return item;
20: }
21: }
Finally, we will register these binders in Global.asax.cs so they will be used to bind the classes.
1:
protected
void Application_Start()
2: {
3: AreaRegistration.RegisterAllAreas();
4:
5: ContainerModelBinder containerModelBinder = new ContainerModelBinder();
6: ModelBinders.Binders.Add(typeof(ContainerModel), containerModelBinder);
7: ModelBinders.Binders.Add(typeof(ItemModel), new ItemModelBinder(containerModelBinder));
8:
9: RegisterRoutes(RouteTable.Routes);
10: }
I'm sure some of my fellow geeks will comment that this could be done more efficiently by simply rewriting some of the methods of the default model binder to get the same desired behavior. I like my method shown here because it extends the binder class instead of modifying it so it minimizes the potential for unforseen problems.
In a future post (if I ever get around to it) I will explore creating a generic version of these binders.