ASP.NET CORE MVC - LINQ
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.
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.
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.
- Review LINQ and query strings
- Update the Index action for search
- Add Category filtering
- Add maximum-price filtering
- Add sorting
- Update Index.cshtml with the search/filter form
- Add sortable column links
- Add Clear Filters
- Test query-string combinations
- 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:
| Method | Purpose |
|---|---|
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. |
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());
}
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
| sortOrder | Result |
|---|---|
| empty | Name A → Z |
name_desc | Name Z → A |
price | Lowest price → highest price |
price_desc | Highest 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
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
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.
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
- Search for a Product by part of its name.
- Filter Products by one Category.
- Filter Products by a maximum price.
- Combine all three conditions.
- Sort the filtered result by Name.
- Sort the filtered result by Price.
- Clear all filters.
- Observe how the query string changes after each operation.
23. Knowledge Check
- What does
Where()do? - What does
OrderBy()do? - What does
OrderByDescending()do? - What is a query string?
- Why is
AsQueryable()useful here? - Why is
ToListAsync()called after filtering and sorting? - Why does the Category filter use
CategoryId? - What does
Contains(search)test? - Why do the sorting links preserve the current filters?
- Does Part 9 require a database migration?
Show suggested answers
- It restricts a query to records satisfying a condition.
- It sorts records in ascending order.
- It sorts records in descending order.
- It is optional URL data following
?, normally represented as name/value pairs. - It keeps the Products source queryable while additional conditions are composed.
- So the final database query includes the required filters and sorting before execution.
- Because
CategoryIdis the Product foreign key. - Whether the Product Name contains the supplied search text.
- So sorting does not unexpectedly remove the user's active search and filter conditions.
- 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()andOrderByDescending(); - 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
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
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.
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.