Programação Por Equipe DevBras

Capa do artigo: Salvar lista editável no ASP.NET Core com EF Core

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:

  1. 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.
  2. Id zero: item novo, insere.
  3. Remover verdadeiro: apaga, se for do banco; ignora, se for linha nova.
  4. 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 em Itens.Index sem nenhum campo correspondente, o binder coloca null na lista e o primeiro acesso a f.Id derruba a action com NullReferenceException. 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 único SaveChangesAsync, 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] e Itens[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.

Avalie este artigo

Referência: https://docs.microsoft.com/pt-br/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-6.0