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 ¬as {
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ística | Rusqlite | SQLx (SQLite) | Diesel (SQLite) | sled |
|---|---|---|---|---|
| Tipo | Bindings SQLite | Async SQL | ORM | Embedded DB |
| Async | Não | Sim | Não | Não |
| SQL | Sim (puro) | Sim (verificado) | DSL tipada | Não (key-value) |
| Compile-time check | Não | Sim (query!()) | Sim (DSL) | N/A |
| Full-text search | Sim (FTS5) | Sim (FTS5) | Limitado | Não |
| Facilidade | Muito fácil | Média | Média-alta | Fácil |
| Performance | Excelente | Boa | Boa | Excelente |
- 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