Ef Core

Global Query Filters — Підводні камені та Інтеграція (Частина 2)

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

Global Query Filters: Підводні Камені та Інтеграція

Це продовження статті «Global Query Filters». Читайте послідовно.


Підводний камінь №1: Include і фільтровані навігаційні властивості

Найнеочевидніша поведінка Global Query Filters — їх застосування до навігаційних властивостей при Include. Це може здивувати і спричинити складно-відтворювані баги.

Сценарій: Order з OrderLineItems

Обидва мають IsDeleted:

public class Order : SoftDeletableEntity
{
    public string OrderNumber { get; set; } = string.Empty;
    public int CustomerId { get; set; }
    public ICollection<OrderLineItem> LineItems { get; set; } = new List<OrderLineItem>();
}

public class OrderLineItem : SoftDeletableEntity
{
    public int OrderId { get; set; }
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public Order Order { get; set; } = null!;
}
// HasQueryFilter для обох
modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted);
modelBuilder.Entity<OrderLineItem>().HasQueryFilter(li => !li.IsDeleted);

Тепер запитаємо замовлення з рядками:

var order = await context.Orders
    .Include(o => o.LineItems)
    .FirstOrDefaultAsync(o => o.Id == orderId);

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

SELECT o.*, li.*
FROM Orders o
LEFT JOIN OrderLineItems li ON li.OrderId = o.Id
    AND li.IsDeleted = 0  -- ← фільтр застосований до JOIN!
WHERE o.IsDeleted = 0
  AND o.Id = @orderId

Зверніть: li.IsDeleted = 0 застосований у JOIN ON умові, не у WHERE. Це означає: замовлення з усіма рядками завантажиться, але рядки з IsDeleted = trueне потраплять у колекцію LineItems. Вони не будуть видалені — просто ніби їх немає.

Це правильна поведінка у більшості випадків. Але розробник може не очікувати, що order.LineItems.Sum(li => li.UnitPrice * li.Quantity) дасть неповну суму, якщо деякі рядки були soft-deleted.

Коли це проблема

// Адмін відновлює замовлення (IgnoreQueryFilters на Order)
var order = await context.Orders
    .IgnoreQueryFilters()
    .Include(o => o.LineItems) // IgnoreQueryFilters стосується OrderLineItems теж!
    .FirstOrDefaultAsync(o => o.Id == orderId);

// Тепер order.LineItems включає і видалені рядки
// Перевірка: скільки рядків у видаленого замовлення?
Console.WriteLine(order!.LineItems.Count); // ВСІ рядки, включно з IsDeleted=true

IgnoreQueryFilters() вимикає GQF для всієї query-graph, включно з навігаційними властивостями.

Роздільний контроль Include з IgnoreQueryFilters

Що якщо потрібно завантажити видалене замовлення, але тільки з активними рядками? Тут стандартний Include не дасть гнучкості — потрібен filtered include:

// Filtered Include: Include з умовою (EF Core 5+)
var order = await context.Orders
    .IgnoreQueryFilters()  // завантажуємо видалене замовлення
    .Include(o => o.LineItems.Where(li => !li.IsDeleted))  // але тільки активні рядки
    .FirstOrDefaultAsync(o => o.Id == orderId);

Filtered Include дозволяє мати власну умову для навігаційної властивості, незалежно від GQF.


Підводний камінь №2: Ланцюговий Include і вкладені фільтри

public class Category : SoftDeletableEntity
{
    public string Name { get; set; } = string.Empty;
    public ICollection<Product> Products { get; set; } = new List<Product>();
}

public class Product : SoftDeletableEntity
{
    public string Name { get; set; } = string.Empty;
    public int CategoryId { get; set; }
    public Category Category { get; set; } = null!;
    public ICollection<Review> Reviews { get; set; } = new List<Review>();
}

public class Review : SoftDeletableEntity
{
    public int ProductId { get; set; }
    public int Rating { get; set; }
    public string Text { get; set; } = string.Empty;
}
// Ланцюговий Include: Category → Products → Reviews
var categories = await context.Categories
    .Include(c => c.Products)
        .ThenInclude(p => p.Reviews)
    .ToListAsync();

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

SELECT c.*, p.*, r.*
FROM Categories c  -- GQF: c.IsDeleted = 0
LEFT JOIN Products p ON p.CategoryId = c.Id
    AND p.IsDeleted = 0  -- GQF для Product
LEFT JOIN Reviews r ON r.ProductId = p.Id
    AND r.IsDeleted = 0  -- GQF для Review
WHERE c.IsDeleted = 0

Всі три рівні фільтруються автоматично. Це один з найпотужніших аспектів GQF — вони «пронизують» весь граф навігаційних властивостей.


Підводний камінь №3: Explicit Loading і GQF

Explicit Loading (явне завантаження) — .Collection().LoadAsync() — також поважає GQF:

var order = await context.Orders.FindAsync(orderId);

// Явне завантаження: GQF застосується і тут
await context.Entry(order!)
             .Collection(o => o.LineItems)
             .LoadAsync();

// order.LineItems містить тільки не-видалені рядки

Якщо потрібно явно завантажити видалені:

// Explicit Loading з IgnoreQueryFilters через Query()
await context.Entry(order!)
             .Collection(o => o.LineItems)
             .Query()
             .IgnoreQueryFilters()
             .LoadAsync();

// order.LineItems тепер включає видалені

Підводний камінь №4: Circular Dependencies у GQF

Якщо OnModelCreating використовує IServiceProvider.GetService<T>() для отримання залежностей у GQF — можна отримати circular dependency або помилку scope:

// НЕБЕЗПЕЧНО: отримуємо scoped сервіс у OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var currentUser = _serviceProvider.GetService<ICurrentUserService>(); // ← Проблема!
    modelBuilder.Entity<Document>()
                .HasQueryFilter(d => d.OwnerId == currentUser!.GetUserId());
}

Проблема: OnModelCreating викликається один раз при першому використанні DbContext. Якщо перший запит виконується до авторизації — currentUser може бути null.

Правильний підхід: зберігати значення у ctor DbContext (Scoped), не в OnModelCreating (Singleton):

public class AppDbContext : DbContext
{
    private readonly int? _currentUserId;
    private readonly int? _currentTenantId;

    // Ці значення вводяться при створенні Scoped DbContext
    public AppDbContext(DbContextOptions<AppDbContext> options,
                        ICurrentUserService? currentUser = null,
                        ITenantService? tenantService = null)
        : base(options)
    {
        _currentUserId = currentUser?.GetUserId();
        _currentTenantId = tenantService?.GetCurrentTenantId();
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Замикання на полях DbContext — безпечно!
        modelBuilder.Entity<Document>()
                    .HasQueryFilter(d =>
                        (_currentTenantId == null || d.TenantId == (int)_currentTenantId) &&
                        !d.IsDeleted);
    }
}

Global Query Filters у TPH/TPC ієрархіях

TPH з GQF на базовому класі

public abstract class Publication
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsActive { get; set; }
}

public class Book : Publication { /* ... */ }
public class Article : Publication { /* ... */ }
// GQF на базовому класі: застосовується до ВСІХ нащадків
modelBuilder.Entity<Publication>()
            .HasQueryFilter(p => p.IsActive);

// Запит до нащадка — фільтр успадковується
var books = await context.Books.ToListAsync();
// SQL: SELECT ... FROM Publications WHERE Discriminator='book' AND IsActive=1

GQF, визначений на базовому класі TPH, автоматично застосовується до всіх нащадків. Не потрібно визначати його окремо для Book, Article тощо.

GQF на конкретному нащадку TPH

// Тільки для Book: додатковий фільтр поверх батьківського
modelBuilder.Entity<Book>()
            .HasQueryFilter(b => b.IsActive && !b.IsOutOfPrint);

// Article не має цього додаткового фільтра
Один HasQueryFilter на entity type: EF Core дозволяє лише один HasQueryFilter на entity type. Якщо викликати двічі — другий перезаписує перший. Для комбінованих умов — один && вираз.

TPC з GQF

// TPC: GQF на абстрактному базовому класі
modelBuilder.Entity<Publication>(b =>
{
    b.UseTpcMappingStrategy();
    b.HasQueryFilter(p => p.IsActive); // Застосовується до Books, Articles, Podcasts
});

SQL для TPC поліморфного запиту з GQF:

SELECT * FROM Books    WHERE IsActive = 1
UNION ALL
SELECT * FROM Articles WHERE IsActive = 1
UNION ALL
SELECT * FROM Podcasts WHERE IsActive = 1

GQF додається до кожного SELECT у UNION ALL.


GQF і HasData: важлива взаємодія

Global Query Filter не впливає на HasData seed — seed записи вставляються безпосередньо у міграціях, не через LINQ. Але є нюанс при перевірці unique constraints:

// Seed: вставляємо "видалений" запис
builder.HasData(new Product { Id = 999, Name = "Legacy Product", IsDeleted = true });

// GQF: WHERE IsDeleted = 0
// Тому context.Products.Find(999) → null (фільтрується)
// Але контрольний SELECT (без GQF) покаже запис у БД

Це правильна поведінка, але важливо пам'ятати: HasData вставляє незалежно від GQF.


Тестування Global Query Filters

Тестування GQF вимагає особливої уваги: потрібно перевірити і що фільтр застосовується, і що IgnoreQueryFilters вимикає його.

Unit Tests з SQLite In-Memory

public class SoftDeleteTests : IAsyncLifetime
{
    private AppDbContext _context = null!;

    public async Task InitializeAsync()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite("DataSource=:memory:")
            .Options;

        _context = new AppDbContext(options);
        await _context.Database.EnsureCreatedAsync();

        // Seed: один активний і один видалений
        _context.Products.AddRange(
            new Product { Id = 1, Name = "Active Product",  IsDeleted = false },
            new Product { Id = 2, Name = "Deleted Product", IsDeleted = true  }
        );
        await _context.SaveChangesAsync();
    }

    public async Task DisposeAsync()
    {
        await _context.Database.EnsureDeletedAsync();
        await _context.DisposeAsync();
    }

    [Fact]
    public async Task QueryFilter_ExcludesDeletedProducts()
    {
        // Звичайний запит: тільки активні
        var products = await _context.Products.ToListAsync();

        Assert.Single(products);
        Assert.Equal("Active Product", products[0].Name);
    }

    [Fact]
    public async Task IgnoreQueryFilters_IncludesDeleted()
    {
        // З IgnoreQueryFilters: всі
        var products = await _context.Products.IgnoreQueryFilters().ToListAsync();

        Assert.Equal(2, products.Count);
    }

    [Fact]
    public async Task FindAsync_ReturnsNull_ForDeletedProduct()
    {
        // FindAsync поважає GQF
        var product = await _context.Products.FindAsync(2);

        Assert.Null(product);
    }

    [Fact]
    public async Task SoftDelete_ViaInterceptor_SetsIsDeletedTrue()
    {
        var product = await _context.Products.FindAsync(1)!;

        _context.Products.Remove(product!); // фізичне видалення → перехоплюється
        await _context.SaveChangesAsync();

        // Перевіряємо у БД (з IgnoreQueryFilters)
        var inDb = await _context.Products.IgnoreQueryFilters()
                                           .FirstAsync(p => p.Id == 1);

        Assert.True(inDb.IsDeleted);
        Assert.NotNull(inDb.DeletedAt);
    }
}

Multi-Tenant Tests

public class MultiTenantTests
{
    private AppDbContext CreateContextForTenant(int tenantId)
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite($"DataSource=:memory:")
            .Options;

        // Створюємо DbContext з конкретним TenantId
        var mockTenantService = new Mock<ITenantService>();
        mockTenantService.Setup(s => s.GetCurrentTenantId()).Returns(tenantId);

        return new AppDbContext(options, tenantService: mockTenantService.Object);
    }

    [Fact]
    public async Task TenantIsolation_EachTenantSeesOwnData()
    {
        // Спільна база для тесту
        var sharedOptions = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite("DataSource=shared_test.db")
            .Options;

        // Підготовка: вставляємо дані для двох тенантів напряму (без GQF)
        using (var adminCtx = new AppDbContext(sharedOptions))
        {
            await adminCtx.Database.EnsureCreatedAsync();
            adminCtx.Articles.AddRange(
                new Article { Id = 1, Title = "Tenant1 Post 1", TenantId = 1 },
                new Article { Id = 2, Title = "Tenant1 Post 2", TenantId = 1 },
                new Article { Id = 3, Title = "Tenant2 Post 1", TenantId = 2 }
            );
            await adminCtx.SaveChangesAsync();
        }

        // Тенант 1 бачить 2 статті
        using (var ctx1 = CreateContextForTenant(1))
        {
            var articles = await ctx1.Articles.ToListAsync();
            Assert.Equal(2, articles.Count);
            Assert.All(articles, a => Assert.Equal(1, a.TenantId));
        }

        // Тенант 2 бачить 1 статтю
        using (var ctx2 = CreateContextForTenant(2))
        {
            var articles = await ctx2.Articles.ToListAsync();
            Assert.Single(articles);
            Assert.Equal(2, articles[0].TenantId);
        }
    }
}

Повна інтеграція у реальний проєкт

Зберемо все у реальний застосунок зі Soft Delete, Multi-Tenancy і GQF.

DbContext з усіма фільтрами

public class ShopDbContext : DbContext
{
    private readonly int? _tenantId;

    public ShopDbContext(DbContextOptions<ShopDbContext> options,
                         ITenantService? tenantService = null)
        : base(options)
    {
        _tenantId = tenantService?.GetCurrentTenantId();
    }

    public DbSet<Product>      Products      => Set<Product>();
    public DbSet<Category>     Categories    => Set<Category>();
    public DbSet<Customer>     Customers     => Set<Customer>();
    public DbSet<Order>        Orders        => Set<Order>();
    public DbSet<OrderLineItem> OrderLineItems => Set<OrderLineItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Тільки tenant-специфічні entity
        modelBuilder.Entity<Product>()
                    .HasQueryFilter(p => p.TenantId == _tenantId && !p.IsDeleted);

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

        modelBuilder.Entity<OrderLineItem>()
                    .HasQueryFilter(li => !li.IsDeleted); // лише soft delete, без tenant

        // Без tenant-фільтра (спільні довідники):
        modelBuilder.Entity<Category>()
                    .HasQueryFilter(c => !c.IsDeleted);

        // Customers: тільки soft delete, без tenant
        modelBuilder.Entity<Customer>()
                    .HasQueryFilter(c => !c.IsDeleted);

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

Реєстрація у DI

// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantService, HttpContextTenantService>();
builder.Services.AddScoped<SoftDeleteInterceptor>();
builder.Services.AddScoped<TenantInterceptor>();

builder.Services.AddDbContext<ShopDbContext>((provider, options) =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))
           .AddInterceptors(
               provider.GetRequiredService<SoftDeleteInterceptor>(),
               provider.GetRequiredService<TenantInterceptor>()
           );
});

Типовий контролер з GQF

[ApiController]
[Route("api/products")]
[Authorize]
public class ProductsController : ControllerBase
{
    private readonly ShopDbContext _context;

    public ProductsController(ShopDbContext context)
    {
        _context = context;
    }

    // GQF автоматично:
    // 1. Фільтрує за TenantId поточного тенанту
    // 2. Виключає видалені продукти
    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var products = await _context.Products
            .Include(p => p.Category) // Category теж фільтрується (тільки не-видалені)
            .OrderBy(p => p.Name)
            .ToListAsync();

        return Ok(products);
    }

    // Soft Delete: фізичне Remove → перехоплюється SoftDeleteInterceptor
    [HttpDelete("{id}")]
    public async Task<IActionResult> Delete(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product is null) return NotFound();

        _context.Products.Remove(product); // → IsDeleted = true через Interceptor
        await _context.SaveChangesAsync();
        return NoContent();
    }

    // Admin: бачить всі продукти ВСІХ тенантів (потребує Admin role)
    [HttpGet("admin/all")]
    [Authorize(Roles = "Admin")]
    public async Task<IActionResult> GetAllForAdmin()
    {
        var products = await _context.Products
            .IgnoreQueryFilters()
            .OrderBy(p => p.TenantId)
            .ThenBy(p => p.Name)
            .ToListAsync();

        return Ok(products);
    }
}

Матриця: коли що обирати

СценарійGQFРучний WhereIgnoreQueryFilters
Soft Delete базовий❌ (забувають)Для адміна/відновлення
Multi-Tenancy❌ (небезпечно)Для системних задач
RLS за відділом✅ (з Scoped сервісом)МожнаДля менеджерів відділу
Фільтр за IsPublished⚠️ (якщо скрізь)Краще (гнучкіше)Не потрібно
Featured тільки❌ (різний контекст)
Архівні записи✅ тільки для архівуАбо так

Правило: GQF підходить, якщо умова застосовується за замовчуванням до всіх запитів цього типу і рідко потрібно її вимикати. Якщо умова потрібна лише в деяких запитах — звичайний .Where() читабельніший.


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

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

Завдання 1.1: Filtered Include з IgnoreQueryFilters

Для Order з OrderLineItem (обидва мають GQF IsDeleted): напишіть запит що:

  • Завантажує видалене замовлення (IgnoreQueryFilters на Order)
  • Але тільки з активними рядками (LineItems.Where(!IsDeleted))

Перевірте SQL в логах — де застосовується AND li.IsDeleted = 0?

Завдання 1.2: GQF у TPH

Для TPH-ієрархії Notification (EmailNotification, SmsNotification) з базовим IsRead (bool) і IsDeleted:

  • Визначте HasQueryFilter(!IsDeleted) на базовому класі
  • Для SmsNotification визначте додатковий фільтр !IsDeleted && DeliveryStatus != "Failed"
  • Що відбудеться, якщо визначити HasQueryFilter і на базовому, і на нащадку?

Завдання 1.3: Тести для GQF

Напишіть 5 тестів для Product з Soft Delete GQF:

  1. GetAll не повертає видалені
  2. FindAsync повертає null для видаленого
  3. IgnoreQueryFilters повертає видалені
  4. Remove через Interceptor → IsDeleted = true, не DELETE
  5. Include(Category) → видалена Category не включається

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

Завдання 2.1: Explicit Loading з IgnoreQueryFilters

var order = await context.Orders.FindAsync(orderId);

// Варіант A: звичайний explicit loading
await context.Entry(order!).Collection(o => o.LineItems).LoadAsync();

// Варіант B: з IgnoreQueryFilters через Query()
await context.Entry(order!).Collection(o => o.LineItems)
             .Query().IgnoreQueryFilters().LoadAsync();

Напишіть тест що доводить різницю між A і B при наявності м'яко-видалених рядків.

Завдання 2.2: Динамічний GQF по ролях

Реалізуйте ArticleDbContext де GQF для Article залежить від ролі поточного користувача:

  • Guest: IsPublished = true
  • Member: IsPublished = true OR AuthorId == currentUserId
  • Admin: без фільтра

Три варіанти підходу:

  1. Три окремих DbContext класи
  2. Один DbContext з _role полем і комплексним GQF
  3. IgnoreQueryFilters + ручний where у сервісному шарі для Admin

Реалізуйте варіант 2. Протестуйте.

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

Завдання 3.1: Повна Multi-Tenant + Soft Delete + RLS система

Реалізуйте ProjectManagementDbContext для системи управління проєктами:

Entity:

  • Tenant (Id, Name)
  • Project (Id, TenantId, Name, OwnerId, IsDeleted)
  • Task (Id, TenantId, ProjectId, AssigneeId, IsDeleted, IsArchived)
  • Comment (Id, TenantId, TaskId, AuthorId, IsDeleted)

GQF правила:

  • Project: TenantId == currentTenant && !IsDeleted
  • Task: TenantId == currentTenant && !IsDeleted && !IsArchived для звичайних, без !IsArchived для архівного endpoint
  • Comment: TenantId == currentTenant && !IsDeleted

Додатково:

  • RLS для Task: AssigneeId == currentUserId OR Project.OwnerId == currentUserId
  • Admin не має обмежень

Реалізуйте і напишіть інтеграційні тести для кожного рівня ізоляції.


Підсумок статті 15

Ця стаття повністю розкрила Global Query Filters в EF Core:

Частина 1:

  • HasQueryFilter — один вираз, автоматично у кожному SQL
  • IgnoreQueryFilters — вимкнення для конкретного запиту
  • Soft Delete — SoftDeleteInterceptor перетворює Remove у soft delete
  • Multi-Tenancy — TenantId з Scoped сервісу у замиканні DbContext
  • Комбінування — один && вираз

Частина 2:

  • Include і GQF — фільтр застосовується у JOIN ON умові, IgnoreQueryFilters на весь граф
  • Filtered Include — Include(o => o.LineItems.Where(...)) для тонкого контролю
  • Explicit Loading з Query().IgnoreQueryFilters() — незалежний контроль
  • TPH/TPC — GQF на базовому класі успадковується нащадками
  • Circular dependencies — зберігати значення у ctor DbContext, не в OnModelCreating
  • Тести — Unit тести з SQLite In-Memory для GQF і IgnoreQueryFilters
  • Матриця вибору — GQF для «завжди», ручний .Where() для «іноді»

Наступна стаття — LINQ-запити (стаття 16) — поглиблений розбір LINQ у контексті EF Core: трансляція у SQL, GroupBy, window functions, розбіжність client/server evaluation.


Додаткові ресурси