ASP.NET CORE MVC - LINQ

ASP.NET CORE MVC TUTORIAL SERIES · PART 9

Search, Filter and Sort Products with LINQ

Use LINQ and query strings to search Products by name, filter them by Category and price, and sort database results without changing the database structure.

Objective

By the end of this tutorial, the Products page will provide search, Category filtering, maximum-price filtering, and sorting while keeping the selected options visible in the interface.

Starting point

Part 8 added Categories and the one-to-many Category–Product relationship. Part 9 continues from that completed project. No new EF Core migration is required because this part changes queries and the user interface, not the database schema.

In this tutorial
  1. Review LINQ and query strings
  2. Update the Index action for search
  3. Add Category filtering
  4. Add maximum-price filtering
  5. Add sorting
  6. Update Index.cshtml with the search/filter form
  7. Add sortable column links
  8. Add Clear Filters
  9. Test query-string combinations
  10. Review the complete final files

1. Open and Verify the Part 8 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

Build the project before making changes:

dotnet build

Open it in Visual Studio Code:

code .

2. Understand LINQ in This Tutorial

LINQ allows C# code to build queries against the Products data. We will use methods including:

MethodPurpose
Where()Keep only records matching a condition.
OrderBy()Sort values in ascending order.
OrderByDescending()Sort values in descending order.
Include()Load each Product's related Category.
ToListAsync()Execute the final query asynchronously and return the results.
_context.Products ↓ Where(...) ↓ Where(...) ↓ OrderBy(...) ↓ ToListAsync() ↓ View

3. Understand Query Strings

A query string passes optional values in the URL after ?.

/Products?search=laptop

Multiple values are joined with &:

/Products?search=laptop&categoryId=1&maxPrice=5000

4. Update ProductsController.cs for Product Search

Step 4.1 — Open the controller

code Controllers/ProductsController.cs

Step 4.2 — Find the existing Index action

From Part 8 it should look like:

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

    return View(products);
}

Step 4.3 — Replace the entire Index action

public async Task<IActionResult> Index(string? search)
{
    var products = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
    {
        products = products.Where(
            p => p.Name.Contains(search));
    }

    ViewData["CurrentSearch"] = search;

    return View(await products.ToListAsync());
}

The query is built first. ToListAsync() is called only after the search condition has been added.

5. Add Category Filtering to ProductsController.cs

Step 5.1 — Keep the same file open

Continue editing:

Controllers/ProductsController.cs

Step 5.2 — Replace the Index action from Section 4

public async Task<IActionResult> Index(
    string? search,
    int? categoryId)
{
    var products = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
    {
        products = products.Where(
            p => p.Name.Contains(search));
    }

    if (categoryId.HasValue)
    {
        products = products.Where(
            p => p.CategoryId == categoryId.Value);
    }

    ViewData["CurrentSearch"] = search;

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

    return View(await products.ToListAsync());
}
Why no new using statement?

Part 8 already added using Microsoft.AspNetCore.Mvc.Rendering; to this controller for SelectList.

6. Add Maximum-Price Filtering

Step 6.1 — Keep ProductsController.cs open

Step 6.2 — Replace the Index action again

public async Task<IActionResult> Index(
    string? search,
    int? categoryId,
    decimal? maxPrice)
{
    var products = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
    {
        products = products.Where(
            p => p.Name.Contains(search));
    }

    if (categoryId.HasValue)
    {
        products = products.Where(
            p => p.CategoryId == categoryId.Value);
    }

    if (maxPrice.HasValue)
    {
        products = products.Where(
            p => p.Price <= maxPrice.Value);
    }

    ViewData["CurrentSearch"] = search;
    ViewData["CurrentMaxPrice"] = maxPrice;

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

    return View(await products.ToListAsync());
}

7. Add Sorting

Step 7.1 — Keep ProductsController.cs open

Step 7.2 — Replace the Index action with the final Part 9 version

public async Task<IActionResult> Index(
    string? search,
    int? categoryId,
    decimal? maxPrice,
    string? sortOrder)
{
    var products = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
    {
        products = products.Where(
            p => p.Name.Contains(search));
    }

    if (categoryId.HasValue)
    {
        products = products.Where(
            p => p.CategoryId == categoryId.Value);
    }

    if (maxPrice.HasValue)
    {
        products = products.Where(
            p => p.Price <= maxPrice.Value);
    }

    products = sortOrder switch
    {
        "name_desc" => products.OrderByDescending(p => p.Name),
        "price" => products.OrderBy(p => p.Price),
        "price_desc" => products.OrderByDescending(p => p.Price),
        _ => products.OrderBy(p => p.Name)
    };

    ViewData["CurrentSearch"] = search;
    ViewData["CurrentCategoryId"] = categoryId;
    ViewData["CurrentMaxPrice"] = maxPrice;
    ViewData["CurrentSort"] = sortOrder;

    ViewData["NameSort"] =
        sortOrder == "name_desc" ? "" : "name_desc";

    ViewData["PriceSort"] =
        sortOrder == "price" ? "price_desc" : "price";

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

    return View(await products.ToListAsync());
}

Step 7.3 — Understand the switch expression

sortOrderResult
emptyName A → Z
name_descName Z → A
priceLowest price → highest price
price_descHighest price → lowest price

8. Add the Search and Filter Form to Index.cshtml

Step 8.1 — Open the Index view

code Views/Products/Index.cshtml

Step 8.2 — Find the Create New Product section

Find:

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

Step 8.3 — Add the filter form immediately after it

<form asp-action="Index"
      method="get"
      class="row g-3 mb-4">

    <div class="col-md-4">
        <label for="search"
               class="form-label">Search</label>

        <input type="text"
               id="search"
               name="search"
               value="@ViewData["CurrentSearch"]"
               class="form-control"
               placeholder="Product name" />
    </div>

    <div class="col-md-3">
        <label for="categoryId"
               class="form-label">Category</label>

        <select id="categoryId"
                name="categoryId"
                class="form-select"
                asp-items="ViewBag.CategoryId">
            <option value="">All Categories</option>
        </select>
    </div>

    <div class="col-md-3">
        <label for="maxPrice"
               class="form-label">Maximum Price</label>

        <input type="number"
               id="maxPrice"
               name="maxPrice"
               value="@ViewData["CurrentMaxPrice"]"
               class="form-control"
               min="0"
               step="0.01" />
    </div>

    <div class="col-md-2 d-flex align-items-end">
        <button type="submit"
                class="btn btn-primary w-100">Apply</button>
    </div>
</form>

9. Add Clear Filters to Index.cshtml

Step 9.1 — Keep Index.cshtml open

Step 9.2 — Add the link immediately after the filter form

<p>
    <a asp-action="Index"
       class="btn btn-outline-secondary">Clear Filters</a>
</p>

Because this link does not supply query-string parameters, it returns the user to the unfiltered Products page.

10. Make the Name Column Sortable

Step 10.1 — Keep Index.cshtml open

Step 10.2 — Find the existing Name heading

<th>Name</th>

Step 10.3 — Replace it

<th>
    <a asp-action="Index"
       asp-route-search="@ViewData["CurrentSearch"]"
       asp-route-categoryId="@ViewData["CurrentCategoryId"]"
       asp-route-maxPrice="@ViewData["CurrentMaxPrice"]"
       asp-route-sortOrder="@ViewData["NameSort"]">
        Name
    </a>
</th>

The existing search and filters are passed into the sorting link so they are not lost when the user changes the sort order.

11. Make the Price Column Sortable

Step 11.1 — Find the existing Price heading

<th>Price</th>

Step 11.2 — Replace it

<th>
    <a asp-action="Index"
       asp-route-search="@ViewData["CurrentSearch"]"
       asp-route-categoryId="@ViewData["CurrentCategoryId"]"
       asp-route-maxPrice="@ViewData["CurrentMaxPrice"]"
       asp-route-sortOrder="@ViewData["PriceSort"]">
        Price
    </a>
</th>

12. Preserve Sorting When the Filter Form Is Submitted

Step 12.1 — Keep Index.cshtml open

Step 12.2 — Find the opening filter form

<form asp-action="Index"
      method="get"
      class="row g-3 mb-4">

Step 12.3 — Add a hidden field immediately inside the form

<input type="hidden"
       name="sortOrder"
       value="@ViewData["CurrentSort"]" />

The beginning of the form should now be:

<form asp-action="Index"
      method="get"
      class="row g-3 mb-4">

    <input type="hidden"
           name="sortOrder"
           value="@ViewData["CurrentSort"]" />

    <div class="col-md-4">
        ...
    </div>

13. Build the Project

Save both modified files:

Controllers/ProductsController.cs
Views/Products/Index.cshtml

Then run:

dotnet build
Checkpoint

Part 9 changes only the controller query and Index view. You should not create an EF Core migration for these changes.

14. Run and Test Search

dotnet run

Open:

/Products

Search for part of a Product name, such as:

laptop

The resulting URL should resemble:

/Products?search=laptop&categoryId=&maxPrice=&sortOrder=

15. Test Category Filtering

Select a Category such as Computers and click Apply.

The URL may resemble:

/Products?search=&categoryId=1&maxPrice=&sortOrder=

Only Products whose CategoryId is 1 should appear.

16. Test Maximum-Price Filtering

Enter:

5000

The query applies:

p.Price <= 5000

17. Combine Search and Filters

Try:

Search: laptop
Category: Computers
Maximum Price: 5000
All Products ↓ Name contains "laptop" ↓ CategoryId = Computers ↓ Price <= 5000 ↓ Sorted result

18. Test Sorting

Click Name repeatedly to switch between ascending and descending name order.

Click Price repeatedly to switch between:

lowest → highest
highest → lowest

Confirm the current search and filters remain in the URL when sorting.

19. Test Clear Filters

Apply several filters, then click Clear Filters.

The browser should return to:

/Products

20. How EF Core Executes the LINQ Query

The application does not normally retrieve every Product and then perform all filtering in C# memory. EF Core translates supported LINQ expressions into a database query.

LINQ in C# ↓ Entity Framework Core ↓ SQL query ↓ SQLite ↓ Matching rows ↓ Product objects
Important

Keep the query as IQueryable while adding database filters and sorting. Calling ToListAsync() too early executes the query before the remaining conditions are added.

21. Troubleshooting

SelectList is not recognised

Open Controllers/ProductsController.cs and confirm:

using Microsoft.AspNetCore.Mvc.Rendering;
Category names do not appear

In ProductsController.cs, confirm the Index query begins with:

_context.Products
    .Include(p => p.Category)
    .AsQueryable()
Filters disappear when sorting

Open Views/Products/Index.cshtml and confirm the Name and Price links pass search, categoryId, and maxPrice using asp-route-....

Sort order disappears after clicking Apply

Confirm the filter form contains the hidden sortOrder input.

No products appear

Clear the filters first. A combination of search text, Category and maximum price may correctly return zero matching rows.

22. Hands-On Exercise

  1. Search for a Product by part of its name.
  2. Filter Products by one Category.
  3. Filter Products by a maximum price.
  4. Combine all three conditions.
  5. Sort the filtered result by Name.
  6. Sort the filtered result by Price.
  7. Clear all filters.
  8. Observe how the query string changes after each operation.

23. Knowledge Check

  1. What does Where() do?
  2. What does OrderBy() do?
  3. What does OrderByDescending() do?
  4. What is a query string?
  5. Why is AsQueryable() useful here?
  6. Why is ToListAsync() called after filtering and sorting?
  7. Why does the Category filter use CategoryId?
  8. What does Contains(search) test?
  9. Why do the sorting links preserve the current filters?
  10. Does Part 9 require a database migration?
Show suggested answers
  1. It restricts a query to records satisfying a condition.
  2. It sorts records in ascending order.
  3. It sorts records in descending order.
  4. It is optional URL data following ?, normally represented as name/value pairs.
  5. It keeps the Products source queryable while additional conditions are composed.
  6. So the final database query includes the required filters and sorting before execution.
  7. Because CategoryId is the Product foreign key.
  8. Whether the Product Name contains the supplied search text.
  9. So sorting does not unexpectedly remove the user's active search and filter conditions.
  10. No. The database structure is unchanged.

24. Part 9 Summary

  • used LINQ to build Product queries;
  • searched Product names with Where();
  • filtered by Category;
  • filtered by maximum price;
  • sorted with OrderBy() and OrderByDescending();
  • accepted filtering values through query strings;
  • preserved active filters when sorting;
  • added a Clear Filters action; and
  • kept query execution asynchronous with ToListAsync().

Appendix — Full Code for Final Verification

How to use this appendix

After completing the tutorial, compare the two files modified in Part 9 with the complete versions below. Part 9 does not require changes to the Product model, Category model, DbContext, Create view, Edit view, Details view or Delete view.

Appendix A — Controllers/ProductsController.cs

Open:

code Controllers/ProductsController.cs
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(
        string? search,
        int? categoryId,
        decimal? maxPrice,
        string? sortOrder)
    {
        var products = _context.Products
            .Include(p => p.Category)
            .AsQueryable();

        if (!string.IsNullOrWhiteSpace(search))
        {
            products = products.Where(
                p => p.Name.Contains(search));
        }

        if (categoryId.HasValue)
        {
            products = products.Where(
                p => p.CategoryId == categoryId.Value);
        }

        if (maxPrice.HasValue)
        {
            products = products.Where(
                p => p.Price <= maxPrice.Value);
        }

        products = sortOrder switch
        {
            "name_desc" =>
                products.OrderByDescending(p => p.Name),

            "price" =>
                products.OrderBy(p => p.Price),

            "price_desc" =>
                products.OrderByDescending(p => p.Price),

            _ =>
                products.OrderBy(p => p.Name)
        };

        ViewData["CurrentSearch"] = search;
        ViewData["CurrentCategoryId"] = categoryId;
        ViewData["CurrentMaxPrice"] = maxPrice;
        ViewData["CurrentSort"] = sortOrder;

        ViewData["NameSort"] =
            sortOrder == "name_desc" ? "" : "name_desc";

        ViewData["PriceSort"] =
            sortOrder == "price" ? "price_desc" : "price";

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

        return View(await products.ToListAsync());
    }

    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 B — Views/Products/Index.cshtml

Open:

code Views/Products/Index.cshtml
@model IEnumerable<Product>

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

<h1>Products</h1>

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

<form asp-action="Index"
      method="get"
      class="row g-3 mb-4">

    <input type="hidden"
           name="sortOrder"
           value="@ViewData["CurrentSort"]" />

    <div class="col-md-4">
        <label for="search"
               class="form-label">Search</label>

        <input type="text"
               id="search"
               name="search"
               value="@ViewData["CurrentSearch"]"
               class="form-control"
               placeholder="Product name" />
    </div>

    <div class="col-md-3">
        <label for="categoryId"
               class="form-label">Category</label>

        <select id="categoryId"
                name="categoryId"
                class="form-select"
                asp-items="ViewBag.CategoryId">
            <option value="">All Categories</option>
        </select>
    </div>

    <div class="col-md-3">
        <label for="maxPrice"
               class="form-label">Maximum Price</label>

        <input type="number"
               id="maxPrice"
               name="maxPrice"
               value="@ViewData["CurrentMaxPrice"]"
               class="form-control"
               min="0"
               step="0.01" />
    </div>

    <div class="col-md-2 d-flex align-items-end">
        <button type="submit"
                class="btn btn-primary w-100">Apply</button>
    </div>
</form>

<p>
    <a asp-action="Index"
       class="btn btn-outline-secondary">Clear Filters</a>
</p>

@if (!Model.Any())
{
    <p>No products match the current search and filters.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>ID</th>

                <th>
                    <a asp-action="Index"
                       asp-route-search="@ViewData["CurrentSearch"]"
                       asp-route-categoryId="@ViewData["CurrentCategoryId"]"
                       asp-route-maxPrice="@ViewData["CurrentMaxPrice"]"
                       asp-route-sortOrder="@ViewData["NameSort"]">
                        Name
                    </a>
                </th>

                <th>
                    <a asp-action="Index"
                       asp-route-search="@ViewData["CurrentSearch"]"
                       asp-route-categoryId="@ViewData["CurrentCategoryId"]"
                       asp-route-maxPrice="@ViewData["CurrentMaxPrice"]"
                       asp-route-sortOrder="@ViewData["PriceSort"]">
                        Price
                    </a>
                </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 C — Final Verification Commands

cd ~/aspnet-mvc-tutorial/ProductManagement

dotnet build
dotnet run

Test examples:

/Products
/Products?search=laptop
/Products?categoryId=1
/Products?maxPrice=5000
/Products?sortOrder=price
/Products?sortOrder=price_desc
/Products?search=laptop&categoryId=1&maxPrice=5000&sortOrder=price
Final Part 9 checkpoint

If searching, Category filtering, maximum-price filtering, Name sorting, Price sorting and Clear Filters all work while Product CRUD continues to function, Part 9 is complete.

Next: Part 10 — ViewModels

In Part 10, we will separate database entities from data prepared specifically for the user interface. We will create ViewModels for the Product list and search/filter interface and introduce the role of ViewModels in reducing direct dependence between entity models and views.