Search

Raw SQL và Stored Procedure Trong EF Core: Khi Nào Dùng?

Raw SQL và Stored Procedure Trong EF Core: Khi Nào Dùng?

LINQ trong EF Core mạnh, nhưng không phải toàn năng. Mình đã từng ngồi cả tiếng cố ép một report query phức tạp vào LINQ — window function, CTE, conditional aggregation — rồi nhận ra SQL generate ra dài gấp ba lần viết tay, chạy chậm gấp mười, và không ai trong team đọc hiểu nổi cái expression tree đó.

Đôi khi viết SQL thẳng vừa nhanh hơn, vừa dễ hiểu hơn, vừa performant hơn. Đó không phải thất bại của ORM — đó là dùng đúng tool cho đúng việc.

EF Core hiểu điều này và cung cấp nhiều escape hatch: FromSql cho query trả entity, SqlQuery<T> cho scalar/DTO, ExecuteSql cho command không trả result set, và support đầy đủ stored procedure. Vấn đề là nhiều dev hoặc không biết mấy API này tồn tại, hoặc dùng sai cách — mất type safety, quên parameterize, hay tạo SQL injection mà không hay.

Bài viết này đi qua từng API, khi nào dùng, dùng thế nào cho an toàn, và quan trọng nhất — khi nào nên quay lại LINQ.

Nguyên tắc: LINQ first, raw SQL khi cần

Trước khi nhảy vào raw SQL, cần nói rõ: LINQ vẫn là lựa chọn mặc định. Nó cho bạn compile-time type checking, tự parameterize, refactor-friendly (đổi tên property → compiler báo lỗi), và hoạt động với change tracking.

Chỉ dùng raw SQL khi một trong những điều sau đúng:

  • LINQ không diễn tả được query bạn cần (window function, recursive CTE, full-text search syntax, database-specific feature)
  • LINQ generate SQL quá kém — subquery lồng nhau, scan toàn bảng thay vì dùng index
  • Cần gọi stored procedure hoặc database function đã tồn tại
  • Bulk operations trên hàng trăm nghìn row — ExecuteUpdate/ExecuteDelete tốt nhưng raw SQL đôi khi vẫn cần cho case phức tạp
  • Report/analytics query mà SQL thuần đọc dễ hơn LINQ gấp bội

Nếu LINQ viết được gọn gàng và generate SQL tốt — đừng dùng raw SQL chỉ vì "quen SQL hơn".

FromSql — query trả về entity

FromSql (trước EF Core 7 là FromSqlRaw/FromSqlInterpolated) chạy raw SQL nhưng trả về entity type — vẫn tracked, vẫn dùng được .Include(), .Where() sau đó.

Cú pháp an toàn (interpolated string)

// ✅ AN TOÀN — EF Core tự parameterize
var minPrice = 100_000m;
var category = "electronics";

var products = await db.Products
    .FromSql($"SELECT * FROM products WHERE price > {minPrice} AND category = {category}")
    .OrderBy(p => p.Name)
    .ToListAsync();

Trông như string interpolation thường nhưng KHÔNG phải. EF Core intercept cái $"..." này và tạo parameterized query:

-- SQL thực tế gửi đến database
SELECT * FROM products WHERE price > @p0 AND category = @p1
ORDER BY name
-- @p0 = 100000, @p1 = 'electronics'

{minPrice} trở thành @p0, không phải nối chuỗi. SQL injection safe.

Cú pháp KHÔNG an toàn — tránh xa

// ❌ NGUY HIỂM — SQL Injection!
var userInput = "electronics'; DROP TABLE products; --";
var products = await db.Products
    .FromSqlRaw($"SELECT * FROM products WHERE category = '{userInput}'")
    .ToListAsync();
// SQL: SELECT * FROM products WHERE category = 'electronics'; DROP TABLE products; --'

FromSqlRaw với string interpolation $"..." KHÔNG được parameterize — nó nối chuỗi thẳng. Đây là trap phổ biến nhất. Nếu dùng FromSqlRaw, phải truyền parameter riêng:

// ✅ FromSqlRaw an toàn — parameter riêng
var products = await db.Products
    .FromSqlRaw("SELECT * FROM products WHERE category = {0}", category)
    .ToListAsync();

Khuyên dùng: luôn dùng FromSql (interpolated), tránh FromSqlRaw trừ khi query cần build dynamic mà interpolated string không đủ.

Kết hợp LINQ sau FromSql

Cái hay của FromSql: kết quả vẫn là IQueryable, bạn chain thêm LINQ:

var results = await db.Products
    .FromSql($"SELECT * FROM products WHERE textsearch @@ to_tsquery('vietnamese', {term})")
    .Where(p => p.IsActive)           // thêm filter bằng LINQ
    .Include(p => p.Category)          // eager loading
    .OrderByDescending(p => p.CreatedAt)
    .Take(20)
    .AsNoTracking()
    .ToListAsync();

EF Core wrap raw SQL thành subquery rồi apply LINQ lên trên:

SELECT t.*, c.*
FROM (
    SELECT * FROM products WHERE textsearch @@ to_tsquery('vietnamese', @p0)
) AS t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.is_active = true
ORDER BY t.created_at DESC
LIMIT 20

Full-text search bằng raw SQL (vì LINQ không hỗ trợ @@ operator), nhưng filter, join, pagination vẫn dùng LINQ. Best of both worlds.

Constraint quan trọng

FromSql có vài rule bắt buộc:

// ❌ KHÔNG được — phải SELECT tất cả column của entity
db.Products.FromSql($"SELECT id, name FROM products");
// Exception: Column 'price' required but not in result set

// ✅ ĐÚNG — SELECT * hoặc liệt kê ĐẦY ĐỦ column
db.Products.FromSql($"SELECT * FROM products WHERE ...");

// ❌ KHÔNG được — query phải trả về một result set
db.Products.FromSql($"DELETE FROM products; SELECT * FROM products");

FromSql trả về entity nên cần đầy đủ column để hydrate object. Nếu chỉ cần vài field, dùng SqlQuery<T> (phần dưới) hoặc LINQ .Select() sau FromSql.

SqlQuery<T> — query trả về DTO/scalar (EF Core 8)

Đây là API mình chờ đợi nhất khi EF Core 8 ra mắt. Trước đó, muốn chạy raw SQL trả về custom type phải dùng ADO.NET hoặc Dapper. Giờ:

// trả về custom DTO — không cần entity
public record ProductReportDto(
    string Category,
    int ProductCount,
    decimal AvgPrice,
    decimal MaxPrice);

var report = await db.Database
    .SqlQuery<ProductReportDto>($"""
        SELECT
            c.name AS Category,
            COUNT(*) AS ProductCount,
            AVG(p.price) AS AvgPrice,
            MAX(p.price) AS MaxPrice
        FROM products p
        JOIN categories c ON p.category_id = c.id
        WHERE p.is_active = true
        GROUP BY c.name
        HAVING COUNT(*) > 5
        ORDER BY AVG(p.price) DESC
        """)
    .ToListAsync();

Không cần map entity, không cần DbSet, không cần [Keyless] attribute. Chỉ cần DTO có property names khớp với column alias.

Scalar query

// đếm — trả về int
var count = await db.Database
    .SqlQuery<int>($"SELECT COUNT(*) AS Value FROM products WHERE price > {minPrice}")
    .SingleAsync();

// lấy một giá trị
var maxPrice = await db.Database
    .SqlQuery<decimal>($"SELECT MAX(price) AS Value FROM products")
    .SingleAsync();

Lưu ý: scalar query yêu cầu column alias là Value. Đây là convention của EF Core 8+ cho primitive types.

Window function — thứ LINQ không làm được

public record ProductRankDto(
    int Id,
    string Name,
    decimal Price,
    string Category,
    int RankInCategory);

var ranked = await db.Database
    .SqlQuery<ProductRankDto>($"""
        SELECT
            id AS Id,
            name AS Name,
            price AS Price,
            category AS Category,
            ROW_NUMBER() OVER (
                PARTITION BY category ORDER BY price DESC
            ) AS RankInCategory
        FROM products
        WHERE is_active = true
        """)
    .ToListAsync();

// sản phẩm đắt nhất mỗi category
var topPerCategory = ranked.Where(r => r.RankInCategory == 1);

ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) — không có cách nào viết bằng LINQ. Đây là use case chính xác cho raw SQL.

Recursive CTE

public record CategoryTreeDto(int Id, string Name, int? ParentId, int Level);

var tree = await db.Database
    .SqlQuery<CategoryTreeDto>($"""
        WITH RECURSIVE cat_tree AS (
            -- base case: root categories
            SELECT id, name, parent_id, 0 AS level
            FROM categories
            WHERE parent_id IS NULL

            UNION ALL

            -- recursive case
            SELECT c.id, c.name, c.parent_id, ct.level + 1
            FROM categories c
            JOIN cat_tree ct ON c.parent_id = ct.id
        )
        SELECT id AS Id, name AS Name, parent_id AS ParentId, level AS Level
        FROM cat_tree
        ORDER BY level, name
        """)
    .ToListAsync();

Recursive CTE cho hierarchical data — category tree, org chart, bill of materials. LINQ chắc chắn không hỗ trợ.

ExecuteSql — command không trả result set

Khi cần UPDATE, DELETE, hoặc DDL mà không cần đọc data về:

// bulk update — nhanh hơn load entity rồi save
var affected = await db.Database.ExecuteSqlAsync(
    $"UPDATE products SET price = price * {1.1m} WHERE category = {category}");
// affected = số row bị ảnh hưởng

// bulk delete
await db.Database.ExecuteSqlAsync(
    $"DELETE FROM audit_logs WHERE created_at < {cutoffDate}");

// refresh materialized view
await db.Database.ExecuteSqlAsync(
    $"REFRESH MATERIALIZED VIEW CONCURRENTLY product_search_index");

ExecuteSql vs ExecuteUpdate (EF Core 7+)

EF Core 7 thêm ExecuteUpdate/ExecuteDelete — LINQ-based bulk operations:

// ✅ ExecuteUpdate — LINQ, type-safe, refactor-friendly
await db.Products
    .Where(p => p.Category == category)
    .ExecuteUpdateAsync(s => s
        .SetProperty(p => p.Price, p => p.Price * 1.1m)
        .SetProperty(p => p.ModifiedAt, DateTimeOffset.UtcNow));

// ✅ ExecuteSql — khi cần SQL-specific syntax
await db.Database.ExecuteSqlAsync($"""
    UPDATE products
    SET price = price * 1.1,
        search_vector = to_tsvector('vietnamese', name || ' ' || description)
    WHERE category = {category}
    """);

ExecuteUpdate an toàn hơn và nên là lựa chọn đầu tiên. Dùng ExecuteSql khi cần database-specific function (ở đây to_tsvector) mà LINQ không hỗ trợ.

Quan trọng: cả ExecuteUpdate lẫn ExecuteSql đều bypass change tracker. Entity đã load trước đó sẽ stale — cần reload nếu dùng tiếp.

Stored Procedure — khi nào thực sự cần

Stored procedure là chủ đề gây tranh cãi. Một bên: "mọi business logic phải ở application layer". Bên kia: "stored procedure nhanh hơn, DBA maintain được, và đã tồn tại 20 năm rồi".

Mình đứng giữa — stored procedure có chỗ của nó, nhưng không nên là default.

Gọi stored procedure trả entity

var topProducts = await db.Products
    .FromSql($"EXEC GetTopSellingProducts @CategoryId = {categoryId}, @TopN = {10}")
    .AsNoTracking()
    .ToListAsync();

Stored procedure phải trả đầy đủ columns khớp với entity — giống constraint của FromSql.

Gọi stored procedure trả custom type

public record SalesReportRow(
    string ProductName,
    int QuantitySold,
    decimal Revenue,
    decimal Profit);

var report = await db.Database
    .SqlQuery<SalesReportRow>(
        $"EXEC GenerateSalesReport @StartDate = {startDate}, @EndDate = {endDate}")
    .ToListAsync();

Stored procedure với output parameter

EF Core không hỗ trợ output parameter trực tiếp qua FromSql. Cần ADO.NET:

public async Task<(List<Product> Products, int TotalCount)> SearchProducts(
    string term, int page, int pageSize)
{
    var termParam = new NpgsqlParameter("@Term", term);
    var pageParam = new NpgsqlParameter("@Page", page);
    var sizeParam = new NpgsqlParameter("@PageSize", pageSize);
    var totalParam = new NpgsqlParameter("@TotalCount", NpgsqlDbType.Integer)
    {
        Direction = ParameterDirection.Output
    };

    var products = await db.Products
        .FromSqlRaw(
            "EXEC SearchProducts @Term, @Page, @PageSize, @TotalCount OUTPUT",
            termParam, pageParam, sizeParam, totalParam)
        .AsNoTracking()
        .ToListAsync();

    var totalCount = (int)totalParam.Value;
    return (products, totalCount);
}

Verbose, nhưng đây là cách duy nhất lấy output parameter khi stored procedure cần trả cả result set lẫn metadata.

Khi nào thực sự nên dùng stored procedure

Legacy system đã có sẵn. Database 15 năm tuổi với 200 stored procedures. Viết lại hết thành LINQ không realistic — gọi từ EF Core và migrate dần.

DBA team maintain logic riêng. Một số tổ chức, DBA viết và optimize query, dev chỉ gọi. Stored procedure là API contract giữa hai team.

Logic cực kỳ performance-sensitive. Stored procedure chạy trên database server, không có network round-trip cho mỗi step. Với logic cần read → compute → write nhiều bước trên cùng dataset lớn, chạy gần data nhanh hơn.

Compliance/audit yêu cầu. Một số industry (banking, healthcare) yêu cầu mọi data modification đi qua stored procedure để DBA audit và control.

Khi nào KHÔNG nên dùng stored procedure

Business logic đơn giản. CRUD, filter, pagination — LINQ thừa sức. Stored procedure thêm complexity mà không thêm giá trị.

Cần unit test. Stored procedure khó mock, khó test isolated. Logic ở C# code thì test với xUnit + Moq ngon lành.

Team không có DBA. Nếu cả team là dev và không ai muốn maintain SQL riêng, đừng tự tạo thêm gánh nặng.

Cần portability. Stored procedure syntax khác nhau giữa PostgreSQL, SQL Server, MySQL. Logic ở C# thì switch database chỉ cần đổi provider.

Pattern an toàn: Repository wrapper

Gom raw SQL vào chỗ tập trung, không rải khắp codebase:

public interface IProductRepository
{
    // LINQ-based — phần lớn use case
    Task<Product?> GetByIdAsync(int id);
    Task<List<Product>> GetByCategoryAsync(string category, int page, int size);

    // Raw SQL — cho query đặc biệt
    Task<List<ProductRankDto>> GetTopPerCategoryAsync(int topN);
    Task<List<ProductReportDto>> GetSalesReportAsync(DateOnly from, DateOnly to);
    Task<int> BulkUpdatePricesAsync(string category, decimal multiplier);
}

public class ProductRepository : IProductRepository
{
    private readonly AppDbContext _db;

    public ProductRepository(AppDbContext db) => _db = db;

    // LINQ — default choice
    public async Task<Product?> GetByIdAsync(int id) =>
        await _db.Products
            .Include(p => p.Category)
            .AsNoTracking()
            .FirstOrDefaultAsync(p => p.Id == id);

    public async Task<List<Product>> GetByCategoryAsync(
        string category, int page, int size) =>
        await _db.Products
            .Where(p => p.Category.Name == category && p.IsActive)
            .OrderBy(p => p.Name)
            .Skip((page - 1) * size)
            .Take(size)
            .AsNoTracking()
            .ToListAsync();

    // Raw SQL — khi LINQ không đủ
    public async Task<List<ProductRankDto>> GetTopPerCategoryAsync(int topN) =>
        await _db.Database
            .SqlQuery<ProductRankDto>($"""
                WITH ranked AS (
                    SELECT *, ROW_NUMBER() OVER (
                        PARTITION BY category_id ORDER BY total_sold DESC
                    ) AS rank
                    FROM products WHERE is_active = true
                )
                SELECT id AS Id, name AS Name, price AS Price,
                       category_id AS CategoryId, rank AS Rank
                FROM ranked WHERE rank <= {topN}
                """)
            .ToListAsync();

    public async Task<int> BulkUpdatePricesAsync(string category, decimal multiplier) =>
        await _db.Database.ExecuteSqlAsync(
            $"UPDATE products SET price = price * {multiplier} WHERE category = {category}");
}

Lợi ích: controller/service không biết raw SQL hay LINQ — nó chỉ gọi repository method. Khi cần migrate stored procedure sang LINQ (hoặc ngược lại), chỉ sửa trong repository.

Dapper: khi nào nên mix?

Có một trường phái dùng EF Core cho write (CRUD + change tracking) và Dapper cho read (raw SQL nhanh gọn). Điều này hoàn toàn hợp lệ:

public class ProductReadRepository
{
    private readonly IDbConnection _connection;

    public ProductReadRepository(IDbConnection connection) =>
        _connection = connection;

    public async Task<IEnumerable<ProductSearchResult>> SearchAsync(
        string term, int page, int size)
    {
        return await _connection.QueryAsync<ProductSearchResult>("""
            SELECT p.id, p.name, p.price, c.name AS category,
                   ts_rank(p.search_vector, query) AS relevance
            FROM products p
            JOIN categories c ON p.category_id = c.id,
                 plainto_tsquery('vietnamese', @Term) query
            WHERE p.search_vector @@ query AND p.is_active = true
            ORDER BY relevance DESC
            OFFSET @Offset ROWS FETCH NEXT @Size ROWS ONLY
            """,
            new { Term = term, Offset = (page - 1) * size, Size = size });
    }
}

Dapper mạnh hơn EF Core raw SQL ở chỗ: API đơn giản hơn, mapping linh hoạt hơn (multi-mapping, dynamic types), và overhead gần bằng zero. Nhược điểm: thêm dependency, team phải biết cả hai.

Từ EF Core 8 với SqlQuery<T>, khoảng cách đã thu hẹp đáng kể. Mình recommend: dùng EF Core cho mọi thứ trước, chỉ thêm Dapper nếu đụng bottleneck mà EF Core raw SQL API không giải quyết gọn.

SQL Injection checklist

Đây là phần không thể bỏ qua. Raw SQL mở cửa cho SQL injection nếu dùng sai:

// ✅ AN TOÀN — interpolated string parameter
db.Products.FromSql($"SELECT * FROM products WHERE name = {userInput}");

// ✅ AN TOÀN — FromSqlRaw với positional parameter
db.Products.FromSqlRaw("SELECT * FROM products WHERE name = {0}", userInput);

// ❌ NGUY HIỂM — string concatenation
db.Products.FromSqlRaw("SELECT * FROM products WHERE name = '" + userInput + "'");

// ❌ NGUY HIỂM — FromSqlRaw + interpolation
db.Products.FromSqlRaw($"SELECT * FROM products WHERE name = '{userInput}'");

// ⚠️  CẨN THẬN — dynamic column/table name KHÔNG parameterize được
var sortColumn = userInput; // "name; DROP TABLE products"
db.Products.FromSqlRaw($"SELECT * FROM products ORDER BY {sortColumn}");
// → SQL injection! Column name không thể parameterize

Rule vàng: giá trị luôn parameterize được. Tên column, tên bảng, ORDER BY direction KHÔNG parameterize được — phải whitelist:

// whitelist cho dynamic sort
var allowedColumns = new HashSet<string> { "name", "price", "created_at" };
var sortColumn = allowedColumns.Contains(userInput) ? userInput : "name";
var direction = sortDirection == "desc" ? "DESC" : "ASC";

var products = await db.Products
    .FromSqlRaw($"SELECT * FROM products ORDER BY {sortColumn} {direction}")
    .ToListAsync();

Logging và debug raw SQL

Bật SQL logging để xem query thực tế:

// trong Program.cs hoặc DbContext
options.LogTo(Console.WriteLine, LogLevel.Information)
       .EnableSensitiveDataLogging(); // hiện parameter values

Hoặc trong development, dùng ToQueryString() kiểm tra trước khi chạy:

var query = db.Products
    .FromSql($"SELECT * FROM products WHERE price > {minPrice}")
    .Where(p => p.IsActive)
    .OrderBy(p => p.Name);

Console.WriteLine(query.ToQueryString());
// hiện full SQL + parameters — verify trước khi execute

Tổng kết

Cheat sheet chọn API:

  • 90% trường hợp → dùng LINQ. Type-safe, refactor-friendly, change tracking.
  • Query phức tạp trả entityFromSql. Window function, full-text search, recursive CTE mà vẫn muốn track.
  • Query trả DTO/scalarSqlQuery<T>. Report, aggregation, custom projection.
  • Bulk update/deleteExecuteUpdate/ExecuteDelete trước, ExecuteSql nếu cần DB-specific syntax.
  • Legacy stored procedureFromSql cho result set, ADO.NET cho output parameter.
  • Read-heavy với SQL phức tạp → cân nhắc Dapper bên cạnh EF Core.

Một điều mình luôn nhắc team: raw SQL là escape hatch, không phải default. Mỗi khi viết raw SQL, hãy tự hỏi "LINQ có làm được không?" — nếu được, dùng LINQ. Nếu không, dùng raw SQL nhưng parameterize mọi thứ, gom vào repository, và log query để debug.

EF Core không bắt bạn chọn một trong hai. Nó cho bạn cả hai — dùng LINQ khi tiện, thoát ra SQL khi cần, trong cùng một codebase.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy