Salvar lista editável no ASP.NET Core com EF Core
Salve a lista editável do ASP.NET Core no EF Core sem apagar o que não veio: recuse Id de outro pedido, insira os novos, remova os marcados e atualize.
Uma tela que edita várias linhas de uma vez, como os itens de um pedido, termina sempre na mesma pergunta: o que o servidor faz com a lista que chegou? A tentação é apagar os itens do banco e inserir os do formulário, ou passar a lista direto para o DbContext. As duas funcionam no teste feliz e falham em produção, de jeitos diferentes: item duplicado, item apagado sem ninguém pedir, ou item de outro pedido alterado por quem editou o valor de um campo escondido.
Este guia monta a conciliação no servidor com ASP.NET Core e EF Core, regra por regra, e mostra o resultado de cada uma com curl. O código foi testado com .NET 10 e EF Core 10.0.14 sobre SQLite. Como o HTML da tela deve nomear os campos para a lista chegar inteira está no post sobre adicionar e remover linhas no formulário.
O formulário não é a entidade
O primeiro passo é não fazer binding direto em Item. A classe que recebe o POST tem só o que o usuário pode mandar, mais um campo que a entidade não tem: Remover.
public class Item
{
public int Id { get; set; }
public int PedidoId { get; set; }
public string Produto { get; set; } = "";
public int Quantidade { get; set; }
}
// o que o formulário manda: não é a entidade
public class ItemForm
{
public int Id { get; set; }
public string Produto { get; set; } = "";
public int Quantidade { get; set; }
public bool Remover { get; set; }
}
public class PedidoForm { public List<ItemForm> Itens { get; set; } = new(); }
PedidoId não está no ItemForm de propósito. O pedido vem da rota e o vínculo de cada item com ele vem do banco. Se o campo existisse no formulário, bastaria mudar o valor no DevTools para mover um item para o pedido de outra pessoa.
As quatro regras da conciliação
O pedido 1093 do exemplo tem três itens no banco: Parafuso (1), Bucha (2) e Broca 6mm (3). Do formulário podem chegar quatro tipos de linha, e cada um tem uma resposta, na ordem em que o código confere:
- Id diferente de zero que não existe neste pedido: recusa o envio inteiro com 400. Não tem como ser engano de tela; ou é bug, ou é alguém testando.
- Id zero: item novo, insere.
- Remover verdadeiro: apaga, se for do banco; ignora, se for linha nova.
- Id que existe neste pedido: atualiza produto e quantidade.
E uma regra que é a ausência de ação: item que está no banco e não veio no formulário não é apagado. A documentação do EF Core sobre entidades desconectadas reconhece que tratar exclusão é delicado justamente porque, muitas vezes, a ausência da entidade é o que significa que ela deve ser apagada. Num formulário HTML, ausência também pode significar índice furado, campo que o JavaScript esqueceu ou binding que parou no meio. Apagar por ausência transforma qualquer um desses bugs em perda de dado. Pedido explícito, com Remover=true, não tem essa ambiguidade.
A action
[HttpPost("/pedidos/{id:int}")]
public async Task<IActionResult> Salvar(int id, [FromForm] PedidoForm form)
{
form.Itens.RemoveAll(i => i is null); // chave no Index sem campos vira null
var pedido = await db.Pedidos.Include(p => p.Itens).SingleOrDefaultAsync(p => p.Id == id);
if (pedido is null) return NotFound();
var doBanco = pedido.Itens.ToDictionary(i => i.Id);
var idsRecebidos = form.Itens.Where(i => i.Id != 0).Select(i => i.Id).ToHashSet();
// 1) Id que não pertence a este pedido: recusa tudo
var estranhos = idsRecebidos.Where(i => !doBanco.ContainsKey(i)).ToList();
if (estranhos.Count > 0) return BadRequest($"Itens fora do pedido: {string.Join(",", estranhos)}");
foreach (var f in form.Itens)
{
if (f.Id == 0) // 2) novo
{
if (!f.Remover)
pedido.Itens.Add(new Item { Produto = f.Produto, Quantidade = f.Quantidade });
continue;
}
var item = doBanco[f.Id];
if (f.Remover) { db.Itens.Remove(item); continue; } // 3) removido
item.Produto = f.Produto; // 4) atualizado
item.Quantidade = f.Quantidade;
}
// item do banco que não voltou no formulário: NÃO apaga (pode ter se perdido no binding)
await db.SaveChangesAsync();
var final = await db.Itens.Where(i => i.PedidoId == id).OrderBy(i => i.Id)
.Select(i => $"{i.Id}:{i.Produto}:{i.Quantidade}").ToListAsync();
return Ok(final); // devolve o pedido salvo, para o teste
}
Três detalhes do código:
- O
RemoveAll(i => i is null)na primeira linha não é enfeite. Quando o formulário declara uma chave emItens.Indexsem nenhum campo correspondente, o binder colocanullna lista e o primeiro acesso af.Idderruba a action comNullReferenceException. Sem o filtro, o teste abaixo devolveu 500. - A checagem de Ids estranhos acontece antes de qualquer alteração. Assim a resposta 400 não deixa metade do pedido salva.
- O
Include(p => p.Itens)carrega os itens do pedido uma vez e o dicionário evita uma consulta por linha. As mudanças entram num únicoSaveChangesAsync, que o EF Core executa numa transação.
Os cinco envios do teste
Com a aplicação em localhost, cada caso é um POST de formulário para /pedidos/1093. O primeiro, com Itens.Index, que é o formato que a tela deve mandar:
salva "remove Bucha, Broca vai a 2, Arruela nova" \ -d 'Itens.Index=i1' -d 'Itens[i1].Id=1' -d 'Itens[i1].Produto=Parafuso' -d 'Itens[i1].Quantidade=10' \ -d 'Itens.Index=i2' -d 'Itens[i2].Id=2' -d 'Itens[i2].Produto=Bucha' -d 'Itens[i2].Quantidade=10' -d 'Itens[i2].Remover=true' \ -d 'Itens.Index=i3' -d 'Itens[i3].Id=3' -d 'Itens[i3].Produto=Broca 6mm' -d 'Itens[i3].Quantidade=2' \ -d 'Itens.Index=n1' -d 'Itens[n1].Id=0' -d 'Itens[n1].Produto=Arruela' -d 'Itens[n1].Quantidade=20'
Os outros quatro simulam o que dá errado: um Id de outro pedido (o item 9 é do pedido 2000), o formulário antigo com índice numérico furado, uma linha existente que perdeu o hidden do Id e uma chave sem campos. As respostas, em sequência:
# remove Bucha, Broca vai a 2, Arruela nova 200 ["1:Parafuso:10","3:Broca 6mm:2","10:Arruela:20"] # Id 9 pertence ao pedido 2000 400 Itens fora do pedido: 9 # formulário antigo com buraco (0 e 2) 200 ["1:Parafuso:12","3:Broca 6mm:2","10:Arruela:20"] # linha existente sem o campo Id 200 ["1:Parafuso:12","3:Broca 6mm:2","10:Arruela:20","11:Parafuso:15"] # chave no Index sem campos 200 ["1:Parafuso:12","3:Broca 6mm:2","10:Arruela:20","11:Parafuso:15"]
Lendo de cima para baixo:
- A Bucha saiu, a Broca foi para 2 e a Arruela entrou como item 10.
- O Id 9 foi barrado e nada mudou.
- Com
Itens[0]eItens[2], o binder ignorou tudo depois do buraco. O Parafuso foi para 12, a Broca ficou como estava e continuou no banco, porque ausência não apaga. Num código que apaga o que não veio, a Broca e a Arruela teriam ido embora aqui. - A linha sem Id chegou com Id 0 e virou uma cópia do Parafuso (item 11). O servidor não tem como saber que era o item 1. A defesa é na tela: Id em hidden em toda linha que veio do banco.
- A chave sem campos foi filtrada e o envio respondeu 200 em vez de 500.
O que a conciliação não resolve sozinha
Clique duplo em Salvar. As linhas novas chegam com Id 0 nas duas requisições, e as duas inserem. Desabilitar o botão ajuda, mas não protege contra reenvio pela rede. Um identificador gerado na tela para a operação e conferido no servidor resolve; a técnica está detalhada no post sobre idempotência no pagamento e vale igual para pedido.
Duas pessoas editando o mesmo pedido. A última a salvar vence, inclusive sobre a quantidade que a outra acabou de mudar. Se isso importa no seu caso, o EF Core tem tokens de concorrência, que fazem o segundo salvamento falhar em vez de sobrescrever.
Validação de negócio. Quantidade negativa, produto vazio e limite de itens por pedido entram antes do foreach, com ModelState ou checagens próprias. A conciliação garante só que cada linha vá para o lugar certo.
Referência: https://docs.microsoft.com/pt-br/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-6.0