Pular para o conteúdo

Bancos de dados em Rust: SQLite e Postgres com sqlx, Diesel e SeaORM

Mais cedo ou mais tarde, o seu programa precisa guardar coisas: usuários, pedidos, recordes, tarefas. Quando o programa para, um Vec ou um HashMap some junto. Um banco de dados guarda os dados em segurança no disco, deixa vários programas compartilharem esses dados e responde muito rápido a perguntas como “me mostre todas as tarefas abertas, das mais novas para as mais antigas”.

O Rust tem crates de banco de dados muito bons. Nesta página você vai escolher um e depois escrever código de verdade: um pequeno “repositório” em Rust puro (que roda aqui mesmo, no Playground), depois SQLite com sqlx, Postgres, migrations e passeios rápidos pelo Diesel e pelo SeaORM. Os crates de banco de dados não estão disponíveis no Playground, então esses exemplos são para o seu computador, com cargo run. Todos eles foram compilados e executados, e a saída mostrada é real.

  • A camada de dados de uma API web. Pegue o servidor de tarefas do Projeto: API web e guarde os dados no Postgres em vez de na memória.
  • Sites grandes e movimentados. O crates.io, o site que hospeda todos os crates do Rust, guarda os dados no Postgres usando o Diesel. O Lemmy, uma plataforma de comunidades open source, também.
  • Ferramentas de desktop e de linha de comando com um arquivo SQLite local: um app de notas, um controle de orçamento, um cache.
  • Workers em segundo plano e jobs de dados que leem linhas, processam e gravam os resultados de volta.

Onde é mais difícil: o Rust não tem uma única stack “oficial”, como o Entity Framework no .NET ou o ActiveRecord no Rails, então você escolhe entre alguns bons crates. A primeira compilação de um crate de banco de dados leva um ou dois minutos. E o SQL verificado em tempo de compilação (um ótimo recurso) exige um pouco de configuração.

Crate Síncrono ou async Verifica as consultas em tempo de compilação? ORM? Escolha quando
rusqlite síncrono não não, SQL puro Uma ferramenta pequena ou um app de desktop com um arquivo SQLite. Sem precisar de async.
sqlx async sim, com as macros query! (opcional) não, SQL puro Você gosta de escrever SQL e quer async. SQLite, Postgres, MySQL. A primeira escolha mais comum para apps web.
Diesel síncrono (o diesel-async adiciona async) sim, todo o query builder é verificado pelos tipos ORM e query builder Você quer que o compilador verifique cada consulta e não se importa de aprender o estilo de consultas dele. Muito maduro.
SeaORM async (existe o sea-orm-sync) os tipos sim, o SQL não (verificado na execução) sim, ORM completo Você quer entidades, relacionamentos e “salve este objeto”, como no Entity Framework. Construído sobre o sqlx.

Os quatro são maduros e muito usados. Se estiver em dúvida, comece pelo sqlx: você escreve SQL normal, então o que aprender vale em qualquer linguagem. Vem do C#? A página Rust para desenvolvedores C# mostra o equivalente do Entity Framework, do Dapper e do ADO.NET nesses crates.

Antes de qualquer banco de dados, vamos ver um padrão que quase todo app usa: o repositório. Ele é o único lugar que sabe salvar e carregar os seus dados. O resto do programa só chama métodos como add e list_open, e não quer saber se os dados moram num HashMap, no SQLite ou no Postgres.

Em Rust, um trait descreve o que o repositório sabe fazer, e uma struct faz o trabalho. Aqui o armazenamento é um HashMap na memória:

use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
done: bool,
}
// O que o app precisa do armazenamento. Nada aqui diz COMO os dados são guardados.
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn mark_done(&mut self, id: u32) -> Result<(), String>;
fn list_open(&self) -> Vec<Task>;
}
struct InMemoryRepo {
tasks: HashMap<u32, Task>,
next_id: u32,
}
impl TaskRepository for InMemoryRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.next_id;
self.tasks.insert(id, Task { id, title: title.to_string(), done: false });
self.next_id += 1;
id
}
fn mark_done(&mut self, id: u32) -> Result<(), String> {
match self.tasks.get_mut(&id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("nenhuma tarefa com id {id}")),
}
}
fn list_open(&self) -> Vec<Task> {
let mut open: Vec<Task> = self.tasks.values().filter(|t| !t.done).cloned().collect();
open.sort_by_key(|t| t.id); // um HashMap não tem ordem, então ordenamos
open
}
}
// Funciona com QUALQUER repositório: na memória hoje, SQLite ou Postgres amanhã.
fn print_open(repo: &impl TaskRepository) {
for task in repo.list_open() {
println!("[ ] {}: {}", task.id, task.title);
}
}
fn main() {
let mut repo = InMemoryRepo { tasks: HashMap::new(), next_id: 1 };
repo.add("Comprar leite");
repo.add("Aprender sqlx");
repo.add("Passear com o cachorro");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

Saída:

[ ] 2: Aprender sqlx
[ ] 3: Passear com o cachorro
Err("nenhuma tarefa com id 99")
▶ Experimente você mesmo

Veja o que um banco de dados vai te dar aqui. O HashMap vira uma tabela, cada Task vira uma linha, e o next_id vira uma coluna id numerada automaticamente. O list_open vira uma consulta com WHERE done = FALSE ORDER BY id. A versão na memória também é útil mais tarde: ela vira uma imitação rápida do banco para os seus testes.

sqlx com SQLite: o seu primeiro banco de dados de verdade

Seção intitulada “sqlx com SQLite: o seu primeiro banco de dados de verdade”

O SQLite é um banco de dados inteiro dentro de um arquivo (ou só na memória). Não tem servidor para instalar, o que o torna perfeito para aprender, para testes e para muitos apps de verdade. Crie um projeto e adicione o sqlx e o tokio:

Terminal window
cargo new todo-db
cd todo-db
cargo add sqlx --features runtime-tokio,sqlite
cargo add tokio --features full
[dependencies]
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }

A feature sqlite embute o SQLite no seu programa, então você não precisa instalar mais nada. O sqlx é async, e é por isso que ele precisa do tokio. Agora substitua o src/main.rs:

use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// Um banco de dados na memória: perfeito para aprender, some quando o programa termina.
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&pool)
.await?;
for title in ["Buy milk", "Learn sqlx", "Walk the dog"] {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&pool)
.await?;
println!("Inserted task {}", result.last_insert_rowid());
}
sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(1_i64)
.execute(&pool)
.await?;
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks ORDER BY id")
.fetch_all(&pool)
.await?;
for task in &tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.title);
}
let open: Option<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE id = ?")
.bind(42_i64)
.fetch_optional(&pool)
.await?;
println!("Task 42: {open:?}");
Ok(())
}

Rode com cargo run:

Inserted task 1
Inserted task 2
Inserted task 3
[x] 1: Buy milk
[ ] 2: Learn sqlx
[ ] 3: Walk the dog
Task 42: None

(Os textos dos exemplos com sqlx, Diesel e SeaORM ficaram em inglês porque a saída mostrada é a de uma execução real desse código.)

Passo a passo:

  • O sqlx::query("...") guarda o SQL. Cada ? é um placeholder (marcador), e o .bind(valor) preenche esses marcadores, em ordem.
  • O .execute(&pool) roda um comando que não devolve linhas (CREATE, INSERT, UPDATE, DELETE). O resultado te informa o rows_affected() e, no SQLite, o last_insert_rowid().
  • O #[derive(sqlx::FromRow)] deixa o sqlx transformar cada linha numa Task. Os nomes das colunas precisam ser iguais aos nomes dos campos.
  • query_as + fetch_all te dá um Vec<Task>. Use fetch_one quando tiver que existir exatamente uma linha, e fetch_optional quando talvez não exista nenhuma. Repare como uma linha que não existe virou None, um Option, e não um crash.
  • Toda chamada devolve um Result, então o ? repassa os erros do banco para o main.

Para manter os dados depois que o programa termina, use um arquivo: SqlitePool::connect("sqlite:todos.db?mode=rwc"). A parte mode=rwc quer dizer “ler, escrever e criar o arquivo se ele não existir”.

Consultas verificadas em tempo de compilação. O sqlx também tem macros como sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). Na hora da compilação, a macro se conecta ao seu banco de desenvolvimento (pelo DATABASE_URL) e confere o SQL. Um erro de digitação como titel interrompe a compilação com no such column: titel, antes que qualquer usuário veja. Isso exige um banco com as suas tabelas durante a compilação, então comece com as funções query comuns e acrescente as macros quando já tiver migrations (veja abaixo).

O Postgres (PostgreSQL) é a escolha mais popular para apps web. Ele roda como um servidor, então você precisa de um rodando: instale pelo postgresql.org, use um banco na nuvem ou suba um com Docker:

Terminal window
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgres

O código quase não muda. Ligue a feature postgres (e adicione uma feature de TLS, como tls-rustls, para um banco na nuvem, que normalmente exige criptografia):

Terminal window
cargo add sqlx --features runtime-tokio,postgres,tls-rustls

Depois conecte com uma connection string (string de conexão), uma URL que diz onde o banco está e como fazer login:

postgres://USER:PASSWORD@HOST:PORT/DATABASE
postgres://postgres:secret@localhost:5432/postgres

Nunca escreva a senha no código. Coloque a URL na variável de ambiente DATABASE_URL e leia na inicialização (veja Variáveis de ambiente):

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let url = std::env::var("DATABASE_URL").expect("set DATABASE_URL first");
let pool = PgPoolOptions::new()
.max_connections(5)
.acquire_timeout(Duration::from_secs(3))
.connect(&url)
.await?;
// O Postgres numera os placeholders: $1, $2, ...
let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1")
.bind(false)
.fetch_all(&pool)
.await?;

Duas diferenças em relação ao SQLite para lembrar: os placeholders do Postgres são $1, $2 em vez de ?, e um id numerado automaticamente costuma ser BIGINT GENERATED ALWAYS AS IDENTITY (ou BIGSERIAL), que cabe num i64. Se a sua senha tiver caracteres como @ ou /, use percent-encoding na URL (o @ vira %40). Ferramentas como o sqlx-cli também leem o DATABASE_URL de um arquivo .env na pasta do projeto. Adicione o .env ao .gitignore para ele nunca ir parar no GitHub.

As suas tabelas vão mudar com o tempo: uma coluna nova aqui, uma tabela nova ali. Migrations (migrações) são arquivos SQL numerados que descrevem cada mudança. Todo banco de dados (o seu, o do seu colega de equipe, o de produção) roda os mesmos arquivos na mesma ordem, e o banco lembra quais já rodou. Instale a ferramenta de linha de comando uma vez:

Terminal window
# all databases
cargo install sqlx-cli
# or only what you need, for a faster build
cargo install sqlx-cli --no-default-features --features sqlite,rustls

A primeira linha instala o suporte a todos os bancos. A segunda instala só o que você precisa, para compilar mais rápido. Depois, na pasta do projeto:

Terminal window
echo "DATABASE_URL=sqlite:todos.db" > .env
sqlx database create
sqlx migrate add create_tasks
Creating migrations/20261018035858_create_tasks.sql

(O seu número vai ser diferente: é a data e a hora.) Escreva a mudança nesse arquivo:

CREATE TABLE tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);

E aplique:

Terminal window
sqlx migrate run
Applied 20261018035858/migrate create tasks (1.449995ms)

Rode de novo e nada acontece, porque essa migration já foi aplicada. O sqlx migrate info lista o que já foi aplicado e o que está pendente. Nunca edite uma migration que já rodou em algum lugar; crie uma nova.

Você também pode deixar o seu programa aplicar as migrations quando ele inicia. A macro migrate! embute os arquivos de ./migrations no seu binário:

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // roda só as migrations que este banco ainda não viu

O Diesel segue outro caminho: em vez de escrever strings de SQL, você monta as consultas com métodos Rust, e o compilador confere tudo contra uma descrição das suas tabelas. Por exemplo, done é uma coluna bool, então tasks::done.eq(true) compila, mas tasks::done.eq("yes") dá erro de compilação. O Diesel é síncrono, o que não é problema para ferramentas e para muitos servidores.

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# Builds SQLite into your program. Only needed if you don't have SQLite installed:
libsqlite3-sys = { version = "0.38", features = ["bundled"] }

A linha do libsqlite3-sys embute o SQLite no seu programa. Ela só é necessária se você não tiver o SQLite instalado.

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// Normalmente o `diesel migration run` gera isto em src/schema.rs para você.
diesel::table! {
tasks (id) {
id -> Integer,
title -> Text,
done -> Bool,
}
}
#[derive(Queryable, Selectable, Debug)]
#[diesel(table_name = tasks)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
struct Task {
id: i32,
title: String,
done: bool,
}
#[derive(Insertable)]
#[diesel(table_name = tasks)]
struct NewTask<'a> {
title: &'a str,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut conn = SqliteConnection::establish(":memory:")?;
// Normalmente uma migration faria isto.
diesel::sql_query(
"CREATE TABLE tasks (
id INTEGER PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
)",
)
.execute(&mut conn)?;
let new_tasks = [NewTask { title: "Buy milk" }, NewTask { title: "Learn Diesel" }];
diesel::insert_into(tasks::table)
.values(&new_tasks[..])
.execute(&mut conn)?;
diesel::update(tasks::table.find(1))
.set(tasks::done.eq(true))
.execute(&mut conn)?;
let all: Vec<Task> = tasks::table
.order(tasks::id)
.select(Task::as_select())
.load(&mut conn)?;
for task in &all {
println!("{} {} done={}", task.id, task.title, task.done);
}
let open: i64 = tasks::table
.filter(tasks::done.eq(false))
.count()
.get_result(&mut conn)?;
println!("{open} task(s) still open");
Ok(())
}
1 Buy milk done=true
2 Learn Diesel done=false
1 task(s) still open

Num projeto de verdade, você instala o diesel_cli (cargo install diesel_cli --no-default-features --features sqlite) e depois roda diesel setup, diesel migration generate create_tasks e diesel migration run. O CLI escreve o src/schema.rs com o código do table! para você, então ele sempre bate com o banco. O guia Getting Started (em inglês) mostra tudo isso passo a passo.

O SeaORM é um ORM async: você descreve cada tabela como uma entidade (uma struct com atributos) e trabalha com objetos em vez de SQL. A sensação é parecida com a do Entity Framework. A versão 2.0 consegue até criar as tabelas a partir das suas entidades.

[dependencies]
sea-orm = { version = "2", features = ["sqlx-sqlite", "runtime-tokio", "entity-registry", "schema-sync"] }
tokio = { version = "1", features = ["full"] }
mod task {
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "task")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub title: String,
pub done: bool,
}
impl ActiveModelBehavior for ActiveModel {}
}
use sea_orm::{entity::*, query::*, Database, DbErr};
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = &Database::connect("sqlite::memory:").await?;
// Cria as tabelas de todas as entidades deste crate. "seaorm_demo" é o
// nome do pacote no Cargo.toml, com - trocado por _.
db.get_schema_registry("seaorm_demo::*").sync(db).await?;
for title in ["Buy milk", "Learn SeaORM"] {
task::ActiveModel {
title: Set(title.to_owned()),
done: Set(false),
..Default::default()
}
.insert(db)
.await?;
}
let mut milk = task::Entity::find_by_id(1).one(db).await?.unwrap().into_active_model();
milk.done = Set(true);
milk.update(db).await?;
let open = task::Entity::find()
.filter(task::Column::Done.eq(false))
.all(db)
.await?;
for t in &open {
println!("open: {} {}", t.id, t.title);
}
Ok(())
}
open: 2 Learn SeaORM

O Model é uma linha que você lê. O ActiveModel é uma linha que você está alterando: o Set(...) marca os campos que devem ser salvos, e o ..Default::default() deixa o id para o banco preencher. Para projetos maiores, o sea-orm-cli gera as entidades a partir de um banco existente e cuida das migrations. Veja a documentação do SeaORM (em inglês).

Esta é a regra mais importante da página. Nunca monte SQL colando a entrada do usuário dentro da string com format!. Veja o motivo, em Rust puro (sem banco de dados nenhum):

// O que o banco de dados recebe, montado de dois jeitos diferentes.
fn pasted_query(name: &str) -> String {
format!("SELECT * FROM users WHERE name = '{name}'")
}
fn bound_query(name: &str) -> (&'static str, Vec<String>) {
("SELECT * FROM users WHERE name = ?", vec![name.to_string()])
}
fn main() {
for input in ["ana", "x' OR '1'='1"] {
println!("entrada: {input}");
println!(" format!: {}", pasted_query(input));
let (sql, params) = bound_query(input);
println!(" bind: {sql} parâmetros: {params:?}");
}
}

Saída:

entrada: ana
format!: SELECT * FROM users WHERE name = 'ana'
bind: SELECT * FROM users WHERE name = ? parâmetros: ["ana"]
entrada: x' OR '1'='1
format!: SELECT * FROM users WHERE name = 'x' OR '1'='1'
bind: SELECT * FROM users WHERE name = ? parâmetros: ["x' OR '1'='1"]
▶ Experimente você mesmo

Quando você cola a entrada, a aspa que vem nela termina a string antes da hora, e o OR '1'='1' vira parte do SQL. Essa condição é sempre verdadeira, então a consulta devolve todos os usuários. Um atacante pode usar o mesmo truque para ler os dados de outras pessoas ou apagar tabelas. Isso se chama SQL injection (injeção de SQL) e é um dos bugs de segurança mais comuns da web.

Com um bind parameter (parâmetro vinculado), o texto do SQL nunca muda. A entrada viaja separada, e o banco a trata só como um valor. Aqui estão as duas versões com sqlx, rodando numa tabela SQLite de verdade com os usuários ana e root:

let user_input = "nobody' OR '1'='1"; // imagine que isto veio de um formulário de login
// NÃO FAÇA: colar a entrada no texto do SQL.
let sql = format!("SELECT name FROM users WHERE name = '{user_input}'");
let rows: Vec<(String,)> = sqlx::query_as(AssertSqlSafe(sql)).fetch_all(&pool).await?;
println!("format!: {rows:?}");
// FAÇA: um texto SQL fixo com o placeholder ?, e a entrada enviada separadamente.
let rows: Vec<(String,)> = sqlx::query_as("SELECT name FROM users WHERE name = ?")
.bind(user_input)
.fetch_all(&pool)
.await?;
println!("bind: {rows:?}");
format!: [("ana",), ("root",)]
bind: []

A consulta colada vazou os dois usuários. A consulta com bind, corretamente, não achou ninguém com aquele nome estranho. E repare no AssertSqlSafe(...) em volta da versão ruim: o sqlx 0.9 te protege por padrão. O sqlx::query e o query_as só aceitam um &'static str fixo, então passar uma string do format! sem esse invólucro não compila:

error[E0277]: dynamic SQL strings should be audited for possible injections
= help: the trait `SqlSafeStr` is not implemented for `&std::string::String`
= note: prefer literal SQL strings with bind parameters or `QueryBuilder` to add dynamic data to a query.

O compilador diz, em inglês, que strings SQL dinâmicas precisam ser auditadas contra injeção e sugere usar strings literais com bind parameters ou o QueryBuilder. Então, quando é que você usaria o AssertSqlSafe? Só quando realmente precisar montar SQL em tempo de execução, por exemplo uma coluna de ordenação que o usuário escolhe. Primeiro confira o valor contra a sua própria lista fixa de nomes permitidos, e depois envolva a string para avisar ao sqlx que você já auditou. Para valores, use sempre o .bind(). O Diesel e o SeaORM fazem o bind dos valores para você, desde que você use os query builders deles e não strings de SQL cru.

Abrir uma conexão com o banco é lento: tem uma ida e volta pela rede, um login e alguma configuração. Um pool abre algumas conexões uma única vez e as empresta. O seu código pega uma conexão emprestada para uma consulta e devolve logo em seguida. É por isso que todos os exemplos acima passaram &pool, e não uma conexão avulsa.

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new()
.filename("visits.db")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4) // nunca mais de 4 ao mesmo tempo
.connect_with(options)
.await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)")
.execute(&pool)
.await?;
// 20 tasks compartilham um pool. Clonar um pool é barato: é só um handle.
let mut handles = Vec::new();
for n in 0..20 {
let pool = pool.clone();
handles.push(tokio::spawn(async move {
sqlx::query("INSERT INTO visits (page) VALUES (?)")
.bind(format!("/page/{}", n % 3))
.execute(&pool)
.await
}));
}
for handle in handles {
handle.await.expect("task panicked")?; // espera os 20 inserts
}

As 20 tasks rodam ao mesmo tempo, mas no máximo 4 conversam com o banco em cada momento. As outras esperam um pouquinho por uma conexão livre. Contar as linhas depois (com um visits.db novo) dá:

/page/0: 7 visits
/page/1: 7 visits
/page/2: 6 visits

Algumas regras práticas:

  • Crie um pool quando o programa iniciar e compartilhe. Num app axum, coloque ele no estado do app (veja o projeto de API web).
  • Não crie um pool novo a cada requisição. Isso joga fora toda a vantagem.
  • O max_connections deve ficar bem abaixo do que o banco permite. Um servidor Postgres permite 100 conexões por padrão, e cada cópia do seu app tem o próprio pool.
  • Defina um acquire_timeout, para que, quando todas as conexões estiverem ocupadas, a requisição falhe com um erro claro em vez de esperar para sempre.

Pegue a ideia do repositório na memória e acrescente um método search: ele devolve as tarefas cujo título contém uma palavra, sem diferenciar maiúsculas de minúsculas, ordenadas por id. Teste com “leite”. Dica: title.to_lowercase().contains(&word.to_lowercase()), e aqui um Vec<Task> basta como armazenamento.

Ver solução
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
}
trait TaskRepository {
fn add(&mut self, title: &str) -> u32;
fn search(&self, word: &str) -> Vec<Task>;
}
struct VecRepo {
tasks: Vec<Task>,
}
impl TaskRepository for VecRepo {
fn add(&mut self, title: &str) -> u32 {
let id = self.tasks.len() as u32 + 1;
self.tasks.push(Task { id, title: title.to_string() });
id
}
fn search(&self, word: &str) -> Vec<Task> {
let word = word.to_lowercase();
let mut found: Vec<Task> = self
.tasks
.iter()
.filter(|t| t.title.to_lowercase().contains(&word))
.cloned()
.collect();
found.sort_by_key(|t| t.id);
found
}
}
fn main() {
let mut repo = VecRepo { tasks: Vec::new() };
repo.add("Comprar leite");
repo.add("Passear com o cachorro");
repo.add("Leite de aveia para o café");
for task in repo.search("leite") {
println!("{}: {}", task.id, task.title);
}
println!("{} resultado(s) para \"gato\"", repo.search("gato").len());
}

Saída:

1: Comprar leite
3: Leite de aveia para o café
0 resultado(s) para "gato"
▶ Experimente você mesmo

O armazenamento mudou de um HashMap para um Vec, mas o código que chama o trait não precisa saber disso. Em SQL, a mesma busca é WHERE title LIKE ? com .bind(format!("%{word}%")). Repare que os sinais de % vão no valor do bind, não no texto do SQL.

2. Um repositório de verdade com sqlx (na sua máquina)

Seção intitulada “2. Um repositório de verdade com sqlx (na sua máquina)”

Escreva uma struct SqliteTaskRepo que guarda um SqlitePool e tem três métodos async: add(title) devolve o id novo, mark_done(id) devolve true se alguma linha mudou, e list_open() devolve as tarefas abertas. Crie a tabela no main, adicione três tarefas e marque a tarefa 1 e a tarefa 99 como concluídas. Dica: o rows_affected() no resultado do execute diz quantas linhas um UPDATE alterou.

Ver solução
use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
struct SqliteTaskRepo {
pool: SqlitePool,
}
impl SqliteTaskRepo {
async fn add(&self, title: &str) -> Result<i64, sqlx::Error> {
let result = sqlx::query("INSERT INTO tasks (title) VALUES (?)")
.bind(title)
.execute(&self.pool)
.await?;
Ok(result.last_insert_rowid())
}
async fn mark_done(&self, id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("UPDATE tasks SET done = TRUE WHERE id = ?")
.bind(id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
async fn list_open(&self) -> Result<Vec<Task>, sqlx::Error> {
sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = FALSE ORDER BY id")
.fetch_all(&self.pool)
.await
}
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE)")
.execute(&pool)
.await?;
let repo = SqliteTaskRepo { pool };
repo.add("Buy milk").await?;
repo.add("Learn sqlx").await?;
repo.add("Walk the dog").await?;
println!("mark 1 done: {}", repo.mark_done(1).await?);
println!("mark 99 done: {}", repo.mark_done(99).await?);
for task in repo.list_open().await? {
println!("[{}] {}: {}", if task.done { "x" } else { " " }, task.id, task.title);
}
Ok(())
}
mark 1 done: true
mark 99 done: false
[ ] 2: Learn sqlx
[ ] 3: Walk the dog

É a mesma forma da versão na memória, só que async e devolvendo Result, porque um banco de dados de verdade pode falhar (um disco cheio, a rede caindo). Próximo passo: troque o Vec do seu projeto de API web por este repositório.

  • rusqlite para SQLite síncrono e simples, sqlx para SQL puro e async (a primeira escolha mais comum), Diesel para um query builder verificado em tempo de compilação, SeaORM para um ORM async parecido com o Entity Framework.
  • Um repositório esconde onde os dados moram por trás de alguns métodos. Comece com um HashMap e troque por um banco de dados depois.
  • No sqlx: query + .bind() + .execute() para alterações, query_as + fetch_all / fetch_one / fetch_optional para ler linhas numa struct com #[derive(FromRow)].
  • Guarde a connection string no DATABASE_URL, não no código. O SQLite usa placeholders ?, o Postgres usa $1, $2.
  • Mude as suas tabelas com migrations (sqlx migrate add / run), nunca na mão.
  • Sempre faça o bind da entrada do usuário. Nunca cole a entrada no SQL com format!. Crie um pool na inicialização e compartilhe.

Teste o que aprendeu

1. Você quer escrever SQL puro, usar async e ter a opção de consultas verificadas em tempo de compilação. Qual crate combina mais?

2. Qual método do sqlx você deve usar quando uma consulta pode devolver zero ou uma linha?

3. Por que format!("... WHERE name = '{name}'") é perigoso?

4. O que um pool de conexões faz?

Pontuação: 0 / 4

  • sqlx no docs.rs (em inglês): a referência da API, com query, query_as e as macros query!, pools e todos os bancos suportados.
  • sqlx-cli no docs.rs (em inglês): como instalar o CLI, criar bancos, rodar e reverter migrations, e usar o modo offline das macros query!.
  • Getting Started with Diesel (em inglês): o guia oficial passo a passo, do diesel setup até inserir, atualizar e apagar linhas.
  • Documentação do SeaORM (em inglês): entidades, relacionamentos, migrations e o gerador de código sea-orm-cli.
  • OWASP SQL Injection Prevention Cheat Sheet (em inglês): como o SQL injection funciona e como as consultas parametrizadas o impedem, em qualquer linguagem.