views:

2448

answers:

3

Is it possible to bind a foreign key relationship on my model to a form input?

Say I have a one-to-many relationship between Car and Manufacturer. I want to have a form for updating Car that includes a select input for setting Manufacturer. I was hoping to be able to do this using the built-in model binding, but I'm starting to think I'll have to do it myself.

My action method signature looks like this:

public JsonResult Save(int id, [Bind(Include="Name, Description, Manufacturer")]Car car)

The form posts the values Name, Description and Manufacturer, where Manufacturer is a primary key of type int. Name and Description get set properly, but not Manufacturer, which makes sense since the model binder has no idea what the PK field is. Does that mean I would have to write a custom IModelBinder that it aware of this? I'm not sure how that would work since my data access repositories are loaded through an IoC container on each Controller constructor.

+3  A: 

Surely each car only has one manufacturer. If that's the case then you ought to have an ManufacturerID field that you can bind the value of the select to. That is, your select should have the Manufacturer name as it's text and the id as the value. In your save value, bind ManufacturerID rather than Manufacturer.

<%= Html.DropDownList( "ManufacturerID",
        (IEnumerable<SelectListItem>)ViewData["Manufacturers"] ) %>

With

ViewData["Manufacturers"] = db.Manufacturers
                              .Select( m => new SelectListItem
                                            {
                                               Text = m.Name,
                                               Value = m.ManufacturerID
                                            } )
                               .ToList();

And

public JsonResult Save(int id,
                       [Bind(Include="Name, Description, ManufacturerID")]Car car)
tvanfosson
If the model is built using POCOs, having a `ManufacturerID` property on the `Car` doesn't seem right to me. Is this really the preferred way of solving this kind of model binding?
Jørn Schou-Rode
I'm not sure what you mean. Normally, I'd have a foreign key field to relate the car and manufacturer entities. It's pretty standard to have this be an "ID" field. I suppose that you might not choose to expose this field in your model, but you certainly could. I typically use LINQtoSQL and I can assure you that there would be both a ManufacturerID property and an associated Manufacturer entity on the Car entity.
tvanfosson
+1  A: 

Maybe it's a late one but you can use a custom model binder to achieve this. Normally I'd do it the same way as @tvanofosson but I had a case where I was adding UserDetails to the AspNetMembershipProvider tables. Since I also use only POCO (and map it from EntityFramework) I didn't want to use an id because it wasn't justified from the business point of view so I created a model only to add/register users. This model had all properties for the user and a Role property as well. I wanted to bind a text name of the role to it's RoleModel representation. That's basically what I did:

public class RoleModelBinder : DefaultModelBinder
{
    public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        string roleName = controllerContext.HttpContext.Request["Role"];

        var model = new RoleModel
                          {
                              RoleName = roleName
                          };

        return model;
    }
}

Then I had to add the following to the Global.asax:

ModelBinders.Binders.Add(typeof(RoleModel), new RoleModelBinder());

And the usage in the view:

<%= Html.DropDownListFor(model => model.Role, new SelectList(Model.Roles, "RoleName", "RoleName", Model.Role))%>

I hope this helps you.

brainnovative
I've reposted this as a question http://stackoverflow.com/questions/3642870/custom-model-binder-for-dropdownlist-not-selecting-correct-value.
nfplee
+1  A: 

Here's my take - this is a custom model binder that when asked to GetPropertyValue, looks to see if the property is an object from my model assembly, and has a IRepository<> registered in my NInject IKernel. If it can get the IRepository from Ninject, it uses that to retrieve the foreign key object.

public class ForeignKeyModelBinder : System.Web.Mvc.DefaultModelBinder
{
    private IKernel serviceLocator;

    public ForeignKeyModelBinder( IKernel serviceLocator )
    {
        Check.Require( serviceLocator, "IKernel is required" );
        this.serviceLocator = serviceLocator;
    }

    /// <summary>
    /// if the property type being asked for has a IRepository registered in the service locator,
    /// use that to retrieve the instance.  if not, use the default behavior.
    /// </summary>
    protected override object GetPropertyValue( ControllerContext controllerContext, ModelBindingContext bindingContext,
        PropertyDescriptor propertyDescriptor, IModelBinder propertyBinder )
    {
        var submittedValue = bindingContext.ValueProvider.GetValue( bindingContext.ModelName );
        if ( submittedValue == null )
        {
            string fullPropertyKey = CreateSubPropertyName( bindingContext.ModelName, "Id" );
            submittedValue = bindingContext.ValueProvider.GetValue( fullPropertyKey );
        }

        if ( submittedValue != null )
        {
            var value = TryGetFromRepository( submittedValue.AttemptedValue, propertyDescriptor.PropertyType );

            if ( value != null )
                return value;
        }

        return base.GetPropertyValue( controllerContext, bindingContext, propertyDescriptor, propertyBinder );
    }

    protected override object CreateModel( ControllerContext controllerContext, ModelBindingContext bindingContext, Type modelType )
    {
        string fullPropertyKey = CreateSubPropertyName( bindingContext.ModelName, "Id" );
        var submittedValue = bindingContext.ValueProvider.GetValue( fullPropertyKey );
        if ( submittedValue != null )
        {
            var value = TryGetFromRepository( submittedValue.AttemptedValue, modelType );

            if ( value != null )
                return value;
        }

        return base.CreateModel( controllerContext, bindingContext, modelType );
    }

    private object TryGetFromRepository( string key, Type propertyType )
    {
        if ( CheckRepository( propertyType ) && !string.IsNullOrEmpty( key ) )
        {
            Type genericRepositoryType = typeof( IRepository<> );
            Type specificRepositoryType = genericRepositoryType.MakeGenericType( propertyType );

            var repository = serviceLocator.TryGet( specificRepositoryType );
            int id = 0;
#if DEBUG
            Check.Require( repository, "{0} is not available for use in binding".FormatWith( specificRepositoryType.FullName ) );
#endif
            if ( repository != null && Int32.TryParse( key, out id ) )
            {
                return repository.InvokeMethod( "GetById", id );
            }
        }

        return null;
    }

    /// <summary>
    /// perform simple check to see if we should even bother looking for a repository
    /// </summary>
    private bool CheckRepository( Type propertyType )
    {
        return propertyType.HasInterface<IModelObject>();
    }

}

you could obviously substitute Ninject for your DI container and your own repository type.

dave thieben