No artigo anterior construímos uma API REST completa — mas com um problema crítico: os dados vivem apenas na memória. Reinicie o servidor e tudo se perde. Hoje vamos resolver isso com SQLx, a biblioteca de banco de dados mais idiomática do ecossistema Rust.
O que torna SQLx especial é sua proposta central: queries verificadas em tempo de compilação. O compilador se conecta ao banco de dados durante a compilação e verifica se suas queries SQL são válidas, se os tipos batem, se as colunas existem. Erros de SQL viram erros de compilação — não surpresas em produção.
Por que SQLx?
O ecossistema Rust tem várias opções para banco de dados:
Diesel — ORM completo, verificação em tempo de compilação, mas schema rígido e curva de aprendizado íngreme.
SeaORM — ORM assíncrono, mais flexível que Diesel, mais próximo de ORMs tradicionais.
SQLx — não é um ORM. É um driver SQL assíncrono com verificação de tipos. Você escreve SQL real, e o compilador verifica se está correto. Ideal para quem quer controle total sobre as queries sem abrir mão da segurança.
Para a maioria dos projetos, SQLx é o ponto ideal entre controle e segurança.
Configurando o projeto
Vamos evoluir a API do artigo anterior adicionando persistência com SQLite (simples de configurar) — os mesmos conceitos se aplicam a PostgreSQL e MySQL.
Cargo.toml:
[package]
name = "api_tarefas"
version = "0.2.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
sqlx = { version = "0.7", features = [
"runtime-tokio",
"sqlite",
"uuid",
"chrono",
"macros",
] }
dotenvy = "0.15"
Instale a CLI do SQLx para gerenciar migrations:
cargo install sqlx-cli --no-default-features --features sqlite
Migrations — versionando o schema
Migrations são arquivos SQL que descrevem evolução do banco de dados. SQLx gerencia isso automaticamente:
# Criar o diretório de migrations e o arquivo do banco
sqlx database create
sqlx migrate add criar_tarefas
Isso cria migrations/TIMESTAMP_criar_tarefas.sql:
migrations/20240301000000_criar_tarefas.sql:
-- Habilitar suporte a UUID no SQLite
-- (SQLite armazena UUID como TEXT)
CREATE TABLE IF NOT EXISTS tarefas (
id TEXT PRIMARY KEY NOT NULL,
titulo TEXT NOT NULL,
descricao TEXT,
concluida BOOLEAN NOT NULL DEFAULT FALSE,
prioridade TEXT NOT NULL DEFAULT 'Media',
criada_em TEXT NOT NULL,
atualizada_em TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tarefas_concluida
ON tarefas(concluida);
CREATE INDEX IF NOT EXISTS idx_tarefas_prioridade
ON tarefas(prioridade);
CREATE INDEX IF NOT EXISTS idx_tarefas_criada_em
ON tarefas(criada_em DESC);
Execute a migration:
sqlx migrate run
Configuração do ambiente
.env:
DATABASE_URL=sqlite:tarefas.db
RUST_LOG=api_tarefas=debug,sqlx=warn,tower_http=info
A camada de repositório
Vamos criar uma camada de repositório que encapsula toda a lógica de acesso ao banco:
src/repositorio.rs:
use chrono::{DateTime, Utc};
use sqlx::SqlitePool;
use uuid::Uuid;
use crate::{
erro::{ErroApi, ResultadoApi},
modelo::{AtualizarTarefa, FiltroTarefas, Prioridade, Tarefa},
};
// Struct intermediária para leitura do banco
// (SQLx mapeia para essa, depois convertemos para Tarefa)
#[derive(Debug, sqlx::FromRow)]
struct TarefaRow {
id: String,
titulo: String,
descricao: Option<String>,
concluida: bool,
prioridade: String,
criada_em: String,
atualizada_em: String,
}
impl TryFrom<TarefaRow> for Tarefa {
type Error = ErroApi;
fn try_from(row: TarefaRow) -> Result<Self, Self::Error> {
let id = Uuid::parse_str(&row.id)
.map_err(|_| ErroApi::ErroInterno)?;
let prioridade = match row.prioridade.as_str() {
"Alta" => Prioridade::Alta,
"Baixa" => Prioridade::Baixa,
_ => Prioridade::Media,
};
let criada_em = DateTime::parse_from_rfc3339(&row.criada_em)
.map_err(|_| ErroApi::ErroInterno)?
.with_timezone(&Utc);
let atualizada_em = DateTime::parse_from_rfc3339(&row.atualizada_em)
.map_err(|_| ErroApi::ErroInterno)?
.with_timezone(&Utc);
Ok(Tarefa {
id,
titulo: row.titulo,
descricao: row.descricao,
concluida: row.concluida,
prioridade,
criada_em,
atualizada_em,
})
}
}
fn prioridade_para_str(p: &Prioridade) -> &'static str {
match p {
Prioridade::Alta => "Alta",
Prioridade::Media => "Media",
Prioridade::Baixa => "Baixa",
}
}
pub struct RepositorioTarefas {
pool: SqlitePool,
}
impl RepositorioTarefas {
pub fn novo(pool: SqlitePool) -> Self {
RepositorioTarefas { pool }
}
pub async fn listar(&self, filtro: &FiltroTarefas) -> ResultadoApi<(Vec<Tarefa>, i64)> {
// Contagem total (para paginação)
let total: i64 = match (&filtro.concluida, &filtro.prioridade) {
(Some(c), Some(p)) => {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM tarefas WHERE concluida = ? AND prioridade = ?",
c,
prioridade_para_str(p)
)
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(Some(c), None) => {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM tarefas WHERE concluida = ?",
c
)
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(None, Some(p)) => {
sqlx::query_scalar!(
"SELECT COUNT(*) FROM tarefas WHERE prioridade = ?",
prioridade_para_str(p)
)
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(None, None) => {
sqlx::query_scalar!("SELECT COUNT(*) FROM tarefas")
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
};
let offset = ((filtro.pagina.saturating_sub(1)) * filtro.por_pagina) as i64;
let limite = filtro.por_pagina as i64;
// Busca paginada
let rows: Vec<TarefaRow> = match (&filtro.concluida, &filtro.prioridade) {
(Some(c), Some(p)) => {
sqlx::query_as!(
TarefaRow,
"SELECT id, titulo, descricao, concluida, prioridade,
criada_em, atualizada_em
FROM tarefas
WHERE concluida = ? AND prioridade = ?
ORDER BY criada_em DESC
LIMIT ? OFFSET ?",
c, prioridade_para_str(p), limite, offset
)
.fetch_all(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(Some(c), None) => {
sqlx::query_as!(
TarefaRow,
"SELECT id, titulo, descricao, concluida, prioridade,
criada_em, atualizada_em
FROM tarefas
WHERE concluida = ?
ORDER BY criada_em DESC
LIMIT ? OFFSET ?",
c, limite, offset
)
.fetch_all(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(None, Some(p)) => {
sqlx::query_as!(
TarefaRow,
"SELECT id, titulo, descricao, concluida, prioridade,
criada_em, atualizada_em
FROM tarefas
WHERE prioridade = ?
ORDER BY criada_em DESC
LIMIT ? OFFSET ?",
prioridade_para_str(p), limite, offset
)
.fetch_all(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
(None, None) => {
sqlx::query_as!(
TarefaRow,
"SELECT id, titulo, descricao, concluida, prioridade,
criada_em, atualizada_em
FROM tarefas
ORDER BY criada_em DESC
LIMIT ? OFFSET ?",
limite, offset
)
.fetch_all(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
}
};
let tarefas: Result<Vec<Tarefa>, _> = rows
.into_iter()
.map(Tarefa::try_from)
.collect();
Ok((tarefas.map_err(|_| ErroApi::ErroInterno)?, total))
}
pub async fn buscar_por_id(&self, id: &Uuid) -> ResultadoApi<Tarefa> {
let id_str = id.to_string();
let row = sqlx::query_as!(
TarefaRow,
"SELECT id, titulo, descricao, concluida, prioridade,
criada_em, atualizada_em
FROM tarefas
WHERE id = ?",
id_str
)
.fetch_optional(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?
.ok_or_else(|| ErroApi::NaoEncontrada(format!("Tarefa {id} não encontrada")))?;
Tarefa::try_from(row)
}
pub async fn criar(&self, tarefa: &Tarefa) -> ResultadoApi<()> {
let id_str = tarefa.id.to_string();
let prioridade = prioridade_para_str(&tarefa.prioridade);
let criada_em = tarefa.criada_em.to_rfc3339();
let atualizada_em = tarefa.atualizada_em.to_rfc3339();
sqlx::query!(
"INSERT INTO tarefas (id, titulo, descricao, concluida, prioridade, criada_em, atualizada_em)
VALUES (?, ?, ?, ?, ?, ?, ?)",
id_str,
tarefa.titulo,
tarefa.descricao,
tarefa.concluida,
prioridade,
criada_em,
atualizada_em
)
.execute(&self.pool)
.await
.map_err(|e| {
if e.to_string().contains("UNIQUE constraint failed") {
ErroApi::Conflito(format!("Tarefa com id {} já existe", tarefa.id))
} else {
ErroApi::ErroInterno
}
})?;
Ok(())
}
pub async fn atualizar(
&self,
id: &Uuid,
payload: &AtualizarTarefa,
) -> ResultadoApi<Tarefa> {
// Busca a tarefa existente
let mut tarefa = self.buscar_por_id(id).await?;
// Aplica as mudanças
if let Some(ref titulo) = payload.titulo {
if titulo.trim().is_empty() {
return Err(ErroApi::TituloVazio);
}
tarefa.titulo = titulo.trim().to_string();
}
if payload.descricao.is_some() {
tarefa.descricao = payload.descricao.clone();
}
if let Some(concluida) = payload.concluida {
tarefa.concluida = concluida;
}
if let Some(ref prioridade) = payload.prioridade {
tarefa.prioridade = prioridade.clone();
}
tarefa.atualizada_em = Utc::now();
// Persiste no banco
let id_str = tarefa.id.to_string();
let prioridade = prioridade_para_str(&tarefa.prioridade);
let atualizada_em = tarefa.atualizada_em.to_rfc3339();
sqlx::query!(
"UPDATE tarefas
SET titulo = ?, descricao = ?, concluida = ?,
prioridade = ?, atualizada_em = ?
WHERE id = ?",
tarefa.titulo,
tarefa.descricao,
tarefa.concluida,
prioridade,
atualizada_em,
id_str
)
.execute(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
Ok(tarefa)
}
pub async fn deletar(&self, id: &Uuid) -> ResultadoApi<()> {
let id_str = id.to_string();
let resultado = sqlx::query!(
"DELETE FROM tarefas WHERE id = ?",
id_str
)
.execute(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
if resultado.rows_affected() == 0 {
return Err(ErroApi::NaoEncontrada(
format!("Tarefa {id} não encontrada")
));
}
Ok(())
}
pub async fn limpar_concluidas(&self) -> ResultadoApi<u64> {
let resultado = sqlx::query!("DELETE FROM tarefas WHERE concluida = TRUE")
.execute(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
Ok(resultado.rows_affected())
}
pub async fn estatisticas(&self) -> ResultadoApi<EstatisticasTarefas> {
let total = sqlx::query_scalar!("SELECT COUNT(*) FROM tarefas")
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
let concluidas = sqlx::query_scalar!(
"SELECT COUNT(*) FROM tarefas WHERE concluida = TRUE"
)
.fetch_one(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
let por_prioridade = sqlx::query!(
"SELECT prioridade, COUNT(*) as contagem
FROM tarefas
GROUP BY prioridade"
)
.fetch_all(&self.pool)
.await
.map_err(|_| ErroApi::ErroInterno)?;
let mut alta = 0i64;
let mut media = 0i64;
let mut baixa = 0i64;
for row in por_prioridade {
match row.prioridade.as_str() {
"Alta" => alta = row.contagem,
"Media" => media = row.contagem,
"Baixa" => baixa = row.contagem,
_ => {}
}
}
Ok(EstatisticasTarefas {
total,
concluidas,
pendentes: total - concluidas,
por_prioridade: PorPrioridade { alta, media, baixa },
})
}
}
#[derive(Debug, serde::Serialize)]
pub struct EstatisticasTarefas {
pub total: i64,
pub concluidas: i64,
pub pendentes: i64,
pub por_prioridade: PorPrioridade,
}
#[derive(Debug, serde::Serialize)]
pub struct PorPrioridade {
pub alta: i64,
pub media: i64,
pub baixa: i64,
}
Atualizando os handlers
Com o repositório criado, os handlers ficam mais limpos:
src/rotas/tarefas.rs (versão com banco):
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use tracing::{info, warn};
use uuid::Uuid;
use crate::{
erro::{ErroApi, ResultadoApi},
modelo::{AtualizarTarefa, CriarTarefa, FiltroTarefas, ListaTarefas, Prioridade, Tarefa},
repositorio::RepositorioTarefas,
};
pub type EstadoApp = axum::extract::State<RepositorioTarefas>;
pub async fn listar_tarefas(
State(repo): State<RepositorioTarefas>,
Query(filtro): Query<FiltroTarefas>,
) -> ResultadoApi<Json<ListaTarefas>> {
let (tarefas, total) = repo.listar(&filtro).await?;
Ok(Json(ListaTarefas {
tarefas,
total: total as usize,
pagina: filtro.pagina,
por_pagina: filtro.por_pagina,
}))
}
pub async fn buscar_tarefa(
State(repo): State<RepositorioTarefas>,
Path(id): Path<Uuid>,
) -> ResultadoApi<Json<Tarefa>> {
let tarefa = repo.buscar_por_id(&id).await?;
Ok(Json(tarefa))
}
pub async fn criar_tarefa(
State(repo): State<RepositorioTarefas>,
Json(payload): Json<CriarTarefa>,
) -> ResultadoApi<(StatusCode, Json<Tarefa>)> {
if payload.titulo.trim().is_empty() {
return Err(ErroApi::TituloVazio);
}
if payload.titulo.len() > 200 {
return Err(ErroApi::DadosInvalidos(
"Título não pode ter mais de 200 caracteres".to_string()
));
}
let tarefa = Tarefa::nova(
payload.titulo.trim().to_string(),
payload.descricao,
payload.prioridade,
);
repo.criar(&tarefa).await?;
info!(id = %tarefa.id, titulo = %tarefa.titulo, "Tarefa criada");
Ok((StatusCode::CREATED, Json(tarefa)))
}
pub async fn atualizar_tarefa(
State(repo): State<RepositorioTarefas>,
Path(id): Path<Uuid>,
Json(payload): Json<AtualizarTarefa>,
) -> ResultadoApi<Json<Tarefa>> {
let tarefa = repo.atualizar(&id, &payload).await?;
info!(id = %id, "Tarefa atualizada");
Ok(Json(tarefa))
}
pub async fn deletar_tarefa(
State(repo): State<RepositorioTarefas>,
Path(id): Path<Uuid>,
) -> ResultadoApi<StatusCode> {
repo.deletar(&id).await?;
info!(id = %id, "Tarefa deletada");
Ok(StatusCode::NO_CONTENT)
}
pub async fn limpar_concluidas(
State(repo): State<RepositorioTarefas>,
) -> ResultadoApi<Json<serde_json::Value>> {
let removidas = repo.limpar_concluidas().await?;
Ok(Json(serde_json::json!({ "removidas": removidas })))
}
pub async fn estatisticas(
State(repo): State<RepositorioTarefas>,
) -> ResultadoApi<Json<crate::repositorio::EstatisticasTarefas>> {
let stats = repo.estatisticas().await?;
Ok(Json(stats))
}
Atualizando o main com pool de conexões
src/main.rs:
mod erro;
mod modelo;
mod repositorio;
mod rotas;
use axum::{routing::get, Json, Router};
use repositorio::RepositorioTarefas;
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use std::time::Duration;
use tower_http::{cors::{Any, CorsLayer}, trace::TraceLayer};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
async fn saude(
axum::extract::State(repo): axum::extract::State<RepositorioTarefas>,
) -> Json<serde_json::Value> {
let stats = repo.estatisticas().await;
Json(serde_json::json!({
"status": "ok",
"versao": env!("CARGO_PKG_VERSION"),
"banco": stats.is_ok(),
}))
}
#[tokio::main]
async fn main() {
// Carregar variáveis de ambiente
dotenvy::dotenv().ok();
// Configurar logging
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "api_tarefas=debug,sqlx=warn".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Conectar ao banco
let url_banco = std::env::var("DATABASE_URL")
.expect("DATABASE_URL não configurada");
info!("Conectando ao banco de dados...");
let pool = SqlitePoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&url_banco)
.await
.expect("Falha ao conectar ao banco de dados");
// Executar migrations automaticamente
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Falha ao executar migrations");
info!("Banco de dados pronto.");
let repo = RepositorioTarefas::novo(pool);
// Configurar CORS
let cors = CorsLayer::new()
.allow_methods(Any)
.allow_headers(Any)
.allow_origin(Any);
// Construir rotas
let app = Router::new()
.route("/saude", get(saude))
.route("/tarefas/estatisticas",
get(rotas::tarefas::estatisticas))
.route("/tarefas",
get(rotas::tarefas::listar_tarefas)
.post(rotas::tarefas::criar_tarefa))
.route("/tarefas/:id",
get(rotas::tarefas::buscar_tarefa)
.put(rotas::tarefas::atualizar_tarefa)
.delete(rotas::tarefas::deletar_tarefa))
.route("/tarefas/concluidas",
axum::routing::delete(rotas::tarefas::limpar_concluidas))
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(repo);
let endereco = "0.0.0.0:3000";
info!("Servidor rodando em http://{endereco}");
let listener = tokio::net::TcpListener::bind(endereco)
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
Transações — garantindo consistência
Para operações que precisam de atomicidade, SQLx suporta transações:
pub async fn transferir_tarefas(
&self,
ids: &[Uuid],
nova_prioridade: &Prioridade,
) -> ResultadoApi<u64> {
let mut tx = self.pool.begin().await
.map_err(|_| ErroApi::ErroInterno)?;
let prioridade_str = prioridade_para_str(nova_prioridade);
let atualizada_em = Utc::now().to_rfc3339();
let mut total_afetadas = 0u64;
for id in ids {
let id_str = id.to_string();
let resultado = sqlx::query!(
"UPDATE tarefas
SET prioridade = ?, atualizada_em = ?
WHERE id = ?",
prioridade_str,
atualizada_em,
id_str
)
.execute(&mut *tx)
.await
.map_err(|_| ErroApi::ErroInterno)?;
total_afetadas += resultado.rows_affected();
}
tx.commit().await
.map_err(|_| ErroApi::ErroInterno)?;
Ok(total_afetadas)
}
Se qualquer operação falhar, tx.commit() não é chamado e a transação é revertida automaticamente quando tx sai de escopo.
Testes com banco em memória
Para testes, use SQLite em memória — cada teste começa com banco limpo:
tests/repositorio.rs:
use sqlx::SqlitePool;
use api_tarefas::{
modelo::{CriarTarefa, FiltroTarefas, Prioridade, Tarefa},
repositorio::RepositorioTarefas,
};
async fn criar_pool_teste() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.unwrap();
sqlx::migrate!("./migrations")
.run(&pool)
.await
.unwrap();
pool
}
fn tarefa_teste(titulo: &str) -> Tarefa {
Tarefa::nova(
titulo.to_string(),
None,
Prioridade::Media,
)
}
#[tokio::test]
async fn criar_e_buscar_tarefa() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let tarefa = tarefa_teste("Minha tarefa");
repo.criar(&tarefa).await.unwrap();
let encontrada = repo.buscar_por_id(&tarefa.id).await.unwrap();
assert_eq!(encontrada.titulo, "Minha tarefa");
assert_eq!(encontrada.concluida, false);
}
#[tokio::test]
async fn buscar_tarefa_inexistente_retorna_erro() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let id = uuid::Uuid::new_v4();
let resultado = repo.buscar_por_id(&id).await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn listar_com_filtro_concluida() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let t1 = tarefa_teste("Pendente");
let mut t2 = tarefa_teste("Concluída");
t2.concluida = true;
repo.criar(&t1).await.unwrap();
repo.criar(&t2).await.unwrap();
let filtro = FiltroTarefas {
pagina: 1,
por_pagina: 10,
concluida: Some(false),
prioridade: None,
};
let (tarefas, total) = repo.listar(&filtro).await.unwrap();
assert_eq!(total, 1);
assert_eq!(tarefas[0].titulo, "Pendente");
}
#[tokio::test]
async fn atualizar_tarefa() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let tarefa = tarefa_teste("Original");
repo.criar(&tarefa).await.unwrap();
let payload = api_tarefas::modelo::AtualizarTarefa {
titulo: Some("Atualizado".to_string()),
descricao: None,
concluida: Some(true),
prioridade: None,
};
let atualizada = repo.atualizar(&tarefa.id, &payload).await.unwrap();
assert_eq!(atualizada.titulo, "Atualizado");
assert_eq!(atualizada.concluida, true);
}
#[tokio::test]
async fn deletar_tarefa() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let tarefa = tarefa_teste("Para deletar");
repo.criar(&tarefa).await.unwrap();
repo.deletar(&tarefa.id).await.unwrap();
let resultado = repo.buscar_por_id(&tarefa.id).await;
assert!(resultado.is_err());
}
#[tokio::test]
async fn estatisticas_corretas() {
let pool = criar_pool_teste().await;
let repo = RepositorioTarefas::novo(pool);
let t1 = tarefa_teste("A");
let mut t2 = tarefa_teste("B");
t2.concluida = true;
let mut t3 = Tarefa::nova("C".to_string(), None, Prioridade::Alta);
repo.criar(&t1).await.unwrap();
repo.criar(&t2).await.unwrap();
repo.criar(&t3).await.unwrap();
let stats = repo.estatisticas().await.unwrap();
assert_eq!(stats.total, 3);
assert_eq!(stats.concluidas, 1);
assert_eq!(stats.pendentes, 2);
assert_eq!(stats.por_prioridade.alta, 1);
assert_eq!(stats.por_prioridade.media, 2);
}
Execute:
cargo test
Migrando para PostgreSQL
Trocar SQLite por PostgreSQL exige mudanças mínimas:
Cargo.toml:
sqlx = { version = "0.7", features = [
"runtime-tokio",
"postgres", # troca sqlite por postgres
"uuid",
"chrono",
"macros",
]}
.env:
DATABASE_URL=postgresql://usuario:senha@localhost/tarefas
Migration — pequenas diferenças de sintaxe:
CREATE TABLE IF NOT EXISTS tarefas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titulo TEXT NOT NULL,
descricao TEXT,
concluida BOOLEAN NOT NULL DEFAULT FALSE,
prioridade TEXT NOT NULL DEFAULT 'Media',
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizada_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Com PostgreSQL, UUID e timestamps são tipos nativos — sem necessidade de conversão manual. As queries ficam ainda mais limpas.
O que o SQLx ensina sobre Rust
SQLx é um exemplo notável de como Rust usa seu sistema de tipos para resolver problemas que outras linguagens tratam como responsabilidade do programador.
A verificação de queries em tempo de compilação é possível porque Rust tem uma fase de compilação rica onde macros procedurais podem executar código arbitrário — incluindo conectar ao banco de dados e verificar tipos. Em Python ou JavaScript, esse erro só aparece em execução. Em Rust com SQLx, ele aparece antes do programa existir.
Esse padrão — erros que seriam de runtime virando erros de compilação — é a essência do que Rust oferece. E quanto mais você trabalha com a linguagem, mais aprecia o valor de descobrir problemas antes de deployar.
Fontes e leituras recomendadas
- SQLx Documentation — https://docs.rs/sqlx
- SQLx GitHub — exemplos e guias — https://github.com/launchbadge/sqlx
- "Zero to Production in Rust" — Luca Palmieri — cap. 5 e 6 cobrem SQLx com PostgreSQL em profundidade — https://www.zero2prod.com
- SQLx CLI — documentação da ferramenta de migrations — https://github.com/launchbadge/sqlx/tree/main/sqlx-cli
- "Database Access in Rust" — comparação entre Diesel, SQLx e SeaORM — https://www.shuttle.rs/blog/2023/10/04/sql-in-rust