ASP.NET CORE MVC - Database One-to-Many Relationships

ASP.NET CORE MVC TUTORIAL SERIES · PART 8

Adding Categories and One-to-Many Relationships

Create a Category model, connect Categories to Products with a one-to-many relationship, update the database with a migration, load related data with Include(), and add Category dropdowns to the Product Create and Edit forms.

Objective

By the end of this tutorial, Products can be associated with Categories and the application can display and edit those relationships through Entity Framework Core and Razor views.

Lab environment

Xubuntu VM, username xubuntu, .NET 8, Visual Studio Code, and the ProductManagement project completed through Part 7.

In this tutorial
  1. Verify the existing project
  2. Understand one-to-many relationships
  3. Create Category.cs
  4. Update Product.cs
  5. Update ApplicationDbContext.cs
  6. Create and apply the migration
  7. Update ProductsController.cs
  8. Update Create.cshtml
  9. Update Edit.cshtml
  10. Update Index.cshtml
  11. Update Details.cshtml
  12. Test and verify the relationship

1. Open and Verify the Existing Project

Open the Xubuntu Terminal and go to the project:

cd ~/aspnet-mvc-tutorial/ProductManagement
pwd

The expected path is:

/home/xubuntu/aspnet-mvc-tutorial/ProductManagement

Confirm the important files from Part 7:

ls Models/Product.cs
ls Data/ApplicationDbContext.cs
ls Controllers/ProductsController.cs
ls Views/Products/Create.cshtml
ls Views/Products/Edit.cshtml
ls Views/Products/Index.cshtml
ls Views/Products/Details.cshtml

Open the project in Visual Studio Code:

code .

Build the existing project before making changes:

dotnet build
Checkpoint

Continue only when the project builds successfully.

2. Understand the One-to-Many Relationship

Category │ ├── Product ├── Product └── Product

One Category can contain many Products. Each Product can reference one Category.

The database relationship will use:

Category.Id
Product.CategoryId
Categories.Id ↑ │ Products.CategoryId

3. Create the Category Model

Step 3.1 — Create the file

From the project directory, create:

touch Models/Category.cs

Step 3.2 — Open the file

code Models/Category.cs

Step 3.3 — Add the Category class

Paste the following into the new file:

using System.ComponentModel.DataAnnotations;

namespace ProductManagement.Models;

public class Category
{
    public int Id { get; set; }

    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    public ICollection<Product> Products { get; set; }
        = new List<Product>();
}

Step 3.4 — Save and verify

ls -l Models/Category.cs
What was added?

Id is the Category primary key, Name stores the Category name, and Products is the collection navigation property representing the many related Products.

4. Update Product.cs

Step 4.1 — Open the file

code Models/Product.cs

Step 4.2 — Find the end of the Product class

Near the bottom, you should already have:

[Range(
    0,
    1000000,
    ErrorMessage = "Quantity cannot be negative.")]
public int Quantity { get; set; }

Step 4.3 — Add the relationship properties

Immediately after Quantity and before the closing brace of the class, add:

public int? CategoryId { get; set; }

public Category? Category { get; set; }

Step 4.4 — Verify the completed lower section

The end of Product.cs should now look like:

[Range(
    0,
    1000000,
    ErrorMessage = "Quantity cannot be negative.")]
public int Quantity { get; set; }

public int? CategoryId { get; set; }

public Category? Category { get; set; }
Why is CategoryId nullable?

Existing Products from Parts 5–7 do not yet have Categories. Using int? allows those rows to remain valid while the relationship is introduced.

5. Update ApplicationDbContext.cs

Step 5.1 — Open the file

code Data/ApplicationDbContext.cs

Step 5.2 — Find the existing Products DbSet

You should already have:

public DbSet<Product> Products { get; set; } = null!;

Step 5.3 — Add Categories DbSet

Immediately below the Products DbSet, add:

public DbSet<Category> Categories { get; set; } = null!;

Step 5.4 — Add OnModelCreating()

Still inside the ApplicationDbContext class, place the following method after the DbSet properties and before the final closing brace:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);

    modelBuilder.Entity<Category>().HasData(
        new Category { Id = 1, Name = "Computers" },
        new Category { Id = 2, Name = "Accessories" },
        new Category { Id = 3, Name = "Storage" }
    );
}

Step 5.5 — Verify the completed file

using Microsoft.EntityFrameworkCore;
using ProductManagement.Models;

namespace ProductManagement.Data;

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<Product> Products { get; set; } = null!;

    public DbSet<Category> Categories { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "Computers" },
            new Category { Id = 2, Name = "Accessories" },
            new Category { Id = 3, Name = "Storage" }
        );
    }
}

6. Build Before Creating the Migration

Save all modified files, then run:

dotnet build

Continue only when the build succeeds.

7. Create and Apply the AddCategories Migration

Step 7.1 — Create the migration

dotnet ef migrations add AddCategories

Step 7.2 — Verify the migration exists

dotnet ef migrations list

You should see:

InitialCreate
AddCategories

Step 7.3 — Apply the migration

dotnet ef database update

Step 7.4 — Inspect the database

sqlite3 ProductManagement.db

At the SQLite prompt:

.tables
.schema Categories
.schema Products
SELECT * FROM Categories;
.quit

You should see Categories such as:

1|Computers
2|Accessories
3|Storage

8. Update ProductsController.cs to Load Related Categories

Step 8.1 — Open the controller

code Controllers/ProductsController.cs

Step 8.2 — Update the Index action

Find the existing Index action:

public async Task<IActionResult> Index()
{
    var products = await _context.Products.ToListAsync();

    return View(products);
}

Replace it with:

public async Task<IActionResult> Index()
{
    var products = await _context.Products
        .Include(p => p.Category)
        .ToListAsync();

    return View(products);
}

Step 8.3 — Update the Details action

Find this existing query inside Details():

var product = await _context.Products
    .FirstOrDefaultAsync(p => p.Id == id);

Replace only that query with:

var product = await _context.Products
    .Include(p => p.Category)
    .FirstOrDefaultAsync(p => p.Id == id);

Step 8.4 — Verify the completed Details action

public async Task<IActionResult> Details(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    var product = await _context.Products
        .Include(p => p.Category)
        .FirstOrDefaultAsync(p => p.Id == id);

    if (product == null)
    {
        return NotFound();
    }

    return View(product);
}

9. Add SelectList Support to ProductsController.cs

Step 9.1 — Keep ProductsController.cs open

At the top of the controller you should already have several using statements.

Step 9.2 — Add the SelectList namespace

Add:

using Microsoft.AspNetCore.Mvc.Rendering;

The top of the controller should now look similar to:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;

10. Update the Create GET Action

Step 10.1 — Find the existing Create GET action

Inside Controllers/ProductsController.cs, find:

[HttpGet]
public IActionResult Create()
{
    return View();
}

Step 10.2 — Replace it

[HttpGet]
public IActionResult Create()
{
    ViewData["CategoryId"] = new SelectList(
        _context.Categories,
        "Id",
        "Name");

    return View();
}
What this does

The controller loads Category IDs and names into a SelectList that the Create view can use for a dropdown.

11. Update Create.cshtml

Step 11.1 — Open the Create view

code Views/Products/Create.cshtml

Step 11.2 — Find the Quantity form group

You should already have a block similar to:

<div class="mb-3">
    <label asp-for="Quantity" class="form-label"></label>
    <input asp-for="Quantity" class="form-control" />
    <span asp-validation-for="Quantity"
          class="text-danger"></span>
</div>

Step 11.3 — Add the Category dropdown

Immediately after the Quantity block and before the Create button, add:

<div class="mb-3">
    <label asp-for="CategoryId"
           class="form-label">Category</label>

    <select asp-for="CategoryId"
            class="form-select"
            asp-items="ViewBag.CategoryId">
        <option value="">-- Select Category --</option>
    </select>

    <span asp-validation-for="CategoryId"
          class="text-danger"></span>
</div>

Step 11.4 — Verify the lower part of the form

<div class="mb-3">
    <label asp-for="Quantity" class="form-label"></label>
    <input asp-for="Quantity" class="form-control" />
    <span asp-validation-for="Quantity"
          class="text-danger"></span>
</div>

<div class="mb-3">
    <label asp-for="CategoryId"
           class="form-label">Category</label>

    <select asp-for="CategoryId"
            class="form-select"
            asp-items="ViewBag.CategoryId">
        <option value="">-- Select Category --</option>
    </select>

    <span asp-validation-for="CategoryId"
          class="text-danger"></span>
</div>

<button type="submit"
        class="btn btn-primary">Create</button>

12. Update the Create POST Action

Step 12.1 — Reopen the controller

code Controllers/ProductsController.cs

Step 12.2 — Find the existing Create POST action

Find:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Product product)
{
    if (ModelState.IsValid)
    {
        _context.Add(product);
        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }

    return View(product);
}

Step 12.3 — Replace it

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Product product)
{
    if (ModelState.IsValid)
    {
        _context.Add(product);
        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }

    ViewData["CategoryId"] = new SelectList(
        _context.Categories,
        "Id",
        "Name",
        product.CategoryId);

    return View(product);
}

The dropdown must be recreated when validation fails because the SelectList is not automatically rebuilt.

13. Update the Edit GET Action

Step 13.1 — Keep ProductsController.cs open

Find the existing Edit GET action.

Step 13.2 — Find this section

var product = await _context.Products.FindAsync(id);

if (product == null)
{
    return NotFound();
}

return View(product);

Step 13.3 — Add the SelectList before return View(product)

var product = await _context.Products.FindAsync(id);

if (product == null)
{
    return NotFound();
}

ViewData["CategoryId"] = new SelectList(
    _context.Categories,
    "Id",
    "Name",
    product.CategoryId);

return View(product);

14. Update Edit.cshtml

Step 14.1 — Open the Edit view

code Views/Products/Edit.cshtml

Step 14.2 — Find the Quantity form group

Find the same Quantity block used in the Create form.

Step 14.3 — Add the Category dropdown

Immediately after Quantity and before the Save Changes button, add:

<div class="mb-3">
    <label asp-for="CategoryId"
           class="form-label">Category</label>

    <select asp-for="CategoryId"
            class="form-select"
            asp-items="ViewBag.CategoryId">
        <option value="">-- Select Category --</option>
    </select>

    <span asp-validation-for="CategoryId"
          class="text-danger"></span>
</div>

15. Update the Edit POST Action

Step 15.1 — Reopen ProductsController.cs

code Controllers/ProductsController.cs

Step 15.2 — Find the end of the Edit POST action

Near the end, you currently have:

return View(product);

Step 15.3 — Add the SelectList immediately before it

ViewData["CategoryId"] = new SelectList(
    _context.Categories,
    "Id",
    "Name",
    product.CategoryId);

return View(product);

16. Update Index.cshtml to Display Category

Step 16.1 — Open the Index view

code Views/Products/Index.cshtml

Step 16.2 — Find the table headings

You should already have headings such as:

<th>ID</th>
<th>Name</th>
<th>Price</th>
<th>Quantity</th>
<th>Action</th>

Step 16.3 — Add the Category heading

Add:

<th>Category</th>

Place it before Action.

Step 16.4 — Add Category to each Product row

Inside the foreach row, add:

<td>@(product.Category?.Name ?? "Unassigned")</td>

Place it before the Action cell.

17. Update Details.cshtml to Display Category

Step 17.1 — Open the Details view

code Views/Products/Details.cshtml

Step 17.2 — Find the Product details table

You should already have rows for ID, Name, Description, Price and Quantity.

Step 17.3 — Add the Category row

Add this row after Quantity:

<tr>
    <th>Category</th>
    <td>@(Model.Category?.Name ?? "Unassigned")</td>
</tr>

18. Build the Completed Part 8 Project

Save all files, then run:

dotnet build

Fix any build errors before running the application.

19. Test Create with a Category

dotnet run

Open:

/Products/Create

The Category dropdown should contain:

Computers
Accessories
Storage

Create a Product and select one Category.

20. Test Index and Details

Open:

/Products

Confirm that the Category name appears in the table.

Open a Product Details page and confirm that the same Category is displayed there.

21. Test Edit

Edit an existing Product and choose a different Category.

Save the changes and confirm the new Category appears in Index and Details.

22. Verify the Relationship in SQLite

sqlite3 ProductManagement.db

Run:

SELECT Id, Name, CategoryId
FROM Products;

Then:

SELECT
    Products.Name,
    Categories.Name
FROM Products
LEFT JOIN Categories
    ON Products.CategoryId = Categories.Id;

Exit:

.quit

23. Final Relationship Architecture

Category ↓ one-to-many Product ↓ ProductsController ↓ ApplicationDbContext ↓ Entity Framework Core ↓ SQLite

24. Troubleshooting

Category dropdown is empty

Check the Categories table:

sqlite3 ProductManagement.db
SELECT * FROM Categories;
.quit

Confirm the AddCategories migration was applied.

Category name does not appear in Index or Details

Check that the controller queries contain:

.Include(p => p.Category)
Dropdown disappears after validation error

Recreate ViewData["CategoryId"] before returning View(product) from the Create or Edit POST action.

SelectList is not recognised

Check the top of ProductsController.cs:

using Microsoft.AspNetCore.Mvc.Rendering;
Migration fails with existing Products

Confirm Product.cs uses:

public int? CategoryId { get; set; }

25. Hands-On Exercise

  1. Create Gaming Laptop under Computers.
  2. Create Wireless Keyboard under Accessories.
  3. Create Portable SSD under Storage.
  4. Confirm Category names appear in Index.
  5. Open each Details page.
  6. Edit one Product and change its Category.
  7. Verify the foreign-key values directly in SQLite.

26. Knowledge Check

  1. What is a one-to-many relationship?
  2. What is a primary key?
  3. What is a foreign key?
  4. What is the purpose of CategoryId?
  5. What is the purpose of Category on Product?
  6. Why is CategoryId nullable?
  7. What does ICollection<Product> represent?
  8. What does Include() do?
  9. Why is SelectList used?
  10. Why must dropdown data be recreated after validation fails?
Show suggested answers
  1. One entity can be associated with many entities of another type.
  2. A key that uniquely identifies a record.
  3. A value that references the primary key of another table.
  4. It stores the related Category key.
  5. It gives access to the related Category object.
  6. So existing Products can remain unassigned safely.
  7. The Products belonging to one Category.
  8. It eagerly loads related data.
  9. To provide Category choices for a dropdown.
  10. Because the SelectList is not automatically recreated after a failed POST.

27. Part 8 Summary

  • created Models/Category.cs;
  • updated Models/Product.cs;
  • updated Data/ApplicationDbContext.cs;
  • created and applied the AddCategories migration;
  • updated Controllers/ProductsController.cs;
  • updated Create and Edit dropdowns;
  • updated Index and Details to display Category names;
  • loaded related data with Include(); and
  • verified the relationship directly in SQLite.

Appendix — Full Code for Final Verification

Purpose of this appendix

Use this section only after completing all Part 8 steps. Compare each file in your project with the complete examples below. The files should contain the same important code even if minor formatting differs.

Appendix A — Models/Category.cs

Open:

code Models/Category.cs

The complete file should be:

using System.ComponentModel.DataAnnotations;

namespace ProductManagement.Models;

public class Category
{
    public int Id { get; set; }

    [Required]
    [StringLength(100)]
    public string Name { get; set; } = string.Empty;

    public ICollection<Product> Products { get; set; }
        = new List<Product>();
}

Appendix B — Models/Product.cs

Open:

code Models/Product.cs

The complete file should be:

using System.ComponentModel.DataAnnotations;

namespace ProductManagement.Models;

public class Product
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Product name is required.")]
    [StringLength(
        100,
        ErrorMessage = "Product name cannot exceed 100 characters.")]
    public string Name { get; set; } = string.Empty;

    [StringLength(
        500,
        ErrorMessage = "Description cannot exceed 500 characters.")]
    public string? Description { get; set; }

    [Range(
        0.01,
        1000000,
        ErrorMessage = "Price must be greater than zero.")]
    public decimal Price { get; set; }

    [Range(
        0,
        1000000,
        ErrorMessage = "Quantity cannot be negative.")]
    public int Quantity { get; set; }

    public int? CategoryId { get; set; }

    public Category? Category { get; set; }
}

Appendix C — Data/ApplicationDbContext.cs

Open:

code Data/ApplicationDbContext.cs

The complete file should be:

using Microsoft.EntityFrameworkCore;
using ProductManagement.Models;

namespace ProductManagement.Data;

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(
        DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    public DbSet<Product> Products { get; set; } = null!;

    public DbSet<Category> Categories { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<Category>().HasData(
            new Category { Id = 1, Name = "Computers" },
            new Category { Id = 2, Name = "Accessories" },
            new Category { Id = 3, Name = "Storage" }
        );
    }
}

Appendix D — Controllers/ProductsController.cs

Open:

code Controllers/ProductsController.cs

The complete file should be:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;

namespace ProductManagement.Controllers;

public class ProductsController : Controller
{
    private readonly ApplicationDbContext _context;

    public ProductsController(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index()
    {
        var products = await _context.Products
            .Include(p => p.Category)
            .ToListAsync();

        return View(products);
    }

    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product == null)
        {
            return NotFound();
        }

        return View(product);
    }

    [HttpGet]
    public IActionResult Create()
    {
        ViewData["CategoryId"] = new SelectList(
            _context.Categories,
            "Id",
            "Name");

        return View();
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(Product product)
    {
        if (ModelState.IsValid)
        {
            _context.Add(product);
            await _context.SaveChangesAsync();

            return RedirectToAction(nameof(Index));
        }

        ViewData["CategoryId"] = new SelectList(
            _context.Categories,
            "Id",
            "Name",
            product.CategoryId);

        return View(product);
    }

    [HttpGet]
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products.FindAsync(id);

        if (product == null)
        {
            return NotFound();
        }

        ViewData["CategoryId"] = new SelectList(
            _context.Categories,
            "Id",
            "Name",
            product.CategoryId);

        return View(product);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(int id, Product product)
    {
        if (id != product.Id)
        {
            return NotFound();
        }

        if (ModelState.IsValid)
        {
            try
            {
                _context.Update(product);
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ProductExists(product.Id))
                {
                    return NotFound();
                }

                throw;
            }

            return RedirectToAction(nameof(Index));
        }

        ViewData["CategoryId"] = new SelectList(
            _context.Categories,
            "Id",
            "Name",
            product.CategoryId);

        return View(product);
    }

    [HttpGet]
    public async Task<IActionResult> Delete(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product == null)
        {
            return NotFound();
        }

        return View(product);
    }

    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteConfirmed(int id)
    {
        var product = await _context.Products.FindAsync(id);

        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }

        return RedirectToAction(nameof(Index));
    }

    private bool ProductExists(int id)
    {
        return _context.Products.Any(p => p.Id == id);
    }
}

Appendix E — Views/Products/Create.cshtml

Open:

code Views/Products/Create.cshtml

The complete file should be:

@model Product

@{
    ViewData["Title"] = "Create Product";
}

<h1>Create Product</h1>

<form asp-action="Create" method="post">

    <div asp-validation-summary="ModelOnly"
         class="text-danger"></div>

    <div class="mb-3">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description"
                  class="form-control"></textarea>
        <span asp-validation-for="Description"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Quantity" class="form-label"></label>
        <input asp-for="Quantity" class="form-control" />
        <span asp-validation-for="Quantity"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="CategoryId"
               class="form-label">Category</label>

        <select asp-for="CategoryId"
                class="form-select"
                asp-items="ViewBag.CategoryId">
            <option value="">-- Select Category --</option>
        </select>

        <span asp-validation-for="CategoryId"
              class="text-danger"></span>
    </div>

    <button type="submit"
            class="btn btn-primary">Create</button>

    <a asp-action="Index"
       class="btn btn-secondary">Cancel</a>
</form>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Appendix F — Views/Products/Edit.cshtml

Open:

code Views/Products/Edit.cshtml

The complete file should be:

@model Product

@{
    ViewData["Title"] = "Edit Product";
}

<h1>Edit Product</h1>

<form asp-action="Edit" method="post">

    <input type="hidden" asp-for="Id" />

    <div asp-validation-summary="ModelOnly"
         class="text-danger"></div>

    <div class="mb-3">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description"
                  class="form-control"></textarea>
        <span asp-validation-for="Description"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Quantity" class="form-label"></label>
        <input asp-for="Quantity" class="form-control" />
        <span asp-validation-for="Quantity"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="CategoryId"
               class="form-label">Category</label>

        <select asp-for="CategoryId"
                class="form-select"
                asp-items="ViewBag.CategoryId">
            <option value="">-- Select Category --</option>
        </select>

        <span asp-validation-for="CategoryId"
              class="text-danger"></span>
    </div>

    <button type="submit"
            class="btn btn-primary">Save Changes</button>

    <a asp-action="Index"
       class="btn btn-secondary">Cancel</a>
</form>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Appendix G — Views/Products/Index.cshtml

Open:

code Views/Products/Index.cshtml

The complete file should be:

@model IEnumerable<Product>

@{
    ViewData["Title"] = "Products";
}

<h1>Products</h1>

<p>
    <a asp-action="Create"
       class="btn btn-primary">Create New Product</a>
</p>

@if (!Model.Any())
{
    <p>No products have been created yet.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>ID</th>
                <th>Name</th>
                <th>Price</th>
                <th>Quantity</th>
                <th>Category</th>
                <th>Action</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var product in Model)
            {
                <tr>
                    <td>@product.Id</td>
                    <td>@product.Name</td>
                    <td>RM @product.Price.ToString("N2")</td>
                    <td>@product.Quantity</td>
                    <td>@(product.Category?.Name ?? "Unassigned")</td>
                    <td>
                        <a asp-action="Details"
                           asp-route-id="@product.Id">Details</a>
                        |
                        <a asp-action="Edit"
                           asp-route-id="@product.Id">Edit</a>
                        |
                        <a asp-action="Delete"
                           asp-route-id="@product.Id">Delete</a>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

Appendix H — Views/Products/Details.cshtml

Open:

code Views/Products/Details.cshtml

The complete file should be:

@model Product

@{
    ViewData["Title"] = "Product Details";
}

<h1>Product Details</h1>

<table class="table">
    <tbody>
        <tr>
            <th>ID</th>
            <td>@Model.Id</td>
        </tr>

        <tr>
            <th>Name</th>
            <td>@Model.Name</td>
        </tr>

        <tr>
            <th>Description</th>
            <td>@Model.Description</td>
        </tr>

        <tr>
            <th>Price</th>
            <td>RM @Model.Price.ToString("N2")</td>
        </tr>

        <tr>
            <th>Quantity</th>
            <td>@Model.Quantity</td>
        </tr>

        <tr>
            <th>Category</th>
            <td>@(Model.Category?.Name ?? "Unassigned")</td>
        </tr>
    </tbody>
</table>

<a asp-action="Index">Back to Products</a>

Appendix I — Views/Products/Delete.cshtml

Part 8 does not require a major change to the Delete view, but this is the complete Part 6 version for comparison:

@model Product

@{
    ViewData["Title"] = "Delete Product";
}

<h1>Delete Product</h1>

<p>Are you sure you want to delete this product?</p>

<dl class="row">
    <dt class="col-sm-2">ID</dt>
    <dd class="col-sm-10">@Model.Id</dd>

    <dt class="col-sm-2">Name</dt>
    <dd class="col-sm-10">@Model.Name</dd>

    <dt class="col-sm-2">Price</dt>
    <dd class="col-sm-10">RM @Model.Price.ToString("N2")</dd>

    <dt class="col-sm-2">Quantity</dt>
    <dd class="col-sm-10">@Model.Quantity</dd>
</dl>

<form asp-action="Delete" method="post">
    <input type="hidden" asp-for="Id" />

    <button type="submit"
            class="btn btn-danger">Delete</button>

    <a asp-action="Index"
       class="btn btn-secondary">Cancel</a>
</form>

Appendix J — Final Verification Commands

After comparing all files, run:

cd ~/aspnet-mvc-tutorial/ProductManagement

dotnet build

dotnet ef migrations list

dotnet ef database update

dotnet run

Then test:

/Products
/Products/Create
/Products/Details/1
/Products/Edit/1
/Products/Delete/1

Finally, verify the relationship directly in SQLite:

sqlite3 ProductManagement.db
SELECT * FROM Categories;

SELECT
    Products.Id,
    Products.Name,
    Products.CategoryId,
    Categories.Name
FROM Products
LEFT JOIN Categories
    ON Products.CategoryId = Categories.Id;

.quit
Final Part 8 checkpoint

If the project builds, Categories appear in the Create/Edit dropdowns, Category names appear in Index and Details, and the SQLite join returns the expected relationships, Part 8 is complete.

Next: Part 9 — Search, Filter and Sort with LINQ

In Part 9, we will use LINQ, query strings, Where(), OrderBy() and OrderByDescending() to search Products, filter by Category and price, and sort database records.