EF Core: Relacionamentos e Migrations

EF Core: Relacionamentos e Migrations

Autores e pedidos como entidades relacionadas no EF Core: um para muitos com regra de exclusão, itens como tipos próprios, Include e projeções, a navegação que fica vazia sem avisar, e migrations para evoluir o esquema sem perder dados, inclusive a renomeação que apaga uma coluna.
Linguagem C#

• • 21 min de leitura

Um autor tem muitos livros, um pedido tem muitos itens, e o banco de dados precisa mudar sem perder o que já guardou: relacionamentos e migrations transformam um modelo de brinquedo num sistema que evolui.

"Entity Framework Core: Objetos no Banco Sem Escrever SQL" terminou com três limitações. O autor era uma string repetida em cada produto, e um erro de digitação criava um "autor" novo. Os pedidos, com seus itens, não existiam no banco. E o EnsureCreated só sabia criar o banco do zero, sem nenhum jeito de acrescentar uma coluna a um banco com dados. Nesta aula resolvemos as três: o autor vira uma entidade relacionada aos produtos, o pedido é persistido com seus itens, e as migrations passam a controlar a evolução do esquema, com a mesma disciplina com que o Git controla a evolução do código.

O autor como entidade

Na aula de SQL, a tabela autores já existia, e os produtos guardavam uma chave estrangeira autor_id. Agora o domínio acompanha:

public sealed class Autor
{
    private readonly List<Produto> _produtos = [];

    public Autor(string nome)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(nome);
        Nome = nome.Trim();
    }

    public int Id { get; private set; }
    public string Nome { get; private set; }
    public IReadOnlyCollection<Produto> Produtos => _produtos.AsReadOnly();
}

E Produto troca a string por uma referência ao autor:

public abstract class Produto : IEntidade
{
    protected Produto(string titulo, Autor autor, decimal preco)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(titulo);
        ArgumentNullException.ThrowIfNull(autor);
        Titulo = titulo.Trim();
        Autor = autor;
        Preco = preco;
    }

    protected Produto() { }   // usado apenas pelo EF Core, ao ler do banco

    public int Id { get; private set; }
    public string Titulo { get; private set; } = null!;
    public int AutorId { get; private set; }
    public Autor Autor { get; private set; } = null!;
    // ... Preco, Formato, Vender e demais membros como antes ...
}

A propriedade Autor é uma propriedade de navegação: ela liga um objeto a outro, e o EF Core a traduz para a chave estrangeira. AutorId expõe essa chave explicitamente, o que é útil para consultas e para associar um autor sem carregá-lo.

Repare numa concessão. Na aula anterior, o EF Core criava os objetos pelo construtor com parâmetros. Mas um construtor não pode receber uma propriedade de navegação: quando o EF Core lê um produto do banco, o objeto Autor pode ainda não ter sido carregado. Por isso cada classe da hierarquia ganha um construtor sem parâmetros, protected na base e private nas derivadas (private LivroImpresso() { }). Ele não aparece para o resto do programa, e o EF Core o usa para criar o objeto e preencher as propriedades diretamente. Os = null! avisam ao compilador que essas propriedades serão preenchidas logo depois, suprimindo o aviso de nulidade de "Nullable Reference Types". É um preço pequeno e localizado: o construtor público, com todas as validações, continua sendo a única forma de o programa criar um produto.

Configurando um relacionamento de um para muitos

public sealed class AutorConfiguracao : IEntityTypeConfiguration<Autor>
{
    public void Configure(EntityTypeBuilder<Autor> builder)
    {
        builder.ToTable("autores");
        builder.Property(a => a.Nome).IsRequired().HasMaxLength(120);
        builder.HasIndex(a => a.Nome).IsUnique();

        builder.HasMany(a => a.Produtos)
               .WithOne(p => p.Autor)
               .HasForeignKey(p => p.AutorId)
               .OnDelete(DeleteBehavior.Restrict);
    }
}

Leia a última instrução em voz alta: um autor tem muitos produtos, cada produto tem um autor, a ligação é pela chave estrangeira AutorId, e apagar um autor que tenha produtos é proibido. O índice único em Nome é o UNIQUE de "SQL Essencial com SQLite": dois autores com o mesmo nome passam a ser impossíveis.

E a coleção Produtos, que expomos como somente leitura com AsReadOnly? O EF Core encontra sozinho o campo _produtos pela convenção de nomes (o nome da propriedade, com sublinhado e inicial minúscula) e trabalha diretamente com ele, sem passar pela propriedade. O encapsulamento de "Propriedades e Encapsulamento" continua intacto.

A regra de exclusão merece uma reflexão. DeleteBehavior.Cascade apagaria automaticamente todos os produtos de um autor removido e, em cadeia, todas as vendas desses produtos: um clique que destrói o histórico de faturamento. Restrict obriga a decidir o que fazer com os produtos antes. Escolha a regra pelo significado de negócio, e não pela conveniência: cascata faz sentido quando os filhos não existem sem o pai, como veremos com os itens de pedido.

O pedido e seus itens

Um item de pedido não tem vida própria: ele só existe dentro de um pedido, e nenhuma outra parte do sistema o referencia diretamente. O EF Core tem um conceito exatamente para isso, os tipos próprios (owned types): objetos que pertencem a uma entidade e são sempre carregados, gravados e apagados junto com ela.

Antes de configurar, um ajuste de modelagem que o banco de dados torna urgente. Em "Classe, Struct ou Record", o ItemPedido guardava uma referência ao produto e calculava o subtotal com o preço atual do produto. Pense no que isso significa num sistema com histórico: se o preço de Dom Casmurro subir amanhã, todos os pedidos antigos passariam a mostrar um valor que o cliente nunca pagou. O item precisa guardar uma fotografia do momento da compra:

public sealed record ItemPedido
{
    public ItemPedido(int produtoId, string titulo, decimal precoUnitario, int quantidade)
    {
        ProdutoId = produtoId;
        Titulo = titulo;
        PrecoUnitario = precoUnitario;
        Quantidade = quantidade;
    }

    public int ProdutoId { get; init; }
    public string Titulo { get; init; }
    public decimal PrecoUnitario { get; init; }

    public int Quantidade
    {
        get;
        init
        {
            ArgumentOutOfRangeException.ThrowIfNegativeOrZero(value);
            field = value;
        }
    }

    public decimal Subtotal => PrecoUnitario * Quantidade;
}

No Pedido, o método Adicionar passa a copiar os dados do produto no momento da inclusão:

public void Adicionar(Produto produto, int quantidade)
{
    if (Status is not StatusPedido.Aberto)
        throw new InvalidOperationException($"O pedido {Numero} está {Status} e não aceita itens.");
    if (produto.Id == 0)
        throw new InvalidOperationException("O produto precisa estar gravado antes de entrar num pedido.");

    _itens.Add(new ItemPedido(produto.Id, produto.Titulo, produto.Preco, quantidade));
}

O número do pedido também passa a ser gerado pelo banco: public int Numero { get; private set; }, sem o contador estático de "Enums e Pattern Matching". A configuração:

public sealed class PedidoConfiguracao : IEntityTypeConfiguration<Pedido>
{
    public void Configure(EntityTypeBuilder<Pedido> builder)
    {
        builder.ToTable("pedidos");
        builder.HasKey(p => p.Numero);
        builder.Property(p => p.Status).HasConversion<string>().HasMaxLength(20);

        builder.OwnsMany(p => p.Itens, itens =>
        {
            itens.ToTable("itens_pedido");
            itens.WithOwner().HasForeignKey("pedido_numero");
            itens.HasKey("Id");   // chave simples: o SQLite só gera valor para ela
            itens.Property(i => i.Titulo).HasMaxLength(200);
            itens.Property(i => i.PrecoUnitario)
                 .HasColumnName("preco_unitario_centavos")
                 .HasConversion(r => (long)Math.Round(r * 100), c => c / 100m);
        });

        builder.Navigation(p => p.Itens).UsePropertyAccessMode(PropertyAccessMode.Field);
    }
}

OwnsMany declara que os itens pertencem ao pedido: eles ganham uma tabela própria, com uma chave estrangeira para o pedido e uma chave gerada automaticamente, e são carregados sempre junto com o pedido, sem nenhuma instrução extra. Apagar um pedido apaga seus itens, a cascata que faz sentido aqui. O status é gravado como texto ("Pago", "Enviado"), e não como número: fica legível para quem consulta o banco diretamente e imune ao problema de reordenar os valores do enum, discutido em "Enums e Pattern Matching". A última linha manda o EF Core usar o campo _itens, e não a propriedade que devolve uma cópia somente leitura. E o HasKey("Id") não é enfeite: por padrão, a chave dos itens é composta (pedido_numero e id), o SQLite só gera valores automáticos para chave de uma coluna, e o primeiro SaveChanges com itens falha com NOT NULL constraint failed: itens_pedido.id. Com a chave simples, a coluna id vira INTEGER PRIMARY KEY AUTOINCREMENT.

Carregando dados relacionados

Ao consultar produtos, o autor de cada um não vem automaticamente. O EF Core carrega só o que foi pedido. Para trazer entidades relacionadas na mesma consulta, use Include:

List<Produto> comAutor = await contexto.Produtos
    .Include(p => p.Autor)
    .OrderBy(p => p.Titulo)
    .ToListAsync();

O SQL gerado faz o JOIN com a tabela de autores, e cada produto chega com o Autor preenchido. No sentido inverso, contexto.Autores.Include(a => a.Produtos) traz cada autor com a coleção dos seus livros, exatamente o trabalho de montar relacionamentos que o Dapper deixava para nós.

Há uma armadilha aqui, e ela é traiçoeira. Sem o Include, a navegação não dá erro: ela simplesmente fica vazia (ou nula, para uma referência). autor.Produtos.Count devolve zero para um autor com dez livros, e nada avisa que os dados não foram carregados. O exercício 2 explora esse caso, e a próxima aula trata do problema de desempenho que costuma aparecer quando se tenta resolvê-lo da forma errada.

Quando você só precisa de alguns dados, e não das entidades completas, uma projeção com Select é ainda melhor, porque o EF Core gera exatamente o SQL necessário:

var resumo = await contexto.Autores
    .Select(a => new
    {
        a.Nome,
        Titulos = a.Produtos.Count,
        MaisBarato = a.Produtos.Min(p => (decimal?)p.Preco)
    })
    .OrderByDescending(x => x.Titulos)
    .ToListAsync();

Nenhum produto é carregado: a contagem e o mínimo viram subconsultas no SQL, e só os números voltam. O (decimal?) trata autores sem produtos, cujo mínimo seria nulo, e não zero.

Migrations: o esquema sob controle de versão

Chegamos à terceira limitação. Até aqui, EnsureCreated criava o banco a partir do modelo. No dia em que o modelo mudar, com uma coluna nova ou uma tabela nova, ele não tem como atualizar um banco existente. A única saída seria apagar tudo. As migrations resolvem isso: cada mudança no modelo gera um arquivo de código com as instruções para levar o banco da versão anterior à nova, e para desfazer a mudança.

Primeiro, a ferramenta de linha de comando do EF Core e o pacote que ela usa:

dotnet tool install --global dotnet-ef
dotnet add src/Livraria.Cli package Microsoft.EntityFrameworkCore.Design

A ferramenta precisa criar um contexto em tempo de desenvolvimento, fora do programa. Como o nosso recebe as opções pelo construtor, ensinamos a ela como montá-lo com uma fábrica:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

public sealed class LivrariaContextFactory : IDesignTimeDbContextFactory<LivrariaContext>
{
    public LivrariaContext CreateDbContext(string[] args) =>
        new(new DbContextOptionsBuilder<LivrariaContext>()
            .UseSqlite("Data Source=livraria-ef.db")
            .UseSnakeCaseNamingConvention()
            .Options);
}

Agora, a primeira migration:

dotnet ef migrations add Inicial --project src/Livraria.Dados --startup-project src/Livraria.Cli

O comando compara o modelo atual com o último estado conhecido (nenhum, na primeira vez) e gera na pasta Migrations do projeto de dados um arquivo como este, resumido:

public partial class Inicial : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "autores",
            columns: table => new
            {
                id = table.Column<int>(nullable: false).Annotation("Sqlite:Autoincrement", true),
                nome = table.Column<string>(maxLength: 120, nullable: false)
            },
            constraints: table => table.PrimaryKey("pk_autores", x => x.id));

        // ... produtos, pedidos, itens_pedido, índices e chaves estrangeiras ...
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "itens_pedido");
        migrationBuilder.DropTable(name: "produtos");
        migrationBuilder.DropTable(name: "pedidos");
        migrationBuilder.DropTable(name: "autores");
    }
}

Up aplica a mudança, e Down a desfaz. A ferramenta também atualiza um arquivo de snapshot, uma fotografia do modelo, que servirá de base para comparar a próxima migration. Para aplicar ao banco:

dotnet ef database update --project src/Livraria.Dados --startup-project src/Livraria.Cli

O EF Core cria no banco uma tabela __EFMigrationsHistory, onde registra quais migrations já foram aplicadas. Ao rodar o comando de novo, só as pendentes são executadas. É isso que permite que o mesmo conjunto de migrations leve qualquer banco, seja o seu de desenvolvimento, o de um colega ou o de produção, ao estado atual, a partir de onde ele estiver.

Evoluir o modelo segue o mesmo ciclo. Suponha que LivroImpresso ganhe uma propriedade Isbn? Isbn, o objeto de valor de "Classe, Struct ou Record", com um conversor que o grava como texto:

dotnet ef migrations add AdicionaIsbn --project src/Livraria.Dados --startup-project src/Livraria.Cli
dotnet ef database update --project src/Livraria.Dados --startup-project src/Livraria.Cli

A nova migration contém apenas o AddColumn da coluna isbn, e os dados existentes são preservados.

Cuidados com migrations

Migrations são código que altera dados de produção, e merecem o mesmo cuidado de qualquer código crítico.

Leia toda migration gerada antes de aplicá-la. A ferramenta deduz a intenção comparando modelos, e às vezes deduz errado. O caso clássico é a renomeação. Uma renomeação simples o EF Core percebe: trocar a propriedade Titulo por TituloOriginal gera um RenameColumn (medido no EF Core 10). Mas, se na mesma migration a coluna também mudar, por exemplo o tamanho máximo de 200 para 250, a ferramenta entende "apagar a coluna titulo e criar a coluna nova", com defaultValue: "", e isso apaga todos os títulos. A correção é editar a migration e trocar as duas operações por um RenameColumn seguido do AlterColumn do tamanho, ou separar as duas mudanças em migrations diferentes. A ferramenta costuma emitir um aviso quando uma operação pode perder dados. Leia esses avisos.

Nunca altere uma migration que já foi aplicada em outro banco. Se ela precisar mudar, crie uma nova. Alterar uma migration aplicada é como reescrever um commit que outras pessoas já baixaram.

O SQLite tem limitações de ALTER TABLE. Várias mudanças, como alterar o tipo de uma coluna, não são suportadas diretamente, e o EF Core as executa recriando a tabela: cria uma nova, copia os dados e troca. Funciona, mas é mais lento em tabelas grandes. Os bancos servidor da próxima parte da fase não têm essa limitação.

Em produção, prefira scripts revisados. O comando dotnet ef migrations script --idempotent gera um arquivo SQL com todas as migrations, que só aplica as que faltam em cada banco. Esse script pode ser revisado por outra pessoa e executado como parte da publicação. Aplicar migrations automaticamente na inicialização do programa, com contexto.Database.MigrateAsync(), é conveniente em desenvolvimento, mas arriscado em produção: várias instâncias do programa subindo ao mesmo tempo podem tentar migrar o banco juntas.

E, uma vez adotadas as migrations, não use mais o EnsureCreated. Ele cria o banco sem a tabela de histórico, e as migrations passam a não saber em que estado o banco está.

Juntando tudo: um pedido completo no banco

await using (var contexto = new LivrariaContext(opcoes))
{
    var machado = new Autor("Machado de Assis");
    var graciliano = new Autor("Graciliano Ramos");

    var dom = new LivroImpresso("Dom Casmurro", machado, 59.90m, pesoGramas: 350, estoque: 10);
    var vidas = new Ebook("Vidas Secas", graciliano, 24.90m, tamanhoMb: 1.8);

    contexto.AddRange(dom, vidas);        // os autores vão junto, pela navegação
    await contexto.SaveChangesAsync();    // agora os produtos têm Id

    var pedido = new Pedido();
    pedido.Adicionar(dom, 2);
    pedido.Adicionar(vidas, 1);
    pedido.Pagar();
    dom.Vender(2);

    contexto.Add(pedido);
    await contexto.SaveChangesAsync();    // INSERT do pedido, dos itens e UPDATE do estoque, numa transação

    Console.WriteLine($"Pedido {pedido.Numero} gravado: {pedido.Status}, {pedido.Itens.Count} itens.");
}

await using (var contexto = new LivrariaContext(opcoes))
{
    Pedido lido = await contexto.Set<Pedido>().FirstAsync();
    foreach (ItemPedido item in lido.Itens)   // os itens vêm junto: são próprios do pedido
        Console.WriteLine($"  {item.Quantidade} x {item.Titulo,-20}{item.Subtotal,10:C}");

    var autores = await contexto.Set<Autor>()
        .Include(a => a.Produtos)
        .OrderBy(a => a.Nome)
        .ToListAsync();
    foreach (Autor autor in autores)
        Console.WriteLine($"{autor.Nome}: {string.Join(", ", autor.Produtos.Select(p => p.Titulo))}");
}

Observe o segundo SaveChangesAsync. Ele grava o pedido, os dois itens e a baixa de estoque de Dom Casmurro, três tabelas diferentes, dentro de uma única transação, que o EF Core abre e confirma sozinho. É o tudo ou nada de "ADO.NET", sem uma linha de código de transação. Ao reler, os itens vêm com o pedido, e os autores trazem seus livros graças ao Include. O relacionamento que o Dapper deixava para nós montarmos agora aparece pronto.

O modelo está completo, mas a facilidade do EF Core tem um lado perigoso. Uma consulta que parece inocente em LINQ pode gerar SQL muito mais caro do que o necessário. Um laço sobre autores que acessa os produtos de cada um pode disparar uma consulta por autor: são cem idas ao banco onde uma bastaria. E o rastreamento de mudanças, tão conveniente, consome memória e tempo em telas que só leem dados. Tirar desempenho do EF Core exige enxergar o SQL por trás de cada consulta, e é isso que vem a seguir.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva uma consulta que devolva, para cada autor, o nome, a quantidade de produtos e o valor total em estoque dos seus livros impressos, sem carregar nenhuma entidade completa.

Ver resposta

✓ Resposta:

var resumo = await contexto.Set<Autor>()
    .Select(a => new
    {
        a.Nome,
        Produtos = a.Produtos.Count,
        ValorEmEstoque = a.Produtos
            .OfType<LivroImpresso>()
            .Sum(l => l.Estoque * l.Preco)
    })
    .OrderBy(x => x.Nome)
    .ToListAsync();

A projeção com Select faz o EF Core gerar subconsultas de contagem e soma no SQL, e só o nome e os números voltam ao programa. A soma merece um comentário honesto, porque o Preco passa por um conversor. No EF Core 10 ela vira COALESCE(SUM(CAST("p"."estoque" AS INTEGER) * "p"."preco_centavos"), 0), e o conversor divide o resultado por 100 na volta: com os dados da aula, Machado de Assis sai com 479,20 (8 × 59,90, depois da venda de dois) e um autor sem livros, com 0. Dá certo porque a conversão é linear e o outro fator é um inteiro sem conversor; uma expressão como l.Preco * l.Preco sairia errada, porque o banco multiplicaria centavos por centavos. Uma tentação a evitar: EF.Property<long>(l, "Preco") para "pegar os centavos" não funciona, porque a propriedade é decimal (a consulta termina em NullReferenceException, medido). Esse atrito é o custo de um banco sem tipo decimal, e desaparece no PostgreSQL, onde o preço pode ser uma coluna numeric.

Exercício 2

O código abaixo imprime "0 livros" para todos os autores, embora o banco tenha livros cadastrados. Explique e corrija.

var autores = await contexto.Set<Autor>().ToListAsync();
foreach (var autor in autores)
    Console.WriteLine($"{autor.Nome}: {autor.Produtos.Count} livros");
Ver resposta

✓ Resposta: A consulta carrega só os autores. A navegação Produtos de cada um fica vazia, porque nada pediu ao EF Core que trouxesse os produtos, e ele não os busca por conta própria (o carregamento preguiçoso, que faria isso, não está habilitado, e é desaconselhado justamente pelo que a próxima aula mostra). Não há exceção nem aviso: Count devolve zero, e o relatório fica silenciosamente errado. Há duas correções, conforme a necessidade. Se as entidades completas forem necessárias: contexto.Set<Autor>().Include(a => a.Produtos).ToListAsync(). Se só a contagem importa, a projeção é melhor, porque não carrega nenhum produto:

var contagens = await contexto.Set<Autor>()
    .Select(a => new { a.Nome, Livros = a.Produtos.Count })
    .ToListAsync();

Esse é um dos erros mais comuns com ORMs, e um bom argumento para testes de integração que verifiquem números conhecidos, como no exercício 2 de "Dapper".

Exercício 3

Acrescente a LivroImpresso a propriedade Isbn? Isbn, com um conversor que a grave como texto, crie a migration e mostre o conteúdo esperado do seu método Up.

Ver resposta

✓ Resposta: No domínio:

public Isbn? Isbn { get; private set; }
public void DefinirIsbn(Isbn isbn) => Isbn = isbn;

Na configuração de LivroImpresso:

builder.Property(l => l.Isbn)
       .HasConversion(i => i!.Value.Valor, texto => new Isbn(texto))
       .HasMaxLength(13);
builder.HasIndex(l => l.Isbn).IsUnique();

O conversor grava só o texto dos 13 dígitos e, ao ler, reconstrói o objeto de valor pelo construtor, que valida o dígito verificador. O EF Core aplica conversores apenas a valores não nulos, então o nulo continua sendo nulo no banco. Depois de dotnet ef migrations add AdicionaIsbn, o Up gerado contém essencialmente:

migrationBuilder.AddColumn<string>(
    name: "isbn", table: "produtos", maxLength: 13, nullable: true);
migrationBuilder.CreateIndex(
    name: "ix_produtos_isbn", table: "produtos", column: "isbn", unique: true);

A coluna é criada como anulável, o que preserva os produtos existentes. Numa tabela única com e-books, que não têm ISBN impresso, ela também precisa aceitar nulo. O índice único impede dois livros com o mesmo ISBN. Em bancos que tratam nulos como distintos num índice único, como o SQLite, vários produtos sem ISBN convivem sem conflito.

Exercício 4

Você renomeou a propriedade Titulo de Produto para TituloOriginal, mudou na mesma hora o tamanho máximo da coluna e gerou uma migration. O Up gerado contém um DropColumn de titulo e um AddColumn de titulo_original. O que aconteceria ao aplicá-la, e como corrigir?

Ver resposta

✓ Resposta: A coluna titulo seria apagada, com todos os títulos, e uma coluna nova seria criada. Como ela é obrigatória, a ferramenta a cria com defaultValue: "": todos os produtos ficariam com o título vazio. Os dados seriam perdidos de forma irreversível, a menos que houvesse um backup. A ferramenta compara apenas os dois modelos. Uma renomeação simples ela reconhece e transforma em RenameColumn; com o nome e o tamanho mudando juntos, para ela "uma coluna sumiu e outra apareceu". Ela costuma avisar que a operação pode perder dados, e o aviso deve ser levado a sério. A correção é editar a migration antes de aplicá-la:

protected override void Up(MigrationBuilder migrationBuilder) =>
    migrationBuilder.RenameColumn(name: "titulo", table: "produtos", newName: "titulo_original");

protected override void Down(MigrationBuilder migrationBuilder) =>
    migrationBuilder.RenameColumn(name: "titulo_original", table: "produtos", newName: "titulo");

O Down também precisa ser corrigido, para que a migration possa ser revertida. A mudança de tamanho vai num AlterColumn depois do RenameColumn, ou, mais simples, numa migration separada. Índices sobre a coluna renomeada talvez precisem de ajuste semelhante. É o motivo pelo qual toda migration deve ser lida como código, numa revisão, antes de chegar a qualquer banco com dados reais.

Exercício 5

Compare as regras de exclusão adequadas para dois relacionamentos da Livraria: autor e produtos, e pedido e itens. Justifique cada escolha.

Ver resposta

✓ Resposta: Autor e produtos: Restrict. Um produto existe por conta própria. Ele tem vendas, estoque e aparece em pedidos (pelo identificador). Apagar um autor e, em cascata, todos os seus livros, e em seguida as vendas desses livros, destruiria o histórico financeiro por causa de uma única ação, talvez feita por engano. Restrict faz o banco recusar a exclusão enquanto houver produtos, obrigando alguém a decidir o destino deles: transferi-los para outro autor, corrigir um cadastro duplicado ou, raramente, apagá-los de forma consciente. Pedido e itens: cascata. Um item não tem significado fora do pedido. Não é consultado sozinho nem referenciado por nenhuma outra tabela, e um item "órfão" seria só lixo no banco. Apagar o pedido deve apagar seus itens, e é exatamente o que o OwnsMany configura, porque tipos próprios são parte do dono. O critério geral: cascata quando o filho não existe sem o pai, e restrição quando o filho tem vida, valor ou referências próprias. Na dúvida, prefira Restrict. Uma exclusão recusada gera uma mensagem de erro, e uma exclusão em cascata indesejada gera uma perda de dados.

Comentários

Mais em Linguagem C#

Da Rivalidade com o Java ao Código Aberto: a História do C#
Da Rivalidade com o Java ao Código Aberto: a História do C#

A história do C#: Anders Hejlsberg, do Turbo Pascal ao Delphi, o processo da…

ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs
ASP.NET Core por dentro: hosting, pipeline de middleware e Minimal APIs

O que acontece entre o socket e o seu código no ASP.NET Core: Kestrel e host…

Produção: CORS, rate limiting, versionamento e OpenTelemetry
Produção: CORS, rate limiting, versionamento e OpenTelemetry

Antes de ir para o público, a API da Livraria aprende a se defender e a ser…