Ef Core

Продвинуті Запити — Query Tags, Bulk та Interceptors (Частина 2)

Query Tags для діагностики SQL, Bulk Insert через BulkExtensions, No-Tracking Bulk Queries, Query Interceptors для автоматичної оптимізації, Plan Cache і EF Core Query Diagnostics.

Продвинуті Запити: Query Tags, Bulk та Interceptors

Це продовження статті «Продвинуті Запити». Читайте послідовно.


TagWith: Мітки для SQL-запитів

Знайти «чий» SQL виконується у базі — не завжди просто. Особливо коли кілька застосунків або сервісів звертаються до однієї БД. TagWith додає коментар у тіло SQL-запиту — видно у SQL Profiler, pg_stat_activity, slow query log.

// Без TagWith: SQL без контексту
var products = await context.Products
    .Where(p => p.Price > 1000)
    .ToListAsync();
// SQL: SELECT ... FROM Products WHERE Price > 1000
// У SQL Profiler: звідки цей запит? Яка Feature? Хто ініціював?

// З TagWith: коментар у SQL
var products = await context.Products
    .TagWith("ProductCatalog: GetExpensiveProducts — called from CatalogController")
    .Where(p => p.Price > 1000)
    .ToListAsync();
// SQL: -- ProductCatalog: GetExpensiveProducts — called from CatalogController
//      SELECT ... FROM Products WHERE Price > 1000

TagWithCallSite (EF Core 7+)

// Автоматично додає файл і рядок з якого викликається запит
var products = await context.Products
    .TagWithCallSite()    // ← додає назву файлу, метод і рядок
    .Where(p => p.Price > 1000)
    .ToListAsync();
// SQL: -- File: ProductRepository.cs:42
//      -- Method: GetExpensiveProductsAsync
//      SELECT ... FROM Products WHERE Price > 1000

TagWith для performance profiling

// Множинні теги для детальної діагностики
var orders = await context.Orders
    .TagWith($"FeatureFlag: OrderManagement")
    .TagWith($"User: {userId}")
    .TagWith($"RequestId: {Activity.Current?.Id}")
    .Where(o => o.CustomerId == customerId)
    .ToListAsync();
// SQL: -- FeatureFlag: OrderManagement
//      -- User: 42
//      -- RequestId: 00-abc123-def456-01
//      SELECT ... FROM Orders WHERE CustomerId = 42

No-Tracking Queries: глобальна та локальна оптимізація

У попередніх статтях AsNoTracking() вже розглядався. Тут — більш глибока оптимізація.

UseQueryTrackingBehavior: глобальне налаштування

// Read-Only DbContext для звітів: жоден запит не трекується
services.AddDbContext<ReportDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});

// Але коли потрібна IdentityResolution (дедублікація без трекінгу):
services.AddDbContext<ReportDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTrackingWithIdentityResolution);
});

AsTracking: явне увімкнення для конкретного запиту

Коли глобальний NoTracking, але для конкретного запиту потрібне трекування:

// Глобально NoTracking, але тут — треки (для SaveChanges)
var order = await context.Orders
    .AsTracking()   // ← явно увімкнути для цього запиту
    .Include(o => o.LineItems)
    .FirstOrDefaultAsync(o => o.Id == orderId);

order!.Status = "Processing";
await context.SaveChangesAsync(); // ← Change Tracker знає про зміни

Bulk Operations через BulkExtensions

Стандартний EF Core не підтримує native bulk insert (батч вставки без N окремих INSERT). AddRange + SaveChanges генерує N INSERT командіFox (хоча і батчує їх у меншу кількість мережевих roundtrips). Для справжнього bulk — потрібні розширення або Raw SQL.

EFCore.BulkExtensions

dotnet add package EFCore.BulkExtensions
using EFCore.BulkExtensions;

// Підготовка великого набору даних
var productsToInsert = Enumerable.Range(1, 10_000)
    .Select(i => new Product
    {
        Name       = $"Product {i}",
        Price      = Random.Shared.Next(100, 50000),
        CategoryId = Random.Shared.Next(1, 10),
        IsActive   = true,
        CreatedAt  = DateTime.UtcNow
    })
    .ToList();

// Bulk Insert: один SQL BULK INSERT замість 10,000 окремих
await context.BulkInsertAsync(productsToInsert, new BulkConfig
{
    BatchSize      = 1000,     // Розмір одного батчу
    BulkCopyTimeout = 60,      // Timeout у секундах
    SetOutputIdentity = true,  // Заповнити Id після вставки
});
// Час: ~200ms замість ~8s для 10K рядків через AddRange

Console.WriteLine($"Inserted {productsToInsert.Count} products");
Console.WriteLine($"First Id: {productsToInsert[0].Id}"); // Id заповнені!

BulkUpdate та BulkDelete

// Bulk Update: UPDATE без завантаження в пам'ять
var productsToUpdate = await context.Products
    .Where(p => p.CategoryId == oldCategoryId)
    .AsNoTracking()
    .ToListAsync();

foreach (var p in productsToUpdate)
    p.CategoryId = newCategoryId;

await context.BulkUpdateAsync(productsToUpdate, new BulkConfig { BatchSize = 1000 });
// Один MERGE statement замість N окремих UPDATE

// Або через ExecuteUpdateAsync (EF Core 7+) — ще простіше:
await context.Products
    .Where(p => p.CategoryId == oldCategoryId)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.CategoryId, newCategoryId));

SqlBulkCopy: SQL Server за рахунок провайдерної специфіки

Для максимальної швидкості в SQL Server:

using Microsoft.Data.SqlClient;

public async Task BulkInsertRawAsync<T>(IEnumerable<T> items, string tableName)
{
    var connection = (SqlConnection)context.Database.GetDbConnection();
    await connection.OpenAsync();

    using var bulkCopy = new SqlBulkCopy(connection)
    {
        DestinationTableName = tableName,
        BatchSize            = 5000,
        BulkCopyTimeout      = 120
    };

    // DataTable з даними
    var dataTable = ToDataTable(items);
    await bulkCopy.WriteToServerAsync(dataTable);
}

COPY для PostgreSQL (Npgsql)

// PostgreSQL: COPY — найшвидший bulk insert
using var writer = await connection.BeginBinaryImportAsync(
    "COPY products (name, price, category_id, is_active) FROM STDIN (FORMAT BINARY)");

foreach (var product in products)
{
    await writer.StartRowAsync();
    await writer.WriteAsync(product.Name,       NpgsqlDbType.Text);
    await writer.WriteAsync(product.Price,      NpgsqlDbType.Numeric);
    await writer.WriteAsync(product.CategoryId, NpgsqlDbType.Integer);
    await writer.WriteAsync(product.IsActive,   NpgsqlDbType.Boolean);
}

await writer.CompleteAsync();
// Швидкість COPY: 50K-200K рядків/секунду залежно від розміру

Query Interceptors для автоматичної оптимізації

Interceptors — потужний механізм для перехоплення і модифікації запитів без зміни коду запитів. У контексті оптимізації — можна додати timeout, query hints, logging автоматично.

Загальний тайм-аут для повільних запитів

public class QueryTimeoutInterceptor : DbCommandInterceptor
{
    private readonly int _timeoutSeconds;

    public QueryTimeoutInterceptor(int timeoutSeconds = 30)
    {
        _timeoutSeconds = timeoutSeconds;
    }

    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        command.CommandTimeout = _timeoutSeconds;
        return base.ReaderExecuting(command, eventData, result);
    }

    public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken cancellationToken = default)
    {
        command.CommandTimeout = _timeoutSeconds;
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
}

Slow Query Logger

public class SlowQueryLoggerInterceptor : DbCommandInterceptor
{
    private readonly ILogger<SlowQueryLoggerInterceptor> _logger;
    private readonly TimeSpan _threshold;
    private readonly ConcurrentDictionary<Guid, Stopwatch> _timers = new();

    public SlowQueryLoggerInterceptor(
        ILogger<SlowQueryLoggerInterceptor> logger,
        int thresholdMs = 500)
    {
        _logger = logger;
        _threshold = TimeSpan.FromMilliseconds(thresholdMs);
    }

    public override DbDataReader ReaderExecuted(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result)
    {
        LogSlowQuery(command, eventData.Duration);
        return base.ReaderExecuted(command, eventData, result);
    }

    public override ValueTask<DbDataReader> ReaderExecutedAsync(
        DbCommand command,
        CommandExecutedEventData eventData,
        DbDataReader result,
        CancellationToken cancellationToken = default)
    {
        LogSlowQuery(command, eventData.Duration);
        return base.ReaderExecutedAsync(command, eventData, result, cancellationToken);
    }

    private void LogSlowQuery(DbCommand command, TimeSpan duration)
    {
        if (duration >= _threshold)
        {
            _logger.LogWarning(
                "Slow query detected: {Duration}ms\nSQL:\n{Sql}",
                duration.TotalMilliseconds,
                command.CommandText);
        }
    }
}

Query Hint Interceptor (SQL Server NOLOCK)

// Додати NOLOCK hint для read-uncommitted запитів (обережно!)
public class NoLockInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result)
    {
        // Лише для SELECT, не для INSERT/UPDATE/DELETE
        if (command.CommandText.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
        {
            // Обгортаємо SELECT у SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
            // або замінюємо назви таблиць на "tableName WITH (NOLOCK)"
            // (спрощено — в реальності потрібен парсинг SQL)
        }
        return base.ReaderExecuting(command, eventData, result);
    }
}

Реєстрація Interceptors

services.AddScoped<SlowQueryLoggerInterceptor>();
services.AddSingleton<QueryTimeoutInterceptor>();

services.AddDbContext<AppDbContext>((provider, options) =>
{
    options.UseSqlServer(connectionString)
           .AddInterceptors(
               provider.GetRequiredService<SlowQueryLoggerInterceptor>(),
               provider.GetRequiredService<QueryTimeoutInterceptor>()
           );
});

EF Core Plan Cache: розуміння механізму

EF Core кешує план трансляції LINQ→SQL у внутрішньому кеші. Це означає:

// Запит 1: перший виклик — компіляція і кешування
var p1 = await context.Products.Where(p => p.Price > 1000).ToListAsync();

// Запит 2: той самий шаблон — з кешу (без повторної компіляції)
var p2 = await context.Products.Where(p => p.Price > 2000).ToListAsync();
// Параметр 2000 стає @p0 — структура запиту та сама

Але кеш може «промахнутись» при:

// ПРОБЛЕМА: динамічне конкатенування WHERE
IQueryable<Product> query = context.Products;
if (filterByName)
    query = query.Where(p => p.Name.Contains(name));  // ← різна структура!
if (filterByPrice)
    query = query.Where(p => p.Price > price);        // ← ще одна структура!

// Кожна комбінація фільтрів — окремий план у кеші
// 2^N унікальних планів для N необов'язкових фільтрів

Вирішення через параметризацію в SQL:

// Один план: всі фільтри завжди присутні, але nullable параметри
var products = await context.Products
    .Where(p =>
        (name == null    || p.Name.Contains(name)) &&
        (minPrice == null || p.Price >= minPrice) &&
        (categoryId == null || p.CategoryId == categoryId))
    .ToListAsync();
// Один план кешується для будь-якої комбінації null/not-null значень

Діагностика: що виконується в базі

// Логування через DbContext options
services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(
               message => Debug.WriteLine(message),
               new[] { DbLoggerCategory.Database.Command.Name },
               LogLevel.Information,
               DbContextLoggerOptions.DefaultWithLocalTime
           )
           .EnableSensitiveDataLogging()     // показує значення параметрів
           .EnableDetailedErrors());         // детальні помилки

// ToQueryString: отримати SQL без виконання (EF Core 5+)
var query = context.Products
    .Where(p => p.Price > 1000)
    .OrderBy(p => p.Name);

string sql = query.ToQueryString(); // ← SQL без виконання
Console.WriteLine(sql);
// SELECT [p].[Id], [p].[Name], ...
// FROM [Products] AS [p]
// WHERE [p].[Price] > 1000.0
// ORDER BY [p].[Name]

Temporal Queries (SQL Server): подорож у часі

Temporal Tables — функціонал SQL Server (2016+) і PostgreSQL (через розширення temporal_tables), що зберігає повну історію змін кожного рядка. EF Core (7+) має першокласну підтримку запитів до цих таблиць.

Як це працює

SQL Server автоматично підтримує два додаткові стовпці: ValidFrom і ValidTo. При кожному UPDATE або DELETE стара версія рядка копіюється у history-таблицю зі значенням ValidTo = момент зміни. Поточна версія залишається у головній таблиці з ValidTo = '9999-12-31'.

Маппінг у EF Core:

public class Product
{
    public int     Id        { get; set; }
    public string  Name      { get; set; } = string.Empty;
    public decimal Price     { get; set; }
    // ValidFrom і ValidTo — керуються SQL Server автоматично (не додаємо у клас)
}

// Конфігурація Temporal Table
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
    public void Configure(EntityTypeBuilder<Product> builder)
    {
        builder.ToTable("Products", t => t.IsTemporal(temporal =>
        {
            temporal.HasPeriodStart("ValidFrom");
            temporal.HasPeriodEnd("ValidTo");
            temporal.UseHistoryTable("ProductsHistory"); // назва history-таблиці
        }));
    }
}

TemporalAsOf: стан на конкретний момент

Найпоширеніший сценарій — побачити як виглядали дані в конкретний момент часу:

// Яка ціна Product #42 була тиждень тому?
var pointInTime = DateTime.UtcNow.AddDays(-7);

var productLastWeek = await context.Products
    .TemporalAsOf(pointInTime)      // ← знімок на конкретний UTC момент
    .Where(p => p.Id == 42)
    .FirstOrDefaultAsync();

Console.WriteLine($"Price 7 days ago: {productLastWeek?.Price}");
// SQL: SELECT ... FROM [Products] FOR SYSTEM_TIME AS OF '2025-03-22T10:00:00'
//      WHERE [p].[Id] = 42

TemporalAll: вся історія рядка

// Вся історія змін Product #42 (поточна + усі старі версії)
var allVersions = await context.Products
    .TemporalAll()                  // ← HEAD таблиця + History таблиця разом
    .Where(p => p.Id == 42)
    .OrderBy(p => EF.Property<DateTime>(p, "ValidFrom"))
    .Select(p => new
    {
        p.Name,
        p.Price,
        ValidFrom = EF.Property<DateTime>(p, "ValidFrom"),
        ValidTo   = EF.Property<DateTime>(p, "ValidTo")
    })
    .ToListAsync();

// Результат: всі версії в хронологічному порядку
// { Name="Laptop",     Price=35000, ValidFrom=2024-01-01, ValidTo=2024-06-15 }
// { Name="Laptop",     Price=40000, ValidFrom=2024-06-15, ValidTo=2024-12-01 }
// { Name="Laptop Pro", Price=45000, ValidFrom=2024-12-01, ValidTo=9999-12-31 }

TemporalBetween і TemporalFromTo

// TemporalBetween: версії що були активними між двома моментами
// (включає рядки що перетинають цей часовий діапазон)
var quarterVersions = await context.Products
    .TemporalBetween(
        DateTime.Parse("2024-10-01"),
        DateTime.Parse("2024-12-31"))
    .Where(p => p.CategoryId == 3)
    .ToListAsync();
// SQL: FOR SYSTEM_TIME BETWEEN '2024-10-01' AND '2024-12-31'

// TemporalFromTo: тільки рядки що ПОЧИНАЛИ існувати в цьому діапазоні
var newVersions = await context.Products
    .TemporalFromTo(
        DateTime.Parse("2024-10-01"),
        DateTime.Parse("2024-12-31"))
    .ToListAsync();
// SQL: FOR SYSTEM_TIME FROM '2024-10-01' TO '2024-12-31'

// TemporalContainedIn: лише рядки що існували ВИКЛЮЧНО у цьому діапазоні
var shortLivedVersions = await context.Products
    .TemporalContainedIn(
        DateTime.Parse("2024-10-01"),
        DateTime.Parse("2024-12-31"))
    .ToListAsync();
// SQL: FOR SYSTEM_TIME CONTAINED IN ('2024-10-01', '2024-12-31')
Temporal + LINQ composable: всі Temporal оператори повертають IQueryable<T> — можна продовжувати ланцюг .Where(), .Select(), .OrderBy() тощо, і EF Core транслює їх разом в один SQL.

Real-world: Audit за допомогою Temporal

// History viewer: хто що змінював і коли
public async Task<List<ProductHistoryDto>> GetProductHistoryAsync(int productId)
{
    return await context.Products
        .TemporalAll()
        .Where(p => p.Id == productId)
        .OrderByDescending(p => EF.Property<DateTime>(p, "ValidFrom"))
        .Select(p => new ProductHistoryDto
        {
            Name      = p.Name,
            Price     = p.Price,
            ValidFrom = EF.Property<DateTime>(p, "ValidFrom"),
            ValidTo   = EF.Property<DateTime>(p, "ValidTo"),
            IsCurrentVersion = EF.Property<DateTime>(p, "ValidTo") > DateTime.UtcNow.AddYears(100)
        })
        .ToListAsync();
}

LINQ GroupBy: трансляція у SQL GROUP BY

GroupBy у LINQ і GROUP BY у SQL мають схожу семантику, але трансляція має свої нюанси. Неправильне використання GroupBy у EF Core — часта причина помилок і Client Evaluation.

Базова агрегація через GroupBy

// Сума і кількість замовлень по кожному статусу
var orderStats = await context.Orders
    .GroupBy(o => o.Status)
    .Select(g => new
    {
        Status = g.Key,
        Count  = g.Count(),
        Total  = g.Sum(o => o.TotalAmount),
        Avg    = g.Average(o => o.TotalAmount)
    })
    .ToListAsync();
// SQL:
// SELECT o.Status, COUNT(*), SUM(o.TotalAmount), AVG(o.TotalAmount)
// FROM Orders o
// GROUP BY o.Status

GroupBy по двох ключах

// Продажі по місяцях і категоріях
var monthlySales = await context.OrderLineItems
    .GroupBy(li => new
    {
        Year     = li.Order.PlacedAt.Year,
        Month    = li.Order.PlacedAt.Month,
        Category = li.Product.Category.Name
    })
    .Select(g => new
    {
        g.Key.Year,
        g.Key.Month,
        g.Key.Category,
        Revenue  = g.Sum(li => li.Quantity * li.UnitPrice),
        Items    = g.Count()
    })
    .OrderBy(r => r.Year).ThenBy(r => r.Month)
    .ToListAsync();
// SQL: GROUP BY YEAR(o.PlacedAt), MONTH(o.PlacedAt), c.Name

GroupBy з Having

LINQ не має явного .Having(), але Where після GroupBy транслюється у HAVING:

// Лише ті категорії у яких більше 10 продуктів (HAVING COUNT > 10)
var popularCategories = await context.Products
    .GroupBy(p => p.Category.Name)
    .Where(g => g.Count() > 10)          // ← транслюється у HAVING COUNT(*) > 10
    .Select(g => new
    {
        Category     = g.Key,
        ProductCount = g.Count(),
        AvgPrice     = g.Average(p => p.Price)
    })
    .OrderByDescending(r => r.ProductCount)
    .ToListAsync();
// SQL:
// SELECT c.Name, COUNT(*), AVG(p.Price)
// FROM Products p JOIN Categories c ON ...
// GROUP BY c.Name
// HAVING COUNT(*) > 10
// ORDER BY COUNT(*) DESC

GroupBy з навігаційними властивостями: обережно!

// ❌ Може призвести до Client Evaluation:
var result = await context.Orders
    .Include(o => o.LineItems)           // Include + GroupBy = проблема
    .GroupBy(o => o.CustomerId)
    .Select(g => new { CustomerId = g.Key, Orders = g.ToList() }) // ToList() у Select!
    .ToListAsync();
// EF Core 5+ відмовляє з помилкою при неможливій трансляції

// ✅ Правильно: агрегація без ToList() у projection
var result = await context.Orders
    .GroupBy(o => o.CustomerId)
    .Select(g => new
    {
        CustomerId = g.Key,
        OrderCount = g.Count(),
        TotalSpent = g.Sum(o => o.TotalAmount)
    })
    .ToListAsync();
// Чиста SQL агрегація без Client Evaluation

Subqueries через LINQ

EF Core вміє транслювати вкладені LINQ-запити у SQL subqueries. Розуміння цього дозволяє будувати складні запити без переходу до Raw SQL.

Scalar Subquery через Select

// Для кожного клієнта: кількість замовлень і сума (scalar subqueries)
var customersWithStats = await context.Customers
    .Select(c => new
    {
        c.Id,
        c.Name,
        OrderCount = context.Orders.Count(o => o.CustomerId == c.Id),
        TotalSpent = context.Orders
            .Where(o => o.CustomerId == c.Id)
            .Sum(o => (decimal?)o.TotalAmount) ?? 0m
    })
    .ToListAsync();
// SQL:
// SELECT c.Id, c.Name,
//   (SELECT COUNT(*) FROM Orders WHERE CustomerId = c.Id),
//   (SELECT SUM(TotalAmount) FROM Orders WHERE CustomerId = c.Id)
// FROM Customers c
EF Core транслює кожне context.X.Where(...) всередині Select як correlated subquery. Якщо це N разів для N клієнтів колонок — може бути дорого. Порівняйте з GroupBy або LEFT JOIN підходом.

EXISTS через Any

// Клієнти що мають хоч одне замовлення (EXISTS)
var activeCustomers = await context.Customers
    .Where(c => context.Orders.Any(o => o.CustomerId == c.Id))
    .ToListAsync();
// SQL: SELECT ... FROM Customers c WHERE EXISTS (
//          SELECT 1 FROM Orders WHERE CustomerId = c.Id)

// Клієнти БЕЗ замовлень (NOT EXISTS)
var inactiveCustomers = await context.Customers
    .Where(c => !context.Orders.Any(o => o.CustomerId == c.Id))
    .ToListAsync();
// SQL: WHERE NOT EXISTS (SELECT 1 FROM Orders WHERE CustomerId = c.Id)

IN через Contains

// Замовлення для конкретних клієнтів (IN subquery)
var premiumCustomerIds = await context.Customers
    .Where(c => c.Tier == "Premium")
    .Select(c => c.Id)
    .ToListAsync();

// Використання в іншому запиті
var premiumOrders = await context.Orders
    .Where(o => premiumCustomerIds.Contains(o.CustomerId))
    .ToListAsync();
// SQL: WHERE CustomerId IN (SELECT Id FROM Customers WHERE Tier = 'Premium')
// EF Core може транслювати у IN або JOIN залежно від розміру списку

// Або одним запитом (correlated subquery):
var premiumOrders2 = await context.Orders
    .Where(o => context.Customers
        .Where(c => c.Tier == "Premium")
        .Select(c => c.Id)
        .Contains(o.CustomerId))
    .ToListAsync();

Lateral JOIN (CROSS APPLY / LATERAL)

// Останнє замовлення кожного клієнта (CROSS APPLY / LATERAL subquery)
var lastOrders = await context.Customers
    .SelectMany(c => context.Orders
        .Where(o => o.CustomerId == c.Id)
        .OrderByDescending(o => o.PlacedAt)
        .Take(1),                           // ← TOP 1 для кожного клієнта
        (customer, order) => new { Customer = customer, LastOrder = order })
    .ToListAsync();
// SQL Server: ... CROSS APPLY (SELECT TOP 1 ... FROM Orders WHERE CustomerId = c.Id ...)
// PostgreSQL: ... LATERAL (SELECT ... FROM orders WHERE customer_id = c.id ...)

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

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

Завдання 1.1: TagWith для production debugging

Для кожного репозиторію додайте TagWith що включає:

  • Назву репозиторію і методу
  • ID запиту (IHttpContextAccessor.HttpContext?.TraceIdentifier)

Перевірте через LogTo(Console.WriteLine) що теги з'являються у SQL.

Завдання 1.2: Bulk Insert порівняння

Вставте 50,000 DemoProduct записів трьома способами:

  1. AddRange + SaveChangesAsync (або батчами по 1000)
  2. ExecuteSqlRawAsync з VALUES
  3. BulkInsertAsync через EFCore.BulkExtensions

Порівняйте час (Stopwatch). Результати занесіть у таблицю.

Завдання 1.3: SlowQueryLogger

Реалізуйте SlowQueryLoggerInterceptor з порогом 200ms. Навмисно виконайте повільний запит (без індексу, великий LIKE) і переконайтеся що попередження з'являється у лозі.

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

Завдання 2.1: Query Plan Cache audit

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

  • Слухає CommandExecuted і рахує унікальні SQL шаблони (нормалізує параметри в @p0)
  • Після N запитів — логує TOP 10 найчастіших запитів

Визначте: які запити найчастіше виконуються у вашому застосунку?

Завдання 2.2: BulkUpsert

Реалізуйте BulkUpsertAsync<T>(IList<T> items) що:

  1. Спочатку перевіряє які entities вже є (за PK) через WHERE Id IN (...)
  2. Розбиває на дві групи: існуючі (UPDATE) і нові (INSERT)
  3. Виконує BulkInsert для нових і BulkUpdate для існуючих

Альтернатива: через ExecuteSqlRaw з MERGE (SQL Server) або INSERT ... ON CONFLICT DO UPDATE (PostgreSQL).

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

Завдання 3.1: Query Optimization Advisor

Реалізуйте QueryOptimizationAdvisor — interceptor що аналізує SQL і виводить поради:

Slow query (1250ms): SELECT * FROM Products WHERE Category=...
⚠️  Advice: Query returns 127 columns but might need only a few.
    Consider using .Select() projection to reduce data transfer.
⚠️  Advice: No ORDER BY but OFFSET/FETCH detected.
    Non-deterministic pagination — consider Keyset Pagination.
⚠️  Advice: Similar query executed 15 times in last 5 seconds.
    Consider Compiled Query for this hot path.

Поради повинні базуватись на:

  • Тривалість > 500ms → "slow query"
  • SELECT * (SELECT [t]. + багато стовпців) → "projection advice"
  • Той самий шаблон SQL > 10 разів/хвилину → "compiled query advice"

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

Ця стаття завершила тему продвинутих запитів:

Частина 1:

  • Compiled Queries: EF.CompileAsyncQuery — один раз транслювати, виконувати без overhead. Статичне поле, параметри як Func<TContext, T1, Task<TResult>>.
  • IAsyncEnumerable/AsAsyncEnumerable(): streaming великих наборів. await foreach — перший рядок одразу, RAM не вичерпується.
  • Keyset Pagination: WHERE lastField < prevValue замість SKIP N — без деградації продуктивності.
  • Проєкція vs Include: Select для read-only, Include для CRUD.

Частина 2:

  • TagWith/TagWithCallSite: SQL коментарі для діагностики — звідки запит, яка Feature.
  • UseQueryTrackingBehavior.NoTracking глобально для Report DbContext.
  • BulkExtensions: BulkInsertAsync, BulkUpdateAsync — справжній bulk SQL. SqlBulkCopy для SQL Server, COPY для PostgreSQL.
  • Query Interceptors: DbCommandInterceptor — тайм-аут, slow query logger, query hints.
  • Plan Cache: один шаблон → один кешований план. Умовні nullable параметри замість динамічних Where.
  • ToQueryString(): SQL без виконання для дебагінгу.

Наступна стаття — Change Tracking (стаття 20) — глибоке занурення у Change Tracker: стани Entity, Snapshot detection, DetectChanges, AsNoTracking, модифікація через Update vs Attach.


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