Banco de dados com sqlx
Nesta aula, você aprenderá a usar a biblioteca sqlx em Rust para interagir com bancos de dados de forma assíncrona. Exploraremos a criação de pools de conexões, execução de queries, mapeamento de resultados com query_as e boas práticas para aplicações async.
O sqlx é uma das bibliotecas mais poderosas e populares do ecossistema Rust para acesso a bancos de dados. Diferente de ORMs tradicionais, ele permite escrever SQL puro com verificação em tempo de compilação, garantindo que suas queries estejam corretas antes mesmo de executar. Nesta aula, vamos explorar os principais recursos do sqlx no contexto de aplicações assíncronas, utilizando o runtime tokio.
Vamos abordar desde a configuração de um pool de conexões até a execução de queries complexas, incluindo o mapeamento automático de resultados para structs. Ao final, você terá uma base sólida para integrar bancos de dados em seus projetos Rust de forma eficiente e segura.
Pool de conexões
Um pool de conexões é um conjunto de conexões com o banco de dados que são reutilizadas entre requisições. Isso é crucial em aplicações web, pois abrir uma nova conexão a cada operação é caro e pode esgotar os recursos do banco. O sqlx oferece o tipo PgPool (para PostgreSQL), MySqlPool (para MySQL) e SqlitePool (para SQLite), que gerenciam automaticamente as conexões.
Para criar um pool, você pode usar a função connect ou connect_with. A primeira é mais simples, enquanto a segunda permite maior controle sobre as opções de conexão, como timeout e tamanho máximo do pool.
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect("postgres://user:password@localhost/dbname")
.await?;
// use o pool
Ok(())
}O pool é clonável e pode ser compartilhado entre threads. Internamente, ele gerencia as conexões de forma assíncrona, garantindo que não haja vazamentos e que as conexões sejam reutilizadas de maneira eficiente.
Queries
Com o pool em mãos, podemos executar queries SQL. O sqlx oferece duas macros principais: query e query_as. A macro query executa uma consulta e retorna um Query que pode ser executada com fetch_one, fetch_all, fetch_optional ou execute.
Vamos ver um exemplo de uma query simples que insere um usuário em uma tabela:
use sqlx::postgres::PgPool;
async fn create_user(pool: &PgPool, name: &str, email: &str) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO users (name, email) VALUES ($1, $2)")
.bind(name)
.bind(email)
.execute(pool)
.await?;
Ok(())
}Para consultas que retornam dados, usamos fetch_all para obter todas as linhas ou fetch_one para uma única linha. O resultado é um conjunto de linhas que podem ser acessadas por índice ou nome da coluna.
async fn get_users(pool: &PgPool) -> Result<Vec<(i32, String, String)>, sqlx::Error> {
let rows = sqlx::query("SELECT id, name, email FROM users")
.fetch_all(pool)
.await?;
let users = rows.iter().map(|row| {
let id: i32 = row.get("id");
let name: String = row.get("name");
let email: String = row.get("email");
(id, name, email)
}).collect();
Ok(users)
}É importante notar que o sqlx não valida as queries em tempo de compilação por padrão. Para isso, você pode usar a macro query! ou query_as!, que verificam a sintaxe e os tipos em tempo de compilação. Porém, isso requer uma conexão com o banco durante a compilação, o que pode ser feito com a feature runtime-tokio e a variável de ambiente DATABASE_URL.
query_as
Para evitar o trabalho manual de extrair cada campo de uma linha, o sqlx oferece a macro query_as, que mapeia automaticamente as colunas para os campos de uma struct. Para isso, a struct deve implementar a trait FromRow, que pode ser derivada.
use sqlx::FromRow;
#[derive(Debug, FromRow)]
struct User {
id: i32,
name: String,
email: String,
}
async fn get_user(pool: &PgPool, user_id: i32) -> Result<Option<User>, sqlx::Error> {
let user = sqlx::query_as::<_, User>("SELECT id, name, email FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(user)
}O uso de query_as torna o código mais limpo e seguro, pois os tipos são verificados em tempo de compilação (se usar a macro com !). Além disso, você pode usar query_as! para validação em tempo de compilação, mas lembre-se de que isso requer acesso ao banco durante a compilação.
Async
O sqlx foi projetado para ser totalmente assíncrono, aproveitando o modelo de concorrência do Rust. Isso significa que as operações de banco de dados não bloqueiam a thread, permitindo que sua aplicação lide com muitas requisições simultâneas com eficiência.
Para usar o sqlx de forma assíncrona, você precisa de um runtime como o tokio. O sqlx oferece suporte a vários runtimes, mas o tokio é o mais comum. No seu Cargo.toml, você deve habilitar a feature correspondente:
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["full"] }Um exemplo de uso assíncrono com tokio e sqlx em um servidor web simples:
use axum::{
extract::State,
routing::get,
Router,
};
use sqlx::postgres::PgPool;
async fn get_users(State(pool): State<PgPool>) -> String {
let users = sqlx::query_as::<_, User>("SELECT id, name, email FROM users")
.fetch_all(&pool)
.await
.unwrap();
format!("{:?}", users)
}
#[tokio::main]
async fn main() {
let pool = PgPoolOptions::new()
.connect("postgres://user:password@localhost/dbname")
.await
.unwrap();
let app = Router::new()
.route("/users", get(get_users))
.with_state(pool);
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}É fundamental lembrar que, em um ambiente assíncrono, você não deve bloquear o executor com operações síncronas. O sqlx garante que todas as operações são não-bloqueantes, mas você deve evitar chamar .await dentro de loops síncronos ou usar std::thread::sleep em vez de tokio::time::sleep.
Boas práticas
Ao trabalhar com sqlx, considere as seguintes práticas:
- Use
query_as!equery!para validação em tempo de compilação sempre que possível. - Configure um pool com limites adequados ao seu ambiente (número de conexões, timeout).
- Utilize transações quando houver múltiplas operações que devem ser atômicas.
- Trate erros de forma robusta, utilizando o tipo
Resulte propagando erros com?. - Monitore o desempenho do pool e ajuste os parâmetros conforme a carga.
Exercícios
- Crie um pool de conexões para SQLite usando
SqlitePoolOptionscom um máximo de 10 conexões e timeout de 5 segundos. Escreva o código necessário. - Escreva uma função assíncrona que insira um novo produto em uma tabela
productscom colunasid(serial),name(text) eprice(numeric). Usesqlx::queryebind. - Defina uma struct
Productcom camposid,nameeprice, e deriveFromRow. Escreva uma consulta usandoquery_aspara buscar todos os produtos com preço maior que um valor fornecido. - Explique por que é importante usar um pool de conexões em vez de criar uma conexão a cada operação. Dê um exemplo prático de como o pool melhora a performance.
- Escreva um exemplo de como executar uma transação com
sqlxpara transferir dinheiro entre duas contas, garantindo que ambas as operações sejam atômicas.
use sqlx::sqlite::SqlitePoolOptions;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = SqlitePoolOptions::new()
.max_connections(10)
.acquire_timeout(Duration::from_secs(5))
.connect("sqlite::memory:")
.await?;
Ok(())
}async fn insert_product(pool: &PgPool, name: &str, price: f64) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO products (name, price) VALUES ($1, $2)")
.bind(name)
.bind(price)
.execute(pool)
.await?;
Ok(())
}#[derive(Debug, FromRow)]
struct Product {
id: i32,
name: String,
price: f64,
}
async fn get_expensive_products(pool: &PgPool, min_price: f64) -> Result<Vec<Product>, sqlx::Error> {
let products = sqlx::query_as::<_, Product>("SELECT id, name, price FROM products WHERE price > $1")
.bind(min_price)
.fetch_all(pool)
.await?;
Ok(products)
}async fn transfer(pool: &PgPool, from_id: i32, to_id: i32, amount: f64) -> Result<(), sqlx::Error> {
let mut tx = pool.begin().await?;
sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
.bind(amount)
.bind(from_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
.bind(amount)
.bind(to_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}