Ef Core

Global Query Filters — Глобальні Фільтри (Частина 1)

Global Query Filters в EF Core — механізм автоматичної фільтрації запитів. Soft Delete через IsDeleted, Multi-Tenancy через TenantId, Row-Level Security. Конфігурація, HasQueryFilter, IgnoreQueryFilters, підводні камені з JOIN.

Global Query Filters: Глобальні Фільтри

Проблема: фільтрація скізь і скрізь

Уявіть застосунок з Soft Delete: видалені записи не видаляються фізично, а отримують IsDeleted = true. Кожен рядок у системі має цей прапорець. Тепер кожен LINQ-запит має додавати .Where(e => !e.IsDeleted):

// Без Global Query Filter: ця умова СКРІЗЬ
var products = await context.Products
    .Where(p => !p.IsDeleted)  // ← треба пам'ятати КОЖНОГО разу
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync();

var orders = await context.Orders
    .Include(o => o.LineItems)
    .Where(o => !o.IsDeleted)  // ← і тут
    .Where(o => o.CustomerId == customerId)
    .ToListAsync();

// Якщо забути — витікають видалені записи у відповідь!
var forgotFilter = await context.Products
    .Where(p => p.CategoryId == categoryId)
    .ToListAsync(); // ← видалені продукти потраплять у результат

Ця проблема — leaky abstraction. Розробник мусить пам'ятати про фільтр у кожному запиті. Забудеш — баг, що важко виявити тестами. Команда з 5 розробників і 200 запитів — 200 місць, де можна забути.

Аналогічна ситуація з Multi-Tenancy: кожен запит має фільтрувати за TenantId. Або Row-Level Security: відображати лише дані того відділу, до якого належить поточний користувач.

Global Query Filter вирішує цю проблему раз і назавжди: фільтр визначається один раз у конфігурації entity і автоматично застосовується до кожного LINQ-запиту до цього типу.


HasQueryFilter: синтаксис і принцип роботи

HasQueryFilter реєструє лямбда-вираз як глобальний фільтр для конкретного entity type. EF Core автоматично додає цей вираз у WHERE частину кожного згенерованого SQL.

Простий Soft Delete

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Name).IsRequired().HasMaxLength(300);
        builder.Property(p => p.Price).HasPrecision(12, 2);

        // Global Query Filter: автоматично додається до КОЖНОГО запиту
        builder.HasQueryFilter(p => !p.IsDeleted);
    }
}

Тепер усі запити до Product автоматично ігнорують видалені:

// Ці два запити ЕКВІВАЛЕНТНІ після впровадження HasQueryFilter:
var products1 = await context.Products.ToListAsync();
var products2 = await context.Products.Where(p => !p.IsDeleted).ToListAsync();

// SQL для products1 — фільтр додається автоматично:
// SELECT ... FROM Products WHERE IsDeleted = 0

// Додатковий фільтр — AND умова
var byCategory = await context.Products
    .Where(p => p.CategoryId == 5)
    .ToListAsync();
// SQL: WHERE IsDeleted = 0 AND CategoryId = 5

// FindAsync теж фільтрується!
var product = await context.Products.FindAsync(productId);
// Якщо product.IsDeleted = true → повернеться null

HasQueryFilter у DbContext через OnModelCreating

Альтернативний підхід — конфігурація прямо в OnModelCreating, без окремого класу конфігурації:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>()
                .HasQueryFilter(p => !p.IsDeleted);

    modelBuilder.Entity<Customer>()
                .HasQueryFilter(c => !c.IsDeleted);

    modelBuilder.Entity<Order>()
                .HasQueryFilter(o => !o.IsDeleted);

    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}

IgnoreQueryFilters: коли потрібно обійти фільтр

Іноді фільтр потрібно вимкнути — наприклад, для адміністративних запитів, архівних звітів або операцій відновлення. IgnoreQueryFilters() вимикає всі глобальні фільтри для конкретного запиту:

// Адмін бачить ВСІ продукти включно з видаленими
var allProducts = await context.Products
    .IgnoreQueryFilters()
    .ToListAsync();
// SQL: SELECT ... FROM Products (БЕЗ WHERE IsDeleted = 0)

// Відновлення конкретного видаленого продукту
var deletedProduct = await context.Products
    .IgnoreQueryFilters()
    .FirstOrDefaultAsync(p => p.Id == productId && p.IsDeleted);

if (deletedProduct is not null)
{
    deletedProduct.IsDeleted = false;
    deletedProduct.DeletedAt = null;
    await context.SaveChangesAsync();
}

// Звіт: кількість видалених (з IgnoreQueryFilters)
var deletedCount = await context.Products
    .IgnoreQueryFilters()
    .CountAsync(p => p.IsDeleted);

IgnoreQueryFilters для навігаційних властивостей

// IgnoreQueryFilters стосується і навігаційних властивостей у тому ж запиті
var ordersWithDeletedItems = await context.Orders
    .Include(o => o.LineItems)
    .IgnoreQueryFilters() // вимикає для Order І для OrderLineItem якщо він теж має фільтр
    .ToListAsync();

Soft Delete: повна реалізація

Soft Delete — найчастіший use case для Global Query Filters. Реалізуємо повноцінний soft delete з аудитом.

Абстрактний базовий клас SoftDeletableEntity

public abstract class SoftDeletableEntity
{
    public int Id { get; set; }
    public bool IsDeleted { get; set; }
    public DateTime? DeletedAt { get; set; }
    public string? DeletedBy { get; set; }
}

Конфігурація через абстрактний базовий клас конфігурації

public abstract class SoftDeletableConfiguration<T> : IEntityTypeConfiguration<T>
    where T : SoftDeletableEntity
{
    public virtual void Configure(EntityTypeBuilder<T> builder)
    {
        builder.HasKey(e => e.Id);

        builder.Property(e => e.IsDeleted).HasDefaultValue(false);
        builder.Property(e => e.DeletedAt);
        builder.Property(e => e.DeletedBy).HasMaxLength(200);

        // Global Query Filter — визначається один раз тут
        builder.HasQueryFilter(e => !e.IsDeleted);

        // Індекс: більшість запитів — по IsDeleted = false
        builder.HasIndex(e => e.IsDeleted)
               .HasFilter("[IsDeleted] = 0");
    }
}

// Конфігурація нащадка — тільки специфічні поля
public class ProductConfiguration : SoftDeletableConfiguration<Product>
{
    public override void Configure(EntityTypeBuilder<Product> builder)
    {
        base.Configure(builder); // Global Query Filter успадковується!

        builder.Property(p => p.Name).IsRequired().HasMaxLength(300);
        builder.Property(p => p.Price).HasPrecision(12, 2);
        builder.HasIndex(p => p.CategoryId);
    }
}

ISoftDeletableService: сервіс з бізнес-логікою

public interface ISoftDeletableService<T> where T : SoftDeletableEntity
{
    Task SoftDeleteAsync(int id, string deletedBy, CancellationToken ct = default);
    Task RestoreAsync(int id, CancellationToken ct = default);
    Task<List<T>> GetDeletedAsync(CancellationToken ct = default);
}

public class SoftDeletableService<T> : ISoftDeletableService<T>
    where T : SoftDeletableEntity
{
    private readonly AppDbContext _context;

    public SoftDeletableService(AppDbContext context)
    {
        _context = context;
    }

    public async Task SoftDeleteAsync(int id, string deletedBy, CancellationToken ct = default)
    {
        // IgnoreQueryFilters у FindAsync — хоча FindAsync фільтрується через GQF
        var entity = await _context.Set<T>().FindAsync([id], ct)
            ?? throw new NotFoundException($"{typeof(T).Name} з Id={id} не знайдено");

        entity.IsDeleted = true;
        entity.DeletedAt = DateTime.UtcNow;
        entity.DeletedBy = deletedBy;

        await _context.SaveChangesAsync(ct);
    }

    public async Task RestoreAsync(int id, CancellationToken ct = default)
    {
        // IgnoreQueryFilters — шукаємо серед видалених
        var entity = await _context.Set<T>()
            .IgnoreQueryFilters()
            .FirstOrDefaultAsync(e => e.Id == id && e.IsDeleted, ct)
            ?? throw new NotFoundException($"Видалений {typeof(T).Name} з Id={id} не знайдено");

        entity.IsDeleted = false;
        entity.DeletedAt = null;
        entity.DeletedBy = null;

        await _context.SaveChangesAsync(ct);
    }

    public async Task<List<T>> GetDeletedAsync(CancellationToken ct = default)
    {
        return await _context.Set<T>()
            .IgnoreQueryFilters()
            .Where(e => e.IsDeleted)
            .OrderByDescending(e => e.DeletedAt)
            .ToListAsync(ct);
    }
}

SaveChangesInterceptor для автоматичного Soft Delete

Замість ручного виклику .IsDeleted = true — перехопити DeleteAsync на рівні EF Core:

public class SoftDeleteInterceptor : SaveChangesInterceptor
{
    private readonly ICurrentUserService _currentUser;

    public SoftDeleteInterceptor(ICurrentUserService currentUser)
    {
        _currentUser = currentUser;
    }

    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData, InterceptionResult<int> result)
    {
        HandleSoftDelete(eventData.Context!);
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        HandleSoftDelete(eventData.Context!);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void HandleSoftDelete(DbContext context)
    {
        var deletedEntries = context.ChangeTracker
            .Entries<SoftDeletableEntity>()
            .Where(e => e.State == EntityState.Deleted)
            .ToList();

        foreach (var entry in deletedEntries)
        {
            // Перетворюємо фізичне видалення на soft delete
            entry.State = EntityState.Modified;
            entry.Entity.IsDeleted = true;
            entry.Entity.DeletedAt = DateTime.UtcNow;
            entry.Entity.DeletedBy = _currentUser.GetUserId();
        }
    }
}

Тепер context.Products.Remove(product) → замість DELETE генерує UPDATE IsDeleted = 1. Прозоро для коду, що викликає.

// Реєстрація у DI:
services.AddScoped<SoftDeleteInterceptor>();
services.AddDbContext<AppDbContext>((provider, options) =>
{
    options.UseSqlServer(connectionString)
           .AddInterceptors(provider.GetRequiredService<SoftDeleteInterceptor>());
});

Multi-Tenancy через TenantId

Multi-Tenancy — архітектурний патерн, де один застосунок обслуговує кількох клієнтів (тенантів), а їхні дані ізольовані. Global Query Filter з TenantId — найпростіший спосіб забезпечити ізоляцію на рівні рядка.

TenantId через DbContext параметр

public class AppDbContext : DbContext
{
    private readonly int _tenantId;

    // TenantId вводиться ззовні (з HTTP Request, JWT-токена тощо)
    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantService tenantService)
        : base(options)
    {
        _tenantId = tenantService.GetCurrentTenantId();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Global Query Filter: TenantId закритий у замиканні
        modelBuilder.Entity<Product>()
                    .HasQueryFilter(p => p.TenantId == _tenantId);

        modelBuilder.Entity<Customer>()
                    .HasQueryFilter(c => c.TenantId == _tenantId);

        modelBuilder.Entity<Order>()
                    .HasQueryFilter(o => o.TenantId == _tenantId);

        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
    }

    // Для операцій між тенантами (тільки для System Admin)
    public IQueryable<T> AllTenantsSet<T>() where T : class
        => Set<T>().IgnoreQueryFilters();
}
// ITenantService: отримує TenantId з поточного HTTP-контексту
public interface ITenantService
{
    int GetCurrentTenantId();
}

public class HttpContextTenantService : ITenantService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public HttpContextTenantService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public int GetCurrentTenantId()
    {
        // Отримуємо TenantId з JWT claim або header
        var claim = _httpContextAccessor.HttpContext?.User
            .FindFirst("tenant_id")?.Value;

        return int.TryParse(claim, out var tenantId)
            ? tenantId
            : throw new UnauthorizedException("TenantId not found in token");
    }
}
// Запити тепер автоматично ізольовані по тенанту:

// Тенант 1: бачить тільки свої продукти
var products = await context.Products.ToListAsync();
// SQL: SELECT ... FROM Products WHERE TenantId = 1 AND IsDeleted = 0

// Тенант 2: бачить тільки свої (без змін у коді!)
var theirProducts = await context.Products.ToListAsync();
// SQL: SELECT ... FROM Products WHERE TenantId = 2 AND IsDeleted = 0

Обов'язковий TenantId при збереженні

public class TenantInterceptor : SaveChangesInterceptor
{
    private readonly ITenantService _tenantService;

    public TenantInterceptor(ITenantService tenantService)
    {
        _tenantService = tenantService;
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        SetTenantId(eventData.Context!);
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void SetTenantId(DbContext context)
    {
        var tenantId = _tenantService.GetCurrentTenantId();

        // Автоматично заповнюємо TenantId для нових записів
        foreach (var entry in context.ChangeTracker.Entries<ITenantEntity>()
                                     .Where(e => e.State == EntityState.Added))
        {
            entry.Entity.TenantId = tenantId;
        }
    }
}

// Інтерфейс для типів з TenantId
public interface ITenantEntity
{
    int TenantId { get; set; }
}

Комбінування фільтрів: Soft Delete + Multi-Tenancy

Global Query Filter — один вираз на entity type. Якщо потрібно і Soft Delete, і TenantId — об'єднуємо в одному HasQueryFilter:

public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    private readonly int _tenantId;

    public ProductConfiguration(int tenantId)
    {
        _tenantId = tenantId;
    }

    public void Configure(EntityTypeBuilder<Product> builder)
    {
        // Комбінований фільтр: і tenant, і soft delete
        builder.HasQueryFilter(p => p.TenantId == _tenantId && !p.IsDeleted);
    }
}

Або у DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var tenantId = _tenantService.GetCurrentTenantId();

    modelBuilder.Entity<Product>().HasQueryFilter(
        p => p.TenantId == tenantId && !p.IsDeleted);

    modelBuilder.Entity<Order>().HasQueryFilter(
        o => o.TenantId == tenantId && !o.IsDeleted);
}

Генерований SQL:

SELECT ... FROM [Products]
WHERE [TenantId] = @tenantId AND [IsDeleted] = 0

Практичні завдання (Частина 1)

Рівень 1 — Базовий

Завдання 1.1: Soft Delete через базовий клас

Реалізуйте abstract class SoftDeletableEntity з IsDeleted, DeletedAt, DeletedBy. Нехай від нього наслідуються Article, Comment, Tag. У SoftDeletableConfiguration<T> задайте HasQueryFilter.

Перевірте:

  • context.Articles.ToListAsync() → видалені не повертаються
  • context.Articles.IgnoreQueryFilters().ToListAsync() → всі записи
  • context.Articles.FindAsync(id) де article.IsDeleted=true → null

Завдання 1.2: SoftDeleteInterceptor

Реалізуйте SoftDeleteInterceptor що перехоплює EntityState.Deleted і змінює на soft delete. Протестуйте:

  1. context.Articles.Remove(article)SaveChanges() → в БД IsDeleted = 1
  2. Ніякого фізичного DELETE у SQL

Завдання 1.3: IgnoreQueryFilters для адміна

Реалізуйте AdminProductService і CustomerProductService, де:

  • AdminProductService.GetAllAsync() — повертає всі включно з видаленими
  • CustomerProductService.GetAvailableAsync() — повертає лише активні

Обидва використовують той самий context, але по-різному застосовують IgnoreQueryFilters.

Рівень 2 — Логіка

Завдання 2.1: Multi-Tenant з Global Query Filter

Реалізуйте multi-tenant BlogDbContext:

  • TenantId у кожному entity (через interface ITenantEntity)
  • HasQueryFilter на основі ITenantService.GetCurrentTenantId()
  • TenantInterceptor що автоматично заповнює TenantId при Insert

Напишіть інтеграційний тест:

  1. Тенант A додає 3 статті
  2. Тенант B додає 2 статті
  3. Запит від Тенанту A → отримує рівно 3 (не 5)
  4. Запит від Тенанту B → отримує рівно 2

Завдання 2.2: Комбінований фільтр

Для Document (TenantId, IsDeleted, IsPublished) реалізуйте три режими перегляду:

  • Публічний (IsPublished = true AND IsDeleted = false AND TenantId = x)
  • Редактор (IsDeleted = false AND TenantId = x, включно з чернетками)
  • Адмін (IgnoreQueryFilters, всі документи всіх тенантів)

Як реалізувати різні Global Query Filters для різних «ролей»? (Підказка: або IgnoreQueryFilters + manual filter, або окремі DbContext класи)

Рівень 3 — Архітектура

Завдання 3.1: Row-Level Security як Global Query Filter

Реалізуйте Row-Level Security для Ticket (системи підтримки):

  • Агент бачить тільки свої тікети (AssigneeId == currentUserId)
  • Менеджер бачить тікети свого відділу (DepartmentId == currentUserDepartmentId)
  • Admin бачить всі

Global Query Filter має бути динамічним — залежати від ролі поточного користувача. Як реалізувати, якщо GQF визначається під час OnModelCreating (один раз), а роль змінюється з кожним HTTP-запитом?

Підходи:

  1. Scoped DbContext з різними GQF залежно від ролі
  2. Один GQF з _currentUser.GetRole() у замиканні
  3. Два окремих DbContext: AgentDbContext і AdminDbContext

Оцініть компроміси і реалізуйте обраний підхід.


Підсумок частини 1

У першій частині розглянули фундаментальні сценарії Global Query Filters:

  • Проблема: фільтр «скізь і скрізь» — leaky abstraction, що призводить до багів при забутому .Where().
  • HasQueryFilter(): один вираз у конфігурації → автоматично у кожному SQL-запиті до цього entity type.
  • IgnoreQueryFilters(): вимикає всі GQF для конкретного запиту. Потрібен для адмін-функцій, відновлення, архівних звітів.
  • Soft Delete: HasQueryFilter(e => !e.IsDeleted) + SoftDeleteInterceptor для перехоплення Remove(). Відновлення через IgnoreQueryFilters + зміна прапорця.
  • Multi-Tenancy: HasQueryFilter(p => p.TenantId == _tenantId) де _tenantId — з singleton/scoped сервісу. TenantInterceptor автоматично заповнює TenantId при Insert.
  • Комбінування: один HasQueryFilter з && для декількох умов.

У другій частині — підводні камені з JOIN і Include, фільтри для вкладених навігаційних властивостей, Global Query Filter для TPH/TPT/TPC, тестування GQF, та повна інтеграція у реальний проєкт.