WebServiceToolkit.Database provides interfaces for building fluent query objects that encapsulate database access patterns.
The Query Object pattern separates query construction from execution, enabling:
- Fluent method chaining
- Reusable query components
- Testable data access
- Clean separation of concerns
Base interface for query objects with CRUD operations:
public interface IModelQuery<T, D>
{
T CreateNew(); // Factory method for new entities
Task AddAsync(T record); // Add entity to data store
Task UpdateAsync(T record); // Update existing entity
Task RemoveAsync(T record); // Remove entity
D Clone(); // Copy query state
}Adds pagination support:
public interface IQPageable<T>
{
T Skip(int value); // Skip N items
T Take(int value); // Take N items
}Adds search and lookup capabilities:
public interface IQSearchable<T, K>
{
T ByPublicId(K id); // Filter by ID
T Search(string search); // Text search
}
// Convenience interface for string IDs
public interface IQSearchable<T> : IQSearchable<T, string> { }Adds sorting support:
public interface IQSortable<T>
{
T SortBy(string column, bool isAsc);
string SortedBy { get; }
bool IsAsc { get; }
}Here's a complete implementation using Entity Framework Core:
using DevInstance.WebServiceToolkit.Database.Queries;
using Microsoft.EntityFrameworkCore;
public class ProductQuery :
IModelQuery<Product, ProductQuery>,
IQPageable<ProductQuery>,
IQSearchable<ProductQuery>,
IQSortable<ProductQuery>
{
private readonly AppDbContext _context;
private IQueryable<Product> _query;
private int _skip = 0;
private int _take = int.MaxValue;
public string SortedBy { get; private set; }
public bool IsAsc { get; private set; } = true;
public ProductQuery(AppDbContext context)
{
_context = context;
_query = context.Products.AsQueryable();
}
// Private constructor for cloning
private ProductQuery(AppDbContext context, IQueryable<Product> query,
int skip, int take, string sortedBy, bool isAsc)
{
_context = context;
_query = query;
_skip = skip;
_take = take;
SortedBy = sortedBy;
IsAsc = isAsc;
}
// IModelQuery<T, D> implementation
public Product CreateNew() => new Product { Id = Guid.NewGuid().ToString() };
public async Task AddAsync(Product record)
{
await _context.Products.AddAsync(record);
await _context.SaveChangesAsync();
}
public async Task UpdateAsync(Product record)
{
_context.Products.Update(record);
await _context.SaveChangesAsync();
}
public async Task RemoveAsync(Product record)
{
_context.Products.Remove(record);
await _context.SaveChangesAsync();
}
public ProductQuery Clone() => new ProductQuery(
_context, _query, _skip, _take, SortedBy, IsAsc);
// IQPageable<T> implementation
public ProductQuery Skip(int value)
{
_skip = value;
return this;
}
public ProductQuery Take(int value)
{
_take = value;
return this;
}
// IQSearchable<T> implementation
public ProductQuery ByPublicId(string id)
{
_query = _query.Where(p => p.Id == id);
return this;
}
public ProductQuery Search(string search)
{
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.ToLower();
_query = _query.Where(p =>
p.Name.ToLower().Contains(term) ||
p.Description.ToLower().Contains(term) ||
p.Category.ToLower().Contains(term));
}
return this;
}
// IQSortable<T> implementation
public ProductQuery SortBy(string column, bool isAsc)
{
SortedBy = column;
IsAsc = isAsc;
_query = (column?.ToLower()) switch
{
"name" => isAsc
? _query.OrderBy(p => p.Name)
: _query.OrderByDescending(p => p.Name),
"price" => isAsc
? _query.OrderBy(p => p.Price)
: _query.OrderByDescending(p => p.Price),
"category" => isAsc
? _query.OrderBy(p => p.Category)
: _query.OrderByDescending(p => p.Category),
"created" => isAsc
? _query.OrderBy(p => p.CreatedAt)
: _query.OrderByDescending(p => p.CreatedAt),
_ => _query.OrderBy(p => p.Id)
};
return this;
}
// Additional query methods
public ProductQuery ByCategory(string category)
{
if (!string.IsNullOrWhiteSpace(category))
{
_query = _query.Where(p => p.Category == category);
}
return this;
}
public ProductQuery InPriceRange(decimal? min, decimal? max)
{
if (min.HasValue)
_query = _query.Where(p => p.Price >= min.Value);
if (max.HasValue)
_query = _query.Where(p => p.Price <= max.Value);
return this;
}
// Execution methods
public async Task<List<Product>> ToListAsync()
{
return await _query.Skip(_skip).Take(_take).ToListAsync();
}
public async Task<Product?> FirstOrDefaultAsync()
{
return await _query.FirstOrDefaultAsync();
}
public async Task<int> CountAsync()
{
return await _query.CountAsync();
}
}public class ProductService : IProductService
{
private readonly ProductQuery _query;
public ProductService(AppDbContext context)
{
_query = new ProductQuery(context);
}
public async Task<Product?> GetByIdAsync(string id)
{
return await _query.Clone()
.ByPublicId(id)
.FirstOrDefaultAsync();
}
public async Task<List<Product>> SearchAsync(string term)
{
return await _query.Clone()
.Search(term)
.SortBy("name", isAsc: true)
.Take(20)
.ToListAsync();
}
}public async Task<ModelList<Product>> GetProductsAsync(ProductQueryParams queryParams)
{
var query = _query.Clone()
.Search(queryParams.Search)
.ByCategory(queryParams.Category)
.InPriceRange(queryParams.MinPrice, queryParams.MaxPrice)
.SortBy(queryParams.SortBy ?? "name", queryParams.IsAscending);
// Count total before pagination
var totalCount = await query.Clone().CountAsync();
// Apply pagination
var items = await query
.Skip(queryParams.Page * queryParams.PageSize)
.Take(queryParams.PageSize)
.ToListAsync();
return new ModelList<Product>
{
Items = items.ToArray(),
TotalCount = totalCount,
PagesCount = (int)Math.Ceiling(totalCount / (double)queryParams.PageSize),
Page = queryParams.Page,
Count = items.Count,
SortBy = query.SortedBy,
IsAsc = query.IsAsc,
Search = queryParams.Search
};
}The Clone() method is essential for reusing query objects without affecting the original state:
// Base query with common filters
var baseQuery = _query.Clone()
.Search("widget")
.ByCategory("electronics");
// Branch for first page
var page1 = baseQuery.Clone().Skip(0).Take(10).ToListAsync();
// Branch for second page (doesn't affect page1 query)
var page2 = baseQuery.Clone().Skip(10).Take(10).ToListAsync();
// Branch for count (doesn't include pagination)
var count = baseQuery.Clone().CountAsync();
// Execute all in parallel
await Task.WhenAll(page1, page2, count);Query objects make testing easier:
public class ProductQueryTests
{
[Fact]
public async Task Search_FiltersProducts()
{
// Arrange
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb")
.Options;
using var context = new AppDbContext(options);
context.Products.AddRange(
new Product { Id = "1", Name = "Widget", Category = "Tools" },
new Product { Id = "2", Name = "Gadget", Category = "Electronics" }
);
await context.SaveChangesAsync();
var query = new ProductQuery(context);
// Act
var results = await query.Search("widget").ToListAsync();
// Assert
Assert.Single(results);
Assert.Equal("Widget", results[0].Name);
}
}-
Always Clone before modifying a query:
// Good var result = await _query.Clone().ByPublicId(id).FirstOrDefaultAsync(); // Risky - modifies the shared query var result = await _query.ByPublicId(id).FirstOrDefaultAsync();
-
Keep queries immutable - each method should return
thisor a new instance, never modify shared state. -
Add domain-specific methods for common filters:
public ProductQuery ActiveOnly() => _query = _query.Where(p => !p.IsDeleted);
-
Compose queries by combining interface implementations:
public interface IProductQuery : IModelQuery<Product, IProductQuery>, IQPageable<IProductQuery>, IQSearchable<IProductQuery>, IQSortable<IProductQuery> { }