Building Dynamic Queries with IQueryable
In this presentation, you will learn how to build a search endpoint that dynamically composes EF Core queries based on the query parameters a client provides. Instead of writing separate queries for every filter combination, you will use IQueryable to build a single, flexible query pipeline.
The Query Parameters DTO
Start by defining a class that represents all possible search parameters. ASP.NET Core model binding maps query string parameters to this object automatically.
csharp1public class ParcelSearchParams2{3 public ParcelStatus? Status { get; set; }4 public ServiceType? ServiceType { get; set; }5 public DateTime? CreatedFrom { get; set; }6 public DateTime? CreatedTo { get; set; }7 public string? City { get; set; }8 public string? Country { get; set; }9 public string? Keyword { get; set; }10 public string? SortBy { get; set; }11 public bool SortDescending { get; set; } = true;12 public int PageSize { get; set; } = 20;13 public string? Cursor { get; set; }14}
Each property is nullable (or has a default) so the client can omit any parameter. When a property is null, the corresponding filter is not applied.
Binding Query Parameters
ASP.NET Core binds query string parameters by name. A request like this:
GET /api/parcels?status=InTransit&serviceType=Express&pageSize=10
Automatically populates Status = ParcelStatus.InTransit, ServiceType = ServiceType.Express, and PageSize = 10. All other properties remain null or at their defaults.
For enum properties, ASP.NET Core handles the conversion from string to enum. Adding [JsonConverter(typeof(JsonStringEnumConverter))] to your enum types ensures consistent serialization in both query strings and JSON responses.
Building the Query Pipeline
The core pattern for dynamic filtering is straightforward: start with the full table and conditionally add .Where() clauses.
csharp1public async Task<PagedResult<ParcelDto>> SearchParcelsAsync(2 ParcelSearchParams searchParams)3{4 IQueryable<Parcel> query = _context.Parcels5 .Include(p => p.ShipperAddress)6 .Include(p => p.RecipientAddress);78 // Apply filters conditionally9 query = ApplyFilters(query, searchParams);1011 // Get total count before pagination12 var totalCount = await query.CountAsync();1314 // Apply sorting15 query = ApplySorting(query, searchParams);1617 // Apply cursor-based pagination18 query = ApplyCursor(query, searchParams);1920 // Materialize results21 var parcels = await query22 .Take(searchParams.PageSize + 1) // Fetch one extra to detect next page23 .ToListAsync();2425 return BuildPagedResult(parcels, searchParams, totalCount);26}
Notice the order: filter first, count, sort, paginate, then materialize. This ensures totalCount reflects all matching records, while pagination limits what is returned.
Applying Individual Filters
Each filter checks whether the parameter was provided before adding a .Where() clause. This is the method that makes the query dynamic.
csharp1private static IQueryable<Parcel> ApplyFilters(2 IQueryable<Parcel> query,3 ParcelSearchParams searchParams)4{5 if (searchParams.Status.HasValue)6 {7 query = query.Where(p => p.Status == searchParams.Status.Value);8 }910 if (searchParams.ServiceType.HasValue)11 {12 query = query.Where(p =>13 p.ServiceType == searchParams.ServiceType.Value);14 }1516 if (searchParams.CreatedFrom.HasValue)17 {18 query = query.Where(p =>19 p.CreatedAt >= searchParams.CreatedFrom.Value);20 }2122 if (searchParams.CreatedTo.HasValue)23 {24 query = query.Where(p =>25 p.CreatedAt <= searchParams.CreatedTo.Value);26 }2728 if (!string.IsNullOrWhiteSpace(searchParams.City))29 {30 query = query.Where(p =>31 p.ShipperAddress.City == searchParams.City ||32 p.RecipientAddress.City == searchParams.City);33 }3435 if (!string.IsNullOrWhiteSpace(searchParams.Country))36 {37 query = query.Where(p =>38 p.ShipperAddress.CountryCode == searchParams.Country ||39 p.RecipientAddress.CountryCode == searchParams.Country);40 }4142 if (!string.IsNullOrWhiteSpace(searchParams.Keyword))43 {44 var keyword = searchParams.Keyword.ToLower();45 query = query.Where(p =>46 p.TrackingNumber.ToLower().Contains(keyword) ||47 p.Description.ToLower().Contains(keyword));48 }4950 return query;51}
How EF Core Handles This
When you chain .Where() calls, EF Core combines them with AND in the generated SQL. If the client only provides status=InTransit and city=Berlin, the SQL looks like:
sql1SELECT p.*, sa.*, ra.*2FROM Parcels p3JOIN Addresses sa ON p.ShipperAddressId = sa.Id4JOIN Addresses ra ON p.RecipientAddressId = ra.Id5WHERE p.Status = 26 AND (sa.City = 'Berlin' OR ra.City = 'Berlin')
Filters for ServiceType, date range, country, and keyword are absent because those parameters were not provided. No unnecessary conditions clutter the SQL.
Keyword Search Across Multiple Columns
Keyword search is the most flexible filter. It matches a single search term against multiple columns using OR logic inside the Where().
csharp1if (!string.IsNullOrWhiteSpace(searchParams.Keyword))2{3 var keyword = searchParams.Keyword.ToLower();4 query = query.Where(p =>5 p.TrackingNumber.ToLower().Contains(keyword) ||6 p.Description.ToLower().Contains(keyword));7}
EF Core translates ToLower().Contains() to a case-insensitive LIKE operation in most database providers:
sql1WHERE LOWER(p.TrackingNumber) LIKE '%pkg-2025%'2 OR LOWER(p.Description) LIKE '%pkg-2025%'
Performance Considerations
LIKE '%term%' with a leading wildcard cannot use standard B-tree indexes. For small to medium datasets (under 100,000 rows), this is acceptable. For larger datasets, consider:
- Full-text search indexes (SQL Server or PostgreSQL) for better performance
- Trigram indexes (PostgreSQL
pg_trgm) for efficient substring matching - Limiting keyword search to indexed prefix matches (
StartsWithinstead ofContains)
For our parcel tracking API, Contains is sufficient. The tracking number field is relatively short, and the description field is optional.
Date Range Filtering
Date range filters use separate CreatedFrom and CreatedTo parameters. This allows the client to specify an open-ended range (only a start date or only an end date) or a closed range (both).
csharp1if (searchParams.CreatedFrom.HasValue)2{3 query = query.Where(p =>4 p.CreatedAt >= searchParams.CreatedFrom.Value);5}67if (searchParams.CreatedTo.HasValue)8{9 query = query.Where(p =>10 p.CreatedAt <= searchParams.CreatedTo.Value);11}
A request with both parameters:
GET /api/parcels?createdFrom=2025-02-01&createdTo=2025-02-15
Produces:
sql1WHERE p.CreatedAt >= '2025-02-01' AND p.CreatedAt <= '2025-02-15'
A request with only createdFrom produces a single condition, and the second WHERE clause is not added.
City and Country Filtering
Address-based filters search across both the shipper and recipient addresses using OR logic. This lets a client find all parcels going to or coming from a specific city.
csharp1if (!string.IsNullOrWhiteSpace(searchParams.City))2{3 query = query.Where(p =>4 p.ShipperAddress.City == searchParams.City ||5 p.RecipientAddress.City == searchParams.City);6}
EF Core navigates the relationships and generates the appropriate JOIN and WHERE clauses:
sql1WHERE sa.City = 'Berlin' OR ra.City = 'Berlin'
This works because we included the navigation properties with .Include() at the start of the query. Even without .Include(), EF Core can still generate the JOIN for filtering purposes, but including them ensures the address data is loaded in the result.
Registering the Endpoint
Wire the search method into your controller or minimal API endpoint:
csharp1[HttpGet]2public async Task<ActionResult<PagedResult<ParcelDto>>> SearchParcels(3 [FromQuery] ParcelSearchParams searchParams)4{5 var result = await _parcelService.SearchParcelsAsync(searchParams);6 return Ok(result);7}
The [FromQuery] attribute tells ASP.NET Core to bind all properties from the query string. Each property name matches a query parameter name (case-insensitive).
Testing with Different Filter Combinations
You can test various combinations to verify the dynamic behavior:
1# No filters - returns all parcels (paginated)2GET /api/parcels34# Single filter5GET /api/parcels?status=InTransit67# Multiple filters combined8GET /api/parcels?status=InTransit&serviceType=Express&city=Berlin910# Date range with keyword11GET /api/parcels?createdFrom=2025-02-01&createdTo=2025-02-15&keyword=PKG1213# Full search with all parameters14GET /api/parcels?status=InTransit&serviceType=Express&createdFrom=2025-02-01&createdTo=2025-02-15&city=Berlin&country=DE&keyword=PKG&sortBy=createdAt&sortDescending=true&pageSize=10
Each combination produces a different SQL query with only the relevant WHERE clauses.
Extracting a Reusable Filter Pattern
As your API grows, you may have multiple entities that need dynamic filtering. Extract the pattern into a common approach:
csharp1private static IQueryable<T> ApplyFilter<T>(2 IQueryable<T> query,3 bool shouldApply,4 Expression<Func<T, bool>> predicate)5{6 return shouldApply ? query.Where(predicate) : query;7}
Usage:
csharp1query = ApplyFilter(query,2 searchParams.Status.HasValue,3 p => p.Status == searchParams.Status!.Value);45query = ApplyFilter(query,6 !string.IsNullOrWhiteSpace(searchParams.City),7 p => p.ShipperAddress.City == searchParams.City ||8 p.RecipientAddress.City == searchParams.City);
This reduces repetitive if statements while keeping the logic explicit. Each call reads as "apply this filter if this condition is true."
Validating Search Parameters
Add basic validation to prevent invalid queries:
csharp1public class ParcelSearchParamsValidator2{3 public static void Validate(ParcelSearchParams searchParams)4 {5 if (searchParams.PageSize < 1)6 searchParams.PageSize = 1;78 if (searchParams.PageSize > 100)9 searchParams.PageSize = 100;1011 if (searchParams.CreatedFrom.HasValue &&12 searchParams.CreatedTo.HasValue &&13 searchParams.CreatedFrom > searchParams.CreatedTo)14 {15 throw new ValidationException(16 "CreatedFrom must be before CreatedTo");17 }18 }19}
Clamp the page size to valid bounds rather than returning an error. This is more ergonomic for API clients. However, do validate logical constraints like the date range order.
Debugging Generated SQL
During development, verify that EF Core generates the SQL you expect. Enable query logging in your DbContext configuration:
csharp1optionsBuilder2 .UseNpgsql(connectionString)3 .LogTo(Console.WriteLine, LogLevel.Information)4 .EnableSensitiveDataLogging();
This prints every SQL query to the console. Check that:
- Only the expected WHERE clauses appear
- JOINs are added only when address filters are used
- No unnecessary subqueries or client evaluation warnings appear
In production, disable EnableSensitiveDataLogging() to avoid logging parameter values.
Summary
In this presentation, you learned:
- Define a DTO with nullable properties to represent optional search parameters
- Use
IQueryableand conditional.Where()calls to build dynamic SQL queries - Chain filters with
ANDlogic; useORwithin a single.Where()for multi-column search - Keyword search uses
Contains()which translates toLIKE '%term%'in SQL - Date range filtering supports open-ended and closed ranges with separate from/to parameters
- City and country filters search across both shipper and recipient addresses
- Clamp page size to valid bounds rather than rejecting invalid values
- Log generated SQL during development to verify query correctness
Next, you will implement cursor-based pagination and sorting on top of these dynamic queries.