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.
O que você pode construir
Seção intitulada “O que você pode construir”- 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.
Qual crate usar?
Seção intitulada “Qual crate usar?”| 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.
Primeiro a forma: um repositório em Rust puro
Seção intitulada “Primeiro a forma: um repositório em Rust puro”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 cachorroErr("nenhuma tarefa com id 99")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:
cargo new todo-dbcd todo-dbcargo add sqlx --features runtime-tokio,sqlitecargo 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 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 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 orows_affected()e, no SQLite, olast_insert_rowid(). - O
#[derive(sqlx::FromRow)]deixa o sqlx transformar cada linha numaTask. Os nomes das colunas precisam ser iguais aos nomes dos campos. query_as+fetch_allte dá umVec<Task>. Usefetch_onequando tiver que existir exatamente uma linha, efetch_optionalquando talvez não exista nenhuma. Repare como uma linha que não existe virouNone, umOption, e não um crash.- Toda chamada devolve um
Result, então o?repassa os erros do banco para omain.
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).
Conectando ao Postgres
Seção intitulada “Conectando ao Postgres”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:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresO 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):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsDepois 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/DATABASEpostgres://postgres:secret@localhost:5432/postgresNunca 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.
Migrations com o sqlx-cli
Seção intitulada “Migrations com o sqlx-cli”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:
# all databasescargo install sqlx-cli
# or only what you need, for a faster buildcargo install sqlx-cli --no-default-features --features sqlite,rustlsA 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:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating 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:
sqlx migrate runApplied 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 viuDiesel em poucas palavras
Seção intitulada “Diesel em poucas palavras”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=true2 Learn Diesel done=false1 task(s) still openNum 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.
SeaORM em poucas palavras
Seção intitulada “SeaORM em poucas palavras”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 SeaORMO 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).
SQL injection: use sempre bind parameters
Seção intitulada “SQL injection: use sempre bind parameters”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"]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.
Pools de conexões
Seção intitulada “Pools de conexões”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 visitsAlgumas 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_connectionsdeve 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.
Prática
Seção intitulada “Prática”1. Busca no repositório
Seção intitulada “1. Busca no repositório”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 leite3: Leite de aveia para o café0 resultado(s) para "gato"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: truemark 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
HashMape troque por um banco de dados depois. - No sqlx:
query+.bind()+.execute()para alterações,query_as+fetch_all/fetch_one/fetch_optionalpara 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
Seção intitulada “Teste o que aprendeu”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
Para saber mais
Seção intitulada “Para saber mais”- sqlx no docs.rs (em inglês): a referência da API, com
query,query_ase as macrosquery!, 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 setupaté 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.