Entity Framework Core надає потужний механізм для перехоплення операцій на різних рівнях свого pipeline — від відкриття з'єднання з базою до виконання SQL-команди до моменту збереження змін. Цей механізм називається Interceptors.
Interceptor — це об'єкт що «вставляється» між EF Core і реальною операцією. Він може:
Концептуально interceptor — це той самий Middleware з ASP.NET Core, але на рівні бази даних. І так само як middleware реєструється у pipeline і використовує патерн «до + після».
DbContext.SaveChangesAsync()
↓
[SaveChangesInterceptor #1] ← перехопити ПЕРЕД
↓
[SaveChangesInterceptor #2] ← перехопити ПЕРЕД
↓
EF Core: детектує зміни, формує команди
↓
[DbCommandInterceptor #1] ← перехопити SQL
↓
[DbCommandInterceptor #2] ← перехопити SQL
↓
Database: виконує SQL
↓
[DbCommandInterceptor #2] ← перехопити результат (зворотній порядок)
↓
[DbCommandInterceptor #1] ← перехопити результат
↓
EF Core: оновлює Change Tracker
↓
[SaveChangesInterceptor #2] ← перехопити ПІСЛЯ
↓
[SaveChangesInterceptor #1] ← перехопити ПІСЛЯ
↓
Повернення до коду
Interceptors реєструються у DbContextOptions і виконуються у порядку реєстрації «до» і у зворотному порядку «після».
| Interceptor | Що перехоплює |
|---|---|
DbCommandInterceptor | SQL команди: SELECT, INSERT, UPDATE, DELETE |
SaveChangesInterceptor | Lifecycle SaveChanges: before/after saving |
IDbConnectionInterceptor | Відкриття/закриття з'єднань |
IDbTransactionInterceptor | Транзакції: begin/commit/rollback |
IMaterializationInterceptor | Матеріалізація entity з DbDataReader |
IInstantiationBindingInterceptor | Створення entity через конструктор |
DbCommandInterceptor — найчастіше використовуваний interceptor. Він перехоплює виконання SQL-команд і має методи для кожного типу: Reader (SELECT), Scalar (COUNT, SUM), NonQuery (INSERT/UPDATE/DELETE).
// Базовий клас — перевизначайте лише потрібні методи
public class MyCommandInterceptor : DbCommandInterceptor
{
// Перед виконанням SELECT (синхронно)
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
// result.HasResult: true якщо хочемо замінити виконання
return base.ReaderExecuting(command, eventData, result);
}
// Перед виконанням SELECT (асинхронно)
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
// Після виконання SELECT
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
return base.ReaderExecuted(command, eventData, result);
}
// Після виконання SELECT (async)
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
// При помилці виконання
public override void CommandFailed(DbCommand command, CommandErrorEventData eventData)
{
base.CommandFailed(command, eventData);
}
}
Один з найкориснішіх практичних interceptors — логування повільних запитів. Він спостерігає за тривалістю виконання і логує ті що перевищують поріг:
public class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
private readonly TimeSpan _slowQueryThreshold;
public SlowQueryInterceptor(
ILogger<SlowQueryInterceptor> logger,
TimeSpan? threshold = null)
{
_logger = logger;
_slowQueryThreshold = threshold ?? TimeSpan.FromMilliseconds(500);
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
LogIfSlow(command, eventData.Duration);
return base.ReaderExecuted(command, eventData, result);
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
LogIfSlow(command, eventData.Duration);
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
public override int NonQueryExecuted(
DbCommand command,
CommandExecutedEventData eventData,
int result)
{
LogIfSlow(command, eventData.Duration);
return base.NonQueryExecuted(command, eventData, result);
}
public override ValueTask<int> NonQueryExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
LogIfSlow(command, eventData.Duration);
return base.NonQueryExecutedAsync(command, eventData, result, cancellationToken);
}
private void LogIfSlow(DbCommand command, TimeSpan duration)
{
if (duration < _slowQueryThreshold) return;
// Нормалізуємо SQL для групування (видаляємо конкретні значення параметрів)
var normalizedSql = NormalizeSql(command.CommandText);
_logger.LogWarning(
"Slow query detected: {Duration}ms (threshold: {Threshold}ms)\n" +
"SQL: {Sql}\n" +
"Parameters: [{Params}]",
(int)duration.TotalMilliseconds,
(int)_slowQueryThreshold.TotalMilliseconds,
normalizedSql,
string.Join(", ", command.Parameters.Cast<DbParameter>()
.Select(p => $"{p.ParameterName}={p.Value}")));
}
private static string NormalizeSql(string sql)
{
// Спрощена нормалізація: залишаємо структуру, видаляємо великі IN-списки
return sql.Length > 500
? sql[..497] + "..."
: sql;
}
}
Interceptor може змінювати SQL-команду перед виконанням. Корисно для додавання Query Hints, force index, NOEXPAND для indexed views:
public class QueryHintInterceptor : DbCommandInterceptor
{
// Словник зіставлення: шаблон SQL → hint
private readonly Dictionary<string, string> _tableHints = new()
{
["Orders"] = "WITH (NOLOCK)", // Read uncommitted для звітів
["AuditLogs"] = "WITH (NOEXPAND)" // Indexed view
};
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
ModifyCommand(command);
return base.ReaderExecuting(command, eventData, result);
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
ModifyCommand(command);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
private void ModifyCommand(DbCommand command)
{
var sql = command.CommandText;
foreach (var (table, hint) in _tableHints)
{
// Замінюємо "FROM [TableName]" на "FROM [TableName] WITH (HINT)"
// Спрощено — реальна реалізація потребує парсингу SQL
sql = Regex.Replace(
sql,
$@"FROM \[{table}\](?!\s+WITH)",
$"FROM [{table}] {hint}",
RegexOptions.IgnoreCase);
}
command.CommandText = sql;
}
}
Корисний для тестування і виявлення N+1:
public class QueryCounterInterceptor : DbCommandInterceptor
{
private int _queryCount = 0;
private readonly List<string> _executedQueries = new();
public int QueryCount => _queryCount;
public IReadOnlyList<string> ExecutedQueries => _executedQueries.AsReadOnly();
public void Reset()
{
_queryCount = 0;
_executedQueries.Clear();
}
public override DbDataReader ReaderExecuted(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result)
{
Interlocked.Increment(ref _queryCount);
lock (_executedQueries) _executedQueries.Add(command.CommandText);
return base.ReaderExecuted(command, eventData, result);
}
public override ValueTask<DbDataReader> ReaderExecutedAsync(
DbCommand command,
CommandExecutedEventData eventData,
DbDataReader result,
CancellationToken cancellationToken = default)
{
Interlocked.Increment(ref _queryCount);
lock (_executedQueries) _executedQueries.Add(command.CommandText);
return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
}
}
Використання у тестах:
public class N1QueryTests : IClassFixture<DbFixture>
{
[Fact]
public async Task GetOrdersWithCustomers_ShouldNotProducN1()
{
// Arrange
var counter = new QueryCounterInterceptor();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite("DataSource=:memory:")
.AddInterceptors(counter)
.Options;
using var context = new AppDbContext(options);
await SeedTestDataAsync(context, ordersCount: 10);
counter.Reset(); // Скидаємо лічильник після seed
// Act
var orders = await context.Orders
.Include(o => o.Customer) // Eager loading — має бути 1 або 2 запити
.ToListAsync();
_ = orders.Select(o => o.Customer.FullName).ToList(); // Звернення до навігаційній
// Assert: не більше 2 SQL (1 для Orders + 1 для Customers при SplitQuery)
Assert.True(counter.QueryCount <= 2,
$"Expected <= 2 queries (no N+1), but got {counter.QueryCount}.\n" +
$"Executed:\n{string.Join("\n---\n", counter.ExecutedQueries)}");
}
}
SaveChangesInterceptor дозволяє перехопити момент до і після SaveChanges. Він має доступ до ChangeTracker і до всіх змін що відбуваються.
public class MySaveChangesInterceptor : SaveChangesInterceptor
{
// Синхронне before (повертає InterceptionResult щоб можна suppress)
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData,
InterceptionResult<int> result)
{
return base.SavingChanges(eventData, result);
}
// Асинхронне before
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
// Після збереження (result = кількість affected rows)
public override int SavedChanges(
SaveChangesCompletedEventData eventData,
int result)
{
return base.SavedChanges(eventData, result);
}
// Після збереження (async)
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
// Якщо збереження завершилось помилкою
public override void SaveChangesFailed(DbContextErrorEventData eventData)
{
base.SaveChangesFailed(eventData);
}
}
Цей підхід переносить логіку аудиту з DbContext Override у окремий клас — краще дотримання Single Responsibility:
public class AuditInterceptor : SaveChangesInterceptor
{
private readonly ICurrentUserService _currentUser;
private readonly ILogger<AuditInterceptor> _logger;
public AuditInterceptor(
ICurrentUserService currentUser,
ILogger<AuditInterceptor> logger)
{
_currentUser = currentUser;
_logger = logger;
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
ApplyAuditFields(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private void ApplyAuditFields(DbContext context)
{
var now = DateTime.UtcNow;
var userId = _currentUser.GetUserIdOrDefault();
foreach (var entry in context.ChangeTracker.Entries<IAuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
break;
case EntityState.Modified:
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
// Не перезаписуємо CreatedAt/By:
entry.Property(e => e.CreatedAt).IsModified = false;
entry.Property(e => e.CreatedBy).IsModified = false;
break;
}
}
}
}
Більш детальний варіант — зберігає повний журнал змін у таблицю AuditLogs:
public class DetailedAuditInterceptor : SaveChangesInterceptor
{
private List<AuditEntry> _pendingAuditEntries = new();
// Крок 1: ПЕРЕД збереженням — збираємо дані про зміни
// (після збереження — Original Values вже не доступні для Added entities)
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
_pendingAuditEntries = CollectAuditEntries(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
// Крок 2: ПІСЛЯ збереження — тепер знаємо згенеровані Id (IDENTITY)
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (_pendingAuditEntries.Count > 0 && eventData.Context is not null)
{
// Заповнюємо Id що були згенеровані при INSERT
FillGeneratedIds(_pendingAuditEntries, eventData.Context);
// Зберігаємо AuditLogs (новий SaveChanges в тому ж Context)
eventData.Context.AddRange(
_pendingAuditEntries.Select(e => e.ToAuditLog()));
await eventData.Context.SaveChangesAsync(cancellationToken);
_pendingAuditEntries.Clear();
}
return await base.SavedChangesAsync(eventData, result, cancellationToken);
}
private List<AuditEntry> CollectAuditEntries(DbContext context)
{
var entries = new List<AuditEntry>();
foreach (var entry in context.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added
or EntityState.Modified
or EntityState.Deleted))
{
var auditEntry = new AuditEntry
{
EntityType = entry.Entity.GetType().Name,
Action = entry.State.ToString(),
EntityKeyRef = entry // зберігаємо reference для заповнення Id після INSERT
};
foreach (var prop in entry.Properties
.Where(p => !p.Metadata.IsPrimaryKey()))
{
if (entry.State == EntityState.Added)
{
auditEntry.NewValues[prop.Metadata.Name] = prop.CurrentValue;
}
else if (entry.State == EntityState.Deleted)
{
auditEntry.OldValues[prop.Metadata.Name] = prop.OriginalValue;
}
else if (prop.IsModified)
{
auditEntry.OldValues[prop.Metadata.Name] = prop.OriginalValue;
auditEntry.NewValues[prop.Metadata.Name] = prop.CurrentValue;
}
}
entries.Add(auditEntry);
}
return entries;
}
private void FillGeneratedIds(List<AuditEntry> entries, DbContext context)
{
foreach (var entry in entries)
{
// Отримати PK після INSERT
var primaryKey = entry.EntityKeyRef?.Properties
.FirstOrDefault(p => p.Metadata.IsPrimaryKey())
?.CurrentValue;
entry.EntityId = primaryKey?.ToString() ?? "unknown";
}
}
}
// Допоміжний клас для збирання даних до SaveChanges
public class AuditEntry
{
public string EntityType { get; set; } = string.Empty;
public string EntityId { get; set; } = string.Empty;
public string Action { get; set; } = string.Empty;
public EntityEntry? EntityKeyRef { get; set; }
public Dictionary<string, object?> OldValues { get; } = new();
public Dictionary<string, object?> NewValues { get; } = new();
public AuditLog ToAuditLog() => new()
{
EntityType = EntityType,
EntityId = EntityId,
Action = Action,
OldValues = OldValues.Count > 0 ? JsonSerializer.Serialize(OldValues) : null,
NewValues = NewValues.Count > 0 ? JsonSerializer.Serialize(NewValues) : null,
Timestamp = DateTime.UtcNow
};
}
Interceptors реєструються через AddDbContext або DbContextOptionsBuilder. Scoped interceptors (що потребують HttpContext тощо) — через AddInterceptors з DI:
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, HttpContextCurrentUserService>();
// Scoped interceptors (один екземпляр на HTTP-запит)
builder.Services.AddScoped<AuditInterceptor>();
builder.Services.AddScoped<SoftDeleteInterceptor>();
// Singleton interceptors (статичні, без залежностей від HTTP-контексту)
builder.Services.AddSingleton<SlowQueryInterceptor>(provider =>
new SlowQueryInterceptor(
provider.GetRequiredService<ILogger<SlowQueryInterceptor>>(),
TimeSpan.FromMilliseconds(300)));
builder.Services.AddSingleton<QueryHintInterceptor>();
// DbContext: отримує interceptors з DI
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
serviceProvider.GetRequiredService<AuditInterceptor>(),
serviceProvider.GetRequiredService<SoftDeleteInterceptor>(),
serviceProvider.GetRequiredService<SlowQueryInterceptor>(),
serviceProvider.GetRequiredService<QueryHintInterceptor>()
);
});
IHttpContextAccessor) — будуть проблеми. Завжди реєструйте Interceptors що залежать від HTTP-контексту як Scoped.Завдання 1.1: SlowQueryInterceptor з налаштуванням
Реалізуйте SlowQueryInterceptor що:
IConfiguration ("EfCore:SlowQueryThresholdMs")LogError, для звичайних — LogWarningWAITFOR DELAY '0:0:1' (SQL Server) або pg_sleep(1) (PostgreSQL) — перевірте що warning з'явивсяЗавдання 1.2: N+1 Detection у тестах
Додайте QueryCounterInterceptor до тестового DbContext (SQLite In-Memory). Напишіть тест для OrderRepository.GetOrdersWithCustomersAsync():
Завдання 1.3: AuditInterceptor без DbContext Override
Перенесіть логіку встановлення CreatedAt/UpdatedAt/CreatedBy/UpdatedBy з override SaveChangesAsync у DbContext у окремий AuditInterceptor : SaveChangesInterceptor. Перевірте через тест: після context.Add(entity); SaveChanges() поля заповнені. Після context.Update(entity); SaveChanges() — CreatedAt не змінився.
Завдання 2.1: Conditional Query Hint Interceptor
Реалізуйте ReadUncommittedHintInterceptor що:
WITH (NOLOCK) (SQL Server) або SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED (загально) лише для запитів що містять TagWith("ReadUncommitted") у SQLTagWith("ReadUncommitted") → SQL містить NOLOCK. Запит без тегу → SQL чистийЗавдання 2.2: Детальний Audit Log
Реалізуйте DetailedAuditInterceptor що після кожного SaveChanges:
Added: зберігає OldValues: null, NewValues: { всі поля }Modified: зберігає лише змінені поля OldValues: { Price: 5000 }, NewValues: { Price: 6000 }Deleted: зберігає OldValues: { всі поля }, NewValues: nullEntityId: заповнюється після INSERT (IDENTITY Id)Напишіть тест: Product.Price змінено з 5000 на 6000 → AuditLog.OldValues = {"Price": 5000}.
Завдання 3.1: Interceptor Pipeline Builder
Реалізуйте InterceptorPipelineBuilder що дозволяє декларативно налаштувати pipeline:
builder.Services.AddDbContextWithInterceptors<AppDbContext>(pipeline =>
{
pipeline
.Add<AuditInterceptor>(ServiceLifetime.Scoped)
.Add<SoftDeleteInterceptor>(ServiceLifetime.Scoped)
.AddConditional<SlowQueryInterceptor>(
condition: env => env.IsProduction(),
lifetime: ServiceLifetime.Singleton)
.AddSingleton<QueryHintInterceptor>();
});
AddConditional реєструє interceptor лише якщо умова виконана. Реалізуйте через extension methods для IServiceCollection.
Перша частина розкрила основи Interceptors в EF Core:
*Executing методи — до виконання, *Executed — після, CommandFailed — при помилці.command.CommandText перед виконанням — Query Hints, NOLOCK.Interlocked.Increment для thread-safety.SavingChanges — до, SavedChanges — після (з доступом до згенерованих Id), SaveChangesFailed — при помилці.AddInterceptors() у DbContextOptions. Scoped interceptors — через DI factory.У другій частині — IDbConnectionInterceptor для connection pooling, IDbTransactionInterceptor, IMaterializationInterceptor для кастомної матеріалізації, Suppress Result для mock-ування БД у тестах, та Composite Interceptor Pattern.
Продуктивність EF Core — Основи (Частина 1)
Чому EF Core може бути катастрофічно повільним і як це виявити. N+1 проблема — найдорожчий антипатерн у ORM. Еволюція рішення від Eager Loading до Split Query. Проєкція як фундаментальна техніка. Logging generated SQL і EF Core Diagnostics.
Interceptors в EF Core — Connection, Transaction та Materialization (Частина 2)
IDbConnectionInterceptor для управління з'єднаннями і connection pooling. IDbTransactionInterceptor для спостереження за транзакціями. IMaterializationInterceptor для кастомної матеріалізації. Suppress Result — замінити виконання своїм результатом. Composite Interceptor Pattern.