Entity Framework Core: Objetos no Banco Sem Escrever SQL

Entity Framework Core: Objetos no Banco Sem Escrever SQL

O domínio da Livraria persistido com Entity Framework Core e SQLite: DbContext e classes de configuração, conversor de reais para centavos, herança em tabela única, construtores com validação, consultas LINQ traduzidas para SQL, rastreamento de mudanças e o que não se traduz.
Linguagem C#

• • 19 min de leitura

Você pergunta em LINQ e altera objetos do domínio normalmente; o Entity Framework descobre sozinho o SQL que precisa para ler e para gravar.

"Dapper: SQL Seu, Sem a Cerimônia" terminou listando o que continuava sendo responsabilidade nossa: escrever cada INSERT e UPDATE, saber o que mudou num objeto e montar relacionamentos a partir de linhas planas. O Entity Framework Core assume exatamente esse trabalho. Ele é o ORM oficial do .NET, reescrito do zero em 2016 junto com o .NET Core, a partir da experiência de oito anos do Entity Framework original. Com ele, as consultas LINQ de "LINQ, Parte I" deixam de rodar sobre listas na memória: são traduzidas para SQL e executadas no banco. Nesta aula conectamos o domínio da Livraria ao EF Core, com a hierarquia de produtos, o dinheiro e o encapsulamento que construímos na Fase 2.

As peças do EF Core

Três conceitos formam o núcleo:

  • Entidades: as classes do domínio que o EF persiste, como LivroImpresso e Ebook. São classes comuns: não precisam herdar de nada nem ter atributos.
  • DbContext: a sessão de trabalho com o banco. Ele sabe quais entidades existem, como elas mapeiam para tabelas, executa consultas e acompanha as mudanças nos objetos carregados.
  • DbSet<T>: o ponto de partida das consultas sobre uma entidade, algo como uma tabela vista como coleção.

Os pacotes, no projeto de dados:

dotnet add src/Livraria.Dados package Microsoft.EntityFrameworkCore.Sqlite
dotnet add src/Livraria.Dados package EFCore.NamingConventions

O primeiro é o EF Core com o provedor do SQLite, construído sobre o Microsoft.Data.Sqlite da aula de ADO.NET. O segundo, mantido pela comunidade, traduz os nomes do C# (PrecoCentavos) para a convenção de bancos (preco_centavos) automaticamente.

Um ajuste no domínio: o Id agora vem do banco

Desde "Classes e Objetos", os produtos geravam o próprio Id com um contador estático, e desde lá apontamos suas fragilidades: ele recomeça a cada execução e não conhece os produtos já gravados. Com um banco de dados, a responsabilidade passa para quem sabe gerá-lo corretamente. Em Produto, o contador sai de cena:

public int Id { get; private set; }   // atribuído pelo banco ao salvar

Um produto recém-criado tem Id zero até ser gravado. Ao salvar, o EF Core recebe do banco o valor gerado e o atribui à propriedade, usando o set privado, que continua inacessível para o resto do programa. É uma das poucas mudanças que o domínio precisa sofrer, e ela o deixa melhor, não pior.

O contexto

using Microsoft.EntityFrameworkCore;

namespace Livraria.Dados;

public sealed class LivrariaContext(DbContextOptions<LivrariaContext> options) : DbContext(options)
{
    public DbSet<Produto> Produtos => Set<Produto>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(LivrariaContext).Assembly);
}

O contexto recebe as opções (qual banco, qual cadeia de conexão) pelo construtor primário. Essa escolha permite configurá-lo de fora, e é o que a API da próxima fase vai fazer com injeção de dependência. OnModelCreating é onde se descreve o mapeamento, e ApplyConfigurationsFromAssembly procura no projeto todas as classes de configuração, uma por entidade, mantendo o contexto pequeno.

Configurando o mapeamento

O EF Core segue convenções: uma propriedade Id vira a chave primária, propriedades públicas com get e set viram colunas, e tipos anuláveis viram colunas que aceitam nulo. Tudo o que foge da convenção é descrito numa classe de configuração, com a API fluente:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public sealed class ProdutoConfiguracao : IEntityTypeConfiguration<Produto>
{
    public void Configure(EntityTypeBuilder<Produto> builder)
    {
        builder.ToTable("produtos");
        builder.HasKey(p => p.Id);

        builder.Property(p => p.Titulo).IsRequired().HasMaxLength(200);
        builder.Property(p => p.Autor).IsRequired().HasMaxLength(120);
        builder.HasIndex(p => p.Titulo);

        builder.Property(p => p.Preco)
               .HasColumnName("preco_centavos")
               .HasConversion(
                   reais => (long)Math.Round(reais * 100),
                   centavos => centavos / 100m);

        builder.HasDiscriminator<string>("tipo")
               .HasValue<LivroImpresso>("impresso")
               .HasValue<Ebook>("ebook")
               .HasValue<Audiolivro>("audiolivro");
    }
}

public sealed class LivroImpressoConfiguracao : IEntityTypeConfiguration<LivroImpresso>
{
    public void Configure(EntityTypeBuilder<LivroImpresso> builder) =>
        builder.Ignore(l => l.LimiteEstoqueBaixo);
}

Três decisões merecem atenção, porque cada uma resolve um problema que já conhecemos.

O dinheiro. Em "SQL Essencial com SQLite" vimos que o SQLite não tem tipo decimal exato. O provedor do EF Core para SQLite até consegue guardar decimal, gravando-o como texto, mas não consegue ordenar nem comparar esses valores no banco. Um conversor de valor resolve de forma elegante: o domínio continua trabalhando com decimal em reais, e o banco guarda inteiros em centavos. As duas funções do conversor dizem como ir e como voltar. E o EF Core aplica o conversor também às consultas: um filtro p.Preco < 40m vira preco_centavos < 4000 no SQL.

A hierarquia. HasDiscriminator mapeia a herança de "Herança: Quando um Tipo É uma Versão Especializada de Outro" para a mesma estratégia de tabela única que usamos à mão na aula de SQL: uma coluna tipo indica a classe de cada linha, e as colunas próprias de cada tipo ficam nulas quando não se aplicam. Ao ler, o EF Core cria automaticamente um LivroImpresso ou um Ebook conforme o valor da coluna. O polimorfismo sobrevive à ida e à volta ao banco.

O que não é dado. LimiteEstoqueBaixo é uma propriedade pública com set, e pela convenção viraria uma coluna. Mas ela é uma configuração de comportamento, e não um dado do livro, então a excluímos com Ignore. O EF Core só mapeia propriedades. O evento EstoqueBaixo, a trava de "Concorrência" e as propriedades calculadas sem set, como Disponivel e Formato, ficam de fora naturalmente.

Como o EF Core respeita o encapsulamento

Uma dúvida legítima: nossos produtos têm propriedades com set privado, validações nos construtores e nenhum construtor vazio. Como o EF Core cria os objetos ao ler do banco?

Ele é mais esperto do que parece. Ao materializar uma entidade, o EF Core procura um construtor cujos parâmetros correspondam, pelo nome, às propriedades mapeadas. O construtor LivroImpresso(string titulo, string autor, decimal preco, int pesoGramas, int estoque) serve perfeitamente: o EF Core o chama com os valores lidos, e as validações rodam. As propriedades que não estão no construtor, como o Id, são preenchidas depois, diretamente no campo interno, sem passar pelo set. Nossa classe não precisou abrir nenhuma porta dos fundos.

Criando o banco e gravando

Para começar rapidamente, EnsureCreated cria o banco e as tabelas a partir do modelo, se ainda não existirem. (Na próxima aula ele será substituído pelas migrations, a forma profissional de evoluir o esquema.)

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

DbContextOptions<LivrariaContext> opcoes = new DbContextOptionsBuilder<LivrariaContext>()
    .UseSqlite("Data Source=livraria-ef.db")
    .UseSnakeCaseNamingConvention()
    .LogTo(Console.WriteLine, LogLevel.Information)
    .Options;

await using (var contexto = new LivrariaContext(opcoes))
{
    await contexto.Database.EnsureCreatedAsync();

    if (!await contexto.Produtos.AnyAsync())
    {
        contexto.Produtos.AddRange(
            new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10),
            new Ebook("Dom Casmurro", "Machado de Assis", 19.90m, tamanhoMb: 2.4),
            new LivroImpresso("Vidas Secas", "Graciliano Ramos", 38.00m, pesoGramas: 250, estoque: 4),
            new Ebook("São Bernardo", "Graciliano Ramos", 22.90m, tamanhoMb: 1.6),
            new LivroImpresso("Grande Sertão: Veredas", "Guimarães Rosa", 89.00m, pesoGramas: 1200, estoque: 2));

        await contexto.SaveChangesAsync();
    }
}

Add e AddRange não gravam nada: eles registram os objetos no contexto como "novos". É o SaveChangesAsync que gera os INSERTs, envia tudo ao banco dentro de uma transação, que o EF Core abre sozinho, recebe os identificadores gerados e os atribui às propriedades Id. Depois do SaveChanges, cada produto já tem o seu Id real.

A opção LogTo faz o EF Core imprimir cada comando SQL que executa. Durante o aprendizado, e na depuração de problemas de desempenho, é indispensável. Você passa a ver o SQL que as suas consultas LINQ produzem.

Consultando com LINQ

As consultas são escritas com os mesmos operadores de "LINQ, Parte I", sobre o DbSet:

await using var contexto = new LivrariaContext(opcoes);

List<Produto> baratos = await contexto.Produtos
    .Where(p => p.Preco < 40m)
    .OrderBy(p => p.Preco)
    .ToListAsync();

O log mostra o SQL gerado, algo como:

SELECT p.id, p.autor, p.preco_centavos, p.tipo, p.titulo, p.estoque, p.peso_gramas, p.tamanho_mb, ...
FROM produtos AS p
WHERE p.preco_centavos < 4000
ORDER BY p.preco_centavos

Como isso é possível, se o LINQ trabalhava com delegates que executam código C#? A resposta é que o DbSet não implementa só IEnumerable<T>, mas IQueryable<T>. Com IQueryable, a lambda p => p.Preco < 40m não é compilada como um método executável. Ela vira uma árvore de expressão, uma estrutura de dados que descreve o código ("a propriedade Preco de p, comparada com a constante 40"), algo parecido com o que os source generators de "Source Generators" enxergam do seu código. O provedor do EF Core percorre essa árvore e a traduz para o SQL do banco em uso. O filtro, a ordenação e até o conversor de centavos são aplicados no banco.

A execução adiada de "LINQ, Parte II" vale integralmente aqui: nada vai ao banco até que a consulta seja materializada com ToListAsync, FirstAsync, CountAsync, AnyAsync ou um await foreach. Os operadores terminados em Async são as versões assíncronas do EF Core para os operadores que executam.

E as consultas conhecem a hierarquia. OfType<LivroImpresso>() vira uma condição sobre o discriminador:

LivroImpresso dom = await contexto.Produtos
    .OfType<LivroImpresso>()
    .SingleAsync(l => l.Titulo == "Dom Casmurro");
SELECT ... FROM produtos AS p
WHERE p.tipo = 'impresso' AND p.titulo = 'Dom Casmurro'
LIMIT 2

Repare no LIMIT 2: para verificar a semântica de Single, "exatamente um", o EF Core pede até dois resultados. Se vierem dois, lança a exceção.

Rastreamento de mudanças: gravar sem escrever UPDATE

Agora o recurso que mais diferencia um ORM completo do Dapper. Os objetos carregados por uma consulta ficam rastreados pelo contexto: ele guarda uma cópia dos valores originais de cada um. Altere os objetos normalmente, usando os métodos do domínio, e peça para salvar:

dom.Vender(2);          // a regra de negócio de sempre, com validação e evento
dom.Preco = 64.90m;     // o set com validação de "Propriedades e Encapsulamento"

await contexto.SaveChangesAsync();

No SaveChanges, o EF Core compara os valores atuais com os originais, descobre que só estoque e preco_centavos mudaram e gera um UPDATE com exatamente essas duas colunas:

UPDATE produtos SET estoque = @p0, preco_centavos = @p1
WHERE id = @p2;

Nenhuma linha de SQL escrita, nenhuma regra de negócio duplicada: a venda passou pela mesma validação de sempre, e a gravação reflete exatamente o que mudou. Para remover, contexto.Produtos.Remove(entidade) seguido de SaveChangesAsync.

O ciclo de vida do contexto

Um DbContext é uma unidade de trabalho de vida curta: carregue o que precisa, faça as alterações, salve e descarte. Três razões sustentam essa regra:

  • Ele não é seguro entre threads. Duas operações simultâneas sobre o mesmo contexto corrompem seu estado interno, pelos motivos de "Concorrência". O EF Core detecta a maioria desses casos e lança exceção.
  • O rastreamento cresce. Cada entidade carregada fica guardada no contexto. Um contexto usado durante horas acumularia milhares de objetos, com o vazamento de memória descrito em "Memória no .NET".
  • Os dados envelhecem. Um objeto rastreado há horas não reflete as mudanças feitas por outros usuários no banco.

Numa API, cada requisição recebe o seu próprio contexto, e é exatamente assim que o ASP.NET o configura por padrão, como veremos na Fase 6.

O que não pode ser traduzido

Nem todo código C# tem um equivalente em SQL. Um método seu dentro de um Where não pode ser traduzido, porque o banco não sabe executá-lo:

static bool EhClassico(Produto p) => p.Titulo.StartsWith("Dom") || p.Autor == "Machado de Assis";

var classicos = await contexto.Produtos.Where(p => EhClassico(p)).ToListAsync();
// InvalidOperationException: a expressão não pôde ser traduzida

Um detalhe para reproduzir o erro: EhClassico precisa ser método de uma classe. Solto no Program.cs, junto das instruções de nível superior, ele vira uma função local, e o erro chega antes, na compilação: CS8110, porque uma árvore de expressão não pode citar função local.

O EF Core recusa com uma mensagem clara, em vez de, silenciosamente, trazer a tabela inteira para a memória e filtrar lá. As versões 1 e 2 do próprio EF Core faziam essa avaliação no cliente, avisando só no log, com consequências de desempenho catastróficas; desde a 3.0 ela é recusada. A correção é escrever a condição diretamente com operações que o provedor conhece (StartsWith, Contains, comparações, operadores lógicos) ou, se a lógica precisar mesmo de C#, trazer primeiro um subconjunto já filtrado e aplicar o resto na memória, de forma explícita.

Juntando tudo: o domínio persistido

await using (var contexto = new LivrariaContext(opcoes))
{
    int digitais = await contexto.Produtos.CountAsync(p => p is Ebook);
    Console.WriteLine($"E-books no catálogo: {digitais}");

    Produto maisCaro = await contexto.Produtos.OrderByDescending(p => p.Preco).FirstAsync();
    Console.WriteLine($"Mais caro: {maisCaro.Titulo} ({maisCaro.Formato}), {maisCaro.Preco:C}");

    LivroImpresso sertao = await contexto.Produtos.OfType<LivroImpresso>()
        .SingleAsync(l => l.Titulo == "Grande Sertão: Veredas");
    try
    {
        sertao.Vender(3);
        await contexto.SaveChangesAsync();
    }
    catch (EstoqueInsuficienteException ex)
    {
        Console.WriteLine($"Venda recusada: {ex.Message}");
    }
}

p is Ebook dentro de uma consulta é traduzido para tipo = 'ebook': o pattern matching de tipo, de "Enums e Pattern Matching", funciona também na tradução. A venda de três exemplares de Grande Sertão, que tem dois, é recusada pela regra do domínio, antes de qualquer SQL, e o SaveChanges nem chega a ser chamado. Perceba o que mudou em relação ao Dapper: o código trabalha com os objetos do domínio da Fase 2, com toda a sua proteção, e o banco virou um detalhe de infraestrutura.

O modelo desta aula, porém, ainda é simples demais para um sistema real. O autor continua sendo uma string repetida em cada produto: um erro de digitação cria um "autor" novo, e renomear um autor exige alterar todos os seus livros. Os pedidos, com seus itens, ainda não existem no banco. E o EnsureCreated só sabe criar o banco do zero: no dia em que o modelo ganhar uma coluna nova, não há como atualizar um banco que já tem dados sem apagá-lo. Relacionamentos entre entidades e a evolução controlada do esquema são os próximos passos.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Com o contexto da aula, escreva consultas que devolvam: a quantidade de livros impressos com estoque abaixo de 5, e os títulos distintos do catálogo em ordem alfabética. Observe o SQL gerado pelo log.

Ver resposta

✓ Resposta:

int poucoEstoque = await contexto.Produtos
    .OfType<LivroImpresso>()
    .CountAsync(l => l.Estoque < 5);

List<string> titulos = await contexto.Produtos
    .Select(p => p.Titulo)
    .Distinct()
    .OrderBy(t => t)
    .ToListAsync();

A primeira vira algo como SELECT COUNT(*) FROM produtos AS p WHERE p.tipo = 'impresso' AND p.estoque < 5: o banco devolve só um número, e nenhum produto é carregado. Com os dados da aula, o resultado é 2 (Vidas Secas e Grande Sertão). A segunda vira SELECT DISTINCT p.titulo FROM produtos AS p ORDER BY p.titulo, e só os títulos trafegam, sem os demais campos. Em ambas, a projeção e a agregação acontecem no banco, a vantagem de IQueryable sobre carregar tudo e aplicar o LINQ na memória. Ler o SQL gerado é o melhor hábito para quem começa com o EF Core: ele mostra exatamente quanto trabalho foi para o banco.

Exercício 2

A consulta abaixo lança InvalidOperationException. Explique o motivo e reescreva-a de duas formas: uma totalmente traduzível e outra que filtre parte no banco e parte na memória, de forma explícita.

static string Normalizar(string s) => s.Trim().ToLowerInvariant().Replace("ã", "a");
var r = await contexto.Produtos.Where(p => Normalizar(p.Titulo).Contains("sertao")).ToListAsync();
Ver resposta

✓ Resposta: Normalizar é um método C# comum (de uma classe; solto no Program.cs, como função local, nem compilaria, com CS8110). Na árvore de expressão, ele aparece como "uma chamada ao método Normalizar", e o provedor não tem como traduzi-lo para SQL, porque o banco não pode executar código C#. O EF Core recusa em vez de trazer tudo para a memória em silêncio. A forma traduzível usa só operações que o provedor conhece:

var r1 = await contexto.Produtos
    .Where(p => p.Titulo.ToLower().Contains("sertão"))
    .ToListAsync();

ToLower e Contains viram funções do SQL (lower e instr, no SQLite). A remoção de acentos, porém, não tem tradução simples no SQLite. A forma híbrida filtra no banco o que dá e faz o resto, explicitamente, na memória:

var candidatos = await contexto.Produtos
    .Where(p => p.Titulo.ToLower().Contains("sert"))
    .ToListAsync();                                          // banco: filtro amplo
var r2 = candidatos.Where(p => Normalizar(p.Titulo).Contains("sertao")).ToList();   // memória: refinamento

O ToListAsync no meio marca a fronteira: antes dele, IQueryable e SQL; depois, IEnumerable e C#. Esse limite visível é a diferença entre uma decisão consciente e a avaliação oculta das versões antigas. Buscas sem acento de verdade exigem recursos do banco, como agrupamentos (collations) e busca textual, e os veremos no PostgreSQL.

Exercício 3

Carregue um livro com o contexto, altere o preço e salve, observando o SQL. Depois repita carregando o livro com .AsNoTracking(). O que acontece no segundo caso, e por quê?

Ver resposta

✓ Resposta:

var livro = await contexto.Produtos.OfType<LivroImpresso>().FirstAsync(l => l.Titulo == "Vidas Secas");
livro.Preco = 42.00m;
await contexto.SaveChangesAsync();   // UPDATE produtos SET preco_centavos = @p0 WHERE id = @p1

var livro2 = await contexto.Produtos.AsNoTracking().OfType<LivroImpresso>().FirstAsync(l => l.Titulo == "Vidas Secas");
livro2.Preco = 45.00m;
await contexto.SaveChangesAsync();   // nenhum comando: nada é gravado

No primeiro caso, o livro é rastreado: o contexto guardou os valores originais e, no SaveChanges, detecta a mudança de preço e gera um UPDATE só daquela coluna. No segundo, AsNoTracking diz ao contexto para não acompanhar os objetos devolvidos. O livro é um objeto comum, desligado do contexto, e alterá-lo não tem efeito nenhum no banco: o SaveChanges não encontra nada para gravar e não emite nenhum comando. Por que existe essa opção? Rastrear tem custo de memória e processamento, e numa consulta que só lê, como uma listagem de catálogo, esse custo é desperdício. Consultas somente leitura com AsNoTracking são uma das otimizações mais importantes do EF Core, e voltam na aula de desempenho. O risco é justamente o deste exercício: alterar um objeto não rastreado e esperar que a mudança seja gravada.

Exercício 4

Um colega guarda um único LivrariaContext num campo estático e o usa em toda a aplicação, inclusive em várias tarefas simultâneas. Liste os problemas que isso causa.

Ver resposta

✓ Resposta: São pelo menos quatro. Primeiro, concorrência: o DbContext não é seguro entre threads. Duas consultas simultâneas corrompem seu estado interno, e o EF Core normalmente detecta e lança InvalidOperationException ("uma segunda operação foi iniciada neste contexto antes que uma operação anterior fosse concluída"). Quando não detecta, o resultado são dados errados. Segundo, memória: todo objeto carregado com rastreamento fica guardado no contexto para sempre, e o campo estático é uma raiz, como no exercício 3 de "Memória no .NET", então nada disso é recolhido. Terceiro, dados envelhecidos: se outro usuário alterar um produto no banco, o contexto continua devolvendo a versão que já tinha carregado, porque consultas por chave consultam primeiro as entidades já rastreadas. Quarto, mudanças misturadas: um SaveChanges numa parte do programa grava também as alterações pendentes que outra parte fez e ainda não queria confirmar. A regra é criar um contexto por unidade de trabalho, usá-lo e descartá-lo. Numa API, um por requisição. Criar contextos é barato. Compartilhá-los é caro.

Exercício 5

O que aconteceria se a linha builder.Ignore(l => l.LimiteEstoqueBaixo); fosse removida? A mudança seria perigosa?

Ver resposta

✓ Resposta: Como LimiteEstoqueBaixo é uma propriedade pública com get e set, a convenção do EF Core a mapearia para uma coluna limite_estoque_baixo na tabela produtos, que ficaria nula para e-books e audiolivros, pela estratégia de tabela única. O programa continuaria funcionando, mas com efeitos que merecem reflexão. O limite passaria a ser persistido: alterá-lo num livro e salvar gravaria o novo valor, e ele sobreviveria entre execuções. Se isso for desejado (um limite configurável por livro, definido pelo gerente), mapear é a escolha certa, e a decisão deixa de ser um erro. Se não for, o banco passa a guardar um detalhe de comportamento que ninguém pediu, e mudar o valor padrão no código deixaria de ter efeito sobre os livros já gravados, porque eles carregariam o valor antigo do banco. A lição é que, com um ORM baseado em convenções, toda propriedade pública é candidata a coluna, e decidir o que é dado persistente e o que é comportamento em memória é uma escolha de modelagem, não um detalhe.

Comentários

Mais em Linguagem C#

EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta
EF Core com Desempenho: N+1, AsNoTracking e o SQL por Trás de Cada Consulta

Como enxergar e corrigir o SQL que o EF Core gera: ToQueryString e TagWith, o…

Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#
Cada Valor Tem um Tipo: Variáveis, Números e Conversões em C#

Variáveis e tipos em C#: var e const, a família dos inteiros e o estouro…

O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos
O aplicativo do cliente em .NET MAUI: Shell, listas, login no celular e acompanhamento de pedidos

O aplicativo do cliente para iPhone e Android em .NET MAUI: Shell com abas e…