Rust

Banco de Dados com SQLx — Persistência Real para sua API Já leu

17 min de leitura

Banco de Dados com SQLx — Persistência Real para sua API
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

 

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
Comentários

Mais em Rust

Ownership — A Ideia que Muda Tudo
Ownership — A Ideia que Muda Tudo

Chegamos ao artigo mais importante da s&eacute;rie. Tudo que aprendemos at&ea...

Programação de Sistemas — Arquivos, Processos e Sinais
Programação de Sistemas — Arquivos, Processos e Sinais

Rust nasceu como linguagem de sistemas. Não é por acaso que ela substituiu C+...

Smart Pointers — Box, Rc e RefCell
Smart Pointers — Box, Rc e RefCell

&nbsp; At&eacute; agora trabalhamos com dados na stack e refer&ecirc;ncias s...