Rusqlite Rust SQLite: Guia Completo | Rust Brasil

Guia do Rusqlite em Rust: queries, prepared statements, transações e tipos customizados com ToSql/FromSql. SQLite em Rust.

Introdução

O Rusqlite é a biblioteca padrão para trabalhar com SQLite em Rust. Ele fornece bindings ergonômicas e seguras sobre a biblioteca C do SQLite, permitindo que você crie, consulte e manipule bancos de dados SQLite com a segurança e expressividade do Rust.

O SQLite é um banco de dados embutido (embedded) que armazena tudo em um único arquivo. Diferente de PostgreSQL ou MySQL, ele não requer um servidor separado, o que o torna perfeito para aplicações desktop, CLIs, dispositivos embarcados, protótipos rápidos e qualquer cenário onde um banco leve e portátil é suficiente.

Por que usar o Rusqlite?

  • Sem servidor: banco de dados em um único arquivo
  • Portátil: funciona em qualquer plataforma que rode Rust
  • Rápido: excelente performance para leituras e escritas locais
  • Simples: API direta e intuitiva
  • Full-text search: suporte a FTS5 para busca textual
  • JSON: extensão JSON1 para manipular dados JSON
  • Backup: API de backup incremental
  • Transações: suporte completo a ACID

Instalação

Adicione o Rusqlite ao seu Cargo.toml:

[dependencies]
rusqlite = { version = "0.32", features = ["bundled"] }

A feature bundled inclui e compila o SQLite junto com seu projeto, eliminando a necessidade de ter o SQLite instalado no sistema. Isso é recomendado para garantir portabilidade.

Features disponíveis:

[dependencies]
rusqlite = { version = "0.32", features = [
    "bundled",       # Incluir SQLite compilado
    "chrono",        # Suporte a tipos de data/hora do chrono
    "serde_json",    # Suporte a serde_json::Value
    "uuid",          # Suporte a uuid::Uuid
    "vtab",          # Virtual tables
    "backup",        # API de backup
    "blob",          # API de blob incremental
    "functions",     # Funções SQL customizadas
    "trace",         # Callbacks de trace/profiling
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Uso Básico

Abrindo e criando um banco de dados

use rusqlite::{Connection, Result};

fn main() -> Result<()> {
    // Abrir (ou criar) banco de dados em arquivo
    let conn = Connection::open("meu_banco.db")?;

    // Banco de dados em memória (perdido ao fechar)
    // let conn = Connection::open_in_memory()?;

    println!("Banco de dados aberto com sucesso!");
    println!("Versão SQLite: {}", rusqlite::version());

    Ok(())
}

Criando tabelas

use rusqlite::{Connection, Result};

fn criar_tabelas(conn: &Connection) -> Result<()> {
    conn.execute_batch(
        "
        CREATE TABLE IF NOT EXISTS usuarios (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            nome TEXT NOT NULL,
            email TEXT NOT NULL UNIQUE,
            idade INTEGER,
            ativo INTEGER NOT NULL DEFAULT 1,
            criado_em TEXT NOT NULL DEFAULT (datetime('now'))
        );

        CREATE TABLE IF NOT EXISTS tarefas (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            usuario_id INTEGER NOT NULL,
            titulo TEXT NOT NULL,
            descricao TEXT,
            concluida INTEGER NOT NULL DEFAULT 0,
            prioridade INTEGER NOT NULL DEFAULT 0,
            criado_em TEXT NOT NULL DEFAULT (datetime('now')),
            FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE
        );

        CREATE INDEX IF NOT EXISTS idx_tarefas_usuario ON tarefas(usuario_id);
        CREATE INDEX IF NOT EXISTS idx_usuarios_email ON usuarios(email);
        ",
    )?;

    println!("Tabelas criadas com sucesso!");
    Ok(())
}

fn main() -> Result<()> {
    let conn = Connection::open("app.db")?;

    // Habilitar WAL mode para melhor performance concorrente
    conn.pragma_update(None, "journal_mode", "WAL")?;

    // Habilitar foreign keys
    conn.pragma_update(None, "foreign_keys", "ON")?;

    criar_tabelas(&conn)?;
    Ok(())
}

Inserindo dados

use rusqlite::{Connection, Result, params};

fn inserir_usuario(conn: &Connection, nome: &str, email: &str, idade: Option<i32>) -> Result<i64> {
    conn.execute(
        "INSERT INTO usuarios (nome, email, idade) VALUES (?1, ?2, ?3)",
        params![nome, email, idade],
    )?;

    // Obter o ID gerado
    Ok(conn.last_insert_rowid())
}

fn inserir_tarefa(
    conn: &Connection,
    usuario_id: i64,
    titulo: &str,
    descricao: Option<&str>,
    prioridade: i32,
) -> Result<i64> {
    conn.execute(
        "INSERT INTO tarefas (usuario_id, titulo, descricao, prioridade) VALUES (?1, ?2, ?3, ?4)",
        params![usuario_id, titulo, descricao, prioridade],
    )?;

    Ok(conn.last_insert_rowid())
}

fn main() -> Result<()> {
    let conn = Connection::open("app.db")?;

    let id = inserir_usuario(&conn, "Maria Silva", "maria@email.com", Some(28))?;
    println!("Usuário criado com ID: {}", id);

    let tarefa_id = inserir_tarefa(&conn, id, "Aprender Rust", Some("Estudar ownership"), 2)?;
    println!("Tarefa criada com ID: {}", tarefa_id);

    Ok(())
}

Consultando dados

use rusqlite::{Connection, Result, params};

#[derive(Debug)]
struct Usuario {
    id: i64,
    nome: String,
    email: String,
    idade: Option<i32>,
    ativo: bool,
}

fn obter_usuario(conn: &Connection, id: i64) -> Result<Option<Usuario>> {
    let mut stmt = conn.prepare(
        "SELECT id, nome, email, idade, ativo FROM usuarios WHERE id = ?1"
    )?;

    let usuario = stmt.query_row(params![id], |row| {
        Ok(Usuario {
            id: row.get(0)?,
            nome: row.get(1)?,
            email: row.get(2)?,
            idade: row.get(3)?,
            ativo: row.get::<_, i32>(4)? != 0,
        })
    }).optional()?;

    Ok(usuario)
}

fn listar_usuarios(conn: &Connection) -> Result<Vec<Usuario>> {
    let mut stmt = conn.prepare(
        "SELECT id, nome, email, idade, ativo FROM usuarios ORDER BY nome"
    )?;

    let usuarios = stmt.query_map([], |row| {
        Ok(Usuario {
            id: row.get(0)?,
            nome: row.get(1)?,
            email: row.get(2)?,
            idade: row.get(3)?,
            ativo: row.get::<_, i32>(4)? != 0,
        })
    })?;

    let mut resultado = Vec::new();
    for usuario in usuarios {
        resultado.push(usuario?);
    }

    Ok(resultado)
}

fn buscar_usuarios(conn: &Connection, termo: &str) -> Result<Vec<Usuario>> {
    let mut stmt = conn.prepare(
        "SELECT id, nome, email, idade, ativo FROM usuarios WHERE nome LIKE ?1 OR email LIKE ?1"
    )?;

    let padrao = format!("%{}%", termo);
    let usuarios = stmt.query_map(params![padrao], |row| {
        Ok(Usuario {
            id: row.get(0)?,
            nome: row.get(1)?,
            email: row.get(2)?,
            idade: row.get(3)?,
            ativo: row.get::<_, i32>(4)? != 0,
        })
    })?;

    usuarios.collect()
}

Atualizando e deletando

use rusqlite::{Connection, Result, params};

fn atualizar_usuario(
    conn: &Connection,
    id: i64,
    nome: &str,
    email: &str,
) -> Result<bool> {
    let linhas = conn.execute(
        "UPDATE usuarios SET nome = ?1, email = ?2 WHERE id = ?3",
        params![nome, email, id],
    )?;

    Ok(linhas > 0)
}

fn desativar_usuario(conn: &Connection, id: i64) -> Result<bool> {
    let linhas = conn.execute(
        "UPDATE usuarios SET ativo = 0 WHERE id = ?1",
        params![id],
    )?;

    Ok(linhas > 0)
}

fn deletar_usuario(conn: &Connection, id: i64) -> Result<bool> {
    let linhas = conn.execute(
        "DELETE FROM usuarios WHERE id = ?1",
        params![id],
    )?;

    Ok(linhas > 0)
}

fn concluir_tarefa(conn: &Connection, tarefa_id: i64) -> Result<bool> {
    let linhas = conn.execute(
        "UPDATE tarefas SET concluida = 1 WHERE id = ?1",
        params![tarefa_id],
    )?;

    Ok(linhas > 0)
}

Recursos Avançados

Prepared Statements reutilizáveis

Para queries executadas repetidamente, use prepared statements:

use rusqlite::{Connection, Result, params};

fn inserir_muitos_usuarios(conn: &Connection, usuarios: &[(&str, &str, Option<i32>)]) -> Result<()> {
    let mut stmt = conn.prepare(
        "INSERT INTO usuarios (nome, email, idade) VALUES (?1, ?2, ?3)"
    )?;

    for (nome, email, idade) in usuarios {
        stmt.execute(params![nome, email, idade])?;
    }

    println!("{} usuários inseridos", usuarios.len());
    Ok(())
}

fn main() -> Result<()> {
    let conn = Connection::open("app.db")?;

    let usuarios = vec![
        ("Ana Costa", "ana@email.com", Some(25)),
        ("Bruno Lima", "bruno@email.com", Some(30)),
        ("Carla Souza", "carla@email.com", None),
        ("Diego Santos", "diego@email.com", Some(22)),
    ];

    inserir_muitos_usuarios(&conn, &usuarios)?;
    Ok(())
}

Transações

use rusqlite::{Connection, Result, params, Transaction};

fn transferir_tarefas(
    conn: &mut Connection,
    de_usuario: i64,
    para_usuario: i64,
) -> Result<u64> {
    let tx = conn.transaction()?;

    // Verificar se ambos os usuários existem
    let de_existe: bool = tx.query_row(
        "SELECT COUNT(*) > 0 FROM usuarios WHERE id = ?1",
        params![de_usuario],
        |row| row.get(0),
    )?;

    let para_existe: bool = tx.query_row(
        "SELECT COUNT(*) > 0 FROM usuarios WHERE id = ?1",
        params![para_usuario],
        |row| row.get(0),
    )?;

    if !de_existe || !para_existe {
        // Rollback automático quando tx é dropado sem commit
        return Ok(0);
    }

    let linhas = tx.execute(
        "UPDATE tarefas SET usuario_id = ?1 WHERE usuario_id = ?2 AND concluida = 0",
        params![para_usuario, de_usuario],
    )?;

    // Registrar a transferência
    tx.execute(
        "INSERT INTO log_transferencias (de_usuario, para_usuario, total_tarefas, data)
         VALUES (?1, ?2, ?3, datetime('now'))",
        params![de_usuario, para_usuario, linhas],
    )?;

    tx.commit()?;

    println!("{} tarefas transferidas", linhas);
    Ok(linhas as u64)
}

// Transação com savepoints aninhados
fn operacao_complexa(conn: &mut Connection) -> Result<()> {
    let tx = conn.transaction()?;

    tx.execute("INSERT INTO usuarios (nome, email) VALUES ('Teste 1', 'test1@email.com')", [])?;

    // Savepoint dentro da transação
    let sp = tx.savepoint()?;
    sp.execute("INSERT INTO usuarios (nome, email) VALUES ('Teste 2', 'test2@email.com')", [])?;

    // Podemos fazer rollback apenas do savepoint
    // sp.rollback()?; // Desfaz apenas "Teste 2"
    sp.commit()?; // Ou confirmar o savepoint

    tx.commit()?; // Confirmar toda a transação
    Ok(())
}

Tipos customizados com ToSql e FromSql

use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
use rusqlite::{Connection, Result, params};

// Enum customizado
#[derive(Debug, Clone, PartialEq)]
enum Prioridade {
    Baixa,
    Media,
    Alta,
    Critica,
}

impl ToSql for Prioridade {
    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
        let valor = match self {
            Prioridade::Baixa => 0,
            Prioridade::Media => 1,
            Prioridade::Alta => 2,
            Prioridade::Critica => 3,
        };
        Ok(ToSqlOutput::from(valor))
    }
}

impl FromSql for Prioridade {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        match value.as_i64()? {
            0 => Ok(Prioridade::Baixa),
            1 => Ok(Prioridade::Media),
            2 => Ok(Prioridade::Alta),
            3 => Ok(Prioridade::Critica),
            n => Err(FromSqlError::OutOfRange(n)),
        }
    }
}

// Struct como JSON
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Configuracao {
    tema: String,
    idioma: String,
    notificacoes: bool,
}

impl ToSql for Configuracao {
    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
        let json = serde_json::to_string(self)
            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
        Ok(ToSqlOutput::from(json))
    }
}

impl FromSql for Configuracao {
    fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
        let texto = value.as_str()?;
        serde_json::from_str(texto)
            .map_err(|e| FromSqlError::Other(Box::new(e)))
    }
}

// Uso
fn exemplo_tipos_customizados(conn: &Connection) -> Result<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS config_usuarios (
            usuario_id INTEGER PRIMARY KEY,
            prioridade_padrao INTEGER NOT NULL DEFAULT 0,
            configuracao TEXT NOT NULL
        )",
        [],
    )?;

    let config = Configuracao {
        tema: "escuro".to_string(),
        idioma: "pt-BR".to_string(),
        notificacoes: true,
    };

    conn.execute(
        "INSERT INTO config_usuarios (usuario_id, prioridade_padrao, configuracao) VALUES (?1, ?2, ?3)",
        params![1, Prioridade::Alta, config],
    )?;

    // Ler de volta
    let mut stmt = conn.prepare(
        "SELECT prioridade_padrao, configuracao FROM config_usuarios WHERE usuario_id = ?1"
    )?;

    let (prioridade, config): (Prioridade, Configuracao) = stmt.query_row(params![1], |row| {
        Ok((row.get(0)?, row.get(1)?))
    })?;

    println!("Prioridade: {:?}", prioridade);
    println!("Config: {:?}", config);

    Ok(())
}

Funções SQL customizadas

use rusqlite::{Connection, Result, functions::FunctionFlags};

fn registrar_funcoes(conn: &Connection) -> Result<()> {
    // Função para calcular distância entre coordenadas (Haversine simplificado)
    conn.create_scalar_function(
        "distancia_km",
        4,
        FunctionFlags::SQLITE_DETERMINISTIC,
        |ctx| {
            let lat1: f64 = ctx.get(0)?;
            let lon1: f64 = ctx.get(1)?;
            let lat2: f64 = ctx.get(2)?;
            let lon2: f64 = ctx.get(3)?;

            let r = 6371.0; // Raio da Terra em km
            let dlat = (lat2 - lat1).to_radians();
            let dlon = (lon2 - lon1).to_radians();

            let a = (dlat / 2.0).sin().powi(2)
                + lat1.to_radians().cos()
                    * lat2.to_radians().cos()
                    * (dlon / 2.0).sin().powi(2);

            let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());

            Ok(r * c)
        },
    )?;

    // Função para capitalizar primeira letra
    conn.create_scalar_function(
        "capitalizar",
        1,
        FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_UTF8,
        |ctx| {
            let texto: String = ctx.get(0)?;
            let capitalizado = texto
                .split_whitespace()
                .map(|palavra| {
                    let mut chars = palavra.chars();
                    match chars.next() {
                        None => String::new(),
                        Some(c) => {
                            c.to_uppercase().to_string() + &chars.as_str().to_lowercase()
                        }
                    }
                })
                .collect::<Vec<_>>()
                .join(" ");

            Ok(capitalizado)
        },
    )?;

    Ok(())
}

fn usar_funcoes(conn: &Connection) -> Result<()> {
    registrar_funcoes(conn)?;

    // Usar a função de distância
    let distancia: f64 = conn.query_row(
        "SELECT distancia_km(-23.5505, -46.6333, -22.9068, -43.1729)",
        [],
        |row| row.get(0),
    )?;
    println!("São Paulo -> Rio: {:.1} km", distancia);

    // Usar a função de capitalizar
    let nome: String = conn.query_row(
        "SELECT capitalizar('maria da silva')",
        [],
        |row| row.get(0),
    )?;
    println!("Capitalizado: {}", nome); // "Maria Da Silva"

    Ok(())
}

Backup do banco de dados

use rusqlite::{Connection, Result, backup};
use std::time::Duration;

fn fazer_backup(origem: &Connection, caminho_destino: &str) -> Result<()> {
    let mut destino = Connection::open(caminho_destino)?;

    let backup = backup::Backup::new(origem, &mut destino)?;

    // Backup com progresso
    let mut restante = -1;
    backup.run_to_completion(100, Duration::from_millis(10), Some(|progress| {
        let novo_restante = progress.remaining;
        if novo_restante != restante {
            restante = novo_restante;
            let total = progress.pagecount;
            let feito = total - restante;
            let pct = if total > 0 { (feito as f64 / total as f64) * 100.0 } else { 0.0 };
            println!("Backup: {:.1}% ({}/{})", pct, feito, total);
        }
    }))?;

    println!("Backup concluído: {}", caminho_destino);
    Ok(())
}

Configuração de performance

use rusqlite::{Connection, Result};

fn configurar_performance(conn: &Connection) -> Result<()> {
    // WAL mode: permite leituras concorrentes durante escritas
    conn.pragma_update(None, "journal_mode", "WAL")?;

    // Sincronização normal (bom equilíbrio entre segurança e performance)
    conn.pragma_update(None, "synchronous", "NORMAL")?;

    // Cache de 64MB em memória
    conn.pragma_update(None, "cache_size", -64000)?;

    // Habilitar memory-mapped I/O (256MB)
    conn.pragma_update(None, "mmap_size", 268435456)?;

    // Habilitar foreign keys
    conn.pragma_update(None, "foreign_keys", "ON")?;

    // Timeout para locks (5 segundos)
    conn.busy_timeout(std::time::Duration::from_secs(5))?;

    Ok(())
}

Boas Práticas

1. Use WAL mode para concorrência

fn abrir_banco_otimizado(caminho: &str) -> rusqlite::Result<Connection> {
    let conn = Connection::open(caminho)?;

    conn.pragma_update(None, "journal_mode", "WAL")?;
    conn.pragma_update(None, "synchronous", "NORMAL")?;
    conn.pragma_update(None, "foreign_keys", "ON")?;
    conn.busy_timeout(std::time::Duration::from_secs(5))?;

    Ok(conn)
}

2. Sempre use parâmetros, nunca concatene strings

use rusqlite::{Connection, Result, params};

// ERRADO: vulnerável a SQL injection!
fn buscar_errado(conn: &Connection, nome: &str) -> Result<()> {
    // NUNCA faça isso:
    // let sql = format!("SELECT * FROM usuarios WHERE nome = '{}'", nome);
    // conn.execute(&sql, [])?;
    Ok(())
}

// CORRETO: use parâmetros
fn buscar_correto(conn: &Connection, nome: &str) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT email FROM usuarios WHERE nome = ?1"
    )?;

    let emails = stmt.query_map(params![nome], |row| row.get(0))?;
    emails.collect()
}

3. Use transações para operações em batch

use rusqlite::{Connection, Result, params};

fn inserir_em_batch(conn: &mut Connection, dados: &[(String, String)]) -> Result<()> {
    // Sem transação: cada INSERT é uma transação separada (LENTO)
    // Com transação: todos os INSERTs em uma única transação (RÁPIDO)

    let tx = conn.transaction()?;

    {
        let mut stmt = tx.prepare(
            "INSERT INTO usuarios (nome, email) VALUES (?1, ?2)"
        )?;

        for (nome, email) in dados {
            stmt.execute(params![nome, email])?;
        }
    }

    tx.commit()?;
    Ok(())
}

4. Encapsule o acesso ao banco

use rusqlite::{Connection, Result};
use std::path::Path;

pub struct Database {
    conn: Connection,
}

impl Database {
    pub fn abrir(caminho: impl AsRef<Path>) -> Result<Self> {
        let conn = Connection::open(caminho)?;

        conn.pragma_update(None, "journal_mode", "WAL")?;
        conn.pragma_update(None, "foreign_keys", "ON")?;
        conn.busy_timeout(std::time::Duration::from_secs(5))?;

        let db = Self { conn };
        db.criar_tabelas()?;

        Ok(db)
    }

    pub fn em_memoria() -> Result<Self> {
        let conn = Connection::open_in_memory()?;
        let db = Self { conn };
        db.criar_tabelas()?;
        Ok(db)
    }

    fn criar_tabelas(&self) -> Result<()> {
        self.conn.execute_batch(
            "CREATE TABLE IF NOT EXISTS usuarios (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nome TEXT NOT NULL,
                email TEXT NOT NULL UNIQUE
            );"
        )?;
        Ok(())
    }

    pub fn conn(&self) -> &Connection {
        &self.conn
    }
}

5. Trate erros de forma específica

use rusqlite::{Error as SqliteError, ErrorCode};

fn tratar_erro(err: SqliteError) -> String {
    match err {
        SqliteError::SqliteFailure(ffi_err, msg) => {
            match ffi_err.code {
                ErrorCode::ConstraintViolation => {
                    format!("Violação de constraint: {}", msg.unwrap_or_default())
                }
                ErrorCode::DatabaseBusy => {
                    "Banco de dados ocupado. Tente novamente.".to_string()
                }
                ErrorCode::DatabaseLocked => {
                    "Banco de dados bloqueado.".to_string()
                }
                _ => format!("Erro SQLite: {:?} - {}", ffi_err.code, msg.unwrap_or_default()),
            }
        }
        SqliteError::QueryReturnedNoRows => {
            "Registro não encontrado".to_string()
        }
        SqliteError::InvalidColumnType(idx, nome, tipo) => {
            format!("Tipo inválido na coluna {} ({}): {:?}", idx, nome, tipo)
        }
        _ => format!("Erro: {}", err),
    }
}

Exemplos Práticos

Aplicação de armazenamento de dados local

use rusqlite::{Connection, Result, params};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

// === Modelos ===

#[derive(Debug, Clone, Serialize, Deserialize)]
struct Nota {
    id: i64,
    titulo: String,
    conteudo: String,
    tags: Vec<String>,
    favorita: bool,
    criado_em: String,
    atualizado_em: String,
}

#[derive(Debug)]
struct NovaNota {
    titulo: String,
    conteudo: String,
    tags: Vec<String>,
}

// === Banco de Dados ===

struct NotasDB {
    conn: Connection,
}

impl NotasDB {
    fn abrir() -> Result<Self> {
        let caminho = Self::caminho_banco();
        let conn = Connection::open(&caminho)?;

        conn.pragma_update(None, "journal_mode", "WAL")?;
        conn.pragma_update(None, "foreign_keys", "ON")?;

        let db = Self { conn };
        db.inicializar()?;

        println!("Banco aberto: {}", caminho.display());
        Ok(db)
    }

    fn caminho_banco() -> PathBuf {
        let mut caminho = dirs::data_local_dir()
            .unwrap_or_else(|| PathBuf::from("."));
        caminho.push("minhas-notas");
        std::fs::create_dir_all(&caminho).ok();
        caminho.push("notas.db");
        caminho
    }

    fn inicializar(&self) -> Result<()> {
        self.conn.execute_batch(
            "
            CREATE TABLE IF NOT EXISTS notas (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                titulo TEXT NOT NULL,
                conteudo TEXT NOT NULL,
                favorita INTEGER NOT NULL DEFAULT 0,
                criado_em TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
                atualizado_em TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
            );

            CREATE TABLE IF NOT EXISTS tags (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                nome TEXT NOT NULL UNIQUE
            );

            CREATE TABLE IF NOT EXISTS nota_tags (
                nota_id INTEGER NOT NULL,
                tag_id INTEGER NOT NULL,
                PRIMARY KEY (nota_id, tag_id),
                FOREIGN KEY (nota_id) REFERENCES notas(id) ON DELETE CASCADE,
                FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
            );

            CREATE INDEX IF NOT EXISTS idx_notas_titulo ON notas(titulo);
            CREATE INDEX IF NOT EXISTS idx_notas_favorita ON notas(favorita);

            -- Full-text search
            CREATE VIRTUAL TABLE IF NOT EXISTS notas_fts USING fts5(
                titulo, conteudo, content='notas', content_rowid='id'
            );

            -- Triggers para manter FTS sincronizado
            CREATE TRIGGER IF NOT EXISTS notas_ai AFTER INSERT ON notas BEGIN
                INSERT INTO notas_fts(rowid, titulo, conteudo)
                VALUES (new.id, new.titulo, new.conteudo);
            END;

            CREATE TRIGGER IF NOT EXISTS notas_ad AFTER DELETE ON notas BEGIN
                INSERT INTO notas_fts(notas_fts, rowid, titulo, conteudo)
                VALUES ('delete', old.id, old.titulo, old.conteudo);
            END;

            CREATE TRIGGER IF NOT EXISTS notas_au AFTER UPDATE ON notas BEGIN
                INSERT INTO notas_fts(notas_fts, rowid, titulo, conteudo)
                VALUES ('delete', old.id, old.titulo, old.conteudo);
                INSERT INTO notas_fts(rowid, titulo, conteudo)
                VALUES (new.id, new.titulo, new.conteudo);
            END;
            "
        )?;
        Ok(())
    }

    fn criar(&self, nova: &NovaNota) -> Result<Nota> {
        let tx = self.conn.unchecked_transaction()?;

        tx.execute(
            "INSERT INTO notas (titulo, conteudo) VALUES (?1, ?2)",
            params![nova.titulo, nova.conteudo],
        )?;

        let nota_id = tx.last_insert_rowid();

        // Inserir tags
        for tag_nome in &nova.tags {
            tx.execute(
                "INSERT OR IGNORE INTO tags (nome) VALUES (?1)",
                params![tag_nome],
            )?;

            tx.execute(
                "INSERT OR IGNORE INTO nota_tags (nota_id, tag_id)
                 SELECT ?1, id FROM tags WHERE nome = ?2",
                params![nota_id, tag_nome],
            )?;
        }

        tx.commit()?;
        self.obter(nota_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
    }

    fn obter(&self, id: i64) -> Result<Option<Nota>> {
        let mut stmt = self.conn.prepare(
            "SELECT id, titulo, conteudo, favorita, criado_em, atualizado_em
             FROM notas WHERE id = ?1"
        )?;

        let nota = stmt.query_row(params![id], |row| {
            Ok(Nota {
                id: row.get(0)?,
                titulo: row.get(1)?,
                conteudo: row.get(2)?,
                tags: Vec::new(),
                favorita: row.get::<_, i32>(3)? != 0,
                criado_em: row.get(4)?,
                atualizado_em: row.get(5)?,
            })
        }).optional()?;

        // Carregar tags
        if let Some(mut nota) = nota {
            nota.tags = self.tags_da_nota(nota.id)?;
            Ok(Some(nota))
        } else {
            Ok(None)
        }
    }

    fn tags_da_nota(&self, nota_id: i64) -> Result<Vec<String>> {
        let mut stmt = self.conn.prepare(
            "SELECT t.nome FROM tags t
             INNER JOIN nota_tags nt ON nt.tag_id = t.id
             WHERE nt.nota_id = ?1
             ORDER BY t.nome"
        )?;

        let tags = stmt.query_map(params![nota_id], |row| row.get(0))?;
        tags.collect()
    }

    fn listar(&self, limite: i64, offset: i64) -> Result<Vec<Nota>> {
        let mut stmt = self.conn.prepare(
            "SELECT id, titulo, conteudo, favorita, criado_em, atualizado_em
             FROM notas ORDER BY atualizado_em DESC LIMIT ?1 OFFSET ?2"
        )?;

        let notas = stmt.query_map(params![limite, offset], |row| {
            Ok(Nota {
                id: row.get(0)?,
                titulo: row.get(1)?,
                conteudo: row.get(2)?,
                tags: Vec::new(),
                favorita: row.get::<_, i32>(3)? != 0,
                criado_em: row.get(4)?,
                atualizado_em: row.get(5)?,
            })
        })?;

        let mut resultado = Vec::new();
        for nota in notas {
            let mut nota = nota?;
            nota.tags = self.tags_da_nota(nota.id)?;
            resultado.push(nota);
        }

        Ok(resultado)
    }

    fn buscar_texto(&self, termo: &str) -> Result<Vec<Nota>> {
        let mut stmt = self.conn.prepare(
            "SELECT n.id, n.titulo, n.conteudo, n.favorita, n.criado_em, n.atualizado_em
             FROM notas_fts f
             INNER JOIN notas n ON n.id = f.rowid
             WHERE notas_fts MATCH ?1
             ORDER BY rank"
        )?;

        let notas = stmt.query_map(params![termo], |row| {
            Ok(Nota {
                id: row.get(0)?,
                titulo: row.get(1)?,
                conteudo: row.get(2)?,
                tags: Vec::new(),
                favorita: row.get::<_, i32>(3)? != 0,
                criado_em: row.get(4)?,
                atualizado_em: row.get(5)?,
            })
        })?;

        let mut resultado = Vec::new();
        for nota in notas {
            let mut nota = nota?;
            nota.tags = self.tags_da_nota(nota.id)?;
            resultado.push(nota);
        }

        Ok(resultado)
    }

    fn favoritar(&self, id: i64, favorita: bool) -> Result<bool> {
        let linhas = self.conn.execute(
            "UPDATE notas SET favorita = ?1, atualizado_em = datetime('now', 'localtime')
             WHERE id = ?2",
            params![favorita as i32, id],
        )?;
        Ok(linhas > 0)
    }

    fn deletar(&self, id: i64) -> Result<bool> {
        let linhas = self.conn.execute(
            "DELETE FROM notas WHERE id = ?1",
            params![id],
        )?;
        Ok(linhas > 0)
    }

    fn estatisticas(&self) -> Result<(i64, i64, i64)> {
        let total: i64 = self.conn.query_row(
            "SELECT COUNT(*) FROM notas", [], |row| row.get(0),
        )?;
        let favoritas: i64 = self.conn.query_row(
            "SELECT COUNT(*) FROM notas WHERE favorita = 1", [], |row| row.get(0),
        )?;
        let total_tags: i64 = self.conn.query_row(
            "SELECT COUNT(*) FROM tags", [], |row| row.get(0),
        )?;
        Ok((total, favoritas, total_tags))
    }
}

fn main() -> Result<()> {
    let db = NotasDB::abrir()?;

    // Criar notas
    let nota1 = db.criar(&NovaNota {
        titulo: "Aprendendo Rust".to_string(),
        conteudo: "Rust é uma linguagem de programação focada em segurança e performance.".to_string(),
        tags: vec!["rust".to_string(), "programação".to_string()],
    })?;
    println!("Nota criada: {} (ID: {})", nota1.titulo, nota1.id);

    let nota2 = db.criar(&NovaNota {
        titulo: "Receita de bolo".to_string(),
        conteudo: "Ingredientes: farinha, ovos, açúcar, leite e fermento.".to_string(),
        tags: vec!["receita".to_string(), "culinária".to_string()],
    })?;
    println!("Nota criada: {} (ID: {})", nota2.titulo, nota2.id);

    // Favoritar
    db.favoritar(nota1.id, true)?;

    // Listar
    let notas = db.listar(10, 0)?;
    println!("\nTodas as notas:");
    for nota in &notas {
        let estrela = if nota.favorita { " *" } else { "" };
        println!("  [{}]{} {} (tags: {:?})", nota.id, estrela, nota.titulo, nota.tags);
    }

    // Busca full-text
    let resultados = db.buscar_texto("Rust")?;
    println!("\nBusca por 'Rust': {} resultados", resultados.len());

    // Estatísticas
    let (total, favoritas, tags) = db.estatisticas()?;
    println!("\nEstatísticas: {} notas, {} favoritas, {} tags", total, favoritas, tags);

    Ok(())
}

Comparação com Alternativas

CaracterísticaRusqliteSQLx (SQLite)Diesel (SQLite)sled
TipoBindings SQLiteAsync SQLORMEmbedded DB
AsyncNãoSimNãoNão
SQLSim (puro)Sim (verificado)DSL tipadaNão (key-value)
Compile-time checkNãoSim (query!())Sim (DSL)N/A
Full-text searchSim (FTS5)Sim (FTS5)LimitadoNão
FacilidadeMuito fácilMédiaMédia-altaFácil
PerformanceExcelenteBoaBoaExcelente
  • Rusqlite vs SQLx (SQLite): Use Rusqlite para apps simples e síncronos. Use SQLx se precisa de async ou quer verificação em compile-time.
  • Rusqlite vs Diesel (SQLite): Diesel é um ORM completo com DSL tipada. Rusqlite é mais simples e direto para quem prefere SQL puro.
  • Rusqlite vs sled: sled é um banco key-value embutido, sem SQL. Use sled para armazenamento simples de chave-valor. Use Rusqlite quando precisa de queries SQL.

Conclusão

O Rusqlite é a forma mais direta e ergonômica de usar SQLite em Rust. Sua API simples, combinada com o poder do SQLite (FTS5, JSON, WAL mode, funções customizadas), o torna perfeito para uma ampla gama de aplicações: CLIs que precisam persistir dados, apps desktop, cache local, protótipos rápidos e dispositivos embarcados.

Para projetos que não precisam de um banco de dados cliente-servidor, o Rusqlite com SQLite é frequentemente a melhor escolha: sem servidores para gerenciar, sem configuração complexa, e com uma performance impressionante para a maioria dos workloads.

Próximos passos

  • Explore o SQLx para acesso assíncrono a SQLite
  • Use Clap para construir CLIs que usam Rusqlite para persistência
  • Aprenda sobre FTS5 para busca textual avançada
  • Configure serde para serializar e deserializar dados entre Rusqlite e JSON