Ef Core

Raw SQL, Views та Stored Procedures (Частина 1)

FromSqlRaw і FromSqlInterpolated в EF Core — виконання сирого SQL зі збереженням усіх переваг ORM. Маппінг Database Views, Keyless Entity Types, параметризація запитів і захист від SQL Injection.

Raw SQL, Views та Stored Procedures

Коли LINQ не вистачає

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 та FromSqlInterpolated

FromSqlRaw: виконання SQL з поверненням Entity

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

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: безпечна інтерполяція рядків

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 → параметри, не вбудовані у рядок
SQL Injection небезпека: 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}..."

FromSql (EF Core 8+): уніфікований API

EF Core 8 ввів FromSql що приймає FormattableString — замінює обидва попередніх:

// EF Core 8+: єдиний FromSql
var products = await context.Products
    .FromSql($"SELECT * FROM Products WHERE Price >= {minPrice}")
    .ToListAsync();
// Автоматично параметризує — безпечно

FromSql + LINQ: комбінування

Після 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();

Обмеження FromSqlRaw

  • SQL обов'язково повертає всі non-nullable стовпці entity (або доведеться мати частково заповнений entity)
  • Не можна використовувати GROUP BY у base SQL і потім .Select() з агрегацією — LINQ не може «загорнути» складний GroupBy у підзапит
  • Результат маппиться на конкретний entity type з 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; }
}

ExecuteSqlRaw та ExecuteSqlInterpolated

Для команд без повернення рядків (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}"
);
ExecuteSql vs LINQ Update: ExecuteSqlRaw виконує SQL напряму — без Change Tracker, без перевірки ConcurrencyToken. Для bulk updates у EF Core 7+ є ExecuteUpdateAsync який безпечніший і підтримує Change Tracker. Використовуйте ExecuteSqlRaw для складних UPDATE/DELETE яких не можна виразити через EF Core API.

Database Views: Keyless Entity Types

Database View — збережений SQL-запит у базі, що поводиться як таблиця (тільки для читання). EF Core маппить Views через Keyless Entity Types — entity без Primary Key.

Навіщо View у EF Core

  • Складні JOIN для звітів, що не варто дублювати у C# коді
  • Агрегаційні проєкції (щоденна статистика продажів)
  • Інтеграція з legacy базою де бізнес-логіка у Views
  • Security через Row-Level Security на рівні View
  • Матеріалізовані Views для продуктивності читання

Keyless Entity Type: конфігурація

// 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

Створення View через Migration

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

PostgreSQL Materialized View

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

Складні Raw SQL запити: CTE та Window Functions

Common Table Expressions (CTE)

// Рекурсивний 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;
}

Window Functions

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

Running Total (накопичувальна сума)

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 — Базовий

Завдання 1.1: FromSql з параметрами

Для Employee (Id, FirstName, LastName, DepartmentId, Salary, HireDate) напишіть:

  1. GetByDepartmentAsync(int deptId) через FromSqlInterpolated
  2. SearchByNameAsync(string q)WHERE FirstName LIKE '%{q}%' OR LastName LIKE '%{q}%'
  3. Перевірте: якщо 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):

  1. Через ExecuteSqlRaw: UPDATE Orders SET IsArchived = 1 WHERE PlacedAt < DATEADD(day, -N, GETUTCDATE())
  2. Поверніть кількість affected rows
  3. Перевірте — чи оновлюється Change Tracker після ExecuteSqlRaw? (Підказка: ні)

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

Завдання 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 — Архітектура

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


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

Перша частина розкрила 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.
  • Database Views: HasNoKey() + ToView(). Readonly, без SaveChanges. Міграція через migrationBuilder.Sql().
  • CTE і Window Functions: SqlQueryRaw<T> для складних аналітичних запитів що LINQ не може виразити.

У другій частині — Stored Procedures (з і без результату, output parameters), DbFunction для маппінгу SQL-функцій у LINQ, Temporal Tables та ExecuteUpdate/ExecuteDelete (EF Core 7+).