DELETE sem WHERE: as travas que impedem e o plano para quando acontece
Monte as travas contra o DELETE que apaga a tabela inteira (id obrigatório, transação com contagem, safe updates, usuário restrito) e saiba recuperar pelo backup e pelo log binário.
Quinta, 11h20. A dona da doceria clica em "excluir" num pedido de teste e a lista inteira de pedidos fica vazia. Os 1.814 pedidos do ano sumiram. Ninguém escreveu DELETE FROM pedidos sem WHERE de propósito. O que aconteceu foi mais sutil: na semana anterior, o formulário passou a enviar pedido_id em vez de id, e a função de exclusão usava o mesmo montador de filtros da listagem. Sem filtro, a listagem mostra tudo. Sem filtro, a exclusão apaga tudo.
A resposta curta: o WHERE esquecido quase nunca é esquecido à mão; ele some quando o id chega vazio. Recuse a exclusão se o id não veio, apague dentro de uma transação que confere quantas linhas foram afetadas, ligue o modo de atualização segura no cliente do MySQL, dê ao site um usuário sem poder de destruição em massa e tenha um caminho de volta ensaiado. Se o básico de excluir um registro por id ainda é novidade, comece por apagar um registro sem apagar a tabela.
Por que o WHERE some sozinho
O código da doceria tinha uma função genérica, reaproveitada em toda parte:
function montarWhere(array $filtros): string
{
$partes = [];
foreach ($filtros as $coluna => $valor) {
if ($valor !== null && $valor !== '') {
$partes[] = "$coluna = " . (int) $valor;
}
}
return $partes ? 'WHERE ' . implode(' AND ', $partes) : '';
}
$where = montarWhere(['id' => $_POST['id'] ?? null]);
$pdo->exec("DELETE FROM pedidos $where");
Para uma tela de busca, ignorar filtro vazio é o comportamento certo. Para uma exclusão, é catastrófico. O $_POST['id'] chegou nulo, o filtro foi ignorado, e o DELETE ficou sem condição. Nenhum erro, nenhum aviso. O (int) evitava injeção e dava uma falsa sensação de cuidado.
Query builders de framework têm a mesma armadilha. Um filtro condicional do tipo "aplica o where só se o valor vier preenchido", seguido de delete(), apaga a tabela quando o valor vem vazio. O padrão se repete em UPDATE: um campo vazio vira "atualize todo mundo".
As travas, da aplicação ao banco
Cada trava abaixo pega um tipo de falha que as outras deixam passar. Na doceria, as cinco entraram juntas.
- Id obrigatório e validado. Exclusão sem id válido é erro, não "sem filtro". Use
filter_inputcomFILTER_VALIDATE_INTe mínimo 1. - Escopo de dono. O
WHEREleva o id e a loja (ou o usuário) dona do registro. Isso evita apagar o pedido de outro cliente trocando o número no formulário. - Transação com contagem. Apague, confira
rowCount()e só confirme se foi exatamente uma linha. Se vier mais, desfaça. - Usuário de banco restrito. O site não precisa de
DROP,TRUNCATEnemALTER. Se a exclusão real é rara, considere nem darDELETEe usar exclusão lógica. - Modo seguro no cliente do MySQL. Para quem roda SQL à mão no servidor, a variável
sql_safe_updatesrecusaUPDATEeDELETEque não usem coluna de índice noWHEREnem tenhamLIMIT.
A função de exclusão reescrita:
function excluirPedido(PDO $pdo, int $lojaId): void
{
$id = filter_input(INPUT_POST, 'pedido_id', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
if (!$id) {
throw new InvalidArgumentException('Pedido não informado.');
}
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'DELETE FROM pedidos WHERE id = :id AND loja_id = :loja LIMIT 1'
);
$stmt->execute(['id' => $id, 'loja' => $lojaId]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('Esperava excluir 1 pedido, afetou ' . $stmt->rowCount());
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
Com o LIMIT 1 e o id obrigatório, a contagem raramente passa de um. Ela existe para o dia em que alguém tirar o LIMIT ou trocar a condição, e para pegar o caso de zero linhas, que normalmente significa id de outra loja ou pedido já excluído.
Exclusão lógica quando o negócio precisa desfazer
Na doceria, "excluir pedido" na verdade queria dizer "tirar da lista". Uma coluna excluido_em DATETIME NULL e um UPDATE pedidos SET excluido_em = NOW() WHERE id = :id AND loja_id = :loja resolvem, com um botão de desfazer por alguns dias. As listagens passam a filtrar excluido_em IS NULL. Mas atenção: quando a pessoa pede a exclusão dos próprios dados, esconder não é apagar, como mostra exclusão que chega no backup.
O SQL à mão no servidor
Para quem administra o banco pelo terminal, o cliente mysql aceita --safe-updates, que liga a mesma proteção na sessão. Também dá para ligar dentro da sessão:
SET SESSION sql_safe_updates = 1;
START TRANSACTION;
DELETE FROM pedidos WHERE id = 1042 AND loja_id = 7;
SELECT ROW_COUNT();
-- confira o número antes de decidir
COMMIT; -- ou ROLLBACK;
O hábito de abrir a transação antes de qualquer DELETE manual é o que separa um susto de um incidente. O MySQL roda com autocommit ligado por padrão: sem START TRANSACTION, o comando é definitivo assim que você aperta Enter. Outro hábito barato: escreva primeiro o SELECT COUNT(*) com o mesmo WHERE, confira o número e só então troque o começo da linha por DELETE.
Quando acontece mesmo assim: o caminho de volta
Pare a aplicação ou ao menos a escrita na tabela afetada, para não misturar dados novos com a recuperação. Depois, a ordem é esta:
- Restaure o backup mais recente num servidor separado, nunca por cima da produção. Um backup que nunca foi restaurado é uma aposta; veja backup de MySQL que restaura.
- Recupere o intervalo entre o backup e o acidente pelo log binário. No MySQL 8 ele vem ligado por padrão; no MariaDB e em versões antigas, confira
SHOW VARIABLES LIKE 'log_bin'hoje, antes de precisar. A ferramentamysqlbinlogreaplica os eventos até um instante, com--stop-datetime, ou até uma posição, com--stop-position, parando logo antes doDELETE. - Copie de volta só a tabela afetada, do servidor de recuperação para a produção, e confira contagens e alguns pedidos conhecidos.
- Escreva o que aconteceu e qual trava faltou. Na doceria, foram duas: o id obrigatório e a transação com contagem.
Erros comuns
- Reaproveitar o montador de filtros da busca na exclusão. Filtro vazio vira "todos" e o
DELETEapaga a tabela. - Confiar no
(int)como validação. Ele evita injeção, mas transforma lixo em zero e não impede o filtro vazio. - Excluir sem escopo de dono. Trocar o número no formulário apaga o pedido de outro cliente.
- Rodar
DELETEmanual comautocommitligado e sem transação. Não há volta depois do Enter. - Restaurar o backup direto na produção. Os pedidos feitos depois do backup somem junto.
- Descobrir no dia do acidente que o log binário estava desligado. Tudo entre o último backup e o erro fica perdido.
Checklist
- Toda função de exclusão recusa id ausente ou inválido.
- Todo
DELETEda aplicação leva id e dono noWHERE. - A exclusão roda em transação e confere
rowCount(). - O usuário de banco do site não tem
DROP,TRUNCATEnemALTER. - Quem administra pelo terminal usa
--safe-updatesousql_safe_updates. - O log binário está ligado e retido por mais tempo que o intervalo entre backups.
- A restauração com recuperação até um instante foi ensaiada pelo menos uma vez.
Mande agora uma requisição de exclusão para o seu sistema sem o campo do id, num ambiente de teste: ela devolve erro ou apaga alguma coisa?
Referência: http://forum.imasters.com.br/topic/342715-resolvido-funo-excluir-dados-phpmysql/