Entity Framework Core генерує SQL з LINQ-виразів. У переважній більшості випадків цього достатньо — LINQ потужний, читабельний і захищений від SQL Injection. Але є сценарії, де генерований EF Core SQL або неефективний, або недостатній:
Window Functions (аналітичні функції): ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...), RANK(), DENSE_RANK(), LAG(), LEAD() — LINQ не вміє генерувати їх нативно в EF Core (лише через EF.Functions де є підтримка).
Складні рекурсивні запити: WITH RECURSIVE (CTE) для ієрархічних структур — дерево категорій, організаційна ієрархія.
специфічні провайдерні функції: PIVOT, UNPIVOT, MERGE, BULK INSERT, COPY (PostgreSQL).
Оптимізавані звіти: коли DBA написав запит, що у кілька разів швидший за будь-яку LINQ-еквівалент — хочеться використати цей SQL напряму.
Stored Procedures: Legacy база з бізнес-логікою у SP, або вимога DBA виконувати тільки через SP.
Database Views: READ-ONLY проєкції кількох таблиць, матеріалізовані view для продуктивності.
EF Core підтримує всі ці сценарії через Raw SQL API.
FromSqlRaw виконує SQL і маппить результат на тип Entity. Важливо: результат залишається IQueryable<T> — можна продовжити додавати LINQ:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
public bool IsActive { get; set; }
}
// Простий raw SQL запит
var products = await context.Products
.FromSqlRaw("SELECT * FROM Products WHERE IsActive = 1")
.ToListAsync();
// Маппить результат на Product entity
// Важливо: запит МАЄ повертати всі стовпці entity (або хоча б ті, що не nullable)
// Інакше — MapColumn error або null-поля
FromSqlRaw приймає параметри через SqlParameter або стандартні positional placeholders {0}, {1}:
// Позиційні placeholders: {0}, {1}
var minPrice = 1000m;
var categoryId = 5;
var products = await context.Products
.FromSqlRaw(
"SELECT * FROM Products WHERE Price >= {0} AND CategoryId = {1}",
minPrice, // {0}
categoryId // {1}
)
.ToListAsync();
// SQL (параметризований): WHERE Price >= @p0 AND CategoryId = @p1
// EF Core автоматично перетворює на параметри — безпечно від SQL Injection!
// Через SqlParameter для іменованих параметрів
using Microsoft.Data.SqlClient;
var products = await context.Products
.FromSqlRaw(
"SELECT * FROM Products WHERE Price >= @minPrice AND CategoryId = @categoryId",
new SqlParameter("@minPrice", minPrice),
new SqlParameter("@categoryId", categoryId)
)
.ToListAsync();
FromSqlInterpolated — зручніший API, що приймає C# interpolated string. Не плутайте з FormattableString: EF Core автоматично витягує параметри з інтерполяції і параметризує їх.
// Інтерполяція: виглядає як звичайний рядок, але повністю параметризований
decimal minPrice = 1000m;
int categoryId = 5;
string city = "Kyiv";
var products = await context.Products
.FromSqlInterpolated(
$"SELECT * FROM Products WHERE Price >= {minPrice} AND CategoryId = {categoryId}")
.ToListAsync();
// SQL: SELECT * FROM Products WHERE Price >= @p0 AND CategoryId = @p1
// minPrice і categoryId → параметри, не вбудовані у рядок
FromSqlRaw з конкатенацією рядків — небезпечно!// ❌ НЕБЕЗПЕЧНО: SQL Injection!
string userInput = "'; DROP TABLE Products; --";
var evil = context.Products.FromSqlRaw(
$"SELECT * FROM Products WHERE Name = '{userInput}'").ToListAsync();
// ✅ БЕЗПЕЧНО: параметризація
var safe = context.Products.FromSqlRaw(
"SELECT * FROM Products WHERE Name = {0}", userInput).ToListAsync();
// або FromSqlInterpolated з $"...{userInput}..."
EF Core 8 ввів FromSql що приймає FormattableString — замінює обидва попередніх:
// EF Core 8+: єдиний FromSql
var products = await context.Products
.FromSql($"SELECT * FROM Products WHERE Price >= {minPrice}")
.ToListAsync();
// Автоматично параметризує — безпечно
Після FromSqlRaw/FromSqlInterpolated результат залишається IQueryable<T>. Можна додавати LINQ:
// Base SQL + LINQ фільтрація зверху
var expensiveProducts = await context.Products
.FromSqlRaw("SELECT * FROM Products WHERE IsActive = 1")
.Where(p => p.Price > 5000) // додає AND Price > 5000 до SQL
.OrderBy(p => p.Price) // додає ORDER BY Price
.Take(10) // додає TOP 10
.ToListAsync();
// Генерований SQL:
// SELECT TOP 10 ... FROM (
// SELECT * FROM Products WHERE IsActive = 1
// ) AS t
// WHERE t.Price > 5000
// ORDER BY t.Price
// FromSql + Include (EF Core 7+)
var ordersWithCustomer = await context.Orders
.FromSqlRaw("SELECT * FROM Orders WHERE Status = 'Pending'")
.Include(o => o.Customer) // ← JOIN Customers
.ToListAsync();
GROUP BY у base SQL і потім .Select() з агрегацією — LINQ не може «загорнути» складний GroupBy у підзапитDbSet, не на довільний типДля довільного типу результату — SqlQuery<T> (EF Core 7+):
// SqlQuery: довільний результат, не лише entity types
var stats = await context.Database
.SqlQueryRaw<ProductStats>(
"SELECT CategoryId, COUNT(*) AS Count, AVG(Price) AS AvgPrice FROM Products GROUP BY CategoryId")
.OrderByDescending(s => s.Count)
.ToListAsync();
// Клас-результат (не entity — немає DbSet)
public class ProductStats
{
public int CategoryId { get; set; }
public int Count { get; set; }
public decimal AvgPrice { get; set; }
}
Для команд без повернення рядків (INSERT, UPDATE, DELETE, DDL) — ExecuteSqlRaw:
// UPDATE без завантаження в пам'ять
int affected = await context.Database.ExecuteSqlRawAsync(
"UPDATE Products SET IsActive = 0 WHERE CategoryId = {0} AND Stock = 0",
categoryId
);
Console.WriteLine($"Деактивовано {affected} продуктів");
// ExecuteSqlInterpolated
await context.Database.ExecuteSqlInterpolatedAsync(
$"DELETE FROM AuditLogs WHERE CreatedAt < {DateTime.UtcNow.AddYears(-2)}"
);
// ExecuteSql (EF Core 8+)
await context.Database.ExecuteSqlAsync(
$"UPDATE Products SET Price = Price * {1.1m} WHERE CategoryId = {categoryId}"
);
ExecuteSqlRaw виконує SQL напряму — без Change Tracker, без перевірки ConcurrencyToken. Для bulk updates у EF Core 7+ є ExecuteUpdateAsync який безпечніший і підтримує Change Tracker. Використовуйте ExecuteSqlRaw для складних UPDATE/DELETE яких не можна виразити через EF Core API.Database View — збережений SQL-запит у базі, що поводиться як таблиця (тільки для читання). EF Core маппить Views через Keyless Entity Types — entity без Primary Key.
// C# клас для View — без PK, без auto-generated Id
public class OrderSummaryView
{
public int OrderId { get; set; }
public string OrderNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public int ItemCount { get; set; }
public decimal TotalAmount { get; set; }
public decimal TaxAmount { get; set; }
public string Status { get; set; } = string.Empty;
public DateTime PlacedAt { get; set; }
}
public class OrderSummaryViewConfiguration : IEntityTypeConfiguration<OrderSummaryView>
{
public void Configure(EntityTypeBuilder<OrderSummaryView> builder)
{
builder.HasNoKey(); // ← Keyless: немає PK
builder.ToView("vw_OrderSummary"); // ← маппить на View у БД
}
}
// У DbContext
public DbSet<OrderSummaryView> OrderSummaries => Set<OrderSummaryView>();
// Використання: як звичайний DbSet, але read-only (без SaveChanges)
var summaries = await context.OrderSummaries
.Where(v => v.Status == "Delivered")
.OrderByDescending(v => v.PlacedAt)
.Take(100)
.ToListAsync();
// SQL: SELECT TOP 100 * FROM vw_OrderSummary WHERE Status='Delivered' ORDER BY PlacedAt DESC
public partial class CreateOrderSummaryView : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
CREATE VIEW [vw_OrderSummary] AS
SELECT
o.[Id] AS [OrderId],
o.[OrderNumber],
c.[FullName] AS [CustomerName],
c.[Email] AS [CustomerEmail],
COUNT(li.[Id]) AS [ItemCount],
o.[TotalAmount],
o.[TaxAmount],
o.[Status],
o.[PlacedAt]
FROM [Orders] o
INNER JOIN [Customers] c ON c.[Id] = o.[CustomerId]
LEFT JOIN [OrderLineItems] li ON li.[OrderId] = o.[Id]
WHERE o.[IsDeleted] = 0
GROUP BY
o.[Id], o.[OrderNumber], c.[FullName], c.[Email],
o.[TotalAmount], o.[TaxAmount], o.[Status], o.[PlacedAt]
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP VIEW [vw_OrderSummary]");
}
}
migrationBuilder.Sql(@"
CREATE MATERIALIZED VIEW ""mv_DailySalesStats"" AS
SELECT
DATE(""PlacedAt"") AS ""Date"",
COUNT(*) AS ""OrderCount"",
SUM(""TotalAmount"") AS ""Revenue"",
AVG(""TotalAmount"") AS ""AvgOrderValue""
FROM ""Orders""
WHERE ""IsDeleted"" = false
GROUP BY DATE(""PlacedAt"");
CREATE INDEX ON ""mv_DailySalesStats"" (""Date"");
");
// Refresh:
// REFRESH MATERIALIZED VIEW ""mv_DailySalesStats"";
// Рекурсивний CTE для ієрархії категорій
var categoryHierarchy = await context.Database
.SqlQueryRaw<CategoryHierarchyResult>(@"
WITH CategoryCTE AS (
-- Базовий випадок: кореневі категорії
SELECT Id, Name, ParentCategoryId, 0 AS [Level], CAST(Name AS NVARCHAR(MAX)) AS Path
FROM Categories
WHERE ParentCategoryId IS NULL
UNION ALL
-- Рекурсивний крок: дочірні категорії
SELECT c.Id, c.Name, c.ParentCategoryId, cte.[Level] + 1,
cte.Path + N' > ' + c.Name
FROM Categories c
INNER JOIN CategoryCTE cte ON cte.Id = c.ParentCategoryId
)
SELECT Id, Name, ParentCategoryId, [Level], Path
FROM CategoryCTE
ORDER BY Path
")
.ToListAsync();
public class CategoryHierarchyResult
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int? ParentCategoryId { get; set; }
public int Level { get; set; }
public string Path { get; set; } = string.Empty;
}
// ROW_NUMBER для ranking в кожній категорії
var rankedProducts = await context.Database
.SqlQueryRaw<ProductRankResult>(@"
SELECT
p.Id,
p.Name,
p.CategoryId,
p.Price,
ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank,
RANK() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRankWithTies,
LAG(p.Price) OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PreviousPrice,
LEAD(p.Price) OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS NextPrice
FROM Products p
WHERE p.IsActive = 1
")
.Where(r => r.PriceRank <= 3) // TOP 3 у кожній категорії
.ToListAsync();
public class ProductRankResult
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public int CategoryId { get; set; }
public decimal Price { get; set; }
public int PriceRank { get; set; }
public int PriceRankWithTies { get; set; }
public decimal? PreviousPrice { get; set; }
public decimal? NextPrice { get; set; }
}
var runningTotal = await context.Database
.SqlQueryRaw<DailySalesWithRunning>(@"
SELECT
CAST(PlacedAt AS DATE) AS OrderDate,
COUNT(*) AS DailyCount,
SUM(TotalAmount) AS DailyRevenue,
SUM(SUM(TotalAmount)) OVER (
ORDER BY CAST(PlacedAt AS DATE)
ROWS UNBOUNDED PRECEDING
) AS RunningRevenue
FROM Orders
WHERE IsDeleted = 0
AND PlacedAt >= DATEADD(month, -3, GETUTCDATE())
GROUP BY CAST(PlacedAt AS DATE)
ORDER BY OrderDate
")
.ToListAsync();
Завдання 1.1: FromSql з параметрами
Для Employee (Id, FirstName, LastName, DepartmentId, Salary, HireDate) напишіть:
GetByDepartmentAsync(int deptId) через FromSqlInterpolatedSearchByNameAsync(string q) → WHERE FirstName LIKE '%{q}%' OR LastName LIKE '%{q}%'q = "'; DROP TABLE Employees; --" — що повертає SQL? (Безпечна параметризація)Завдання 1.2: Keyless View для Dashboard
Створіть View vw_DepartmentSummary (DepartmentName, EmployeeCount, AvgSalary, MaxSalary, NewHiresThisYear). Налаштуйте Keyless Entity Type. Напишіть запит для dashboard: відсортуйте за AvgSalary DESC.
Завдання 1.3: ExecuteSqlRaw для bulk операцій
Напишіть метод ArchiveOldOrdersAsync(int daysOld):
ExecuteSqlRaw: UPDATE Orders SET IsArchived = 1 WHERE PlacedAt < DATEADD(day, -N, GETUTCDATE())Завдання 2.1: CTE для ієрархічної структури
Реалізуйте GetCategoryTreeAsync(int? rootId = null) через рекурсивний CTE що повертає всю ієрархію категорій з рівнями і повними шляхами (Electronics > Laptops > Gaming Laptops). Якщо rootId вказаний — тільки піддерево від цього вузла.
Завдання 2.2: Window Function для рейтингу
Реалізуйте GetTopProductsPerCategoryAsync(int topN) через ROW_NUMBER що повертає топ-N продуктів за продажами в кожній категорії за місяць. SQL:
ROW_NUMBER() OVER (PARTITION BY CategoryId ORDER BY MonthlySales DESC) AS Rank
Результат: тільки де Rank <= topN.
Завдання 3.1: Raw SQL Repository
Реалізуйте IRawSqlRepository з методами:
QueryAsync<T>(string sql, params object[] parameters) → List<T>ExecuteAsync(string sql, params object[] parameters) → int (affected rows)QueryFromViewAsync<T>(string viewName, Expression<Func<T, bool>>? filter = null) де T — Keyless EntityДодайте логування SQL перед виконанням (без EF Core логування — власний ILogger<IRawSqlRepository>).
Перша частина розкрила Raw SQL у EF Core:
FromSqlRaw/FromSqlInterpolated: виконання SQL з маппінгом на entity. Залишається IQueryable<T> — можна додавати LINQ. Параметризація — обов'язково через placeholders або FormattableString, а не конкатенація.FromSql (EF Core 8+): уніфікований API.SqlQueryRaw<T>: довільний тип результату, не лише entity types. Ідеальний для звітів і агрегацій.ExecuteSqlRaw: INSERT/UPDATE/DELETE/DDL без повернення рядків. Повертає кількість affected rows.HasNoKey() + ToView(). Readonly, без SaveChanges. Міграція через migrationBuilder.Sql().SqlQueryRaw<T> для складних аналітичних запитів що LINQ не може виразити.У другій частині — Stored Procedures (з і без результату, output parameters), DbFunction для маппінгу SQL-функцій у LINQ, Temporal Tables та ExecuteUpdate/ExecuteDelete (EF Core 7+).
Завантаження Пов'язаних Даних (Частина 2)
Lazy Loading — усі ризики, проксі і ILazyLoader, N+1 детектування. Explicit Loading для точкового контролю. Порівняльна таблиця трьох стратегій. Practical guide і architectural best practices.
Raw SQL — Stored Procedures, DbFunction та Bulk Operations (Частина 2)
Stored Procedures у EF Core — виклик з і без результату, OUTPUT параметри. HasDbFunction для маппінгу SQL-функцій у LINQ. ExecuteUpdate та ExecuteDelete (EF Core 7+) для ефективних bulk операцій без завантаження в пам'ять.