Extensão — Dados que Sobrevivem: Integrando SQLite com RAII

Extensão — Dados que Sobrevivem: Integrando SQLite com RAII

Envolver a API C do SQLite numa classe é o exercício de RAII mais próximo do mundo real: um recurso de terceiros que precisa ser fechado, sob pena de perda. O artigo trata da conexão que proíbe cópia e permite movimento, e da injeção de SQL que só declarações preparadas previnem de fato.
Linguagem C++

13 min de leitura

No artigo Extensão — C++ no Mundo da Web e da Persistência mapeamos o território de C++ na web e na persistência. Hoje pisamos nele: integramos o SQLite a um programa C++, criando um banco de dados real, em disco, cujos dados sobrevivem ao fim do programa. Nosso banco do capstone era brilhante mas volátil — desligou, evaporou. O SQLite é seu irmão persistente: um banco relacional completo que vive num único arquivo. E faremos a integração do jeito C++: o SQLite tem uma API em C puro, cheia de sqlite3_open, sqlite3_close e ponteiros a gerenciar à mão — exatamente o tipo de código que o curso inteiro ensinou a domar. Vamos envolvê-la em classes RAII (Fase 2), transformando a API C bruta em código C++ seguro. É a prova viva de que os princípios que você domina resgatam até bibliotecas legadas — e o momento em que seu conhecimento ganha persistência real.

A API C do SQLite: poderosa e perigosa

O SQLite é distribuído como uma biblioteca C. Sua API é eficiente mas tem os perigos clássicos do C que você conhece: você abre a conexão e é obrigado a fechá-la, gerencia ponteiros manualmente, e checa códigos de retorno a cada passo. Um uso cru se pareceria com isto — e repare no desconforto:

// Estilo C cru — funciona, mas é PERIGOSO (não faça assim em C++).
#include <sqlite3.h>
#include <iostream>

int main() {
    sqlite3* db = nullptr;
    if (sqlite3_open("dados.db", &db) != SQLITE_OK) {   // abre; checa código de retorno
        std::cerr << "erro ao abrir\n";
        return 1;
    }
    // ... usar db ...
    // Se um 'return' antecipado ou exceção ocorrer AQUI, o close abaixo é pulado
    // e a conexão VAZA — o problema de sempre do gerenciamento manual.
    sqlite3_close(db);   // precisa lembrar SEMPRE, em todos os caminhos
    return 0;
}

É o fopen/fclose do artigo Nascimento e Morte de um Objeto — Construtores, Destrutores e o RAII na Prática de novo, e o malloc/free da Fase 2: um recurso que você adquire e precisa liberar à mão, com todos os riscos de esquecer. A solução é a que você já domina — RAII.

Envolvendo a conexão em RAII

Criamos uma classe BancoSQLite que abre a conexão no construtor e a fecha no destrutor. A partir dela, a conexão nunca vaza, porque a liberação está amarrada ao fim de vida do objeto:

// banco_sqlite.h
#pragma once
#include <sqlite3.h>
#include <string>
#include <stdexcept>

class BancoSQLite {
public:
    // Construtor: ADQUIRE o recurso (abre a conexão). Lança se falhar.
    explicit BancoSQLite(const std::string& caminho) {
        if (sqlite3_open(caminho.c_str(), &db_) != SQLITE_OK) {
            std::string msg = sqlite3_errmsg(db_);
            sqlite3_close(db_);   // fecha o que foi parcialmente aberto
            throw std::runtime_error("SQLite: falha ao abrir: " + msg);
        }
    }

    // Destrutor: LIBERA o recurso (fecha a conexão). Roda SEMPRE, automaticamente.
    ~BancoSQLite() {
        if (db_) sqlite3_close(db_);
    }

    // Proíbe cópia: uma conexão tem UM dono (como unique_ptr — Fase 2).
    BancoSQLite(const BancoSQLite&) = delete;
    BancoSQLite& operator=(const BancoSQLite&) = delete;

    // Permite MOVIMENTO: transferir a posse da conexão (Fase 2).
    BancoSQLite(BancoSQLite&& outro) noexcept : db_(outro.db_) {
        outro.db_ = nullptr;   // o movido não deve fechar o que já não é seu
    }

    // Executa um comando SQL simples (sem resultados a ler).
    void executar(const std::string& sql) {
        char* erro = nullptr;
        if (sqlite3_exec(db_, sql.c_str(), nullptr, nullptr, &erro) != SQLITE_OK) {
            std::string msg = erro ? erro : "erro desconhecido";
            sqlite3_free(erro);   // a API C exige liberar a mensagem de erro
            throw std::runtime_error("SQLite: erro ao executar: " + msg);
        }
    }

    sqlite3* handle() const { return db_; }   // acesso ao handle bruto, quando necessário

private:
    sqlite3* db_ = nullptr;   // o recurso possuído
};

Contemple quanta coisa do curso está aqui. RAII (construtor adquire, destrutor libera — Fase 2). Cópia proibida e movimento permitido, exatamente como o unique_ptr (Regra dos Cinco, artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco) — a conexão é um recurso de dono único. Exceções para reportar falhas de abertura e execução (Fase 6). const-correctness no handle() (Fase 3). O .c_str() para passar std::string à API C que espera const char* (Fase 1, artigo O Fim do char — Textos de Verdade com std::string). Esta classe é* o curso, aplicado a uma biblioteca real. E o resultado: quem usa BancoSQLite nunca vaza uma conexão, nunca esquece um close — a segurança é garantida por construção.

Persistência de verdade: dados que sobrevivem

Agora o momento da verdade. Vamos criar uma tabela, inserir dados, e — o ponto crucial — verificar que eles persistem entre execuções do programa:

// main.cpp
#include "banco_sqlite.h"
#include <iostream>

int main() {
    // Abre (ou cria, se não existir) o arquivo de banco.
    BancoSQLite banco("usuarios.db");

    // Cria a tabela se ainda não existir (SQL real).
    banco.executar(
        "CREATE TABLE IF NOT EXISTS usuarios ("
        "  id INTEGER PRIMARY KEY,"
        "  nome TEXT NOT NULL,"
        "  idade INTEGER"
        ")");

    // Insere dados — que serão GRAVADOS EM DISCO.
    banco.executar("INSERT INTO usuarios (nome, idade) VALUES ('Ana', 30)");
    banco.executar("INSERT INTO usuarios (nome, idade) VALUES ('Bruno', 25)");

    std::cout << "dados inseridos e persistidos em usuarios.db\n";
    return 0;
    // A conexão fecha AUTOMATICAMENTE aqui (destrutor de 'banco').
}

Rode este programa duas vezes. Na primeira, ele cria o arquivo usuarios.db e insere os dados. Na segunda, o arquivo já existe, a tabela já está lá (graças ao IF NOT EXISTS), e as novas inserções se somam às antigas — porque os dados da primeira execução sobreviveram no disco. Essa é a diferença fundamental do banco em memória do capstone: aqui, os dados persistem. Você fechou o programa, desligou o computador, e os registros continuam lá, num arquivo que você pode copiar, versionar, ou abrir com qualquer ferramenta SQLite. É a persistência real, envolvida em código C++ seguro.

A honestidade sobre esta integração

Prometo a franqueza de sempre. Mantive o exemplo deliberadamente simples para focar no princípio (RAII sobre a API C), e devo ser transparente sobre o que não mostrei. Primeiro, ler resultados de consultas (SELECT) no SQLite é mais elaborado que executar comandos — envolve "preparar" a consulta, iterar sobre as linhas de resultado com sqlite3_step, e extrair cada coluna; isso também deveria ser envolvido em RAII (uma classe Consulta que gerencia o statement preparado), e o exercício 4 pede exatamente isso. Segundo, montar SQL concatenando strings — como fiz com valores fixos aqui — é perigoso com dados de entrada reais: abre a porta para injeção de SQL, uma das vulnerabilidades de segurança mais clássicas; em código de produção, usa-se prepared statements com parâmetros ligados (?), que separam o comando dos dados. Não mostrei isso para não sobrecarregar, mas é essencial, e o menciono porque omiti-lo sem aviso seria irresponsável. Terceiro, existem wrappers C++ prontos para o SQLite (como o SQLiteCpp) que já fazem todo esse trabalho de RAII por você — usá-los é o que se faz na prática; construímos o nosso aqui pelo valor pedagógico de ver o princípio em ação. A lição fica: os conceitos do curso domam APIs C, mas o mundo real tem camadas adicionais (segurança, leitura de resultados) que uma introdução apenas aponta.

Envolver a API C do SQLite numa classe é o exercício mais honesto de RAII do curso: um recurso real, de biblioteca de terceiros, que precisa ser fechado. Proibir cópia e permitir movimento não é formalidade — dois objetos com o mesmo sqlite3* fechariam a conexão duas vezes. E a parte que não é sobre C++: concatenar valores no SQL abre injeção, e o que previne isso são declarações preparadas com parâmetros ligados, não escape manual.

Fontes e leituras recomendadas

  • sqlite.org/cintro.html: a introdução oficial à API C do SQLite, com open, exec, prepare e step.
  • github.com/SRombauts/SQLiteCpp: o wrapper C++ RAII para SQLite, que faz na prática o que esboçamos — vale estudar seu código.
  • sqlite.org/lang.html: a referência da linguagem SQL suportada pelo SQLite, para escrever suas consultas.
  • "SQL Injection" (owasp.org): sobre a vulnerabilidade mencionada e por que prepared statements são essenciais.
  • Anthony Williams e Scott Meyers: revisitar RAII e a Regra dos Cinco, cujos princípios sustentam a classe BancoSQLite.

Exercícios

Exercício 1

Compile e rode o exemplo desta aula duas vezes (você precisará ter o SQLite instalado e linká-lo: -lsqlite3). Confirme que os dados se acumulam entre execuções, provando a persistência. Descreva o que observou.

Ver resposta

✓ Resposta: Ao rodar duas vezes (compilando com algo como g++ -std=c++20 main.cpp -lsqlite3 -o app), observa-se que, após a primeira execução, o arquivo usuarios.db é criado com Ana e Bruno; após a segunda, o arquivo já existe, o CREATE TABLE IF NOT EXISTS não recria a tabela, e as novas inserções adicionam mais uma Ana e um Bruno — de modo que a tabela agora tem quatro registros (dois de cada execução). Isso prova a persistência: os dados da primeira execução sobreviveram no disco e estavam lá quando a segunda execução começou. (Se o exemplo tivesse um SELECT COUNT(*), ele mostraria 2 após a primeira rodada e 4 após a segunda.) A observação-chave é que, ao contrário do banco em memória, nada se perdeu ao programa terminar.

Exercício 2

Explique como a classe BancoSQLite aplica RAII para tornar segura a API C do SQLite. Que problema concreto do uso cru (mostrado no início da aula) ela elimina?

Ver resposta

✓ Resposta: BancoSQLite aplica RAII abrindo a conexão (sqlite3_open) no construtor e fechando-a (sqlite3_close) no destrutor. Como o destrutor roda automaticamente ao fim do escopo do objeto — em qualquer caminho de saída, inclusive durante o desenrolamento de uma exceção —, a conexão é sempre fechada, sem que o programador precise lembrar. O problema concreto que isso elimina é o vazamento de conexão mostrado no uso cru: lá, se um return antecipado ou uma exceção ocorresse entre o sqlite3_open e o sqlite3_close, o close seria pulado e a conexão vazaria. Com a classe RAII, o fechamento está amarrado ao tempo de vida do objeto, não a uma linha específica de código, então nenhum caminho de saída pode vazar a conexão. É o mesmo padrão que o unique_ptr e o lock_guard aplicam a memória e mutexes, agora aplicado a uma conexão de banco.

Exercício 3

Explique por que BancoSQLite proíbe a cópia mas permite o movimento. O que daria errado se a cópia fosse permitida e dois objetos BancoSQLite compartilhassem o mesmo sqlite3*?

Ver resposta

✓ Resposta: BancoSQLite proíbe a cópia porque uma conexão SQLite (sqlite3*) é um recurso de dono único: copiar o objeto copiaria o ponteiro db_, fazendo dois objetos apontarem para a mesma conexão. Quando ambos fossem destruídos, ambos chamariam sqlite3_close sobre o mesmo handle — um double-close, comportamento indefinido que pode corromper o estado da biblioteca ou travar (exatamente o problema de double-free do artigo Quando Copiar Dá Errado — a Regra dos Três e dos Cinco, aplicado a conexões). Permite o movimento porque transferir a posse é seguro e útil: o construtor de movimento copia o ponteiro para o novo objeto e zera o ponteiro do original (outro.db_ = nullptr), de modo que apenas um objeto é dono da conexão a qualquer momento, e o objeto movido (agora com db_ nulo) não fecha nada em seu destrutor. Assim, você pode devolver um BancoSQLite de uma função ou guardá-lo num container, transferindo a posse sem risco. É a Regra dos Cinco em ação: cópia deletada, movimento implementado, para um tipo que possui um recurso de dono único.

Exercício 4

Esboce uma classe Consulta que envolva um prepared statement do SQLite em RAII (o statement é criado com sqlite3_prepare_v2 e destruído com sqlite3_finalize). Descreva quais métodos ela teria para executar uma consulta SELECT e iterar sobre os resultados.

Ver resposta

✓ Resposta: Esboço de Consulta com RAII sobre um prepared statement:

class Consulta {
public:
    // Construtor: PREPARA o statement (adquire o recurso).
    Consulta(BancoSQLite& banco, const std::string& sql) {
        if (sqlite3_prepare_v2(banco.handle(), sql.c_str(), -1, &stmt_, nullptr) != SQLITE_OK)
            throw std::runtime_error("falha ao preparar consulta");
    }
    // Destrutor: FINALIZA o statement (libera o recurso).
    ~Consulta() { if (stmt_) sqlite3_finalize(stmt_); }

    Consulta(const Consulta&) = delete;             // dono único
    Consulta& operator=(const Consulta&) = delete;

    // Avança para a próxima linha de resultado; devolve true se há linha, false no fim.
    bool proxima_linha() { return sqlite3_step(stmt_) == SQLITE_ROW; }

    // Extrai valores da linha atual, por índice de coluna.
    int64_t coluna_int(int i) { return sqlite3_column_int64(stmt_, i); }
    std::string coluna_texto(int i) {
        return reinterpret_cast<const char*>(sqlite3_column_text(stmt_, i));
    }
private:
    sqlite3_stmt* stmt_ = nullptr;
};

Uso: Consulta c(banco, "SELECT nome, idade FROM usuarios"); while (c.proxima_linha()) { std::cout << c.coluna_texto(0) << " " << c.coluna_int(1) << "\n"; }. A classe prepara o statement no construtor, finaliza no destrutor (RAII), e oferece proxima_linha (que envolve sqlite3_step) para iterar e coluna_* para extrair valores — envolvendo a API C de leitura de resultados em código C++ seguro.

Exercício 5

A aula alerta sobre injeção de SQL ao concatenar strings. Explique o que é essa vulnerabilidade com um exemplo concreto (um valor malicioso), e descreva como prepared statements com parâmetros ligados a previnem.

Ver resposta

✓ Resposta: A injeção de SQL é uma vulnerabilidade em que dados de entrada, ao serem concatenados diretamente num comando SQL, conseguem alterar a estrutura do comando em vez de serem tratados como meros valores. Exemplo concreto: suponha que você monte uma consulta concatenando o nome digitado por um usuário: "SELECT * FROM usuarios WHERE nome = '" + nome + "'". Se um usuário mal-intencionado digitar como nome o valor ' OR '1'='1, o comando resultante vira SELECT * FROM usuarios WHERE nome = '' OR '1'='1' — cuja condição é sempre verdadeira, retornando todos os usuários, não o pretendido. Pior, um valor como '; DROP TABLE usuarios; -- poderia apagar a tabela. O dado deixou de ser um valor e virou comando. Os prepared statements com parâmetros ligados previnem isso separando o comando dos dados: você escreve o SQL com marcadores (SELECT * FROM usuarios WHERE nome = ?), prepara-o, e então liga o valor ao marcador com uma função dedicada (sqlite3_bind_text), que trata o valor como dado puro, jamais interpretado como parte da estrutura do comando. Não importa o que o usuário digite — aspas, OR, DROP —, tudo é tratado como o conteúdo literal do parâmetro, nunca como SQL executável. É por isso que prepared statements com binding são obrigatórios ao lidar com entrada não confiável: eles tornam a injeção estruturalmente impossível.

Comentários

Mais em Linguagem C++

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong
Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Pong

Detectar que dois retângulos se tocam são quatro comparações; responder à…

O Fio que Une Tudo — Iteradores como Conceito Central
O Fio que Une Tudo — Iteradores como Conceito Central

Um ponteiro generalizado: é isso que o iterador é, e é o que permite escrever…

O Mapa da Jornada: de C a C++ em 52 Passos
O Mapa da Jornada: de C a C++ em 52 Passos

Quem vem do C chega com vantagem e com risco: a fundação está pronta, mas o…