Ef Core

Interceptors в EF Core (Частина 1)

EF Core Interceptors — механізм перехоплення операцій на найнижчому рівні ORM. DbCommandInterceptor для модифікації SQL, SaveChangesInterceptor для аудиту та domain events, IDbConnectionInterceptor для управління з'єднаннями. Архітектура pipeline interceptors.

Interceptors в EF Core

Що таке Interceptori і навіщо вони потрібні

Entity Framework Core надає потужний механізм для перехоплення операцій на різних рівнях свого pipeline — від відкриття з'єднання з базою до виконання SQL-команди до моменту збереження змін. Цей механізм називається Interceptors.

Interceptor — це об'єкт що «вставляється» між EF Core і реальною операцією. Він може:

  • Спостерігати: дивитись що відбувається (логування, метрики) без зміни поведінки
  • Модифікувати: змінювати SQL-запит, параметри, результат
  • Замінювати: повністю замінити операцію своєю реалізацією (suppress + Result)

Концептуально interceptor — це той самий Middleware з ASP.NET Core, але на рівні бази даних. І так само як middleware реєструється у pipeline і використовує патерн «до + після».

Архітектура Pipeline Interceptors

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 і виконуються у порядку реєстрації «до» і у зворотному порядку «після».

Типи Interceptors в EF Core

InterceptorЩо перехоплює
DbCommandInterceptorSQL команди: SELECT, INSERT, UPDATE, DELETE
SaveChangesInterceptorLifecycle SaveChanges: before/after saving
IDbConnectionInterceptorВідкриття/закриття з'єднань
IDbTransactionInterceptorТранзакції: begin/commit/rollback
IMaterializationInterceptorМатеріалізація entity з DbDataReader
IInstantiationBindingInterceptorСтворення entity через конструктор

DbCommandInterceptor: перехоплення SQL

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);
    }
}

Slow Query Logger Interceptor

Один з найкориснішіх практичних 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;
    }
}

SQL Rewriting Interceptor: модифікація запитів

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;
    }
}

Query Counter Interceptor: підрахунок 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: перехоплення збереження

SaveChangesInterceptor дозволяє перехопити момент до і після SaveChanges. Він має доступ до ChangeTracker і до всіх змін що відбуваються.

Базова структура SaveChangesInterceptor

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);
    }
}

Automatic Audit Interceptor через SaveChangesInterceptor

Цей підхід переносить логіку аудиту з 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;
            }
        }
    }
}

Detailed Audit Log Interceptor

Більш детальний варіант — зберігає повний журнал змін у таблицю 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 у DI

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>()
           );
});
Scoped Interceptors і DbContext: DbContext реєструється як Scoped. Якщо Interceptor теж Scoped — все правильно: обидва живуть в межах одного HTTP-запиту. Якщо Interceptor Singleton, але через нього ви намагаєтесь отримати Scoped сервіс (наприклад, IHttpContextAccessor) — будуть проблеми. Завжди реєструйте Interceptors що залежать від HTTP-контексту як Scoped.

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

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

Завдання 1.1: SlowQueryInterceptor з налаштуванням

Реалізуйте SlowQueryInterceptor що:

  1. Приймає поріг через IConfiguration ("EfCore:SlowQueryThresholdMs")
  2. Логує: SQL (обрізаний до 200 символів), тривалість, тип команди (Reader/NonQuery)
  3. Для дуже повільних (> 2s) — LogError, для звичайних — LogWarning
  4. Напишіть тест: виконайте штучно повільний запит через WAITFOR DELAY '0:0:1' (SQL Server) або pg_sleep(1) (PostgreSQL) — перевірте що warning з'явився

Завдання 1.2: N+1 Detection у тестах

Додайте QueryCounterInterceptor до тестового DbContext (SQLite In-Memory). Напишіть тест для OrderRepository.GetOrdersWithCustomersAsync():

  1. Завантаження через Include → Assert QueryCount <= 2
  2. Завантаження без Include (навмисно) → Assert QueryCount > 5 (N+1 видно)

Завдання 1.3: AuditInterceptor без DbContext Override

Перенесіть логіку встановлення CreatedAt/UpdatedAt/CreatedBy/UpdatedBy з override SaveChangesAsync у DbContext у окремий AuditInterceptor : SaveChangesInterceptor. Перевірте через тест: після context.Add(entity); SaveChanges() поля заповнені. Після context.Update(entity); SaveChanges()CreatedAt не змінився.

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

Завдання 2.1: Conditional Query Hint Interceptor

Реалізуйте ReadUncommittedHintInterceptor що:

  1. Додає WITH (NOLOCK) (SQL Server) або SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED (загально) лише для запитів що містять TagWith("ReadUncommitted") у SQL
  2. Не чіпає інші запити
  3. Тест: запит з TagWith("ReadUncommitted") → SQL містить NOLOCK. Запит без тегу → SQL чистий

Завдання 2.2: Детальний Audit Log

Реалізуйте DetailedAuditInterceptor що після кожного SaveChanges:

  • Для Added: зберігає OldValues: null, NewValues: { всі поля }
  • Для Modified: зберігає лише змінені поля OldValues: { Price: 5000 }, NewValues: { Price: 6000 }
  • Для Deleted: зберігає OldValues: { всі поля }, NewValues: null
  • EntityId: заповнюється після INSERT (IDENTITY Id)

Напишіть тест: Product.Price змінено з 5000 на 6000 → AuditLog.OldValues = {"Price": 5000}.

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

Завдання 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.


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

Перша частина розкрила основи Interceptors в EF Core:

  • Архітектура: Interceptors — middleware на рівні БД. Pipeline: before → operation → after, у зворотному порядку для after.
  • DbCommandInterceptor: перехоплення SQL — Reader/NonQuery/Scalar. *Executing методи — до виконання, *Executed — після, CommandFailed — при помилці.
  • SlowQueryInterceptor: логування запитів що перевищують поріг. Нормалізація SQL для групування.
  • Query Rewriting: модифікація command.CommandText перед виконанням — Query Hints, NOLOCK.
  • QueryCounterInterceptor: підрахунок SQL для N+1 тестів. Interlocked.Increment для thread-safety.
  • SaveChangesInterceptor: перехоплення lifecycle SaveChanges. SavingChanges — до, SavedChanges — після (з доступом до згенерованих Id), SaveChangesFailed — при помилці.
  • Реєстрація: через AddInterceptors() у DbContextOptions. Scoped interceptors — через DI factory.

У другій частиніIDbConnectionInterceptor для connection pooling, IDbTransactionInterceptor, IMaterializationInterceptor для кастомної матеріалізації, Suppress Result для mock-ування БД у тестах, та Composite Interceptor Pattern.