dotnet-cqrs/docs/core-features/dynamic-queries/queryable-providers.md

14 KiB

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

public interface IQueryableProvider<TSource>
{
    IQueryable<TSource> GetQueryable();
}

Simple EF Core Provider

public class ProductQueryableProvider : IQueryableProvider<Product>
{
    private readonly ApplicationDbContext _context;

    public ProductQueryableProvider(ApplicationDbContext context)
    {
        _context = context;
    }

    public IQueryable<Product> GetQueryable()
    {
        return _context.Products.AsNoTracking();
    }
}

Registration

builder.Services.AddDynamicQuery<Product, ProductDto>()
    .AddDynamicQueryWithProvider<Product, ProductQueryableProvider>();

Advanced Patterns

Provider with Default Filters

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

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

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

// 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

// 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

public IQueryable<Product> GetQueryable()
{
    // ✅ Good - No tracking overhead
    return _context.Products.AsNoTracking();

    // ❌ Bad - Unnecessary change tracking
    // return _context.Products;
}

Selective Includes

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

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

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

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

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

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

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

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

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