Уявіть застосунок з 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 реєструє лямбда-вираз як глобальний фільтр для конкретного entity type. EF Core автоматично додає цей вираз у WHERE частину кожного згенерованого SQL.
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
Альтернативний підхід — конфігурація прямо в 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() вимикає всі глобальні фільтри для конкретного запиту:
// Адмін бачить ВСІ продукти включно з видаленими
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 стосується і навігаційних властивостей у тому ж запиті
var ordersWithDeletedItems = await context.Orders
.Include(o => o.LineItems)
.IgnoreQueryFilters() // вимикає для Order І для OrderLineItem якщо він теж має фільтр
.ToListAsync();
Soft Delete — найчастіший use case для Global Query Filters. Реалізуємо повноцінний soft delete з аудитом.
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);
}
}
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);
}
}
Замість ручного виклику .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 — архітектурний патерн, де один застосунок обслуговує кількох клієнтів (тенантів), а їхні дані ізольовані. Global Query Filter з TenantId — найпростіший спосіб забезпечити ізоляцію на рівні рядка.
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
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; }
}
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: 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. Протестуйте:
context.Articles.Remove(article) → SaveChanges() → в БД IsDeleted = 1Завдання 1.3: IgnoreQueryFilters для адміна
Реалізуйте AdminProductService і CustomerProductService, де:
AdminProductService.GetAllAsync() — повертає всі включно з видаленимиCustomerProductService.GetAvailableAsync() — повертає лише активніОбидва використовують той самий context, але по-різному застосовують IgnoreQueryFilters.
Завдання 2.1: Multi-Tenant з Global Query Filter
Реалізуйте multi-tenant BlogDbContext:
TenantId у кожному entity (через interface ITenantEntity)HasQueryFilter на основі ITenantService.GetCurrentTenantId()TenantInterceptor що автоматично заповнює TenantId при InsertНапишіть інтеграційний тест:
Завдання 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.1: Row-Level Security як Global Query Filter
Реалізуйте Row-Level Security для Ticket (системи підтримки):
AssigneeId == currentUserId)DepartmentId == currentUserDepartmentId)Global Query Filter має бути динамічним — залежати від ролі поточного користувача. Як реалізувати, якщо GQF визначається під час OnModelCreating (один раз), а роль змінюється з кожним HTTP-запитом?
Підходи:
_currentUser.GetRole() у замиканніAgentDbContext і AdminDbContextОцініть компроміси і реалізуйте обраний підхід.
У першій частині розглянули фундаментальні сценарії Global Query Filters:
.Where().HasQueryFilter(): один вираз у конфігурації → автоматично у кожному SQL-запиті до цього entity type.IgnoreQueryFilters(): вимикає всі GQF для конкретного запиту. Потрібен для адмін-функцій, відновлення, архівних звітів.HasQueryFilter(e => !e.IsDeleted) + SoftDeleteInterceptor для перехоплення Remove(). Відновлення через IgnoreQueryFilters + зміна прапорця.HasQueryFilter(p => p.TenantId == _tenantId) де _tenantId — з singleton/scoped сервісу. TenantInterceptor автоматично заповнює TenantId при Insert.HasQueryFilter з && для декількох умов.У другій частині — підводні камені з JOIN і Include, фільтри для вкладених навігаційних властивостей, Global Query Filter для TPH/TPT/TPC, тестування GQF, та повна інтеграція у реальний проєкт.
Seed Data — SQL-скрипти, Bogus та Стратегії (Частина 2)
Seeding через migrationBuilder.Sql(), завантаження seed з CSV і JSON файлів, використання Bogus для генерації fake-даних, EnsureCreated для тестів, ExecuteSqlRaw для bulk insert та повна матриця вибору стратегії.
Global Query Filters — Підводні камені та Інтеграція (Частина 2)
Підводні камені Global Query Filters — Include і JOIN з фільтрованими навігаційними властивостями, GQF у TPH/TPC ієрархіях, фільтри і оголошення міграцій, тестування GQF, інтеграція у реальний проєкт.