Це продовження статті «Продвинуті Запити». Читайте послідовно.
Знайти «чий» 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
// Автоматично додає файл і рядок з якого викликається запит
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
// Множинні теги для детальної діагностики
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
У попередніх статтях AsNoTracking() вже розглядався. Тут — більш глибока оптимізація.
// Read-Only DbContext для звітів: жоден запит не трекується
services.AddDbContext<ReportDbContext>(options =>
{
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
// Але коли потрібна IdentityResolution (дедублікація без трекінгу):
services.AddDbContext<ReportDbContext>(options =>
{
options.UseSqlServer(connectionString)
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTrackingWithIdentityResolution);
});
Коли глобальний 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 знає про зміни
Стандартний EF Core не підтримує native bulk insert (батч вставки без N окремих INSERT). AddRange + SaveChanges генерує N INSERT командіFox (хоча і батчує їх у меншу кількість мережевих roundtrips). Для справжнього bulk — потрібні розширення або Raw SQL.
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 заповнені!
// 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));
Для максимальної швидкості в 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);
}
// 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 рядків/секунду залежно від розміру
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);
}
}
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);
}
}
}
// Додати 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);
}
}
services.AddScoped<SlowQueryLoggerInterceptor>();
services.AddSingleton<QueryTimeoutInterceptor>();
services.AddDbContext<AppDbContext>((provider, options) =>
{
options.UseSqlServer(connectionString)
.AddInterceptors(
provider.GetRequiredService<SlowQueryLoggerInterceptor>(),
provider.GetRequiredService<QueryTimeoutInterceptor>()
);
});
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 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-таблиці
}));
}
}
Найпоширеніший сценарій — побачити як виглядали дані в конкретний момент часу:
// Яка ціна 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
// Вся історія змін 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: версії що були активними між двома моментами
// (включає рядки що перетинають цей часовий діапазон)
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')
IQueryable<T> — можна продовжувати ланцюг .Where(), .Select(), .OrderBy() тощо, і EF Core транслює їх разом в один SQL.// 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();
}
GroupBy у LINQ і GROUP BY у SQL мають схожу семантику, але трансляція має свої нюанси. Неправильне використання GroupBy у EF Core — часта причина помилок і Client Evaluation.
// Сума і кількість замовлень по кожному статусу
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
// Продажі по місяцях і категоріях
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
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
// ❌ Може призвести до 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
EF Core вміє транслювати вкладені LINQ-запити у SQL subqueries. Розуміння цього дозволяє будувати складні запити без переходу до Raw SQL.
// Для кожного клієнта: кількість замовлень і сума (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
context.X.Where(...) всередині Select як correlated subquery. Якщо це N разів для N клієнтів колонок — може бути дорого. Порівняйте з GroupBy або LEFT JOIN підходом.// Клієнти що мають хоч одне замовлення (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 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();
// Останнє замовлення кожного клієнта (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 ...)
Завдання 1.1: TagWith для production debugging
Для кожного репозиторію додайте TagWith що включає:
IHttpContextAccessor.HttpContext?.TraceIdentifier)Перевірте через LogTo(Console.WriteLine) що теги з'являються у SQL.
Завдання 1.2: Bulk Insert порівняння
Вставте 50,000 DemoProduct записів трьома способами:
AddRange + SaveChangesAsync (або батчами по 1000)ExecuteSqlRawAsync з VALUESBulkInsertAsync через EFCore.BulkExtensionsПорівняйте час (Stopwatch). Результати занесіть у таблицю.
Завдання 1.3: SlowQueryLogger
Реалізуйте SlowQueryLoggerInterceptor з порогом 200ms. Навмисно виконайте повільний запит (без індексу, великий LIKE) і переконайтеся що попередження з'являється у лозі.
Завдання 2.1: Query Plan Cache audit
Реалізуйте QueryPlanAuditInterceptor що:
CommandExecuted і рахує унікальні SQL шаблони (нормалізує параметри в @p0)Визначте: які запити найчастіше виконуються у вашому застосунку?
Завдання 2.2: BulkUpsert
Реалізуйте BulkUpsertAsync<T>(IList<T> items) що:
WHERE Id IN (...)BulkInsert для нових і BulkUpdate для існуючихАльтернатива: через ExecuteSqlRaw з MERGE (SQL Server) або INSERT ... ON CONFLICT DO UPDATE (PostgreSQL).
Завдання 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.
Поради повинні базуватись на:
SELECT * (SELECT [t]. + багато стовпців) → "projection advice"Ця стаття завершила тему продвинутих запитів:
Частина 1:
EF.CompileAsyncQuery — один раз транслювати, виконувати без overhead. Статичне поле, параметри як Func<TContext, T1, Task<TResult>>.IAsyncEnumerable/AsAsyncEnumerable(): streaming великих наборів. await foreach — перший рядок одразу, RAM не вичерпується.WHERE lastField < prevValue замість SKIP N — без деградації продуктивності.Select для read-only, Include для CRUD.Частина 2:
TagWith/TagWithCallSite: SQL коментарі для діагностики — звідки запит, яка Feature.UseQueryTrackingBehavior.NoTracking глобально для Report DbContext.BulkInsertAsync, BulkUpdateAsync — справжній bulk SQL. SqlBulkCopy для SQL Server, COPY для PostgreSQL.DbCommandInterceptor — тайм-аут, slow query logger, query hints.ToQueryString(): SQL без виконання для дебагінгу.Наступна стаття — Change Tracking (стаття 20) — глибоке занурення у Change Tracker: стани Entity, Snapshot detection, DetectChanges, AsNoTracking, модифікація через Update vs Attach.
Продвинуті Запити — Compiled Queries, Bulk та Оптимізація (Частина 1)
Compiled Queries для повторюваних запитів, асинхронне перерахування через IAsyncEnumerable, Keyset Pagination замість OFFSET, проєкція vs Include — порівняння підходів і інструменти діагностики продуктивності.
Change Tracker — Відстеження Змін (Частина 1)
Глибоке розуміння Change Tracker в EF Core — як DbContext відстежує стани Entity, Identity Map, Snapshot-based Detection, EntityState lifecycle. Різниця між Tracked і Untracked об'єктами, ручне керування через Entry та ChangeTracker API.