O SQL continua sendo escrito por você, e o que desaparece é o trabalho braçal de abrir leitores, lembrar posições de colunas e montar objetos campo a campo.
"ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection" terminou com uma conta desanimadora. Das trinta linhas do método de listagem, só o SELECT expressava a pergunta de negócio. O resto era cerimônia: criar o comando, adicionar parâmetros um a um, percorrer o leitor, lembrar a posição de cada coluna, testar IsDBNull e montar o resultado. Em 2011, a equipe do Stack Overflow enfrentava exatamente esse problema, em escala, num dos sites mais acessados do mundo. A solução que Sam Saffron e Marc Gravell escreveram para uso interno foi publicada como código aberto com o nome de Dapper, e se tornou uma das bibliotecas mais baixadas do ecossistema .NET. Nesta aula, a cerimônia vai embora, e o SQL continua inteiro nas suas mãos.
Um micro-ORM
Um ORM (Object-Relational Mapper) é uma ferramenta que faz a ponte entre objetos do programa e tabelas do banco. Os ORMs completos, como o Entity Framework das próximas aulas, geram o SQL por você, acompanham as mudanças nos objetos e gerenciam relacionamentos. O Dapper se define como um micro-ORM: ele faz uma única coisa, e muito bem. Recebe o SQL que você escreveu, envia os parâmetros com segurança e transforma cada linha do resultado num objeto. Nada de geração de SQL, nada de rastrear mudanças, nada de mágica.
Tecnicamente, o Dapper é um conjunto de métodos de extensão sobre a interface de conexão do ADO.NET, o mesmo mecanismo de "LINQ, Parte I". Você continua usando SqliteConnection, NpgsqlConnection ou qualquer outra conexão, e ela passa a ter métodos como QueryAsync e ExecuteAsync:
dotnet add src/Livraria.Dados package Dapper
Consultando: QueryAsync<T>
Compare com a listagem da aula anterior:
using Dapper;
using Microsoft.Data.Sqlite;
public sealed class ProdutoLinha
{
public int Id { get; set; }
public string Tipo { get; set; } = "";
public string Titulo { get; set; } = "";
public string Autor { get; set; } = "";
public long PrecoCentavos { get; set; }
public int? Estoque { get; set; }
public decimal Preco => PrecoCentavos / 100m;
}
await using var conexao = new SqliteConnection("Data Source=livraria.db;Foreign Keys=True");
IEnumerable<ProdutoLinha> produtos = await conexao.QueryAsync<ProdutoLinha>("""
SELECT p.id, p.tipo, p.titulo, a.nome AS autor, p.preco_centavos, p.estoque
FROM produtos p
JOIN autores a ON a.id = p.autor_id
WHERE p.preco_centavos <= @PrecoMaximo
ORDER BY p.preco_centavos
""",
new { PrecoMaximo = 4000 });
Uma chamada. O Dapper abre a conexão se ela estiver fechada, cria o comando, transforma o objeto anônimo new { PrecoMaximo = 4000 } em parâmetros, executa, percorre o leitor e, para cada linha, cria um ProdutoLinha e preenche cada propriedade com a coluna de mesmo nome, sem diferenciar maiúsculas de minúsculas. Colunas nulas viram null em propriedades anuláveis como int? Estoque. Inteiros que o SQLite devolve como long são convertidos para int. Ao final, fecha a conexão que ele mesmo abriu.
E a segurança continua integral: cada propriedade do objeto anônimo vira um parâmetro de verdade, enviado separadamente do texto, exatamente como os Parameters.AddWithValue da aula anterior. Com o Dapper, fica mais fácil fazer a coisa certa do que a errada.
Nomes com sublinhado
Há um detalhe na consulta acima. A coluna se chama preco_centavos, em snake_case, a convenção comum em bancos de dados, e a propriedade se chama PrecoCentavos, em PascalCase, a convenção do C#. Por padrão, o Dapper não considera esses nomes iguais, e o pior é o que acontece nesse caso: nada. A propriedade simplesmente fica com o valor padrão, zero, sem erro nem aviso. Todos os livros sairiam de graça.
Há duas soluções. A primeira é dar à coluna o nome da propriedade no próprio SQL: p.preco_centavos AS PrecoCentavos. A segunda é ligar, uma vez, na inicialização do programa, a opção que ensina o Dapper a ignorar os sublinhados:
DefaultTypeMap.MatchNamesWithUnderscores = true;
Esse silêncio é a principal fragilidade de qualquer mapeamento por convenção de nomes, e é por ele que os testes de integração, que rodam as consultas contra um banco de verdade, valem tanto quanto os testes de unidade de "Testes Automatizados com xUnit".
Os métodos do dia a dia
Os nomes dos métodos seguem os operadores do LINQ, e com eles a mesma semântica sobre ausência e excesso de resultados:
QueryAsync<T>: todas as linhas, como uma sequência.QueryFirstOrDefaultAsync<T>: a primeira linha, ou o valor padrão se não houver nenhuma.QuerySingleAsync<T>: exatamente uma linha. Lança exceção se houver zero ou mais de uma, como oSinglede "LINQ, Parte I".QuerySingleOrDefaultAsync<T>: zero ou uma linha. Lança exceção se houver mais de uma.ExecuteScalarAsync<T>: um único valor, já convertido paraT, sem oobjecte oDBNulldo ADO.NET.ExecuteAsync: comandos sem resultado (INSERT,UPDATE,DELETE), devolvendo o número de linhas afetadas.
O resultado de uma consulta também pode ser uma tupla, mapeada pela posição das colunas, sem precisar de uma classe só para isso:
var faturamento = await conexao.QueryAsync<(string Autor, long Centavos)>("""
SELECT a.nome, SUM(v.quantidade * p.preco_centavos)
FROM vendas v
JOIN produtos p ON p.id = v.produto_id
JOIN autores a ON a.id = p.autor_id
GROUP BY a.nome
ORDER BY 2 DESC
""");
foreach (var (autor, centavos) in faturamento)
Console.WriteLine($"{autor,-20}{centavos / 100m,12:C}");
ORDER BY 2 ordena pela segunda coluna do SELECT, uma abreviação útil em consultas de relatório.
Um recurso exclusivo do Dapper que resolve um incômodo real: passar uma lista para um IN. No ADO.NET puro, seria preciso gerar um marcador para cada item. O Dapper expande a lista sozinho:
var escolhidos = await conexao.QueryAsync<ProdutoLinha>(
"SELECT id, titulo, preco_centavos FROM produtos WHERE id IN @Ids",
new { Ids = new[] { 1, 3, 5 } });
O comando enviado ao banco tem três parâmetros, um por identificador, e continua imune a injeção.
Transações e cancelamento
Transações funcionam como no ADO.NET, passando a transação a cada chamada. A venda à prova de corridas da aula anterior fica assim:
await using var conexao = new SqliteConnection(cadeia);
await conexao.OpenAsync(ct);
await using var transacao = conexao.BeginTransaction();
int afetadas = await conexao.ExecuteAsync("""
UPDATE produtos SET estoque = estoque - @Quantidade
WHERE id = @Id AND (estoque IS NULL OR estoque >= @Quantidade)
""",
new { Id = produtoId, Quantidade = quantidade }, transacao);
if (afetadas == 0)
throw new InvalidOperationException($"Produto {produtoId} inexistente ou sem estoque suficiente.");
await conexao.ExecuteAsync(
"INSERT INTO vendas (produto_id, quantidade, data) VALUES (@Id, @Quantidade, @Data)",
new { Id = produtoId, Quantidade = quantidade, Data = data.ToString("yyyy-MM-dd") }, transacao);
await transacao.CommitAsync(ct);
A lógica é idêntica. O código de parâmetros ocupa uma linha por comando, em vez de três. Para cancelamento, os métodos do Dapper aceitam um CommandDefinition, um pacote com o SQL, os parâmetros, a transação e o CancellationToken de "Assincronia Avançada": await conexao.QueryAsync<T>(new CommandDefinition(sql, parametros, cancellationToken: ct)).
O que o Dapper não faz
A honestidade sobre as limitações é o que permite escolher bem entre as ferramentas desta fase.
O SQL continua sendo texto. Um erro de digitação no nome de uma coluna só aparece em execução, e renomear uma propriedade não atualiza o SQL. É o mesmo problema das strings na reflexão de "Reflexão e Atributos", e a defesa é a mesma: testes de integração que executem cada consulta.
Não há rastreamento de mudanças. Se você carrega um produto, altera o preço e quer gravar, precisa escrever o UPDATE. O Dapper não sabe o que mudou.
Relacionamentos são com você. Montar um autor com a lista dos seus livros a partir de um JOIN exige código de agrupamento, ou o recurso de multi-mapping do Dapper, que divide cada linha em vários objetos, mas que também não monta as coleções sozinho.
Reflexão em execução. O Dapper descobre as propriedades e gera o código de mapeamento na primeira vez que vê cada consulta, o que não combina com a publicação Native AOT de "Source Generators". Para esse cenário existe o Dapper.AOT, do mesmo autor, que usa geração de código na compilação.
E há o outro lado da balança. O Dapper é rápido, com desempenho praticamente igual ao do ADO.NET escrito à mão, e transparente: o SQL que roda é exatamente o que você escreveu, e você usa qualquer recurso do banco, de funções específicas a consultas de relatório complexas. É por isso que muitas equipes combinam ferramentas: um ORM completo para as operações do dia a dia e o Dapper para consultas de relatório e caminhos críticos de desempenho.
Juntando tudo: as consultas da Livraria com Dapper
using Dapper;
using Microsoft.Data.Sqlite;
namespace Livraria.Dados;
public sealed class ConsultasLivraria(string cadeiaDeConexao)
{
public async Task<IReadOnlyList<ProdutoLinha>> ListarDisponiveisAsync(decimal? precoMaximo = null)
{
await using var conexao = new SqliteConnection(cadeiaDeConexao);
var produtos = await conexao.QueryAsync<ProdutoLinha>("""
SELECT p.id, p.tipo, p.titulo, a.nome AS autor, p.preco_centavos, p.estoque
FROM produtos p
JOIN autores a ON a.id = p.autor_id
WHERE (p.estoque IS NULL OR p.estoque > 0)
AND (@PrecoMaximo IS NULL OR p.preco_centavos <= @PrecoMaximo)
ORDER BY p.preco_centavos
""",
new { PrecoMaximo = precoMaximo is null ? (long?)null : (long)Math.Round(precoMaximo.Value * 100) });
return produtos.AsList();
}
public async Task<ProdutoLinha?> ObterAsync(int id)
{
await using var conexao = new SqliteConnection(cadeiaDeConexao);
return await conexao.QuerySingleOrDefaultAsync<ProdutoLinha>("""
SELECT p.id, p.tipo, p.titulo, a.nome AS autor, p.preco_centavos, p.estoque
FROM produtos p JOIN autores a ON a.id = p.autor_id
WHERE p.id = @Id
""", new { Id = id });
}
public async Task<IReadOnlyList<(string Autor, decimal Total)>> FaturamentoPorAutorAsync()
{
await using var conexao = new SqliteConnection(cadeiaDeConexao);
var linhas = await conexao.QueryAsync<(string Autor, long Centavos)>("""
SELECT a.nome, SUM(v.quantidade * p.preco_centavos)
FROM vendas v
JOIN produtos p ON p.id = v.produto_id
JOIN autores a ON a.id = p.autor_id
GROUP BY a.nome
ORDER BY 2 DESC
""");
return linhas.Select(l => (l.Autor, l.Centavos / 100m)).ToList();
}
}
E o programa, com a configuração global feita uma única vez:
using System.Globalization;
using Dapper;
CultureInfo.CurrentCulture = new CultureInfo("pt-BR");
DefaultTypeMap.MatchNamesWithUnderscores = true;
var consultas = new ConsultasLivraria("Data Source=livraria.db;Foreign Keys=True");
foreach (var p in await consultas.ListarDisponiveisAsync(precoMaximo: 40m))
Console.WriteLine($"{p.Titulo,-25} {p.Autor,-18} {p.Preco,9:C}");
Console.WriteLine();
foreach (var (autor, total) in await consultas.FaturamentoPorAutorAsync())
Console.WriteLine($"{autor,-20}{total,12:C}");
ProdutoLinha? sertao = await consultas.ObterAsync(6);
Console.WriteLine(sertao is null ? "\nNão encontrado." : $"\n{sertao.Titulo}: {sertao.Estoque} em estoque");
A listagem com filtro opcional, que ocupava trinta linhas no ADO.NET, cabe em doze, e quase todas são SQL. AsList() é um método do Dapper que devolve a sequência como List<T> sem copiá-la quando ela já é uma lista, o que ela é por padrão.
Resta uma distância entre essas consultas e o domínio que construímos na Fase 2. ProdutoLinha é um objeto de dados solto, com preço em centavos e tipo em texto, e não um LivroImpresso que sabe vender a si mesmo e protege seu estoque. Para trabalhar com o domínio de verdade, cada consulta precisaria converter as linhas nos objetos certos. Cada alteração, como uma venda, uma mudança de preço ou uma reposição, precisaria de um UPDATE escrito à mão, com o cuidado de gravar exatamente o que mudou. E a hierarquia de produtos, as relações entre autores e livros e o ciclo de vida do pedido continuariam sendo responsabilidade de quem escreve o SQL. Existe uma ferramenta que assume todo esse trabalho: você pergunta em LINQ, altera objetos do domínio normalmente, e ela descobre sozinha o SQL necessário para ler e para gravar.
Fontes e leituras recomendadas
- Dapper (repositório oficial): https://github.com/DapperLib/Dapper. Traz a documentação, os exemplos e a lista completa de métodos e recursos.
- Learn Dapper: https://www.learndapper.com. É um guia com exemplos de cada método, transações, multi-mapping e listas em
IN. - Dapper.AOT: https://github.com/DapperLib/DapperAOT. É a variação baseada em geração de código, compatível com Native AOT.
- Nick Craver, "Stack Overflow: The Architecture – 2016 Edition": https://nickcraver.com/blog/2016/02/17/stack-overflow-the-architecture-2016-edition/. Mostra o contexto em que o Dapper nasceu e como ele era usado num dos sites mais acessados do mundo.
- SQLite FAQ, questão 19 ("INSERT is really slow"): https://www.sqlite.org/faq.html#q19. Explica por que inserir muitas linhas fora de uma transação é tão lento, tema do exercício 4.
Exercícios
Exercício 1
Escreva um método que devolva, com Dapper, os produtos de um autor cujo nome é passado por parâmetro, ignorando maiúsculas e minúsculas.
Ver resposta
✓ Resposta:
public async Task<IReadOnlyList<ProdutoLinha>> ListarPorAutorAsync(string autor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(autor);
await using var conexao = new SqliteConnection(cadeiaDeConexao);
var produtos = await conexao.QueryAsync<ProdutoLinha>("""
SELECT p.id, p.tipo, p.titulo, a.nome AS autor, p.preco_centavos, p.estoque
FROM produtos p JOIN autores a ON a.id = p.autor_id
WHERE a.nome = @Autor COLLATE NOCASE
ORDER BY p.titulo
""", new { Autor = autor.Trim() });
return produtos.AsList();
}
O nome vai como parâmetro, então uma entrada maliciosa nunca vira código. COLLATE NOCASE é a forma do SQLite de comparar sem diferenciar maiúsculas, o equivalente do StringComparison.OrdinalIgnoreCase de "Operadores, Texto e Conversa com o Usuário". Assim como lá, ele só trata as letras sem acento do ASCII: "machado de assis" encontra "Machado de Assis", mas "SÃO BERNARDO" não seria igual a "São Bernardo". Cada banco tem sua forma de tratar comparações sensíveis a idioma, e veremos a do PostgreSQL e a do MySQL mais adiante na fase.
Exercício 2
Um colega removeu a linha DefaultTypeMap.MatchNamesWithUnderscores = true; e todos os produtos passaram a aparecer com preço zero, sem nenhum erro. Explique e dê duas correções que não dependam dessa opção global.
Ver resposta
✓ Resposta: Sem a opção, o Dapper procura uma propriedade chamada exatamente preco_centavos, ignorando só maiúsculas. PrecoCentavos não tem o sublinhado e não é considerada correspondente. O Dapper não trata uma coluna sem propriedade correspondente como erro, porque é comum consultas trazerem colunas a mais, e simplesmente a ignora. A propriedade fica com o valor padrão, zero, e o preço calculado a partir dela também. A primeira correção é dar à coluna, no SQL, o nome da propriedade: p.preco_centavos AS PrecoCentavos. É explícita e vale só para aquela consulta. A segunda é declarar a propriedade com o nome da coluna. Isso funciona, mas espalha o estilo do banco pelo código C#, e a convenção de nomes do .NET desaconselha. A lição geral, mais importante que as duas correções, é que um mapeamento por convenção falha em silêncio, e só um teste que execute a consulta e verifique um valor conhecido, como o preço de Dom Casmurro ser R$ 59,90, pega esse tipo de erro.
Exercício 3
Para buscar um produto pelo ISBN, que deveria ser único, qual destes métodos você usaria, e por quê: QueryFirstOrDefaultAsync, QuerySingleAsync ou QuerySingleOrDefaultAsync?
Ver resposta
✓ Resposta: QuerySingleOrDefaultAsync. A busca por ISBN tem dois resultados esperados: o livro existe, e vem uma linha, ou não existe, e não vem nenhuma. QuerySingleOrDefaultAsync devolve o objeto ou null nesses dois casos. E há um terceiro resultado, que não deveria acontecer: duas linhas com o mesmo ISBN, o que indica dados corrompidos. Nesse caso, QuerySingleOrDefaultAsync lança exceção, e é exatamente o que queremos, porque o problema aparece imediatamente, em vez de o sistema escolher um dos dois livros ao acaso. QueryFirstOrDefaultAsync esconderia a duplicação, devolvendo a primeira linha. QuerySingleAsync trataria a ausência, que é um caso normal, como erro. É o mesmo raciocínio de "LINQ, Parte I": escolher o operador pela semântica de ausência e de excesso, e não por hábito. A proteção definitiva, claro, é uma restrição UNIQUE na coluna, para que o banco nem aceite a duplicação.
Exercício 4
Importe 10 mil vendas para o SQLite com conexao.ExecuteAsync(sqlDoInsert, listaDeVendas), que o Dapper executa uma vez para cada item. Meça o tempo sem transação e dentro de uma transação. Explique a diferença.
Ver resposta
✓ Resposta:
using System.Diagnostics;
var vendas = Enumerable.Range(0, 10_000)
.Select(i => new { Id = 1 + i % 7, Quantidade = 1, Data = "2026-09-10" })
.ToList();
const string Sql = "INSERT INTO vendas (produto_id, quantidade, data) VALUES (@Id, @Quantidade, @Data)";
await using var conexao = new SqliteConnection("Data Source=livraria.db;Foreign Keys=True");
await conexao.OpenAsync();
var relogio = Stopwatch.StartNew();
await conexao.ExecuteAsync(Sql, vendas);
Console.WriteLine($"Sem transação: {relogio.ElapsedMilliseconds} ms");
relogio.Restart();
await using (var transacao = conexao.BeginTransaction())
{
await conexao.ExecuteAsync(Sql, vendas, transacao);
await transacao.CommitAsync();
}
Console.WriteLine($"Com transação: {relogio.ElapsedMilliseconds} ms");
A diferença vai de dezenas a milhares de vezes, conforme o disco. Medido no Linux com .NET 10: num disco com ext4, cerca de 60 segundos sem transação e 45 ms com ela, perto de 1.300 vezes; num sistema de arquivos em memória, que não espera o disco, 0,7 s contra 33 ms, umas 20 vezes. Sem uma transação explícita, o SQLite trata cada INSERT como uma transação própria. Para garantir a durabilidade do ACID de "SQL Essencial com SQLite", cada confirmação precisa esperar que os dados estejam fisicamente gravados no disco, e essa espera, repetida dez mil vezes, domina o tempo. Dentro de uma transação única, os dez mil INSERTs são confirmados de uma vez, com uma única espera pelo disco. O FAQ do SQLite trata exatamente disso. A lição vale para qualquer banco: operações em lote devem ser agrupadas em transações, tanto pela consistência (tudo ou nada) quanto pelo desempenho.
Exercício 5
Liste três tarefas que o ADO.NET exigia e o Dapper eliminou, e três que continuam sendo sua responsabilidade com o Dapper.
Ver resposta
✓ Resposta: Eliminadas: (1) criar os parâmetros um a um com AddWithValue, porque um objeto anônimo vira parâmetros automaticamente; (2) percorrer o DbDataReader, lembrar a posição de cada coluna e chamar o Get correspondente, porque o mapeamento por nome monta os objetos sozinho; (3) tratar DBNull, já que nulos viram null em propriedades anuláveis e escalares chegam convertidos para o tipo pedido. Continuam com você: (1) escrever todo o SQL, inclusive INSERT e UPDATE, sem verificação em tempo de compilação dos nomes de tabelas e colunas; (2) saber o que mudou num objeto para gravar só isso, porque o Dapper não rastreia alterações; (3) montar relacionamentos, como um autor com a coleção dos seus livros, a partir de linhas planas de um JOIN. As três tarefas que sobraram são exatamente o que um ORM completo, como o Entity Framework, se propõe a fazer. O preço é que ele esconde o SQL, o que traz ganhos de produtividade e riscos de desempenho que as próximas aulas vão examinar com cuidado.