ASP.NET MVC 3: DefaultModelBinder with inheritance/polymorphism
Asked Answered
P

5

18

First, sorry for the big post (I've tried to do some research first) and for the mix of technologies on the same question (ASP.NET MVC 3, Ninject and MvcContrib).

I'm developing a project with ASP.NET MVC 3 to handle some client orders.

In short: I have some objects inherited from and abstract class Order and I need to parse them when a POST request is made to my controller. How can I resolve the correct type? Do I need to override the DefaultModelBinder class or there are some other way to do this? Can somebody provide me some code or other links on how to do this? Any help would be great! If the post is confusing I can do any change to make it clear!

So, I have the following inheritance tree for the orders I need to handle:

public abstract partial class Order {

    public Int32 OrderTypeId {get; set; }

    /* rest of the implementation ommited */
}

public class OrderBottling : Order { /* implementation ommited */ }

public class OrderFinishing : Order { /* implementation ommited */ }

This classes are all generated by Entity Framework, so I won't modify them because I will need to update the model (I know I can extend them). Also, there will be more orders, but all derived from Order.

I have a generic view (Create.aspx) in order to create a order and this view calls a strongly-typed partial view for each of the inherited orders (in this case OrderBottling and OrderFinishing). I defined a Create() method for a GET request and other for a POST request on OrderControllerclass. The second is like the following:

public class OrderController : Controller
{
    /* rest of the implementation ommited */

    [HttpPost]
    public ActionResult Create(Order order) { /* implementation ommited */ }
}

Now the problem: when I receive the POST request with the data from the form, MVC's default binder tries to instantiate an Order object, which is OK since the type of the method is that. But because Order is abstract, it cannot be instantiated, which is what is supposed to do.

The question: how can I discover which concrete Order type is sent by the view?

I've already searched here on Stack Overflow and googled a lot about this (I'm working on this problem for about 3 days now!) and found some ways to solve some similar problems, but I couldn't find anything like my real problem. Two options for solving this:

  • override ASP.NET MVC DefaultModelBinder and use Direct Injection to discover which type is the Order;
  • create a method for each order (not beautiful and would be problematic to maintain).

I haven't tried the second option because I don't think it's the right way to solve the problem. For the first option I've tried Ninject to resolve the type of the order and instantiate it. My Ninject module is like the following:

private class OrdersService : NinjectModule
{
    public override void Load()
    {
        Bind<Order>().To<OrderBottling>();
        Bind<Order>().To<OrderFinishing>();
    }
}

I've have tried to get one of the types throught Ninject's Get<>() method, but it tells me that the are more then one way to resolve the type. So, I understand the module is not well implemented. I've also tried to implement like this for both types: Bind<Order>().To<OrderBottling>().WithPropertyInject("OrderTypeId", 2);, but it has the same problem... What would be the right way to implement this module?

I've also tried use MvcContrib Model Binder. I've done this:

[DerivedTypeBinderAware(typeof(OrderBottling))]
[DerivedTypeBinderAware(typeof(OrderFinishing))]
public abstract partial class Order { }

and on Global.asax.cs I've done this:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    RegisterRoutes(RouteTable.Routes);

    ModelBinders.Binders.Add(typeof(Order), new DerivedTypeModelBinder());
}

But this throws an exception: System.MissingMethodException: Cannot create an abstract class. So, I presume the binder isn't or can't resolve to the correct type.

Many many thanks in advance!

Edit: first of all, thank you Martin and Jason for your answers and sorry for the delay! I tried both approaches and both worked! I marked Martin's answer as correct because it is more flexible and meets some of the needs for my project. Specifically, the IDs for each request are stored on a database and putting them on the class can break the software if I change the ID only in one place (database or on the class). Martin's approach is very flexible in that point.

@Martin: on my code I changed the line

var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);

to

var concreteType = Assembly.GetAssembly(typeof(Order)).GetType(concreteTypeValue.AttemptedValue);

because my classes where on another project (and so, on a different assembly). I'm sharing this because it's seems a like more flexible than getting only the executing assembly that cannot resolve types on external assemblies. In my case all the order classes are on the same assembly. It's not better nor a magic formula, but I think is interesting to share this ;)

Patsy answered 28/3, 2011 at 13:59 Comment(9)
a full stack trace always makes it easier to diagnose problems.Fidele
@Patsy - Just to make sure, does it work if you remove the abstract modifier from Order?Spancel
@Sergi: no, it doesn't work either. One of the previous times I've generated the model, I didn't put Order as abstract, and the result was the same but with a different error (can't remember what was it).Patsy
@Sergi, without the abstract modifier the default model binder would instantiate an Order object and populate the properties of the Order object; it wouldn't bind properties of a subtypeIselaisenberg
@Martin - The properties of the subtype are lost anyway because the action takes an Order as argument, aren't they? The ModelBinder will try and make sense of an Order object based on what properties it can match.Spancel
@Sergi: yes, you are right, but the properties for a specifi order can't be lost! That's why Order would be, let's say this way, "a common type" for all orders. If the model binder can resolve the to correct order type, the problem is solved.Patsy
@Patsy - I understand your problem, thanks for the clarification. I just took @Martin's comment (incorrectly) out of the context of your question for a moment there; hence my rebate :)Spancel
Found this post as I was trying to do the exact same thing. The only thing I would add, since this was my issue as well, if the types come from a separate assembly, but the sub classes are in the same assembly you could use modelType.Assembly.GetType(concreteTypeValue.AttemptedValue) instead, this will keep the abstration between the binder and your business model.Donation
I would like to point out that in Ninject, you can't bind the same abstract type to multiple concrete types, though to achieve the same effect you could bind the abstract type to a factory method to achieve the same. This won't solve your actual problem but it will sort out the Ninject-related error.Parris
I
20

I've tried to do something similar before and I came to the conclusion that there's nothing built in which will handle this.

The option I went with was to create my own model binder (though inherited from the default so its not too much code). It looked for a post back value with the name of the type called xxxConcreteType where xxx was another type it was binding to. This means that a field must be posted back with the value of the type you're trying to bind; in this case OrderConcreteType with a value of either OrderBottling or OrderFinishing.

Your other alternative is to use UpdateModel or TryUpdateModel and ommit the parameter from your method. You will need to determine which kind of model you're updating before calling this (either by a parameter or otherwise) and instantiate the class beforehand, then you can use either method to popuplate it

Edit:

Here is the code..

public class AbstractBindAttribute : CustomModelBinderAttribute
{
    public string ConcreteTypeParameter { get; set; }

    public override IModelBinder GetBinder()
    {
        return new AbstractModelBinder(ConcreteTypeParameter);
    }

    private class AbstractModelBinder : DefaultModelBinder
    {
        private readonly string concreteTypeParameterName;

        public AbstractModelBinder(string concreteTypeParameterName)
        {
            this.concreteTypeParameterName = concreteTypeParameterName;
        }

        protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
        {
            var concreteTypeValue = bindingContext.ValueProvider.GetValue(concreteTypeParameterName);

            if (concreteTypeValue == null)
                throw new Exception("Concrete type value not specified for abstract class binding");

            var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);

            if (concreteType == null)
                throw new Exception("Cannot create abstract model");

            if (!concreteType.IsSubclassOf(modelType))
                throw new Exception("Incorrect model type specified");

            var concreteInstance = Activator.CreateInstance(concreteType);

            bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => concreteInstance, concreteType);

            return concreteInstance;
        }
    }
}

Change your action method to look like this:

public ActionResult Create([AbstractBind(ConcreteTypeParameter = "orderType")] Order order) { /* implementation ommited */ }

You would need to put the following in your view:

@Html.Hidden("orderType, "Namespace.xxx.OrderBottling")
Iselaisenberg answered 28/3, 2011 at 14:34 Comment(4)
Hi Martin! Thanks for the idea! What you describe was one of the options I've seen during the DefaultModelBinder research I've made. I didn't tried this way, but if it is the best, shure I'll give it a try! I would be really thankful if you can put here some code! :)Patsy
Has anyone been able to get this working in more complex scenarios? I've successfully utilized this with changing the "var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue); " to search all assemblies that are loaded, but I need to be able to have a parent<==>child viewmodel where the child is a viewmodel with a property being changed of an abstract type... doesn't seem to like this very well... Any hints?Joung
See my additional answer below for a version that checks all loaded assemblies.Kirghiz
Your solution do not support multiple inheritance, neither collections or generic classes. Check my solution which works well with all kind of models.Environs
A
7

You can create a custome ModelBinder that operates when your action accepts a certain type, and it can create an object of whatever type you want to return. The CreateModel() method takes a ControllerContext and ModelBindingContext that give you access to the parameters passed by route, url querystring and post that you can use to populate your object with values. The default model binder implementation converts values for properties of the same name to put them in the fields of the object.

What I do here is simply check one of the values to determine what type to create, then call the DefaultModelBinder.CreateModel() method switching the type it is to create to the appropriate type.

public class OrderModelBinder : DefaultModelBinder
{
    protected override object CreateModel(
        ControllerContext controllerContext,
        ModelBindingContext bindingContext,
        Type modelType)
    {
        // get the parameter OrderTypeId
        ValueProviderResult result;
        result = bindingContext.ValueProvider.GetValue("OrderTypeId");
        if (result == null)
            return null; // OrderTypeId must be specified

        // I'm assuming 1 for Bottling, 2 for Finishing
        if (result.AttemptedValue.Equals("1"))
            return base.CreateModel(controllerContext,
                    bindingContext,
                    typeof(OrderBottling));
        else if (result.AttemptedValue.Equals("2"))
            return base.CreateModel(controllerContext,
                    bindingContext,
                    typeof(OrderFinishing));
        return null; // unknown OrderTypeId
    }
}

Set it to be used when you have an Order parameter on your actions by adding this to Application_Start() in Global.asax.cs:

ModelBinders.Binders.Add(typeof(Order), new OrderModelBinder());
Aspidistra answered 29/3, 2011 at 1:11 Comment(0)
I
5

You can also build a generic ModelBinder that works for all of your abstract models. My solution requires you to add a hidden field to your view called 'ModelTypeName' with the value set to the name of the concrete type that you want. However, it should be possible to make this thing smarter and pick a concrete type by matching type properties to fields in the view.

In your Global.asax.cs Application_Start():

ModelBinders.Binders.DefaultBinder = new CustomModelBinder();

CustomModelBinder:

public class CustomModelBinder : DefaultModelBinder 
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        if (modelType.IsAbstract)
        {
            var modelTypeValue = controllerContext.Controller.ValueProvider.GetValue("ModelTypeName");
            if (modelTypeValue == null)
                throw new Exception("View does not contain ModelTypeName");

            var modelTypeName = modelTypeValue.AttemptedValue;

            var type = modelType.Assembly.GetTypes().SingleOrDefault(x => x.IsSubclassOf(modelType) && x.Name == modelTypeName);
            if(type == null)
                throw new Exception("Invalid ModelTypeName");

            var concreteInstance = Activator.CreateInstance(type);

            bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => concreteInstance, type);

            return concreteInstance;

        }

        return base.CreateModel(controllerContext, bindingContext, modelType);
    }
}
Incensory answered 22/9, 2011 at 15:48 Comment(0)
E
2

My solution for that problem support complex models that can contain other abstract class, multiple inheritance, collections or generic classes.

public class EnhancedModelBinder : DefaultModelBinder
{
    protected override object CreateModel(ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType)
    {
        Type type = modelType;
        if (modelType.IsGenericType)
        {
            Type genericTypeDefinition = modelType.GetGenericTypeDefinition();
            if (genericTypeDefinition == typeof(IDictionary<,>))
            {
                type = typeof(Dictionary<,>).MakeGenericType(modelType.GetGenericArguments());
            }
            else if (((genericTypeDefinition == typeof(IEnumerable<>)) || (genericTypeDefinition == typeof(ICollection<>))) || (genericTypeDefinition == typeof(IList<>)))
            {
                type = typeof(List<>).MakeGenericType(modelType.GetGenericArguments());
            }
            return Activator.CreateInstance(type);            
        }
        else if(modelType.IsAbstract)
        {
            string concreteTypeName = bindingContext.ModelName + ".Type";
            var concreteTypeResult = bindingContext.ValueProvider.GetValue(concreteTypeName);

            if (concreteTypeResult == null)
                throw new Exception("Concrete type for abstract class not specified");

            type = Assembly.GetExecutingAssembly().GetTypes().SingleOrDefault(t => t.IsSubclassOf(modelType) && t.Name == concreteTypeResult.AttemptedValue);

            if (type == null)
                throw new Exception(String.Format("Concrete model type {0} not found", concreteTypeResult.AttemptedValue));

            var instance = Activator.CreateInstance(type);
            bindingContext.ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(() => instance, type);
            return instance;
        }
        else
        {
            return Activator.CreateInstance(modelType);
        }
    }
}

As you see you have to add field (of name Type) that contains information what concrete class inheriting from abstract class should be created. For example classes: class abstract Content, class TextContent, the Content should have Type set to "TextContent". Remember to switch default model binder in global.asax:

protected void Application_Start()
{
    ModelBinders.Binders.DefaultBinder = new EnhancedModelBinder();
    [...]

For more information and sample project check following link.

Environs answered 11/5, 2012 at 10:35 Comment(0)
K
0

Change the line:

var concreteType = Assembly.GetExecutingAssembly().GetType(concreteTypeValue.AttemptedValue);

To this:

            Type concreteType = null;
            var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
            foreach (var assembly in loadedAssemblies)
            {
                concreteType = assembly.GetType(concreteTypeValue.AttemptedValue);
                if (null != concreteType)
                {
                    break;
                }
            }

This is a naive implementation that checks every assembly for the type. I'm sure there's smarter ways to do it, but this works well enough.

Kirghiz answered 21/2, 2012 at 3:21 Comment(1)
Hi @Corey! Thanks for the tip, but as you can see on my edit to the question I've already done that. It is a more concise way of writing what you proposed. The software that has this code and for which I made this question this is working for almost a year an flawlessly :) Thanks for the interest!Patsy

© 2022 - 2024 — McMap. All rights reserved.