557 lines
14 KiB
Markdown
557 lines
14 KiB
Markdown
# Queryable Providers
|
|
|
|
Implement data source providers for dynamic queries.
|
|
|
|
## Overview
|
|
|
|
Queryable providers (`IQueryableProvider<TSource>`) supply the base `IQueryable<TSource>` that dynamic queries operate on. They encapsulate data source logic and enable:
|
|
|
|
- ✅ **Data source abstraction** - Hide EF Core, Dapper, or other data access
|
|
- ✅ **Default filtering** - Apply base filters to all queries
|
|
- ✅ **Performance optimization** - Configure tracking, includes, indexes
|
|
- ✅ **Multiple sources** - Different providers for different scenarios
|
|
- ✅ **Testability** - Mock providers for unit tests
|
|
|
|
## Basic Provider
|
|
|
|
### IQueryableProvider Interface
|
|
|
|
```csharp
|
|
public interface IQueryableProvider<TSource>
|
|
{
|
|
IQueryable<TSource> GetQueryable();
|
|
}
|
|
```
|
|
|
|
### Simple EF Core Provider
|
|
|
|
```csharp
|
|
public class ProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public ProductQueryableProvider(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products.AsNoTracking();
|
|
}
|
|
}
|
|
```
|
|
|
|
### Registration
|
|
|
|
```csharp
|
|
builder.Services.AddDynamicQuery<Product, ProductDto>()
|
|
.AddDynamicQueryWithProvider<Product, ProductQueryableProvider>();
|
|
```
|
|
|
|
## Advanced Patterns
|
|
|
|
### Provider with Default Filters
|
|
|
|
```csharp
|
|
public class ActiveProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public ActiveProductQueryableProvider(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products
|
|
.AsNoTracking()
|
|
.Where(p => p.IsActive) // Only active products
|
|
.Where(p => p.DeletedAt == null); // Not soft-deleted
|
|
}
|
|
}
|
|
```
|
|
|
|
### Provider with Includes
|
|
|
|
```csharp
|
|
public class OrderQueryableProvider : IQueryableProvider<Order>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public OrderQueryableProvider(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public IQueryable<Order> GetQueryable()
|
|
{
|
|
return _context.Orders
|
|
.AsNoTracking()
|
|
.Include(o => o.Customer)
|
|
.Include(o => o.Items)
|
|
.ThenInclude(i => i.Product);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Provider with Tenant Filtering
|
|
|
|
```csharp
|
|
public class TenantProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly ITenantContext _tenantContext;
|
|
|
|
public TenantProductQueryableProvider(
|
|
ApplicationDbContext context,
|
|
ITenantContext tenantContext)
|
|
{
|
|
_context = context;
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
var tenantId = _tenantContext.TenantId;
|
|
|
|
return _context.Products
|
|
.AsNoTracking()
|
|
.Where(p => p.TenantId == tenantId);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Multiple Providers
|
|
|
|
### Scenario: Different Providers for Different Use Cases
|
|
|
|
```csharp
|
|
// Provider 1: All products (admin use)
|
|
public class AllProductsQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products.AsNoTracking();
|
|
}
|
|
}
|
|
|
|
// Provider 2: Active products only (public use)
|
|
public class ActiveProductsQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products
|
|
.AsNoTracking()
|
|
.Where(p => p.IsActive);
|
|
}
|
|
}
|
|
|
|
// Provider 3: Products in stock (sales use)
|
|
public class InStockProductsQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products
|
|
.AsNoTracking()
|
|
.Where(p => p.IsActive)
|
|
.Where(p => p.Stock > 0);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Registration with Different Queries
|
|
|
|
```csharp
|
|
// Admin query - all products
|
|
public record AdminProductDynamicQuery : IDynamicQuery<Product, ProductDto>
|
|
{
|
|
public List<IFilter>? Filters { get; set; }
|
|
public List<ISort>? Sorts { get; set; }
|
|
}
|
|
|
|
builder.Services.AddDynamicQuery<Product, ProductDto, AdminProductDynamicQuery>()
|
|
.AddDynamicQueryWithProvider<Product, AllProductsQueryableProvider>();
|
|
|
|
// Public query - active products
|
|
public record PublicProductDynamicQuery : IDynamicQuery<Product, ProductDto>
|
|
{
|
|
public List<IFilter>? Filters { get; set; }
|
|
public List<ISort>? Sorts { get; set; }
|
|
}
|
|
|
|
builder.Services.AddDynamicQuery<Product, ProductDto, PublicProductDynamicQuery>()
|
|
.AddDynamicQueryWithProvider<Product, ActiveProductsQueryableProvider>();
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### AsNoTracking for Read-Only Queries
|
|
|
|
```csharp
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
// ✅ Good - No tracking overhead
|
|
return _context.Products.AsNoTracking();
|
|
|
|
// ❌ Bad - Unnecessary change tracking
|
|
// return _context.Products;
|
|
}
|
|
```
|
|
|
|
### Selective Includes
|
|
|
|
```csharp
|
|
public IQueryable<Order> GetQueryable()
|
|
{
|
|
// ✅ Good - Include only what's needed in DTO
|
|
return _context.Orders
|
|
.AsNoTracking()
|
|
.Include(o => o.Customer);
|
|
|
|
// ❌ Bad - Unnecessary includes
|
|
// return _context.Orders
|
|
// .Include(o => o.Customer)
|
|
// .Include(o => o.Items) // Not needed in DTO
|
|
// .Include(o => o.ShippingAddress) // Not needed in DTO
|
|
// .Include(o => o.BillingAddress); // Not needed in DTO
|
|
}
|
|
```
|
|
|
|
### Projections in Provider
|
|
|
|
```csharp
|
|
public class ProductSummaryQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
// Only select columns needed for the DTO
|
|
return _context.Products
|
|
.AsNoTracking()
|
|
.Select(p => new Product
|
|
{
|
|
Id = p.Id,
|
|
Name = p.Name,
|
|
Price = p.Price,
|
|
Category = p.Category
|
|
// Omit large columns like Description, Images, etc.
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
## Caching Strategies
|
|
|
|
### In-Memory Caching
|
|
|
|
```csharp
|
|
public class CachedProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly IMemoryCache _cache;
|
|
private readonly ApplicationDbContext _context;
|
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
|
|
|
public CachedProductQueryableProvider(
|
|
IMemoryCache cache,
|
|
ApplicationDbContext context)
|
|
{
|
|
_cache = cache;
|
|
_context = context;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
const string cacheKey = "products_queryable";
|
|
|
|
if (!_cache.TryGetValue<List<Product>>(cacheKey, out var products))
|
|
{
|
|
products = _context.Products
|
|
.AsNoTracking()
|
|
.ToList();
|
|
|
|
_cache.Set(cacheKey, products, CacheDuration);
|
|
}
|
|
|
|
return products.AsQueryable();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note:** Only cache for small, relatively static datasets. Large or frequently changing data should not be cached this way.
|
|
|
|
## User-Specific Providers
|
|
|
|
### Current User's Data
|
|
|
|
```csharp
|
|
public class UserOrderQueryableProvider : IQueryableProvider<Order>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
|
|
public UserOrderQueryableProvider(
|
|
ApplicationDbContext context,
|
|
IHttpContextAccessor httpContextAccessor)
|
|
{
|
|
_context = context;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
}
|
|
|
|
public IQueryable<Order> GetQueryable()
|
|
{
|
|
var userId = _httpContextAccessor.HttpContext?.User
|
|
.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return _context.Orders
|
|
.AsNoTracking()
|
|
.Where(o => false); // No results for unauthenticated users
|
|
}
|
|
|
|
return _context.Orders
|
|
.AsNoTracking()
|
|
.Where(o => o.UserId == userId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Role-Based Filtering
|
|
|
|
```csharp
|
|
public class RoleBasedProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
|
|
|
public RoleBasedProductQueryableProvider(
|
|
ApplicationDbContext context,
|
|
IHttpContextAccessor httpContextAccessor)
|
|
{
|
|
_context = context;
|
|
_httpContextAccessor = httpContextAccessor;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
var user = _httpContextAccessor.HttpContext?.User;
|
|
|
|
if (user == null)
|
|
return _context.Products.Where(p => false);
|
|
|
|
var query = _context.Products.AsNoTracking();
|
|
|
|
// Admins see all products
|
|
if (user.IsInRole("Admin"))
|
|
return query;
|
|
|
|
// Managers see active products
|
|
if (user.IsInRole("Manager"))
|
|
return query.Where(p => p.IsActive);
|
|
|
|
// Regular users see active, in-stock products
|
|
return query
|
|
.Where(p => p.IsActive)
|
|
.Where(p => p.Stock > 0);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing
|
|
|
|
### Mock Queryable Provider
|
|
|
|
```csharp
|
|
public class MockProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly List<Product> _products;
|
|
|
|
public MockProductQueryableProvider(List<Product> products)
|
|
{
|
|
_products = products;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _products.AsQueryable();
|
|
}
|
|
}
|
|
|
|
// Unit test
|
|
[Fact]
|
|
public async Task DynamicQuery_FiltersProducts()
|
|
{
|
|
// Arrange
|
|
var products = new List<Product>
|
|
{
|
|
new() { Id = 1, Name = "Laptop", Category = "Electronics", Price = 999 },
|
|
new() { Id = 2, Name = "Book", Category = "Books", Price = 20 },
|
|
new() { Id = 3, Name = "Mouse", Category = "Electronics", Price = 25 }
|
|
};
|
|
|
|
var provider = new MockProductQueryableProvider(products);
|
|
|
|
// Use provider in tests...
|
|
}
|
|
```
|
|
|
|
### In-Memory EF Core Provider
|
|
|
|
```csharp
|
|
public class InMemoryProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public InMemoryProductQueryableProvider(ApplicationDbContext context)
|
|
{
|
|
_context = context;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
return _context.Products.AsNoTracking();
|
|
}
|
|
}
|
|
|
|
// Test fixture
|
|
public class DynamicQueryTests : IDisposable
|
|
{
|
|
private readonly ApplicationDbContext _context;
|
|
|
|
public DynamicQueryTests()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
.Options;
|
|
|
|
_context = new ApplicationDbContext(options);
|
|
|
|
// Seed test data
|
|
_context.Products.AddRange(
|
|
new Product { Id = 1, Name = "Test Product 1" },
|
|
new Product { Id = 2, Name = "Test Product 2" }
|
|
);
|
|
_context.SaveChanges();
|
|
}
|
|
|
|
[Fact]
|
|
public void Provider_ReturnsExpectedProducts()
|
|
{
|
|
var provider = new InMemoryProductQueryableProvider(_context);
|
|
var queryable = provider.GetQueryable();
|
|
|
|
Assert.Equal(2, queryable.Count());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern 1: Base Query with Security
|
|
|
|
```csharp
|
|
public abstract class SecureQueryableProvider<TSource> : IQueryableProvider<TSource>
|
|
where TSource : class, ITenantEntity
|
|
{
|
|
protected readonly ApplicationDbContext _context;
|
|
protected readonly ITenantContext _tenantContext;
|
|
|
|
protected SecureQueryableProvider(
|
|
ApplicationDbContext context,
|
|
ITenantContext tenantContext)
|
|
{
|
|
_context = context;
|
|
_tenantContext = tenantContext;
|
|
}
|
|
|
|
public IQueryable<TSource> GetQueryable()
|
|
{
|
|
var tenantId = _tenantContext.TenantId;
|
|
|
|
return _context.Set<TSource>()
|
|
.AsNoTracking()
|
|
.Where(e => e.TenantId == tenantId);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
public class ProductQueryableProvider : SecureQueryableProvider<Product>
|
|
{
|
|
public ProductQueryableProvider(
|
|
ApplicationDbContext context,
|
|
ITenantContext tenantContext)
|
|
: base(context, tenantContext)
|
|
{
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern 2: Composite Provider
|
|
|
|
```csharp
|
|
public class CompositeProductQueryableProvider : IQueryableProvider<Product>
|
|
{
|
|
private readonly IEnumerable<IQueryableProvider<Product>> _providers;
|
|
|
|
public CompositeProductQueryableProvider(
|
|
IEnumerable<IQueryableProvider<Product>> providers)
|
|
{
|
|
_providers = providers;
|
|
}
|
|
|
|
public IQueryable<Product> GetQueryable()
|
|
{
|
|
IQueryable<Product> result = null;
|
|
|
|
foreach (var provider in _providers)
|
|
{
|
|
var queryable = provider.GetQueryable();
|
|
result = result == null ? queryable : result.Union(queryable);
|
|
}
|
|
|
|
return result ?? Enumerable.Empty<Product>().AsQueryable();
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Use AsNoTracking() for read-only queries
|
|
- Filter at the provider level for security (tenant isolation)
|
|
- Include only necessary related entities
|
|
- Use dependency injection for context and services
|
|
- Create separate providers for different use cases
|
|
- Test providers independently
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't track entities in providers
|
|
- Don't perform synchronous operations (ToList, Count)
|
|
- Don't include unnecessary related entities
|
|
- Don't cache large datasets in memory
|
|
- Don't skip security filters
|
|
- Don't expose internal implementation details
|
|
|
|
## See Also
|
|
|
|
- [Dynamic Queries Overview](README.md)
|
|
- [Getting Started](getting-started.md)
|
|
- [Alter Queryable Services](alter-queryable-services.md)
|
|
- [Interceptors](interceptors.md)
|