Це продовження статті «Global Query Filters». Читайте послідовно.
Найнеочевидніша поведінка Global Query Filters — їх застосування до навігаційних властивостей при Include. Це може здивувати і спричинити складно-відтворювані баги.
Обидва мають 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 не дасть гнучкості — потрібен 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.
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 — вони «пронизують» весь граф навігаційних властивостей.
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 тепер включає видалені
Якщо 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);
}
}
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 тощо.
// Тільки для Book: додатковий фільтр поверх батьківського
modelBuilder.Entity<Book>()
.HasQueryFilter(b => b.IsActive && !b.IsOutOfPrint);
// Article не має цього додаткового фільтра
HasQueryFilter на entity type. Якщо викликати двічі — другий перезаписує перший. Для комбінованих умов — один && вираз.// 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.
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.
Тестування GQF вимагає особливої уваги: потрібно перевірити і що фільтр застосовується, і що IgnoreQueryFilters вимикає його.
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);
}
}
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.
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);
}
}
// 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>()
);
});
[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 | Ручний Where | IgnoreQueryFilters |
|---|---|---|---|
| Soft Delete базовий | ✅ | ❌ (забувають) | Для адміна/відновлення |
| Multi-Tenancy | ✅ | ❌ (небезпечно) | Для системних задач |
| RLS за відділом | ✅ (з Scoped сервісом) | Можна | Для менеджерів відділу |
| Фільтр за IsPublished | ⚠️ (якщо скрізь) | Краще (гнучкіше) | Не потрібно |
| Featured тільки | ❌ (різний контекст) | ✅ | — |
| Архівні записи | ❌ | ✅ тільки для архіву | Або так |
Правило: GQF підходить, якщо умова застосовується за замовчуванням до всіх запитів цього типу і рідко потрібно її вимикати. Якщо умова потрібна лише в деяких запитах — звичайний .Where() читабельніший.
Завдання 1.1: Filtered Include з IgnoreQueryFilters
Для Order з OrderLineItem (обидва мають GQF 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:
GetAll не повертає видаленіFindAsync повертає null для видаленогоIgnoreQueryFilters повертає видаленіRemove через Interceptor → IsDeleted = true, не DELETEInclude(Category) → видалена Category не включаєтьсяЗавдання 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 залежить від ролі поточного користувача:
IsPublished = trueIsPublished = true OR AuthorId == currentUserIdТри варіанти підходу:
_role полем і комплексним GQFIgnoreQueryFilters + ручний where у сервісному шарі для AdminРеалізуйте варіант 2. Протестуйте.
Завдання 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 && !IsDeletedTask: TenantId == currentTenant && !IsDeleted && !IsArchived для звичайних, без !IsArchived для архівного endpointComment: TenantId == currentTenant && !IsDeletedДодатково:
Task: AssigneeId == currentUserId OR Project.OwnerId == currentUserIdРеалізуйте і напишіть інтеграційні тести для кожного рівня ізоляції.
Ця стаття повністю розкрила Global Query Filters в EF Core:
Частина 1:
HasQueryFilter — один вираз, автоматично у кожному SQLIgnoreQueryFilters — вимкнення для конкретного запитуSoftDeleteInterceptor перетворює Remove у soft delete&& виразЧастина 2:
IgnoreQueryFilters на весь графInclude(o => o.LineItems.Where(...)) для тонкого контролюQuery().IgnoreQueryFilters() — незалежний контрольOnModelCreatingIgnoreQueryFilters.Where() для «іноді»Наступна стаття — LINQ-запити (стаття 16) — поглиблений розбір LINQ у контексті EF Core: трансляція у SQL, GroupBy, window functions, розбіжність client/server evaluation.
Global Query Filters — Глобальні Фільтри (Частина 1)
Global Query Filters в EF Core — механізм автоматичної фільтрації запитів. Soft Delete через IsDeleted, Multi-Tenancy через TenantId, Row-Level Security. Конфігурація, HasQueryFilter, IgnoreQueryFilters, підводні камені з JOIN.
LINQ-запити в EF Core (Частина 1)
Глибокий розбір LINQ в EF Core — трансляція у SQL, IQueryable vs IEnumerable, Server vs Client Evaluation, проєкції Select, GroupBy, підзапити, складні умови фільтрації. Анатомія LINQ-виразу та типові помилки.