Database in Rust: SQLite e Postgres con sqlx, Diesel e SeaORM
Prima o poi il tuo programma deve ricordarsi delle cose: utenti, ordini, punteggi, cose da fare. Quando il programma si chiude, un Vec o una HashMap spariscono. Un database conserva i dati al sicuro su disco, permette a più programmi di condividerli e risponde molto in fretta a domande come «mostrami tutte le attività aperte, dalla più recente».
Rust ha ottimi crate per i database. In questa pagina ne scegli uno e poi scrivi codice vero: un piccolo «repository» in Rust puro (che gira qui nel Playground), poi SQLite con sqlx, Postgres, le migrazioni e un breve giro tra Diesel e SeaORM. I crate per i database non sono disponibili nel Playground, quindi quegli esempi sono da provare sul tuo computer con cargo run. Li abbiamo compilati ed eseguiti tutti, e l’output che vedi è reale.
Cosa puoi costruire
Sezione intitolata “Cosa puoi costruire”- Lo strato dati di un’API web. Prendi il server di cose da fare del Progetto: API web con Axum e salva i dati in Postgres invece che in memoria.
- Siti grandi e trafficati. crates.io, il sito che ospita tutti i crate di Rust, salva i suoi dati in Postgres tramite Diesel. Lo stesso vale per Lemmy, una piattaforma di community open source.
- Strumenti desktop e da riga di comando con un file SQLite locale: un’app per gli appunti, un gestore del budget, una cache.
- Worker in background e job sui dati che leggono righe, le elaborano e riscrivono i risultati.
Dove è più difficile: Rust non ha un unico stack «ufficiale» come Entity Framework in .NET o ActiveRecord in Rails, quindi scegli tra alcuni buoni crate. La prima compilazione di un crate per database richiede un paio di minuti. E l’SQL controllato in fase di compilazione (una funzione bellissima) richiede un po’ di configurazione.
Quale crate?
Sezione intitolata “Quale crate?”| Crate | Sincrono o async | Controlla le query in compilazione? | ORM? | Sceglilo quando |
|---|---|---|---|---|
| rusqlite | sincrono | no | no, SQL puro | Un piccolo tool o un’app desktop con un solo file SQLite. Niente async. |
| sqlx | async | sì, con le macro query! (facoltative) |
no, SQL puro | Ti piace scrivere SQL e vuoi l’async. SQLite, Postgres, MySQL. La scelta più comune per le app web. |
| Diesel | sincrono (diesel-async aggiunge l’async) |
sì, tutto il query builder è controllato dai tipi | ORM e query builder | Vuoi che il compilatore controlli ogni query e non ti pesa imparare il suo stile. Molto maturo. |
| SeaORM | async (esiste sea-orm-sync) |
i tipi sì, l’SQL no (controllato a runtime) | sì, ORM completo | Vuoi entità, relazioni e «salva questo oggetto» come in Entity Framework. Costruito su sqlx. |
Sono tutti e quattro maturi e molto usati. Se non sei sicuro, inizia con sqlx: scrivi SQL normale, quindi quello che impari vale in qualsiasi linguaggio. Arrivi da C#? La pagina Rust per sviluppatori C# mette a confronto Entity Framework, Dapper e ADO.NET con questi crate.
Prima la forma: un repository in Rust puro
Sezione intitolata “Prima la forma: un repository in Rust puro”Prima di qualsiasi database, guardiamo un pattern che usa quasi ogni app: il repository. È l’unico posto che sa come salvare e caricare i dati. Il resto del programma chiama solo metodi come add e list_open, e non gli importa se i dati stanno in una HashMap, in SQLite o in Postgres.
In Rust un trait descrive cosa sa fare il repository, e una struct fa il lavoro. Qui i dati stanno in una HashMap in memoria:
use std::collections::HashMap;
#[derive(Debug, Clone)]struct Task { id: u32, title: String, done: bool,}
// Cosa serve all'app dallo storage. Qui niente dice COME si salvano i dati.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!("nessuna attività con 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); // una HashMap non ha un ordine, quindi ordiniamo open }}
// Funziona con QUALSIASI repository: oggi in memoria, domani SQLite o Postgres.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("Comprare il latte"); repo.add("Imparare sqlx"); repo.add("Portare fuori il cane");
repo.mark_done(1).unwrap(); print_open(&repo); println!("{:?}", repo.mark_done(99));}Output:
[ ] 2: Imparare sqlx[ ] 3: Portare fuori il caneErr("nessuna attività con id 99")Guarda cosa ti darà un database qui. La HashMap diventa una tabella, ogni Task diventa una riga e next_id diventa una colonna id numerata automaticamente. list_open diventa una query con WHERE done = FALSE ORDER BY id. La versione in memoria torna utile anche dopo: è un finto database velocissimo per i tuoi test.
sqlx con SQLite: il tuo primo database vero
Sezione intitolata “sqlx con SQLite: il tuo primo database vero”SQLite è un intero database in un solo file (o solo in memoria). Non c’è nessun server da installare, quindi è perfetto per imparare, per i test e per tante app reali. Crea un progetto e aggiungi sqlx e 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"] }La feature sqlite include SQLite direttamente nel tuo programma, così non devi installare nient’altro. sqlx è asincrono, ed è per questo che ha bisogno di tokio. Ora sostituisci 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> { // Un database in memoria: perfetto per imparare, sparisce quando il programma finisce. 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(())}Eseguilo con cargo run (il programma e il suo output restano in inglese, così come li abbiamo eseguiti):
Inserted task 1Inserted task 2Inserted task 3[x] 1: Buy milk[ ] 2: Learn sqlx[ ] 3: Walk the dogTask 42: NonePasso per passo:
sqlx::query("...")contiene l’SQL. Ogni?è un segnaposto (placeholder), e.bind(valore)lo riempie, nell’ordine..execute(&pool)esegue un’istruzione che non restituisce righe (CREATE,INSERT,UPDATE,DELETE). Il risultato ti dicerows_affected()e, per SQLite,last_insert_rowid().#[derive(sqlx::FromRow)]permette a sqlx di trasformare ogni riga in unTask. I nomi delle colonne devono corrispondere ai nomi dei campi.query_as+fetch_allti dà unVec<Task>. Usafetch_onequando deve esserci esattamente una riga efetch_optionalquando potrebbe non essercene nessuna. Nota come la riga mancante sia diventataNone, unOption, e non un crash.- Ogni chiamata restituisce un
Result, quindi?passa gli errori del database su fino amain.
Per conservare i dati dopo la fine del programma, usa invece un file: SqlitePool::connect("sqlite:todos.db?mode=rwc"). La parte mode=rwc significa «leggi, scrivi e crea il file se manca».
Query controllate in compilazione. sqlx ha anche delle macro come sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). In fase di compilazione la macro si collega al tuo database di sviluppo (da DATABASE_URL) e controlla l’SQL. Un errore di battitura come titel blocca la build con no such column: titel, prima che un utente lo veda. Per questo serve un database con le tue tabelle mentre compili, quindi inizia con le normali funzioni query e aggiungi le macro quando avrai le migrazioni (più sotto).
Collegarsi a Postgres
Sezione intitolata “Collegarsi a Postgres”Postgres (PostgreSQL) è la scelta più popolare per le app web. Gira come server, quindi te ne serve uno attivo: installalo da postgresql.org, usa un database nel cloud oppure avviane uno con Docker:
docker run --name todo-pg -e POSTGRES_PASSWORD=secret -p 5432:5432 -d postgresIl codice cambia appena. Attiva la feature postgres (aggiungi una feature TLS come tls-rustls per un database nel cloud, che di solito richiede la cifratura):
cargo add sqlx --features runtime-tokio,postgres,tls-rustlsPoi collegati con una stringa di connessione, un URL che dice dove si trova il database e come fare il login:
postgres://USER:PASSWORD@HOST:PORT/DATABASEpostgres://postgres:secret@localhost:5432/postgresNon scrivere mai la password nel codice. Metti l’URL nella variabile d’ambiente DATABASE_URL e leggila all’avvio (vedi Ambiente e processi):
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?;
// Postgres numera i suoi segnaposto: $1, $2, ...let tasks: Vec<Task> = sqlx::query_as("SELECT id, title, done FROM tasks WHERE done = $1") .bind(false) .fetch_all(&pool) .await?;Due differenze da SQLite da ricordare: in Postgres i segnaposto sono $1, $2 invece di ?, e un id numerato automaticamente di solito è BIGINT GENERATED ALWAYS AS IDENTITY (oppure BIGSERIAL), che corrisponde a un i64. Se la password contiene caratteri come @ o /, codificali con il percent-encoding nell’URL (@ diventa %40). Strumenti come sqlx-cli leggono DATABASE_URL anche da un file .env nella cartella del progetto. Aggiungi .env a .gitignore, così non finisce mai su GitHub.
Migrazioni con sqlx-cli
Sezione intitolata “Migrazioni con sqlx-cli”Le tue tabelle cambieranno nel tempo: una nuova colonna qui, una nuova tabella là. Le migrazioni sono file SQL numerati che descrivono ogni modifica. Ogni database (il tuo, quello di un collega, quello di produzione) esegue gli stessi file nello stesso ordine, e il database si ricorda quali ha già eseguito. Installa una volta lo strumento da riga di comando:
# tutti i databasecargo install sqlx-cli
# oppure solo quello che ti serve, per una build più velocecargo install sqlx-cli --no-default-features --features sqlite,rustlsPoi, nella cartella del progetto:
echo "DATABASE_URL=sqlite:todos.db" > .envsqlx database createsqlx migrate add create_tasksCreating migrations/20261018035858_create_tasks.sql(Il tuo numero sarà diverso: è la data e l’ora.) Scrivi la modifica in quel file:
CREATE TABLE tasks ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, done BOOLEAN NOT NULL DEFAULT FALSE);E applicala:
sqlx migrate runApplied 20261018035858/migrate create tasks (1.449995ms)Se lo esegui di nuovo non succede niente, perché quella migrazione è già applicata. sqlx migrate info elenca quelle applicate e quelle in attesa. Non modificare mai una migrazione che è già stata eseguita da qualche parte: aggiungine una nuova.
Puoi anche far applicare le migrazioni al tuo programma quando si avvia. La macro migrate! incorpora nel binario i file di ./migrations:
let pool = SqlitePool::connect("sqlite::memory:").await?;sqlx::migrate!().run(&pool).await?; // esegue solo le migrazioni che questo database non ha ancora vistoDiesel in breve
Sezione intitolata “Diesel in breve”Diesel prende un’altra strada: invece di scrivere stringhe SQL, costruisci le query con metodi Rust, e il compilatore le controlla rispetto a una descrizione delle tue tabelle. Per esempio, done è una colonna bool, quindi tasks::done.eq(true) compila, ma tasks::done.eq("yes") è un errore di compilazione. Diesel è sincrono, il che va benissimo per i tool e per molti server.
[dependencies]diesel = { version = "2.3", features = ["sqlite"] }# Include SQLite nel programma. Serve solo se non hai SQLite installato:libsqlite3-sys = { version = "0.38", features = ["bundled"] }use diesel::prelude::*;use diesel::sqlite::SqliteConnection;
// Di solito `diesel migration run` lo genera per te in src/schema.rs.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:")?;
// Di solito lo farebbe una migrazione. 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 openIn un progetto vero installi diesel_cli (cargo install diesel_cli --no-default-features --features sqlite), poi esegui diesel setup, diesel migration generate create_tasks e diesel migration run. La CLI scrive per te src/schema.rs con il codice table!, così corrisponde sempre al database. La guida Getting Started (in inglese) ti accompagna in tutti i passaggi.
SeaORM in breve
Sezione intitolata “SeaORM in breve”SeaORM è un ORM asincrono: descrivi ogni tabella come un’entità (una struct con degli attributi) e lavori con oggetti invece che con l’SQL. Somiglia molto a Entity Framework. La versione 2.0 può persino creare le tabelle partendo dalle entità.
[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?;
// Crea le tabelle per tutte le entità di questo crate. "seaorm_demo" è il // nome del pacchetto in Cargo.toml, con - trasformato in _. 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 SeaORMIl Model è una riga che leggi. L’ActiveModel è una riga che stai modificando: Set(...) segna i campi da salvare, e ..Default::default() lascia che sia il database a riempire id. Per i progetti più grandi, sea-orm-cli genera le entità da un database esistente e gestisce le migrazioni. Consulta la documentazione di SeaORM (in inglese).
SQL injection: usa sempre i parametri bind
Sezione intitolata “SQL injection: usa sempre i parametri bind”Questa è la regola più importante della pagina. Non costruire mai l’SQL incollando l’input dell’utente nella stringa con format!. Ecco perché, in Rust puro (senza database):
// Cosa riceve il database, costruito in due modi diversi.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!("input: {input}"); println!(" format!: {}", pasted_query(input)); let (sql, params) = bound_query(input); println!(" bind: {sql} parametri: {params:?}"); }}Output:
input: ana format!: SELECT * FROM users WHERE name = 'ana' bind: SELECT * FROM users WHERE name = ? parametri: ["ana"]input: x' OR '1'='1 format!: SELECT * FROM users WHERE name = 'x' OR '1'='1' bind: SELECT * FROM users WHERE name = ? parametri: ["x' OR '1'='1"]Quando incolli, l’apice nell’input chiude la stringa in anticipo, e OR '1'='1' diventa parte dell’SQL. Quella condizione è sempre vera, quindi la query restituisce tutti gli utenti. Un attaccante può usare lo stesso trucco per leggere i dati di altre persone o cancellare tabelle. Si chiama SQL injection ed è uno dei bug di sicurezza più comuni sul web.
Con un parametro bind, il testo SQL non cambia mai. L’input viaggia separato e il database lo tratta solo come un valore. Ecco le due versioni con sqlx, eseguite su una vera tabella SQLite con gli utenti ana e root:
let user_input = "nobody' OR '1'='1"; // immagina che arrivi da un modulo di login
// NON FARLO: incollare l'input nel testo 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:?}");
// FAI COSÌ: un testo SQL fisso con un segnaposto ?, e l'input inviato a parte.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: []La query incollata ha rivelato entrambi gli utenti. Quella con bind, giustamente, non ha trovato nessuno con quel nome strano. E nota il wrapper AssertSqlSafe(...) nella versione sbagliata: sqlx 0.9 ti protegge di default. sqlx::query e query_as accettano solo un &'static str fisso, quindi passare una stringa di format! senza quel wrapper non compila (il messaggio del compilatore resta in inglese):
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.Il compilatore ti dice che le stringhe SQL dinamiche vanno verificate contro le injection, e ti consiglia SQL letterale con parametri bind oppure QueryBuilder. Quindi quando useresti mai AssertSqlSafe? Solo quando devi davvero costruire l’SQL a runtime, per esempio una colonna di ordinamento scelta dall’utente. Prima controlla il valore rispetto a una tua lista fissa di nomi ammessi, poi avvolgi la stringa per dire a sqlx che l’hai verificata. Per i valori usa sempre .bind(). Diesel e SeaORM fanno il bind dei valori al posto tuo, purché usi i loro query builder e non stringhe SQL grezze.
Pool di connessioni
Sezione intitolata “Pool di connessioni”Aprire una connessione al database è lento: c’è un viaggio in rete, un login e un po’ di configurazione. Un pool apre alcune connessioni una volta sola e le presta. Il tuo codice prende in prestito una connessione per una query e la restituisce subito. Ecco perché tutti gli esempi sopra passano &pool e non una singola connessione.
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new() .filename("visits.db") .create_if_missing(true);
let pool = SqlitePoolOptions::new() .max_connections(4) // mai più di 4 alla volta .connect_with(options) .await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)") .execute(&pool) .await?;
// 20 task condividono un pool. Clonare un pool costa poco: è solo un 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")?; // aspetta tutti e 20 gli inserimenti}I 20 task girano contemporaneamente, ma al massimo 4 parlano con il database in un dato momento. Gli altri aspettano un istante una connessione libera. Contando le righe alla fine (partendo da un visits.db nuovo) si ottiene:
/page/0: 7 visits/page/1: 7 visits/page/2: 6 visitsAlcune regole pratiche:
- Crea un solo pool all’avvio del programma e condividilo. In un’app axum, mettilo nello stato dell’app (vedi il progetto API web).
- Non creare un nuovo pool per ogni richiesta. Butteresti via tutto il vantaggio.
max_connectionsdeve restare ben sotto il limite del database. Un server Postgres permette 100 connessioni di default, e ogni copia della tua app ha il suo pool.- Imposta un
acquire_timeout, così quando tutte le connessioni sono occupate una richiesta fallisce con un errore chiaro invece di aspettare per sempre.
Pratica
Sezione intitolata “Pratica”1. Cerca nel repository
Sezione intitolata “1. Cerca nel repository”Riprendi l’idea del repository in memoria e aggiungi un metodo search: restituisce le attività il cui titolo contiene una parola, senza distinguere maiuscole e minuscole, ordinate per id. Provalo con «latte». Suggerimento: title.to_lowercase().contains(&word.to_lowercase()), e qui un Vec<Task> basta come storage.
Mostra la soluzione
#[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("Comprare il latte"); repo.add("Portare fuori il cane"); repo.add("Latte d'avena per il cappuccino");
for task in repo.search("latte") { println!("{}: {}", task.id, task.title); } println!("{} risultati per \"gatto\"", repo.search("gatto").len());}Output:
1: Comprare il latte3: Latte d'avena per il cappuccino0 risultati per "gatto"Lo storage è passato da una HashMap a un Vec, ma il codice che usa il trait non ha bisogno di saperlo. In SQL la stessa ricerca è WHERE title LIKE ? con .bind(format!("%{word}%")). Nota che i segni % vanno nel valore passato con bind, non nel testo SQL.
2. Un repository vero con sqlx (sul tuo computer)
Sezione intitolata “2. Un repository vero con sqlx (sul tuo computer)”Scrivi una struct SqliteTaskRepo che contiene un SqlitePool e ha tre metodi async: add(title) restituisce il nuovo id, mark_done(id) restituisce true se una riga è cambiata, e list_open() restituisce le attività aperte. Crea la tabella in main, aggiungi tre attività e segna come completate l’attività 1 e l’attività 99. Suggerimento: rows_affected() sul risultato di execute ti dice quante righe ha cambiato un UPDATE.
Mostra la soluzione
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 dogHa la stessa forma della versione in memoria, solo che è async e restituisce Result, perché un database vero può fallire (un disco pieno, una rete che cade). Prossimo passo: sostituisci il Vec del tuo progetto API web con questo repository.
Riepilogo
Sezione intitolata “Riepilogo”- rusqlite per SQLite semplice e sincrono, sqlx per SQL puro asincrono (la scelta più comune), Diesel per un query builder controllato in compilazione, SeaORM per un ORM asincrono come Entity Framework.
- Un repository nasconde dove stanno i dati dietro pochi metodi. Inizia con una
HashMape passa a un database più tardi. - In sqlx:
query+.bind()+.execute()per le modifiche,query_as+fetch_all/fetch_one/fetch_optionalper leggere le righe in una struct con#[derive(FromRow)]. - Tieni la stringa di connessione in
DATABASE_URL, non nel codice. SQLite usa i segnaposto?, Postgres usa$1,$2. - Modifica le tabelle con le migrazioni (
sqlx migrate add/run), mai a mano. - Fai sempre il bind dell’input dell’utente. Non incollarlo mai nell’SQL con
format!. Crea un solo pool all’avvio e condividilo.
Verifica veloce
Sezione intitolata “Verifica veloce”Verifica veloce
1. Vuoi scrivere SQL puro, usare l'async e avere la possibilità di query controllate in compilazione. Quale crate è il più adatto?
2. Quale metodo di sqlx dovresti usare quando una query può restituire zero o una riga?
3. Perché format!("... WHERE name = '{name}'") è pericoloso?
4. Cosa fa un pool di connessioni?
Punteggio: 0 / 4
Per approfondire
Sezione intitolata “Per approfondire”- sqlx su docs.rs (in inglese): il riferimento dell’API, con
query,query_ase le macroquery!, i pool e tutti i database supportati. - sqlx-cli su docs.rs (in inglese): come installare la CLI, creare database, eseguire e annullare migrazioni e usare la modalità offline per le macro
query!. - Getting Started with Diesel (in inglese): la guida ufficiale passo per passo, da
diesel setupall’inserimento, aggiornamento ed eliminazione di righe. - Documentazione di SeaORM (in inglese): entità, relazioni, migrazioni e il generatore di codice
sea-orm-cli. - OWASP SQL Injection Prevention Cheat Sheet (in inglese): come funziona la SQL injection e come le query parametrizzate la bloccano, in qualsiasi linguaggio.