Polymorphic model binding

asp.net Mvcasp.net Mvc-3PolymorphismModel Binding

asp.net Mvc Problem Overview


This question has been asked before in earlier versions of MVC. There is also this blog entry about a way to work around the problem. I'm wondering if MVC3 has introduced anything that might help, or if there are any other options.

In a nutshell. Here's the situation. I have an abstract base model, and 2 concrete subclasses. I have a strongly typed view that renders the models with EditorForModel(). Then I have custom templates to render each concrete type.

The problem comes at post time. If I make the post action method take the base class as the parameter, then MVC can't create an abstract version of it (which i would not want anyways, i'd want it to create the actual concrete type). If I create multiple post action methods that vary only by parameter signature, then MVC complains that it's ambiguous.

So as far as I can tell, I have a few choices on how to solve this proble. I don't like any of them for various reasons, but i will list them here:

  1. Create a custom model binder as Darin suggests in the first post I linked to.
  2. Create a discriminator attribute as the second post I linked to suggests.
  3. Post to different action methods based on type
  4. ???

I don't like 1, because it is basically configuration that is hidden. Some other developer working on the code may not know about it and waste a lot of time trying to figure out why things break when changes things.

I don't like 2, because it seems kind of hacky. But, i'm leaning towards this approach.

I don't like 3, because that means violating DRY.

Any other suggestions?

Edit:

I decided to go with Darin's method, but made a slight change. I added this to my abstract model:

[HiddenInput(DisplayValue = false)]
public string ConcreteModelType { get { return this.GetType().ToString(); }}

Then a hidden automatically gets generated in my DisplayForModel(). The only thing you have to remember is that if you're not using DisplayForModel(), you'll have to add it yourself.

asp.net Mvc Solutions


Solution 1 - asp.net Mvc

Since I obviously opt for option 1 (:-)) let me try to elaborate it a little more so that it is less breakable and avoid hardcoding concrete instances into the model binder. The idea is to pass the concrete type into a hidden field and use reflection to instantiate the concrete type.

Suppose that you have the following view models:

public abstract class BaseViewModel
{
    public int Id { get; set; }
}

public class FooViewModel : BaseViewModel
{
    public string Foo { get; set; }
}

the following controller:

public class HomeController : Controller
{
    public ActionResult Index()
    {
        var model = new FooViewModel { Id = 1, Foo = "foo" };
        return View(model);
    }

    [HttpPost]
    public ActionResult Index(BaseViewModel model)
    {
        return View(model);
    }
}

the corresponding Index view:

@model BaseViewModel
@using (Html.BeginForm())
{
    @Html.Hidden("ModelType", Model.GetType())    
    @Html.EditorForModel()
    <input type="submit" value="OK" />
}

and the ~/Views/Home/EditorTemplates/FooViewModel.cshtml editor template:

@model FooViewModel
@Html.EditorFor(x => x.Id)
@Html.EditorFor(x => x.Foo)

Now we could have the following custom model binder:

public class BaseViewModelBinder : DefaultModelBinder
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        var typeValue = bindingContext.ValueProvider.GetValue("ModelType");
        var type = Type.GetType(
            (string)typeValue.ConvertTo(typeof(string)),
            true
        );
        if (!typeof(BaseViewModel).IsAssignableFrom(type))
        {
            throw new InvalidOperationException("Bad Type");
        }
        var model = Activator.CreateInstance(type);
        bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => model, type);
        return model;
    }
}

The actual type is inferred from the value of the ModelType hidden field. It is not hardcoded, meaning that you could add other child types later without having to ever touch this model binder.

This same technique could be easily be applied to collections of base view models.

Solution 2 - asp.net Mvc

I have just thought of an intersting solution to this problem. Instead of using Parameter bsed model binding like this:

[HttpPost]
public ActionResult Index(MyModel model) {...}

I can instead use TryUpdateModel() to allow me to determine what kind of model to bind to in code. For example I do something like this:

[HttpPost]
public ActionResult Index() {...}
{
    MyModel model;
    if (ViewData.SomeData == Something) {
        model = new MyDerivedModel();
    } else {
        model = new MyOtherDerivedModel();
    }
    
    TryUpdateModel(model);

    if (Model.IsValid) {...}

    return View(model);
}

This actually works a lot better anyways, because if i'm doing any processing, then I would have to cast the model to whatever it actually is anyways, or use is to to figure out the correct Map to call with AutoMapper.

I guess those of us who haven't been using MVC since day 1 forget about UpdateModel and TryUpdateModel, but it still has its uses.

Solution 3 - asp.net Mvc

It took me a good day to come up with an answer to a closely related problem - although I'm not sure it's precisely the same issue, I'm posting it here in case others are looking for a solution to the same exact problem.

In my case, I have an abstract base-type for a number of different view-model types. So in the main view-model, I have a property of an abstract base-type:

class View
{
    public AbstractBaseItemView ItemView { get; set; }
}

I have a number of sub-types of AbstractBaseItemView, many of which define their own exclusive properties.

My problem is, the model-binder does not look at the type of object attached to View.ItemView, but instead looks only at the declared property-type, which is AbstractBaseItemView - and decides to bind only the properties defined in the abstract type, ignoring properties specific to the concrete type of AbstractBaseItemView that happens to be in use.

The work-around for this isn't pretty:

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

// ...

public class ModelBinder : DefaultModelBinder
{
    // ...
    
    override protected ICustomTypeDescriptor GetTypeDescriptor(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (bindingContext.ModelType.IsAbstract && bindingContext.Model != null)
        {
            var concreteType = bindingContext.Model.GetType();

            if (Nullable.GetUnderlyingType(concreteType) == null)
            {
                return new AssociatedMetadataTypeTypeDescriptionProvider(concreteType).GetTypeDescriptor(concreteType);
            }
        }

        return base.GetTypeDescriptor(controllerContext, bindingContext);
    }
    
    // ...
}

Although this change feels hacky and is very "systemic", it seems to work - and does not, as far as I can figure, pose a considerable security-risk, since it does not tie into CreateModel() and thus does not allow you to post whatever and trick the model-binder into creating just any object.

It also works only when the declared property-type is an abstract type, e.g. an abstract class or an interface.

On a related note, it occurs to me that other implementations I've seen here that override CreateModel() probably will only work when you're posting entirely new objects - and will suffer from the same problem I ran into, when the declared property-type is of an abstract type. So you most likely won't be able to edit specific properties of concrete types on existing model objects, but only create new ones.

So in other words, you will probably need to integrate this work-around into your binder to also be able to properly edit objects that were added to the view-model prior to binding... Personally, I feel that's a safer approach, since I control what concrete type gets added - so the controller/action can, indirectly, specify the concrete type that may be bound, by simply populating the property with an empty instance.

Solution 4 - asp.net Mvc

Using Darin's method to discriminate your model types via a hidden field in your view, I would recommend that you use a custom RouteHandler to distinguish your model types, and direct each one to a uniquely named action on your controller. For example, if you have two concrete models, Foo and Bar, for your Create action in your controller, make a CreateFoo(Foo model) action and a CreateBar(Bar model) action. Then, make a custom RouteHandler, as follows:

public class MyRouteHandler : IRouteHandler
{
    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        var httpContext = requestContext.HttpContext;
        var modelType = httpContext.Request.Form["ModelType"]; 
        var routeData = requestContext.RouteData;
        if (!String.IsNullOrEmpty(modelType))
        {
            var action = routeData.Values["action"];
            routeData.Values["action"] = action + modelType;
        }
        var handler = new MvcHandler(requestContext);
        return handler; 
    }
}

Then, in Global.asax.cs, change RegisterRoutes() as follows:

public static void RegisterRoutes(RouteCollection routes) 
{ 
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); 

    AreaRegistration.RegisterAllAreas(); 

    routes.Add("Default", new Route("{controller}/{action}/{id}", 
        new RouteValueDictionary( 
            new { controller = "Home",  
                  action = "Index",  
                  id = UrlParameter.Optional }), 
        new MyRouteHandler())); 
} 

Then, when a Create request comes in, if a ModelType is defined in the returned form, the RouteHandler will append the ModelType to the action name, allowing a unique action to be defined for each concrete model.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionErik FunkenbuschView Question on Stackoverflow
Solution 1 - asp.net MvcDarin DimitrovView Answer on Stackoverflow
Solution 2 - asp.net MvcErik FunkenbuschView Answer on Stackoverflow
Solution 3 - asp.net Mvcmindplay.dkView Answer on Stackoverflow
Solution 4 - asp.net MvccounsellorbenView Answer on Stackoverflow