459 lines
12 KiB
Markdown
459 lines
12 KiB
Markdown
# Basic Queries
|
|
|
|
Common query patterns for retrieving data.
|
|
|
|
## Overview
|
|
|
|
Basic queries are the most common read operations in CQRS applications. This guide covers standard patterns for fetching entities, lists, and aggregated data.
|
|
|
|
## Single Entity Queries
|
|
|
|
### Get by ID
|
|
|
|
```csharp
|
|
public record GetUserQuery
|
|
{
|
|
public int UserId { get; init; }
|
|
}
|
|
|
|
public record UserDto
|
|
{
|
|
public int Id { get; init; }
|
|
public string Name { get; init; } = string.Empty;
|
|
public string Email { get; init; } = string.Empty;
|
|
public DateTime CreatedAt { get; init; }
|
|
}
|
|
|
|
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var user = await _context.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Id == query.UserId, cancellationToken);
|
|
|
|
if (user == null)
|
|
throw new KeyNotFoundException($"User {query.UserId} not found");
|
|
|
|
return new UserDto
|
|
{
|
|
Id = user.Id,
|
|
Name = user.Name,
|
|
Email = user.Email,
|
|
CreatedAt = user.CreatedAt
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Get by Unique Field
|
|
|
|
```csharp
|
|
public record GetUserByEmailQuery
|
|
{
|
|
public string Email { get; init; } = string.Empty;
|
|
}
|
|
|
|
public class GetUserByEmailQueryHandler : IQueryHandler<GetUserByEmailQuery, UserDto>
|
|
{
|
|
public async Task<UserDto> HandleAsync(GetUserByEmailQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var user = await _context.Users
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(u => u.Email == query.Email, cancellationToken);
|
|
|
|
if (user == null)
|
|
throw new KeyNotFoundException($"User with email {query.Email} not found");
|
|
|
|
return MapToDto(user);
|
|
}
|
|
}
|
|
```
|
|
|
|
## List Queries
|
|
|
|
### Simple List
|
|
|
|
```csharp
|
|
public record ListUsersQuery
|
|
{
|
|
}
|
|
|
|
public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, List<UserDto>>
|
|
{
|
|
public async Task<List<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var users = await _context.Users
|
|
.AsNoTracking()
|
|
.OrderBy(u => u.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return users.Select(u => new UserDto
|
|
{
|
|
Id = u.Id,
|
|
Name = u.Name,
|
|
Email = u.Email,
|
|
CreatedAt = u.CreatedAt
|
|
}).ToList();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Paginated List
|
|
|
|
```csharp
|
|
public record ListUsersQuery
|
|
{
|
|
public int Page { get; init; } = 1;
|
|
public int PageSize { get; init; } = 10;
|
|
}
|
|
|
|
public record PagedResult<T>
|
|
{
|
|
public List<T> Items { get; init; } = new();
|
|
public int TotalCount { get; init; }
|
|
public int Page { get; init; }
|
|
public int PageSize { get; init; }
|
|
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
|
}
|
|
|
|
public class ListUsersQueryHandler : IQueryHandler<ListUsersQuery, PagedResult<UserDto>>
|
|
{
|
|
public async Task<PagedResult<UserDto>> HandleAsync(ListUsersQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var totalCount = await _context.Users.CountAsync(cancellationToken);
|
|
|
|
var users = await _context.Users
|
|
.AsNoTracking()
|
|
.OrderBy(u => u.Name)
|
|
.Skip((query.Page - 1) * query.PageSize)
|
|
.Take(query.PageSize)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return new PagedResult<UserDto>
|
|
{
|
|
Items = users.Select(MapToDto).ToList(),
|
|
TotalCount = totalCount,
|
|
Page = query.Page,
|
|
PageSize = query.PageSize
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Filtered List
|
|
|
|
```csharp
|
|
public record ListActiveUsersQuery
|
|
{
|
|
public bool? IsActive { get; init; }
|
|
}
|
|
|
|
public class ListActiveUsersQueryHandler : IQueryHandler<ListActiveUsersQuery, List<UserDto>>
|
|
{
|
|
public async Task<List<UserDto>> HandleAsync(ListActiveUsersQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var usersQuery = _context.Users.AsNoTracking();
|
|
|
|
if (query.IsActive.HasValue)
|
|
{
|
|
usersQuery = usersQuery.Where(u => u.IsActive == query.IsActive.Value);
|
|
}
|
|
|
|
var users = await usersQuery
|
|
.OrderBy(u => u.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return users.Select(MapToDto).ToList();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Search Queries
|
|
|
|
### Text Search
|
|
|
|
```csharp
|
|
public record SearchUsersQuery
|
|
{
|
|
public string Keyword { get; init; } = string.Empty;
|
|
public int Page { get; init; } = 1;
|
|
public int PageSize { get; init; } = 10;
|
|
}
|
|
|
|
public class SearchUsersQueryHandler : IQueryHandler<SearchUsersQuery, PagedResult<UserDto>>
|
|
{
|
|
public async Task<PagedResult<UserDto>> HandleAsync(SearchUsersQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var usersQuery = _context.Users.AsNoTracking();
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
|
{
|
|
var keyword = query.Keyword.ToLower();
|
|
usersQuery = usersQuery.Where(u =>
|
|
u.Name.ToLower().Contains(keyword) ||
|
|
u.Email.ToLower().Contains(keyword));
|
|
}
|
|
|
|
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
|
|
|
var users = await usersQuery
|
|
.OrderBy(u => u.Name)
|
|
.Skip((query.Page - 1) * query.PageSize)
|
|
.Take(query.PageSize)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return new PagedResult<UserDto>
|
|
{
|
|
Items = users.Select(MapToDto).ToList(),
|
|
TotalCount = totalCount,
|
|
Page = query.Page,
|
|
PageSize = query.PageSize
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### Multi-Criteria Search
|
|
|
|
```csharp
|
|
public record SearchProductsQuery
|
|
{
|
|
public string? Keyword { get; init; }
|
|
public string? Category { get; init; }
|
|
public decimal? MinPrice { get; init; }
|
|
public decimal? MaxPrice { get; init; }
|
|
public bool? InStock { get; init; }
|
|
}
|
|
|
|
public class SearchProductsQueryHandler : IQueryHandler<SearchProductsQuery, List<ProductDto>>
|
|
{
|
|
public async Task<List<ProductDto>> HandleAsync(SearchProductsQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var productsQuery = _context.Products.AsNoTracking();
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Keyword))
|
|
{
|
|
var keyword = query.Keyword.ToLower();
|
|
productsQuery = productsQuery.Where(p =>
|
|
p.Name.ToLower().Contains(keyword) ||
|
|
p.Description.ToLower().Contains(keyword));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(query.Category))
|
|
{
|
|
productsQuery = productsQuery.Where(p => p.Category == query.Category);
|
|
}
|
|
|
|
if (query.MinPrice.HasValue)
|
|
{
|
|
productsQuery = productsQuery.Where(p => p.Price >= query.MinPrice.Value);
|
|
}
|
|
|
|
if (query.MaxPrice.HasValue)
|
|
{
|
|
productsQuery = productsQuery.Where(p => p.Price <= query.MaxPrice.Value);
|
|
}
|
|
|
|
if (query.InStock.HasValue)
|
|
{
|
|
productsQuery = productsQuery.Where(p => p.Stock > 0);
|
|
}
|
|
|
|
var products = await productsQuery
|
|
.OrderBy(p => p.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return products.Select(MapToDto).ToList();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Aggregation Queries
|
|
|
|
### Count
|
|
|
|
```csharp
|
|
public record GetUserCountQuery
|
|
{
|
|
}
|
|
|
|
public class GetUserCountQueryHandler : IQueryHandler<GetUserCountQuery, int>
|
|
{
|
|
public async Task<int> HandleAsync(GetUserCountQuery query, CancellationToken cancellationToken)
|
|
{
|
|
return await _context.Users.CountAsync(cancellationToken);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Sum and Average
|
|
|
|
```csharp
|
|
public record GetOrderStatisticsQuery
|
|
{
|
|
public int CustomerId { get; init; }
|
|
}
|
|
|
|
public record OrderStatistics
|
|
{
|
|
public int TotalOrders { get; init; }
|
|
public decimal TotalAmount { get; init; }
|
|
public decimal AverageOrderValue { get; init; }
|
|
}
|
|
|
|
public class GetOrderStatisticsQueryHandler : IQueryHandler<GetOrderStatisticsQuery, OrderStatistics>
|
|
{
|
|
public async Task<OrderStatistics> HandleAsync(GetOrderStatisticsQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var statistics = await _context.Orders
|
|
.Where(o => o.CustomerId == query.CustomerId)
|
|
.GroupBy(o => o.CustomerId)
|
|
.Select(g => new OrderStatistics
|
|
{
|
|
TotalOrders = g.Count(),
|
|
TotalAmount = g.Sum(o => o.TotalAmount),
|
|
AverageOrderValue = g.Average(o => o.TotalAmount)
|
|
})
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
return statistics ?? new OrderStatistics();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Complex Queries
|
|
|
|
### Nested Data
|
|
|
|
```csharp
|
|
public record GetOrderWithDetailsQuery
|
|
{
|
|
public int OrderId { get; init; }
|
|
}
|
|
|
|
public record OrderDto
|
|
{
|
|
public int Id { get; init; }
|
|
public CustomerDto Customer { get; init; } = null!;
|
|
public List<OrderItemDto> Items { get; init; } = new();
|
|
public decimal TotalAmount { get; init; }
|
|
}
|
|
|
|
public class GetOrderWithDetailsQueryHandler : IQueryHandler<GetOrderWithDetailsQuery, OrderDto>
|
|
{
|
|
public async Task<OrderDto> HandleAsync(GetOrderWithDetailsQuery query, CancellationToken cancellationToken)
|
|
{
|
|
var order = await _context.Orders
|
|
.AsNoTracking()
|
|
.Include(o => o.Customer)
|
|
.Include(o => o.Items)
|
|
.ThenInclude(i => i.Product)
|
|
.FirstOrDefaultAsync(o => o.Id == query.OrderId, cancellationToken);
|
|
|
|
if (order == null)
|
|
throw new KeyNotFoundException($"Order {query.OrderId} not found");
|
|
|
|
return new OrderDto
|
|
{
|
|
Id = order.Id,
|
|
Customer = new CustomerDto
|
|
{
|
|
Id = order.Customer.Id,
|
|
Name = order.Customer.Name
|
|
},
|
|
Items = order.Items.Select(i => new OrderItemDto
|
|
{
|
|
ProductName = i.Product.Name,
|
|
Quantity = i.Quantity,
|
|
Price = i.Price
|
|
}).ToList(),
|
|
TotalAmount = order.TotalAmount
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### Use AsNoTracking
|
|
|
|
```csharp
|
|
// ✅ Good - AsNoTracking for read-only queries
|
|
var users = await _context.Users
|
|
.AsNoTracking()
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// ❌ Bad - Change tracking overhead
|
|
var users = await _context.Users
|
|
.ToListAsync(cancellationToken);
|
|
```
|
|
|
|
### Use Projections
|
|
|
|
```csharp
|
|
// ✅ Good - Select only needed columns
|
|
var users = await _context.Users
|
|
.AsNoTracking()
|
|
.Select(u => new UserDto
|
|
{
|
|
Id = u.Id,
|
|
Name = u.Name,
|
|
Email = u.Email
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// ❌ Bad - Fetch entire entity then map
|
|
var users = await _context.Users
|
|
.AsNoTracking()
|
|
.ToListAsync(cancellationToken);
|
|
var dtos = users.Select(MapToDto).ToList();
|
|
```
|
|
|
|
### Avoid N+1 Queries
|
|
|
|
```csharp
|
|
// ✅ Good - Use Include for related data
|
|
var orders = await _context.Orders
|
|
.Include(o => o.Customer)
|
|
.Include(o => o.Items)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// ❌ Bad - N+1 query problem
|
|
var orders = await _context.Orders.ToListAsync(cancellationToken);
|
|
foreach (var order in orders)
|
|
{
|
|
order.Customer = await _context.Customers.FindAsync(order.CustomerId);
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Use AsNoTracking for read-only queries
|
|
- Use projections (Select) to fetch only needed data
|
|
- Always materialize queries (ToListAsync, FirstOrDefaultAsync)
|
|
- Throw KeyNotFoundException for missing entities
|
|
- Use pagination for large result sets
|
|
- Optimize with indexes
|
|
- Accept CancellationToken
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't return IQueryable
|
|
- Don't return domain entities
|
|
- Don't modify state
|
|
- Don't fetch unnecessary data
|
|
- Don't skip pagination
|
|
- Don't ignore performance
|
|
|
|
## See Also
|
|
|
|
- [Query Registration](query-registration.md)
|
|
- [Query Authorization](query-authorization.md)
|
|
- [Dynamic Queries](../dynamic-queries/README.md)
|
|
- [Best Practices: Query Design](../../best-practices/query-design.md)
|