delete 9. CRUD Avançado - DELETE

A operação DELETE é a mais perigosa do CRUD. Uma vez executada incorretamente, dados podem ser perdidos permanentemente. Em aplicações profissionais, raramente se usa "hard delete" (remover fisicamente do banco). Ao invés disso, implementa-se soft delete (marcar como deletado com flag), permitindo recuperação e mantendo integridade referencial.

Além da escolha entre soft/hard delete, você precisa decidir sobre cascata: quando um post é deletado, o que acontece com seus comentários? E com uploads de imagem? E referências em outras tabelas? O ON DELETE CASCADE do SQL automatiza isso, mas pode causar perdas inesperadas de dados.

Nesta seção, você aprenderá a implementar soft delete com lixeira (usuários podem recuperar por 30 dias), hard delete com confirmação, bulk delete com transações e auditoria completa de quem deletou o quê e quando.

report
🚨 ATENÇÃO: Deletes acidentais são a causa #1 de desastres em produção. SEMPRE implemente confirmação no frontend, log de auditoria no backend e backups regulares. Em produção, considere delay de 24h antes de hard delete.

compare_arrows Soft Delete vs Hard Delete

visibility_off Soft Delete (Recomendado)

UPDATE posts 
SET deleted_at = NOW(),
    deleted_by = 123
WHERE id = 456;

Prós:
• Recuperação possível
• Mantém integridade referencial
• Auditoria completa
• Conformidade LGPD/GDPR

Contras:
• Banco cresce indefinidamente
• Queries mais complexas (WHERE deleted_at IS NULL)

delete_forever Hard Delete (Cuidado!)

DELETE FROM posts 
WHERE id = 456;

Prós:
• Libera espaço em disco
• Queries mais simples

Contras:
IRREVERSÍVEL
• Pode quebrar foreign keys
• Perde histórico
• Pode violar LGPD (direito ao esquecimento requer prova)

lightbulb
💡 Estratégia Híbrida: Use soft delete + hard delete agendado. Soft delete marca como deletado, depois um job noturno faz hard delete de registros com deleted_at < NOW() - INTERVAL 30 DAY.

code Implementando Soft Delete

1️⃣ Estrutura da Tabela

-- Adicionar colunas de soft delete
ALTER TABLE posts 
ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD COLUMN deleted_by INT NULL,
ADD INDEX idx_deleted_at (deleted_at);

-- Foreign key para rastrear quem deletou
ALTER TABLE posts
ADD CONSTRAINT fk_deleted_by 
FOREIGN KEY (deleted_by) REFERENCES users(id);

2️⃣ Endpoint de Soft Delete

prepare('
        SELECT user_id, deleted_at 
        FROM posts 
        WHERE id = ?
    ');
    $stmt->execute([$postId]);
    $post = $stmt->fetch();
    
    if (!$post) {
        http_response_code(404);
        echo json_encode(['success' => false, 'message' => 'Post não encontrado']);
        exit;
    }
    
    if ($post['deleted_at']) {
        http_response_code(400);
        echo json_encode(['success' => false, 'message' => 'Post já foi deletado']);
        exit;
    }
    
    // Verificar permissão
    $stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?');
    $stmt->execute([$userId]);
    $userRole = $stmt->fetchColumn();
    
    if ($post['user_id'] != $userId && $userRole !== 'admin') {
        http_response_code(403);
        echo json_encode(['success' => false, 'message' => 'Sem permissão']);
        exit;
    }
    
    // SOFT DELETE
    $stmt = $pdo->prepare('
        UPDATE posts 
        SET deleted_at = NOW(), 
            deleted_by = ? 
        WHERE id = ?
    ');
    $stmt->execute([$userId, $postId]);
    
    // Log de auditoria
    $pdo->prepare('
        INSERT INTO audit_logs (user_id, action, table_name, record_id, created_at) 
        VALUES (?, ?, ?, ?, NOW())
    ')->execute([$userId, 'DELETE', 'posts', $postId]);
    
    echo json_encode([
        'success' => true,
        'message' => 'Post movido para lixeira',
        'recoverable_until' => date('Y-m-d H:i:s', strtotime('+30 days'))
    ]);
    
} catch (Exception $e) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>

3️⃣ Recuperar da Lixeira (Restore)

prepare('
        SELECT user_id, deleted_at, deleted_by 
        FROM posts 
        WHERE id = ? AND deleted_at IS NOT NULL
    ');
    $stmt->execute([$postId]);
    $post = $stmt->fetch();
    
    if (!$post) {
        http_response_code(404);
        echo json_encode(['success' => false, 'message' => 'Post não encontrado na lixeira']);
        exit;
    }
    
    // Verificar se quem deletou ou admin
    $stmt = $pdo->prepare('SELECT role FROM users WHERE id = ?');
    $stmt->execute([$userId]);
    $userRole = $stmt->fetchColumn();
    
    if ($post['deleted_by'] != $userId && $userRole !== 'admin') {
        http_response_code(403);
        echo json_encode(['success' => false, 'message' => 'Apenas quem deletou pode restaurar']);
        exit;
    }
    
    // RESTAURAR
    $stmt = $pdo->prepare('
        UPDATE posts 
        SET deleted_at = NULL, 
            deleted_by = NULL 
        WHERE id = ?
    ');
    $stmt->execute([$postId]);
    
    // Log
    $pdo->prepare('
        INSERT INTO audit_logs (user_id, action, table_name, record_id, created_at) 
        VALUES (?, ?, ?, ?, NOW())
    ')->execute([$userId, 'RESTORE', 'posts', $postId]);
    
    echo json_encode(['success' => true, 'message' => 'Post restaurado com sucesso']);
    
} catch (Exception $e) {
    http_response_code(400);
    echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
?>

delete_sweep Hard Delete Permanente

prepare('SELECT role FROM users WHERE id = ?');
    $stmt->execute([$userId]);
    if ($stmt->fetchColumn() !== 'admin') {
        http_response_code(403);
        echo json_encode(['success' => false, 'message' => 'Apenas administradores']);
        exit;
    }
    
    // Verificar se post existe
    $stmt = $pdo->prepare('SELECT * FROM posts WHERE id = ?');
    $stmt->execute([$postId]);
    $post = $stmt->fetch();
    
    if (!$post) {
        http_response_code(404);
        echo json_encode(['success' => false, 'message' => 'Post não encontrado']);
        exit;
    }
    
    // Iniciar TRANSAÇÃO (atomicidade)
    $pdo->beginTransaction();
    
    try {
        // 1. Deletar comentários associados
        $stmt = $pdo->prepare('DELETE FROM comments WHERE post_id = ?');
        $stmt->execute([$postId]);
        $deletedComments = $stmt->rowCount();
        
        // 2. Deletar imagens (e arquivos físicos)
        $stmt = $pdo->prepare('SELECT filename FROM images WHERE post_id = ?');
        $stmt->execute([$postId]);
        $images = $stmt->fetchAll(PDO::FETCH_COLUMN);
        
        foreach ($images as $filename) {
            $filepath = __DIR__ . '/../../uploads/images/' . $filename;
            if (file_exists($filepath)) {
                unlink($filepath);
            }
        }
        
        $stmt = $pdo->prepare('DELETE FROM images WHERE post_id = ?');
        $stmt->execute([$postId]);
        $deletedImages = $stmt->rowCount();
        
        // 3. Deletar post (IRREVERSÍVEL!)
        $stmt = $pdo->prepare('DELETE FROM posts WHERE id = ?');
        $stmt->execute([$postId]);
        
        // 4. Log de auditoria (ANTES de commit!)
        $pdo->prepare('
            INSERT INTO audit_logs (user_id, action, table_name, record_id, details, created_at) 
            VALUES (?, ?, ?, ?, ?, NOW())
        ')->execute([
            $userId, 
            'HARD_DELETE', 
            'posts', 
            $postId,
            json_encode([
                'title' => $post['title'],
                'deleted_comments' => $deletedComments,
                'deleted_images' => $deletedImages
            ])
        ]);
        
        // COMMIT - tornar permanente
        $pdo->commit();
        
        echo json_encode([
            'success' => true,
            'message' => 'Post deletado permanentemente',
            'details' => [
                'deleted_comments' => $deletedComments,
                'deleted_images' => $deletedImages
            ]
        ]);
        
    } catch (Exception $e) {
        // ROLLBACK em caso de erro
        $pdo->rollBack();
        throw $e;
    }
    
} catch (Exception $e) {
    error_log("Hard Delete Error: " . $e->getMessage());
    http_response_code(500);
    echo json_encode(['success' => false, 'message' => 'Erro ao deletar']);
}
?>
warning
⚠️ Transações são Essenciais: Use BEGIN TRANSACTION e COMMIT para garantir atomicidade. Se deletar o post mas falhar ao deletar comentários, a transação faz ROLLBACK automático, deixando tudo intacto.

storage Estratégias de Cascata

linear_scale CASCADE

ON DELETE CASCADE

Deleta automaticamente registros relacionados. Perigoso - pode causar deleções em massa não intencionais.

block RESTRICT

ON DELETE RESTRICT

Bloqueia delete se há registros relacionados. Seguro - força limpeza manual.

settings_backup_restore SET NULL

ON DELETE SET NULL

Seta foreign key como NULL. Útil para manter comentários após deletar post.

-- Exemplo de foreign key com estratégia
ALTER TABLE comments
ADD CONSTRAINT fk_post_id
FOREIGN KEY (post_id) REFERENCES posts(id)
ON DELETE CASCADE;  -- Deletar post = deletar comentários

ALTER TABLE posts
ADD CONSTRAINT fk_category_id
FOREIGN KEY (category_id) REFERENCES categories(id)
ON DELETE RESTRICT;  -- Não permite deletar categoria com posts

schedule Job de Limpeza Automática

prepare('
        SELECT id, title FROM posts 
        WHERE deleted_at IS NOT NULL 
        AND deleted_at < NOW() - INTERVAL 30 DAY
    ');
    $stmt->execute();
    $postsToDelete = $stmt->fetchAll();
    
    $count = 0;
    foreach ($postsToDelete as $post) {
        try {
            $pdo->beginTransaction();
            
            // Deletar relacionados
            $pdo->prepare('DELETE FROM comments WHERE post_id = ?')->execute([$post['id']]);
            $pdo->prepare('DELETE FROM images WHERE post_id = ?')->execute([$post['id']]);
            
            // Hard delete
            $pdo->prepare('DELETE FROM posts WHERE id = ?')->execute([$post['id']]);
            
            // Log
            $pdo->prepare('
                INSERT INTO audit_logs (user_id, action, table_name, record_id, details, created_at) 
                VALUES (NULL, ?, ?, ?, ?, NOW())
            ')->execute(['AUTO_CLEANUP', 'posts', $post['id'], json_encode(['title' => $post['title']])]);
            
            $pdo->commit();
            $count++;
            
        } catch (Exception $e) {
            $pdo->rollBack();
            error_log("Cleanup Error for post {$post['id']}: " . $e->getMessage());
        }
    }
    
    echo "✅ Cleanup concluído: $count posts deletados permanentemente\n";
    
} catch (Exception $e) {
    error_log("Cleanup Job Error: " . $e->getMessage());
    exit(1);
}
?>
event
📅 Configurar Cron: No servidor Linux, adicione em crontab -e:
0 3 * * * /usr/bin/php /var/www/api/cron/cleanup-deleted-posts.php >> /var/log/cleanup.log 2>&1
arrow_forward
✅ Próxima Seção (FINAL): Vamos implementar Middleware de Autenticação para proteger todas as rotas CRUD criadas, verificando JWT automaticamente em cada requisição.