InícioDesafiosDesafio 08 › Gabarito

Gabarito · Desafio 08 Nível: Intermediário

Gabarito: salvar o lote parcial quando um registro falha

Esta página destrincha a solução do oitavo desafio. Se você ainda não tentou, vale abrir uma org, montar um lote com uma conta sem nome no meio e ver o insert comum desfazer tudo. Sentir o tudo ou nada na prática antes de trocá-lo ensina mais do que ler o gabarito de primeira.

Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio

O problema em uma frase

O insert comum do Apex é atômico: ou grava a lista inteira, ou não grava nada. Basta uma linha reprovar numa validação para a plataforma lançar DmlException, com uma mensagem como REQUIRED_FIELD_MISSING, Required fields are missing: [Name], e desfazer o lote todo. Numa importação real, onde dado sujo é a regra e não a exceção, isso significa perder 199 registros bons por causa de um torto. O desafio pede a versão que grava o que dá para gravar e presta contas do resto.

A virada de chave é esta: o Apex tem uma forma de DML que não é tudo ou nada. Database.insert(lista, false) grava os válidos, deixa os inválidos de fora e, em vez de lançar exceção, devolve um resultado por linha dizendo o que aconteceu. O trabalho passa a ser ler esse resultado e traduzir cada falha em algo acionável.

A solução completa

public with sharing class ImportadorLote {

    // Relatorio do import: os numeros e o detalhe de cada falha.
    public class ResultadoImport {
        public Integer totalRecebido;
        public Integer gravados;
        public Integer falhados;
        public List<FalhaLinha> falhas;

        public ResultadoImport() {
            this.totalRecebido = 0;
            this.gravados = 0;
            this.falhados = 0;
            this.falhas = new List<FalhaLinha>();
        }
    }

    // Uma linha que falhou: onde estava, o que era e por que falhou.
    public class FalhaLinha {
        public Integer indice;
        public String nome;
        public String erro;

        public FalhaLinha(Integer indice, String nome, String erro) {
            this.indice = indice;
            this.nome = nome;
            this.erro = erro;
        }
    }

    public static ResultadoImport importar(List<Account> contas) {
        ResultadoImport r = new ResultadoImport();

        // Entrada vazia ou nula: nada a gravar, relatorio zerado, sem DML.
        if (contas == null || contas.isEmpty()) {
            return r;
        }

        r.totalRecebido = contas.size();

        // allOrNone = false: grava os validos e devolve um SaveResult por linha,
        // na mesma ordem da entrada, em vez de abortar tudo no primeiro erro.
        Database.SaveResult[] resultados = Database.insert(contas, false);

        for (Integer i = 0; i < resultados.size(); i++) {
            Database.SaveResult sr = resultados[i];

            if (sr.isSuccess()) {
                r.gravados++;
                continue;
            }

            // Um registro pode acumular varios erros: junte todos.
            List<String> mensagens = new List<String>();
            for (Database.Error e : sr.getErrors()) {
                mensagens.add(e.getStatusCode() + ': ' + e.getMessage());
            }

            r.falhados++;
            r.falhas.add(new FalhaLinha(i, contas[i].Name, String.join(mensagens, ' | ')));
        }

        return r;
    }
}

Destrinchando parte por parte

1. O relatório que presta contas, não só grava

public class ResultadoImport {
    public Integer totalRecebido;
    public Integer gravados;
    public Integer falhados;
    public List<FalhaLinha> falhas;

    public ResultadoImport() {
        this.totalRecebido = 0;
        this.gravados = 0;
        this.falhados = 0;
        this.falhas = new List<FalhaLinha>();
    }
}

Devolver uma classe de relatório, e não void, é a decisão de design que dá valor ao método. Uma importação que só grava e cala não serve para nada quando algo falha: quem chamou precisa saber quanto entrou, quanto ficou de fora e por quê. Os três contadores mais a lista de falhas transportam essa resposta inteira.

Inicializar tudo no construtor (zeros e lista vazia) elimina uma classe de bug: o método retorna em mais de um ponto, e em nenhum deles falhas será null. Quem consome nunca precisa checar nulo antes de iterar, o que cumpre direto a regra da borda sobre entrada nula.

2. A guarda de entrada, antes de tocar no banco

if (contas == null || contas.isEmpty()) {
    return r;
}

r.totalRecebido = contas.size();

A regra pede que lista nula ou vazia devolva zeros sem gravar nada. Tratar isso na primeira linha, antes de qualquer DML, é mais barato e mais claro do que deixar o Database.insert lidar com lista vazia. Como o ResultadoImport já nasce zerado, basta retornar o objeto como está.

Só depois da guarda o totalRecebido recebe o tamanho da lista. A partir daqui existe pelo menos um registro para gravar, e o resto do método pode assumir isso.

3. O coração: allOrNone igual a false

Database.SaveResult[] resultados = Database.insert(contas, false);

Este é o trecho que resolve o desafio. O segundo parâmetro do Database.insert é o allOrNone. Com true (o padrão, e o mesmo comportamento do insert da DML), qualquer linha inválida derruba tudo. Com false, a plataforma grava as linhas válidas, ignora as inválidas e devolve um Database.SaveResult por registro, na mesma ordem da entrada.

O ponto sutil, e que a regra da borda cobra, é que nesse modo o método não lança DmlException por causa de linha reprovada. Ele só marca aquele resultado como sem sucesso. É por isso que um lote inteiramente inválido devolve relatório em vez de estourar: não há exceção para tratar, há resultados para ler. Uma DML só, para o lote todo, também mata a tentação de gravar registro a registro num laço, que estouraria o limite de DML em qualquer volume real.

4. Ler o resultado linha por linha

for (Integer i = 0; i < resultados.size(); i++) {
    Database.SaveResult sr = resultados[i];

    if (sr.isSuccess()) {
        r.gravados++;
        continue;
    }
    // ... trata a falha
}

O laço é por índice de propósito. A lista de SaveResult vem alinhada com a lista de entrada: o resultado na posição i corresponde à conta na posição i. Percorrer com um índice inteiro, em vez de um for-each, é o que permite recuperar tanto o veredito (resultados[i]) quanto o registro original (contas[i]) sem manter um mapa paralelo. É essa garantia de ordem que cumpre a regra da correlação de índice.

sr.isSuccess() separa os dois caminhos. Sucesso apenas incrementa o contador e segue com continue. Todo o trabalho interessante mora no ramo da falha.

5. Extrair o erro real, com código e mensagem

List<String> mensagens = new List<String>();
for (Database.Error e : sr.getErrors()) {
    mensagens.add(e.getStatusCode() + ': ' + e.getMessage());
}

r.falhados++;
r.falhas.add(new FalhaLinha(i, contas[i].Name, String.join(mensagens, ' | ')));

sr.getErrors() devolve uma lista, não um erro único, porque uma mesma linha pode reprovar em vários pontos ao mesmo tempo: campo obrigatório faltando e uma regra de validação, por exemplo. Iterar sobre todos e juntar com String.join preserva o quadro completo, em vez de reportar só o primeiro problema e esconder o resto.

Cada Database.Error traz getStatusCode() (o código estável, como REQUIRED_FIELD_MISSING, ótimo para o código reagir) e getMessage() (o texto legível para o humano). Guardar os dois dá o melhor dos mundos. O FalhaLinha ainda registra o índice e o Name do registro, então o relatório final aponta exatamente qual linha corrigir, não um "algo deu errado" genérico.

Erros comuns

  • Usar o insert puro dentro de um try/catch. Capturar a DmlException não salva os válidos: o rollback já aconteceu antes de você tratar. O tudo ou nada é do insert, não do catch.
  • Gravar em laço para isolar o erro. Fazer um insert por registro dentro de um for até funciona com três contas, mas estoura o limite de 150 DML por transação no primeiro lote grande. O allOrNone = false resolve com uma DML só.
  • Reportar mensagem genérica. Devolver "falha ao salvar" joga fora a informação que a plataforma já deu de graça. Sem o getStatusCode e o getMessage reais, o usuário não sabe o que corrigir.
  • Ler só o primeiro erro da linha. getErrors() é uma lista. Pegar só getErrors()[0] esconde os demais problemas do mesmo registro e obriga o usuário a reimportar várias vezes, descobrindo um erro por vez.
  • Perder a correlação com a entrada. Sem o índice, o relatório diz "uma linha falhou" sem dizer qual. Confiar na ordem do SaveResult e guardar o i é o que torna a falha acionável.
  • Deixar falhas poder ser nulo. Inicializar no construtor evita o NullPointerException na camada que consome, principalmente no caminho de lista vazia.

Como saber se a sua solução passou

O script de teste do desafio cobre os casos que mais reprovam: os válidos gravam com uma inválida no meio, a falha aponta o índice certo com erro real, o lote todo inválido não lança e a entrada nula volta zerada. O critério objetivo é este:

List<Account> lote = new List<Account>{
    new Account(Name = 'Valida'), new Account(), new Account(Name = 'Outra')
};
ImportadorLote.ResultadoImport r = ImportadorLote.importar(lote);
System.assertEquals(2, r.gravados, 'Os validos gravam');
System.assertEquals(1, r.falhados, 'So a invalida falha');
System.assertEquals(1, r.falhas[0].indice, 'A falha aponta a posicao certa');
System.assert(!String.isBlank(r.falhas[0].erro), 'A falha traz o erro real');

Se qualquer chamada estoura com DmlException em vez de devolver o relatório, o allOrNone ainda está em true. Se gravados vier zero quando havia linha válida, provavelmente o rollback do tudo ou nada ainda está em jogo. E se Limits.getDmlStatements() passar de um por chamada, há gravação em laço escondida.

Um passo além

Para levar a rotina ao nível que se usa em produção, três evoluções:

  • Respeitar a segurança de quem chama. Existe a sobrecarga Database.insert(lista, false, AccessLevel.USER_MODE), que aplica CRUD e FLS do usuário em cima do salvamento parcial. Numa importação exposta a usuário final isso deixa de ser opcional. O raciocínio completo está em segurança em Apex: CRUD, FLS e sharing.
  • Escalar para centenas de milhares. O salvamento parcial brilha ainda mais dentro de um Batch Apex, onde cada chunk grava o que é válido e acumula as falhas sem derrubar o job inteiro. Quando e como sair da transação síncrona está em Queueable, Batch e Schedulable: qual async usar.
  • Não estourar os limites da transação. Um lote parcial ainda respeita o teto de linhas por DML e de registros processados. Entender esses limites antes de aumentar o tamanho do lote evita trocar um erro por outro. A base está em governor limits em Apex: o que são e como não bater.

O que fica deste desafio: gravar em lote e tratar erro não são fases separadas. O allOrNone = false e o SaveResult transformam uma importação frágil em uma que sobrevive a dado sujo e diz, linha por linha, o que corrigir. Esse reflexo separa quem escreve o caminho feliz de quem entrega rotina de produção.