In Part III, we started creating a very basic MVC application to host our store. We also created a simple table to store products and methods to perform CRUD transactions and validate data. Now we we introduce building product categories that can be related to products in a “many-to-many” relationship. This means that multiple products can be assigned to a category and a product can be assigned to multiple categories.
Creating the Tables
The category table is very simple and consists of a name and description.
* Copyright Streetlight Technologies L.L.C. All rights reserved.
*/
CREATE TABLE dbo.ProductCategory
(
[ProductCategoryId] int identity(1, 1), -- Primary Key
[Name] varchar(MAX), -- Prouct category name
[Description] varchar(MAX), -- Product category description
CONSTRAINT ProductCategory_PK PRIMARY KEY CLUSTERED
(
ProductCategoryId
)
)
go
Next, we will create a table to relate categories to products.
/* ProductCategoryProduct.sql
* Copyright Streetlight Technologies L.L.C. All rights reserved.
*/
CREATE TABLE dbo.ProductCategoryProduct
(
[ProductCategoryId] int not null, -- Primary Key
[ProductId] int not null,
CONSTRAINT ProductCategoryProduct_PK PRIMARY KEY CLUSTERED
(
ProductCategoryId,
ProductId
)
)
go
Then we create the foreign keys to link the three tables together.
ALTER TABLE dbo.ProductCategoryProduct
ADD CONSTRAINT ProductCategoryProduct_ProductCategory_FK FOREIGN KEY
(ProductCategoryId) REFERENCES ProductCategory(ProductCategoryId)
go
ALTER TABLE dbo.ProductCategoryProduct
ADD CONSTRAINT ProductCategoryProduct_Product_FK FOREIGN KEY
(ProductId) REFERENCES Product(ProductId)
go
After refreshing our entity model, it looks like this:
Note that the “ProductCategoryProduct” table does not appear in our model. That is because there is no additional data other than foreign keys in the table so the Entity Framework considers the table trivial and handles it via the navigation properties in the Product and ProductCategory entities. As we will see in the implementation of the database code, this isn’t ideal. However, since it is the standard behavior for the Entity Framework and it should cause significant performance problems for a relatively low-traffic e-commerce site (we assume we are not hosting Amazon.com or eBay), we are going to work around this limitation.
Wiring Up the Data Access Layer
The first four methods to handle product categories are basically the same as the CRUD methods for products we saw in Part I.
/// <summary>
/// Returns a list of all categories.
/// </summary>
/// <returns>New instance of List<ProductCategory> containing data for all categories</returns>
public List<ProductCategory> ListAllCategories()
{
return _dataManager
.Context
.ProductCategories
.ToList()
.Select(c => new ProductCategory
{
Id = Convert.ToString(c.ProductCategoryId),
Name = c.Name,
Description = c.Description
})
.ToList();
}
/// <summary>
/// Stores the category provided as a new record.
/// </summary>
/// <param name="category">Category to be saved</param>
public void CreateNewCategory(ProductCategory category)
{
if (category == null)
{
throw new ArgumentNullException("category");
}
DataModels.ProductCategory categoryData = new DataModels.ProductCategory
{
Name = category.Name,
Description = category.Description
};
_dataManager.Context.ProductCategories.Add(categoryData);
_dataManager.Context.SaveChanges();
category.Id = Convert.ToString(categoryData.ProductCategoryId);
}
/// <summary>
/// Gets the category with the specified ID.
/// </summary>
/// <param name="id">ID of category to return</param>
/// <returns>New instance of ProductCategory with data for provided ID.</returns>
public ProductCategory GetCategory(string id)
{
int idValue = ConversionHelper.TryParsePositiveInt("id", id);
DataModels.ProductCategory categoryData = _dataManager
.Context
.ProductCategories
.FirstOrDefault(c => c.ProductCategoryId == idValue);
if (categoryData == null)
{
return null;
}
return new ProductCategory { Id = id, Name = categoryData.Name, Description = categoryData.Description };
}
/// <summary>
/// Saves the provided category as an existing recod.
/// </summary>
/// <exception cref="System.InvalidOperationException">Thrown when no caetgory is found for the ID
/// provided as category.Id.</exception>
/// <param name="category">Category to save</param>
public void SaveCategory(ProductCategory category)
{
if (category == null)
{
throw new ArgumentNullException("category");
}
int id = ConversionHelper.TryParsePositiveInt("category.Id", category.Id);
DataModels.ProductCategory categoryData = _dataManager.Context.ProductCategories.FirstOrDefault(c => c.ProductCategoryId == id);
if (category == null)
{
throw new InvalidOperationException(string.Format("Category not found for id {0}.", id));
}
categoryData.Name = category.Name;
category.Description = category.Description;
_dataManager.Context.SaveChanges();
}
Since we need to relate products to categories, we need to be able to find out what categories are already assigned to a product, assign categories, and remove categories. The validation in this case is a little more simple. We are going to follow the “law of least astonishment” and simply ignore an attempt to create a duplicate assignment or delete an assignment which does not exist.
/// <summary>
/// Lists categories associated with the specified ID.
/// </summary>
/// <param name="id">ID of product to list categories for</param>
/// <returns>New instance of List<ProductCategory> containing categories associated with product.</returns>
public List<ProductCategory> ListCategoriesForProduct(string id)
{
int idValue = ConversionHelper.TryParsePositiveInt("id", id);
DataModels.Product productData = _dataManager
.Context
.Products
.Include("ProductCategories")
.FirstOrDefault(p => p.ProductId == idValue);
if (productData == null)
{
throw new InvalidOperationException(string.Format("Product not found for id {0}.", idValue));
}
return productData
.ProductCategories
.Select(c => new ProductCategory { Id = Convert.ToString(c.ProductCategoryId), Name = c.Name, Description = c.Description })
.ToList();
}
/// <summary>
/// Adds the product with the specified ID to the category with the specified ID. If a relationship
/// already exists, nothing is done.
/// </summary>
/// <param name="productId">ID of product to add</param>
/// <param name="categoryId">ID of category to add to</param>
public void AddProductToCategory(string productId, string categoryId)
{
int productIdValue = ConversionHelper.TryParsePositiveInt("productId", productId);
int categoryIdValue = ConversionHelper.TryParsePositiveInt("categoryId", categoryId);
DataModels.ProductCategory category = _dataManager
.Context
.ProductCategories
.Include("Products")
.FirstOrDefault(c => c.ProductCategoryId == categoryIdValue);
if (category == null)
{
throw new InvalidOperationException(string.Format("Category not found for id {0}.", categoryIdValue));
}
if (category.Products.Any(p => p.ProductId == productIdValue))
{
return;
}
DataModels.Product product = _dataManager
.Context
.Products
.FirstOrDefault(p => p.ProductId == productIdValue);
if (product == null)
{
throw new InvalidOperationException(string.Format("Product not found for id {0}.", productIdValue));
}
category.Products.Add(product);
_dataManager.Context.SaveChanges();
}
/// <summary>
/// Removes the product with the specified ID from the category with the specified ID. If no relationship
/// exists, nothing is done.
/// </summary>
/// <param name="productId">ID of product to remove</param>
/// <param name="categoryId">ID of category to remove from</param>
public void RemoveProductFromCategory(string productId, string categoryId)
{
int productIdValue = ConversionHelper.TryParsePositiveInt("productId", productId);
int categoryIdValue = ConversionHelper.TryParsePositiveInt("categoryId", categoryId);
DataModels.ProductCategory category = _dataManager
.Context
.ProductCategories
.Include("Products")
.FirstOrDefault(c => c.ProductCategoryId == categoryIdValue);
if (category == null)
{
throw new InvalidOperationException(string.Format("Category not found for id {0}.", categoryIdValue));
}
if (!category.Products.Any(p => p.ProductId == productIdValue))
{
return;
}
DataModels.Product product = _dataManager
.Context
.Products
.FirstOrDefault(p => p.ProductId == productIdValue);
if (product == null)
{
throw new InvalidOperationException(string.Format("Product not found for id {0}.", productIdValue));
}
category.Products.Remove(product);
_dataManager.Context.SaveChanges();
}
Next, we create the ProductCategoriesController to do the CRUD transactions.
public class ProductCategoriesController : StoreController
{
public ActionResult Index()
{
List<ProductCategory> model = TransactionManager.Products.ListAllCategories();
return View("List", model);
}
public ActionResult List()
{
List<ProductCategory> model = TransactionManager.Products.ListAllCategories();
return View(model);
}
public ActionResult Create()
{
ProductCategory model = new ProductCategory();
return View(model);
}
[HttpPost]
public ActionResult Create(ProductCategory model)
{
if (ModelState.IsValid)
{
TransactionManager.Products.CreateNewCategory(model);
return RedirectToAction("Edit", new { id = model.Id });
}
return View(model);
}
public ActionResult Edit(string id)
{
ProductCategory model = TransactionManager.Products.GetCategory(id);
return View(model);
}
[HttpPost]
public ActionResult Edit(ProductCategory model)
{
if (ModelState.IsValid)
{
TransactionManager.Products.SaveCategory(model);
}
return View(model);
}
}
Then, we add methods to the ProductsController to list categories and add and remove categories.
public ActionResult CategoryList(string id)
{
ViewBag.Id = id;
List<ProductCategory> model = TransactionManager.Products.ListCategoriesForProduct(id);
return PartialView(model);
}
public ActionResult AddCategory(string id)
{
ViewBag.Id = id;
List<ProductCategory> model = TransactionManager.Products.ListAllCategories();
return PartialView(model);
}
[HttpPost]
public ActionResult AddCategory(string id, string categoryId)
{
TransactionManager.Products.AddProductToCategory(id, categoryId);
return RedirectToAction("Edit", new { id = id });
}
public ActionResult RemoveCategory(string id, string categoryId)
{
TransactionManager.Products.RemoveProductFromCategory(id, categoryId);
return RedirectToAction("Edit", new { id = id });
}
The CategoryList action displays a partial view with a list of categories associated with a product and links to remove the category. The AddCategory action displays a partial view with a drop-down list to select a category and a button to add it to the product. The resulting product view looks like this: