How do I ModelBind a many-to-many relationship wit

2019-02-15 07:42发布

问题:

I'm coming across the same problem in my MVC 3 applications. I've got a view to create an new product and that product can be assigned to one or more categories. Here are my EF Code First Model Classes:

public class Product 
{
    public int ProductID { get; set; }
    public string Name { get; set; }
    public virtual ICollection<Category> Categories { get; set; }
}

public class Category 
{
    public int CategoryID { get; set; }
    public string Name { get; set; }
    public virtual ICollection<Product> Products { get; set; }
}

So, I create a view model for the create product view and include the product and a list of the categories:

public class ProductEditViewModel
{
    public Product Product { get; set; }
    public List<SelectListItem> CategorySelections { get; set; }

    public ProductEditViewModel(Product product, List<Category> categories)
    {
        this.Product = product;
        CategorySelections = categories.Select(c => new SelectListItem()
        {
            Text = c.Name,
            Value = c.CategoryID.ToString(),
            Selected = (product != null ? product.Categories.Contains(c) : false)
        }).ToList();
    }
}

So, I render a view with an input for the name and a list of checkboxes for each category (named "Product.Categories"). When my form gets posted back I want to save the product with its associated categories (or if the ModelState is invalid, to redisplay the view with the category selections the user made intact).

[HttpPost]
public ActionResult Create(Product product)
{
    if (ModelState.IsValid)
    {
        db.Products.Add(product);
        db.SaveChanges();
        return RedirectToAction("Index");
    }

    return View(new ProductEditViewModel(product, db.Categories.ToList()));
}

When i do that and select one or more categories, the ModelState is invalid and it returns the Edit view with the following validation error:

The value '25,2' is invalid. // 25 and 2 being the CategoryIDs

It makes sense to me that it can't bind 25 and 2 into actual category objects, but is there a standard way to use a custom ModelBinder that would allow me to translate IDs into Categories and attach them to the context?

回答1:

What you could try is the following: Bind to your ViewModel instead of Product in your post action:

[HttpPost]
public ActionResult Create(ProductEditViewModel viewModel)
{
    if (ModelState.IsValid)
    {
        foreach (var value in viewModel.CategorySelections
                                       .Where(c => c.Selected)
                                       .Select(c => c.Value))
        {
            // Attach "stub" entity only with key to make EF aware that the
            // category already exists in the DB to avoid creating a new category
            var category = new Category { CategoryID = int.Parse(value) };
            db.Categories.Attach(category);

            viewModel.Product.Categories.Add(category);
        }
        db.Products.Add(viewModel.Product);
        db.SaveChanges();
        return RedirectToAction("Index");
    }

    return View(new ProductEditViewModel(
        viewModel.Product, db.Categories.ToList()));
}

I am not sure though if this is the "standard way".

Edit

The return case when the model is invalid cannot work in my example above because viewModel.Product.Categories collection is empty, so you would get no selected category item in the view and not the items which the user had selected before.

I don't know how exactly you bind the collection to the view (your "list of checkboxes"?) but when using a ListBox which allows multiple selection then there seems to be a solution along the lines of this answer: Challenges with selecting values in ListBoxFor. I just had asked Darin in the comments if the list of selected item ids also will get bound to the ViewModel in an post action and he confirmed that.



回答2:

Thanks @Slauma, that got me on the right track. Here is my Create and Edit post methods that detail how to manage the relationships (the edit is a bit trickier, because it has to add items that don't exist in the database and delete items that have been removed and do exist in the database). I added a SelectedCategories property (List of ints) to my ProductEditViewModel to hold the result from the form.

[HttpPost]
public ActionResult Create(ProductEditViewModel)
{
    viewModel.Product.Categories = new List<Category>();

    foreach (var id in viewModel.SelectedCategories)
    {
        var category = new Category { CategoryID = id };
        db.Category.Attach(category);

        viewModel.Product.Categories.Add(category);
    }

    if (ModelState.IsValid)
    {
        db.Products.Add(viewModel.Product);
        db.SaveChanges();
        return RedirectToAction("Index");
    }

    return View(new ProductEditViewModel(viewModel.Product, GetCategories()));
}

For the Edit method I had to query the database for the current product and then compare that with the viewModel.

[HttpPost]
public ActionResult Edit(ProductEditViewModel viewModel)
{
    var product = db.Products.Find(viewModel.Product.ProductID);

    if (ModelState.IsValid)
    {
        UpdateModel<Product>(product, "Product");

        var keys = product.CategoryKeys; // Returns CategoryIDs

        // Add categories not already in database
        foreach (var id in viewModel.SelectedCategorys.Except(keys))
        {
            var category = new Category { CategoryID = id }; // Create a stub
            db.Categorys.Attach(category);

            product.Categories.Add(Category);
        }

        // Delete categories not in viewModel, but in database
        foreach (var id in keys.Except(viewModel.SelectedCategories))
        {
            var category = product.Categories.Where(c => c.CategoryID == id).Single();

            product.Categories.Remove(category);
        }

        db.SaveChanges();
        return RedirectToAction("Index");
    }
    else
    {
        // Update viewModel categories so it keeps users selections
        foreach (var id in viewModel.SelectedCategories)
        {
            var category = new Category { CategoryID = id }; // Create a stub
            db.Categories.Attach(category);

            viewModel.Product.Categories.Add(category);
        }
    }

    return View(new ProductEditViewModel(viewModel.Product, GetCategories()));
}

It is more code that I was hoping it would be, but it is actually pretty efficient with using the stubs and only adding/deleting what has changed.



回答3:

I had a similar issue few days ago. Ended up using a "hack" - MVC 3 - Binding to a Complex Type with a List type property

Please leave a message if you find an alternative way.