//JorgenHoc
← Todos os artigos
EF CorePor Jorge CalderónAtualizado 11 min read

Consultas SQL Brutas no EF Core — Quando e Como Usá-las

Aprenda quando o SQL bruto supera o LINQ no EF Core, como usar FromSqlRaw, FromSqlInterpolated e ExecuteSqlRaw com segurança, e como prevenir injeção de SQL com parametrização.

#entity-framework#dotnet#database

A tradução LINQ do EF Core lida com a maioria das consultas de forma elegante, mas alguns cenários exigem SQL bruto: funções de janela, CTEs, agregações complexas ou recursos específicos do banco de dados que o LINQ não consegue expressar. O EF Core fornece várias APIs para isso — cada uma com diferentes características de segurança.

Cada afirmação deste artigo é verificada por samples/ef-core-raw-sql — 19 verificações que incluem uma injeção de SQL real contra o padrão vulnerável, para que a diferença de segurança entre as APIs seja dado e não prosa (veja Verifique você mesmo).

As Três APIs de SQL Bruto

APICaso de UsoRastreamento de EntidadesSeguro contra Injeção de SQL
FromSqlRawConsultar entidades com SQL brutoSimSomente com parâmetros
FromSqlInterpolatedConsultar entidades com SQL interpoladoSimSempre
ExecuteSqlRawSQL sem consulta (INSERT/UPDATE/DELETE)N/ASomente com parâmetros
ExecuteSqlInterpolatedSem consulta com SQL interpoladoN/ASempre

FromSqlRaw

Consulta entidades usando uma string SQL bruta. Sempre use parâmetros para entrada do usuário:

// SEGURO — consulta parametrizada
var categoryId = 5;
var products = await _db.Products
    .FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
    .ToListAsync();
 
// SEGURO — parâmetros nomeados (SQL Server)
var products = await _db.Products
    .FromSqlRaw("SELECT * FROM Products WHERE CategoryId = @categoryId",
        new SqlParameter("@categoryId", categoryId))
    .ToListAsync();
⚠️

Nunca concatene a entrada do usuário em uma string de consulta FromSqlRaw. FromSqlRaw("SELECT * FROM Products WHERE Name = '" + userInput + "'") é uma vulnerabilidade de injeção de SQL — e não teórica: o sample passa ' OR '1'='1 por exatamente esse padrão e todas as linhas da tabela voltam, enquanto a mesma entrada através de FromSqlInterpolated vira o parâmetro @p0 e não encontra nenhuma. O analisador do EF Core marca a concatenação em FromSqlRaw com o aviso EF1003; trate-o como erro de compilação.

Composição com LINQ

Consultas SQL brutas podem ser compostas com LINQ — o EF Core as envolve em uma subconsulta:

// SQL bruto para a consulta base, depois compõe com LINQ
var products = await _db.Products
    .FromSqlRaw("SELECT * FROM Products WHERE CategoryId = {0}", categoryId)
    .Where(p => p.Price > 50)        // Adiciona cláusula WHERE
    .OrderBy(p => p.Name)            // Adiciona ORDER BY
    .Include(p => p.Category)        // Adiciona JOIN
    .ToListAsync();

SQL gerado aproximadamente:

SELECT p.*, c.*
FROM (SELECT * FROM Products WHERE CategoryId = 5) AS p
JOIN Categories AS c ON p.CategoryId = c.Id
WHERE p.Price > 50
ORDER BY p.Name

FromSqlInterpolated — Sempre Seguro contra Injeção de SQL

FromSqlInterpolated trata os valores de interpolação de string do C# como parâmetros SQL automaticamente:

// Isso parece interpolação de string, mas o EF Core converte em parâmetros
int categoryId = 5;
string nameFilter = "Widget%";
 
var products = await _db.Products
    .FromSqlInterpolated(
        $"SELECT * FROM Products WHERE CategoryId = {categoryId} AND Name LIKE {nameFilter}")
    .OrderBy(p => p.Price)
    .ToListAsync();

Apesar da sintaxe $"...", o EF Core NÃO concatena os valores. Ele os extrai e cria parâmetros SQL adequados:

-- SQL real executado (sem possibilidade de injeção):
SELECT * FROM Products WHERE CategoryId = @p0 AND Name LIKE @p1
-- @p0 = 5, @p1 = 'Widget%'
💡

Prefira FromSqlInterpolated em vez de FromSqlRaw para todas as consultas que incluam valores fornecidos pelo usuário ou valores em tempo de execução. A API interpolada é sempre segura; a API bruta requer disciplina para ser usada corretamente.

Consultas Complexas Onde o LINQ Não é Suficiente

Funções de Janela

O EF Core não consegue traduzir funções de janela como ROW_NUMBER(), RANK() ou LAG():

// Função de janela para produtos classificados por preço dentro da categoria
var rankedProducts = await _db.Products
    .FromSqlRaw(@"
        SELECT
            p.*,
            ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank
        FROM Products p
    ")
    .ToListAsync();

Como PriceRank não é uma propriedade em Product, mapeie para um DTO em vez disso:

// DTO para o resultado
public record RankedProduct(int Id, string Name, decimal Price, int CategoryId, int PriceRank);
 
// Use SQL bruto com Dapper ou ADO.NET para resultados que não são entidades
// Ou adicione PriceRank como propriedade [NotMapped] e use FromSqlRaw

Para consultas que não são entidades, use _db.Database.SqlQueryRaw<T> (EF Core 7+):

// EF Core 7+ — consulta para qualquer tipo, não apenas tipos de entidade
var rankedProducts = await _db.Database
    .SqlQueryRaw<RankedProduct>(@"
        SELECT
            p.Id,
            p.Name,
            p.Price,
            p.CategoryId,
            CAST(ROW_NUMBER() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS int) AS PriceRank
        FROM Products p
    ")
    .ToListAsync();
⚠️

O CAST(... AS int) não é opcional. ROW_NUMBER() retorna bigint no SQL Server, e materializá-lo em uma propriedade int do DTO lança InvalidCastException em tempo de execução — os tipos precisam coincidir exatamente, nada é convertido em silêncio. (Records posicionais de C# funcionam bem como destino de SqlQueryRaw<T>; o que morde são os tipos de coluna.)

CTEs (Expressões de Tabela Comum)

var topCategories = await _db.Database
    .SqlQueryRaw<CategorySummary>(@"
        WITH ProductCounts AS (
            SELECT
                CategoryId,
                COUNT(*) AS ProductCount,
                AVG(Price) AS AvgPrice
            FROM Products
            GROUP BY CategoryId
        )
        SELECT
            c.Id,
            c.Name,
            pc.ProductCount,
            pc.AvgPrice
        FROM Categories c
        JOIN ProductCounts pc ON c.Id = pc.CategoryId
        ORDER BY pc.ProductCount DESC
    ")
    .ToListAsync();

Pesquisa de Texto Completo

A pesquisa de texto completo do SQL Server não pode ser expressa em LINQ:

var searchTerm = "entity framework performance";
var results = await _db.Articles
    .FromSqlInterpolated(
        $"SELECT * FROM Articles WHERE CONTAINS(Content, {searchTerm})")
    .Include(a => a.Author)
    .ToListAsync();

Recursos Específicos do Banco de Dados

// SQL Server — instrução MERGE
await _db.Database.ExecuteSqlInterpolatedAsync($@"
    MERGE Products AS target
    USING (SELECT {id} AS Id, {newName} AS Name) AS source
    ON target.Id = source.Id
    WHEN MATCHED THEN UPDATE SET Name = source.Name
    WHEN NOT MATCHED THEN INSERT (Id, Name) VALUES (source.Id, source.Name);
");

ExecuteSqlRaw / ExecuteSqlInterpolated para Operações Sem Consulta

Para INSERT, UPDATE, DELETE ou DDL que não retornam entidades:

// Atualização em massa com ExecuteSqlInterpolated (sempre seguro)
int categoryId = 5;
decimal discountFactor = 0.9m;
 
int rowsAffected = await _db.Database.ExecuteSqlInterpolatedAsync(
    $"UPDATE Products SET Price = Price * {discountFactor} WHERE CategoryId = {categoryId}");
 
Console.WriteLine($"Foram atualizados {rowsAffected} produtos");
 
// Ou com ExecuteSqlRaw e parâmetros explícitos
await _db.Database.ExecuteSqlRawAsync(
    "UPDATE Products SET Price = Price * @discount WHERE CategoryId = @catId",
    new SqlParameter("@discount", discountFactor),
    new SqlParameter("@catId", categoryId));
💡

As extensões LINQ ExecuteUpdateAsync e ExecuteDeleteAsync do EF Core 7+ geralmente são melhores que o SQL bruto para operações em massa porque são type-safe e se compõem com LINQ. Use SQL bruto quando a consulta for genuinamente complexa.

Chamando Stored Procedures

// Stored procedure que retorna entidades
var products = await _db.Products
    .FromSqlRaw("EXEC dbo.GetProductsByCategory @CategoryId = {0}", categoryId)
    .ToListAsync();
 
// Stored procedure com parâmetro de saída
var outputParam = new SqlParameter("@TotalCount", SqlDbType.Int)
{
    Direction = ParameterDirection.Output
};
 
await _db.Database.ExecuteSqlRawAsync(
    "EXEC dbo.ProcessOrders @BatchSize = {0}, @TotalCount = @TotalCount OUTPUT",
    100,
    outputParam);
 
var totalCount = (int)outputParam.Value;
Console.WriteLine($"Foram processados {totalCount} pedidos");
⚠️

Os resultados de uma stored procedure não podem ser compostos com LINQ. FromSqlRaw("EXEC ...") seguido de .Where(...), .Include(...) ou qualquer coisa que precise traduzir-se para SQL lança InvalidOperationException — um EXEC não pode ser embrulhado em uma subconsulta como um SELECT pode. Filtre dentro da procedure, ou materialize com ToListAsync() primeiro e filtre em memória.

Sem Rastreamento com SQL Bruto

As consultas SQL brutas participam do rastreamento de alterações do EF Core por padrão. Para consultas somente leitura:

var products = await _db.Products
    .FromSqlInterpolated($"SELECT * FROM Products WHERE Price > {minPrice}")
    .AsNoTracking()  // Sem rastreamento de alterações — melhor desempenho para leituras
    .ToListAsync();

Quando o SQL Bruto Supera o LINQ

CenárioUsar SQL Bruto
Funções de janela (ROW_NUMBER, RANK, LAG)Sim
CTEs ou consultas recursivasSim
Pesquisa de texto completoSim
Instruções MERGE / UPSERTSim
Stored proceduresSim
Hints específicos do banco de dados (NOLOCK, FORCESEEK)Sim
Agregações complexas com ROLLUP/CUBESim
Consultas que geram SQL ruim via LINQSim
CRUD simples e consultas filtradasNão — LINQ é suficiente

Verificando o SQL Gerado

Antes de recorrer ao SQL bruto, verifique o que o LINQ gera — pode ser perfeitamente adequado:

// Registrar consultas no console em desenvolvimento
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
    if (builder.Environment.IsDevelopment())
        options.LogTo(Console.WriteLine, LogLevel.Information);
});

Ou inspecionar a consulta sem executá-la:

var query = _db.Products
    .Where(p => p.CategoryId == 5)
    .OrderBy(p => p.Price)
    .Select(p => new { p.Id, p.Name, p.Price });
 
// Obter o SQL sem executar
var sql = query.ToQueryString();
Console.WriteLine(sql);

Exemplo Específico do PostgreSQL

// Consulta JSONB do PostgreSQL — não pode ser expressa em LINQ portátil
var results = await _db.Database
    .SqlQueryRaw<OrderResult>(@"
        SELECT id, data->>'customer_name' AS CustomerName,
               (data->>'total')::numeric AS Total
        FROM orders
        WHERE data @> '{""status"": ""pending""}'::jsonb
        ORDER BY created_at DESC
    ")
    .ToListAsync();

Verifique você mesmo

Cada afirmação acima é verificada por samples/ef-core-raw-sql — 19 verificações que lançam exceção se falharem (EF Core 10, SQL Server LocalDB). Os destaques:

  • A injeção é real: ' OR '1'='1 concatenado em FromSqlRaw vaza as 12 linhas semeadas; a entrada idêntica através de FromSqlInterpolated não encontra nenhuma.
  • Compor Where + OrderBy + Include sobre um SELECT bruto executa como uma única instrução, com a subconsulta visível no ToQueryString().
  • ROW_NUMBER() em uma propriedade int do DTO lança InvalidCastException; com CAST(... AS int), o rank 1 cai no produto mais caro de cada categoria.
  • Uma CTE materializa em um record comum; uma stored procedure materializa entidades rastreadas, recusa composição LINQ com InvalidOperationException e devolve seu parâmetro OUTPUT.
  • AsNoTracking() mantém o change tracker vazio (contra 11 entidades rastreadas sem ele); ToQueryString() executa zero instruções; MERGE percorre seus dois caminhos, insert e update.
Saída de console do sample de SQL bruto: 19 verificações que passam. FromSqlRaw com parâmetros posicionais e nomeados devolve 4 produtos; compor Where, OrderBy e Include sobre SQL bruto executa como uma única instrução. A seção de injeção mostra a entrada ' OR '1'='1 vazando as 12 linhas através de FromSqlRaw concatenado, e devolvendo 0 linhas através de FromSqlInterpolated onde vira o parâmetro @p0. SqlQueryRaw de um ROW_NUMBER em uma propriedade int lança InvalidCastException, e com CAST as int devolve 12 linhas com rank 1 no produto mais caro de cada categoria; uma CTE materializa em um DTO. Uma stored procedure materializa entidades rastreadas, recusa composição LINQ com InvalidOperationException e devolve seu parâmetro OUTPUT de 6. AsNoTracking deixa o tracker vazio contra 11 rastreadas sem ele, ToQueryString executa zero instruções, e MERGE percorre seus caminhos de insert e update.
As 19 verificações, direto do console — as duas linhas de injeção são as primeiras a ler: entrada idêntica, as 12 linhas vazadas por concatenação contra 0 por interpolação.
💡

Execute primeiro o seed.sql e depois dotnet run — o seed também cria as duas stored procedures. A pesquisa de texto completo e o exemplo JSONB do PostgreSQL são as duas seções não verificadas: o LocalDB não tem mecanismo de texto completo e o sample roda apenas no SQL Server.

Quando o SQL Bruto Se Torna o Caso Comum

Se você perceber que a maioria das suas consultas migrou para SQL bruto, trate isso como um sinal e não como um hábito. Nesse ponto a comparação em EF Core vs Dapper é a relevante — o Dapper foi construído exatamente para esse estilo de acesso e não cobra por um change tracker que você não está usando.

Antes de concluir que o LINQ é o gargalo, no entanto, compare o SQL gerado com os padrões de o problema de consultas N+1. Uma consulta LINQ lenta é muito mais frequentemente um Include ou uma projeção ausente do que uma limitação real do tradutor.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados