Aller au contenu

Les bases de données en Rust : SQLite et Postgres avec sqlx, Diesel et SeaORM

Tôt ou tard, votre programme a besoin de se souvenir de choses : des utilisateurs, des commandes, des meilleurs scores, des tâches à faire. Quand le programme s’arrête, un Vec ou une HashMap disparaît. Une base de données garde les données en sécurité sur le disque, permet à plusieurs programmes de les partager, et répond très vite à des questions comme « montre-moi toutes les tâches en cours, les plus récentes d’abord ».

Rust a de très bonnes crates pour les bases de données. Sur cette page, vous allez en choisir une, puis écrire du vrai code : un petit « repository » (dépôt de données) en Rust pur (il tourne directement ici, dans le Playground), puis SQLite avec sqlx, Postgres, les migrations, et un rapide tour de Diesel et de SeaORM. Les crates de bases de données ne sont pas disponibles dans le Playground : ces exemples-là sont donc pour votre propre ordinateur, avec cargo run. Chacun d’eux a été compilé et exécuté, et la sortie affichée est réelle.

  • La couche de données d’une API web. Reprenez le serveur de tâches du Projet : API web avec Axum et gardez ses données dans Postgres plutôt qu’en mémoire.
  • De gros sites très fréquentés. crates.io, le site qui héberge toutes les crates Rust, stocke ses données dans Postgres via Diesel. C’est aussi le cas de Lemmy, une plateforme communautaire open source.
  • Des outils de bureau et en ligne de commande avec un fichier SQLite local : une appli de notes, un suivi de budget, un cache.
  • Des workers en arrière-plan et des traitements de données qui lisent des lignes, les traitent et réécrivent les résultats.

Là où c’est plus difficile : Rust n’a pas de solution « officielle » unique comme Entity Framework en .NET ou ActiveRecord avec Rails, donc vous choisissez parmi quelques bonnes crates. La première compilation d’une crate de base de données prend une minute ou deux. Et le SQL vérifié à la compilation (une super fonctionnalité) demande un peu de mise en place.

Crate Synchrone ou async Vérifie vos requêtes à la compilation ? ORM ? Choisissez-la quand
rusqlite synchrone non non, du SQL simple Un petit outil ou une appli de bureau avec un seul fichier SQLite. Pas besoin d’async.
sqlx async oui, avec les macros query! (en option) non, du SQL simple Vous aimez écrire du SQL et voulez de l’async. SQLite, Postgres, MySQL. Le premier choix habituel pour les applis web.
Diesel synchrone (diesel-async ajoute l’async) oui, tout le constructeur de requêtes est vérifié par les types ORM et constructeur de requêtes Vous voulez que le compilateur vérifie chaque requête, et apprendre son style de requêtes ne vous dérange pas. Très mature.
SeaORM async (sea-orm-sync existe) les types oui, le SQL non (vérifié à l’exécution) oui, ORM complet Vous voulez des entités, des relations et un « enregistre cet objet » comme avec Entity Framework. Construit sur sqlx.

Ces quatre crates sont mûres et très utilisées. Si vous hésitez, commencez par sqlx : vous écrivez du SQL normal, donc ce que vous apprenez sert dans tous les langages. Vous venez de C# ? La page Rust pour les développeurs C# fait correspondre Entity Framework, Dapper et ADO.NET à ces crates.

Avant toute base de données, regardons un modèle que presque toutes les applis utilisent : le repository. C’est l’unique endroit qui sait enregistrer et charger vos données. Le reste du programme appelle seulement des méthodes comme add et list_open, et ne se soucie pas de savoir si les données vivent dans une HashMap, dans SQLite ou dans Postgres.

En Rust, un trait décrit ce que le repository sait faire, et une struct fait le travail. Ici, le stockage est une HashMap en mémoire :

use std::collections::HashMap;
#[derive(Debug, Clone)]
struct Task {
id: u32,
title: String,
done: bool,
}
// Ce dont l'appli a besoin côté stockage. Rien ici ne dit COMMENT c'est stocké.
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!("aucune tâche avec l'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); // une HashMap n'a pas d'ordre, donc on trie
open
}
}
// Fonctionne avec N'IMPORTE QUEL repository : en mémoire aujourd'hui, SQLite ou Postgres demain.
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("Acheter du lait");
repo.add("Apprendre sqlx");
repo.add("Promener le chien");
repo.mark_done(1).unwrap();
print_open(&repo);
println!("{:?}", repo.mark_done(99));
}

Sortie :

[ ] 2: Apprendre sqlx
[ ] 3: Promener le chien
Err("aucune tâche avec l'id 99")
▶ Essayez par vous-même

Regardez ce qu’une base de données va vous apporter ici. La HashMap devient une table, chaque Task devient une ligne, et next_id devient une colonne id numérotée automatiquement. list_open devient une requête avec WHERE done = FALSE ORDER BY id. La version en mémoire reste utile plus tard : elle fait un faux stockage très rapide pour vos tests.

sqlx avec SQLite : votre première vraie base de données

Section intitulée « sqlx avec SQLite : votre première vraie base de données »

SQLite est une base de données complète dans un seul fichier (ou simplement en mémoire). Il n’y a aucun serveur à installer, ce qui la rend parfaite pour apprendre, pour les tests, et pour beaucoup de vraies applis. Créez un projet et ajoutez sqlx et 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"] }

La feature sqlite intègre SQLite dans votre programme : vous n’avez rien d’autre à installer. sqlx est async, c’est pour cela qu’il a besoin de tokio. Remplacez maintenant src/main.rs (les exemples qui tournent sur votre machine gardent leurs textes en anglais, car leur sortie a été copiée telle quelle) :

use sqlx::sqlite::SqlitePool;
#[derive(Debug, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
done: bool,
}
#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
// Une base de données en mémoire : parfaite pour apprendre, disparue à la fin du programme.
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(())
}

Lancez-le avec 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

Étape par étape :

  • sqlx::query("...") contient le SQL. Chaque ? est un placeholder (emplacement réservé), et .bind(valeur) le remplit, dans l’ordre.
  • .execute(&pool) exécute une instruction qui ne renvoie aucune ligne (CREATE, INSERT, UPDATE, DELETE). Le résultat vous donne rows_affected() et, pour SQLite, last_insert_rowid().
  • #[derive(sqlx::FromRow)] permet à sqlx de transformer chaque ligne en Task. Les noms des colonnes doivent correspondre aux noms des champs.
  • query_as + fetch_all donne un Vec<Task>. Utilisez fetch_one quand il doit y avoir exactement une ligne, et fetch_optional quand il peut n’y en avoir aucune. Remarquez qu’une ligne absente devient None, une Option, et pas un plantage.
  • Chaque appel renvoie un Result, donc ? fait remonter les erreurs de la base jusqu’à main.

Pour garder les données après la fin du programme, utilisez plutôt un fichier : SqlitePool::connect("sqlite:todos.db?mode=rwc"). La partie mode=rwc veut dire « lire, écrire, et créer le fichier s’il n’existe pas ».

Requêtes vérifiées à la compilation. sqlx propose aussi des macros comme sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE done = ?", done). À la compilation, la macro se connecte à votre base de développement (celle de DATABASE_URL) et vérifie le SQL. Une faute de frappe comme titel arrête la compilation avec no such column: titel (« cette colonne n’existe pas »), avant qu’un utilisateur ne la voie. Il faut pour cela une base contenant vos tables pendant la compilation : commencez donc par les fonctions query simples, et ajoutez les macros une fois que vous avez des migrations (voir plus bas).

Postgres (PostgreSQL) est le choix le plus populaire pour les applis web. Il tourne comme un serveur, il vous en faut donc un qui fonctionne : installez-le depuis postgresql.org, utilisez une base de données dans le cloud, ou démarrez-en un avec Docker :

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

Le code change à peine. Activez la feature postgres (ajoutez une feature TLS comme tls-rustls pour une base dans le cloud, qui exige généralement le chiffrement) :

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

Connectez-vous ensuite avec une chaîne de connexion, une URL qui dit où se trouve la base et comment s’y identifier :

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

N’écrivez jamais le mot de passe dans votre code. Mettez l’URL dans la variable d’environnement DATABASE_URL et lisez-la au démarrage (voir Variables d’environnement) :

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 numérote ses 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?;

Deux différences avec SQLite à retenir : les placeholders de Postgres sont $1, $2 au lieu de ?, et un id numéroté automatiquement est en général un BIGINT GENERATED ALWAYS AS IDENTITY (ou un BIGSERIAL), qui tient dans un i64. Si votre mot de passe contient des caractères comme @ ou /, encodez-les en pourcentage dans l’URL (@ devient %40). Des outils comme sqlx-cli lisent aussi DATABASE_URL depuis un fichier .env dans le dossier de votre projet. Ajoutez .env à .gitignore pour qu’il ne finisse jamais sur GitHub.

Vos tables vont évoluer avec le temps : une nouvelle colonne ici, une nouvelle table là. Les migrations sont des fichiers SQL numérotés qui décrivent chaque changement. Chaque base (la vôtre, celle de votre collègue, la production) exécute les mêmes fichiers dans le même ordre, et la base se souvient de ceux qu’elle a déjà exécutés. Installez l’outil en ligne de commande une fois pour toutes :

Terminal window
# toutes les bases de données
cargo install sqlx-cli
# ou seulement ce dont vous avez besoin, pour compiler plus vite
cargo install sqlx-cli --no-default-features --features sqlite,rustls

Puis, dans le dossier de votre projet :

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

(Votre numéro sera différent : c’est la date et l’heure.) Écrivez le changement dans ce fichier :

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

Puis appliquez-le :

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

Relancez la commande et rien ne se passe, car cette migration est déjà appliquée. sqlx migrate info liste ce qui est appliqué et ce qui est en attente. Ne modifiez jamais une migration qui a déjà été exécutée quelque part : ajoutez-en plutôt une nouvelle.

Vous pouvez aussi laisser votre programme appliquer les migrations au démarrage. La macro migrate! intègre les fichiers de ./migrations dans votre binaire :

let pool = SqlitePool::connect("sqlite::memory:").await?;
sqlx::migrate!().run(&pool).await?; // n'exécute que les migrations que cette base n'a pas encore vues

Diesel prend une autre route : au lieu d’écrire des chaînes SQL, vous construisez les requêtes avec des méthodes Rust, et le compilateur les vérifie par rapport à une description de vos tables. Par exemple, done est une colonne bool, donc tasks::done.eq(true) compile, mais tasks::done.eq("yes") est une erreur de compilation. Diesel est synchrone, ce qui convient très bien aux outils et à beaucoup de serveurs.

[dependencies]
diesel = { version = "2.3", features = ["sqlite"] }
# Intègre SQLite dans votre programme. Utile seulement si SQLite n'est pas installé :
libsqlite3-sys = { version = "0.38", features = ["bundled"] }
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
// Normalement, `diesel migration run` génère ceci pour vous dans 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:")?;
// Normalement, une migration ferait ceci.
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

Dans un vrai projet, vous installez diesel_cli (cargo install diesel_cli --no-default-features --features sqlite), puis vous lancez diesel setup, diesel migration generate create_tasks et diesel migration run. Le CLI écrit pour vous src/schema.rs avec le code table!, qui correspond donc toujours à la base. Le guide Getting Started (en anglais) explique tout cela pas à pas.

SeaORM est un ORM async : vous décrivez chaque table comme une entité (une struct avec des attributs), et vous travaillez avec des objets plutôt qu’avec du SQL. Ça ressemble beaucoup à Entity Framework. La version 2.0 peut même créer les tables à partir de vos entités.

[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?;
// Crée les tables de toutes les entités de cette crate. "seaorm_demo" est le
// nom du package dans Cargo.toml, avec les - remplacés par des _.
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

Le Model est une ligne que vous lisez. L’ActiveModel est une ligne que vous modifiez : Set(...) marque les champs à enregistrer, et ..Default::default() laisse la base remplir id. Pour les projets plus gros, sea-orm-cli génère les entités à partir d’une base existante et gère les migrations. Voir la documentation de SeaORM (en anglais).

Injection SQL : utilisez toujours des paramètres liés

Section intitulée « Injection SQL : utilisez toujours des paramètres liés »

C’est la règle la plus importante de la page. Ne construisez jamais du SQL en collant une saisie de l’utilisateur dans la chaîne avec format!. Voici pourquoi, en Rust pur (pas besoin de base de données) :

// Ce que la base de données reçoit, construit de deux façons différentes.
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!("entrée : {input}");
println!(" format!: {}", pasted_query(input));
let (sql, params) = bound_query(input);
println!(" bind: {sql} paramètres : {params:?}");
}
}

Sortie :

entrée : ana
format!: SELECT * FROM users WHERE name = 'ana'
bind: SELECT * FROM users WHERE name = ? paramètres : ["ana"]
entrée : x' OR '1'='1
format!: SELECT * FROM users WHERE name = 'x' OR '1'='1'
bind: SELECT * FROM users WHERE name = ? paramètres : ["x' OR '1'='1"]
▶ Essayez par vous-même

Quand on colle la saisie, l’apostrophe qu’elle contient termine la chaîne trop tôt, et OR '1'='1' devient une partie du SQL. Cette condition est toujours vraie, donc la requête renvoie tous les utilisateurs. Un attaquant peut utiliser la même astuce pour lire les données des autres ou supprimer des tables. On appelle cela une injection SQL, et c’est l’une des failles de sécurité les plus courantes sur le web.

Avec un paramètre lié (bind), le texte SQL ne change jamais. La saisie voyage à part, et la base la traite uniquement comme une valeur. Voici les deux versions avec sqlx, exécutées sur une vraie table SQLite qui contient les utilisateurs ana et root :

let user_input = "nobody' OR '1'='1"; // imaginez que ceci vienne d'un formulaire de connexion
// À NE PAS FAIRE : coller la saisie dans le texte 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:?}");
// À FAIRE : un texte SQL fixe avec un placeholder ?, et la saisie envoyée à part.
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 requête collée a laissé fuiter les deux utilisateurs. La requête liée n’a trouvé personne avec ce nom étrange, ce qui est correct. Et remarquez l’enveloppe AssertSqlSafe(...) dans la mauvaise version : sqlx 0.9 vous protège par défaut. sqlx::query et query_as n’acceptent qu’un &'static str fixe, donc passer une chaîne format! sans cette enveloppe ne compile pas :

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.

En français : les chaînes SQL dynamiques doivent être vérifiées contre les injections possibles, et sqlx vous conseille de préférer des chaînes SQL littérales avec des paramètres liés, ou QueryBuilder. Alors, quand utiliser AssertSqlSafe ? Seulement quand vous devez vraiment construire du SQL à l’exécution, par exemple une colonne de tri choisie par l’utilisateur. Vérifiez d’abord la valeur par rapport à votre propre liste fixe de noms autorisés, puis enveloppez la chaîne pour dire à sqlx que vous l’avez vérifiée. Pour les valeurs, utilisez toujours .bind(). Diesel et SeaORM lient les valeurs pour vous, tant que vous utilisez leurs constructeurs de requêtes et pas des chaînes SQL brutes.

Ouvrir une connexion à une base de données est lent : il y a un aller-retour réseau, une identification et un peu de préparation. Un pool ouvre quelques connexions une fois pour toutes et les prête. Votre code emprunte une connexion le temps d’une requête et la rend tout de suite. C’est pour cela que tous les exemples ci-dessus passaient &pool, et pas une connexion unique.

use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
let options = SqliteConnectOptions::new()
.filename("visits.db")
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(4) // jamais plus de 4 à la fois
.connect_with(options)
.await?;
sqlx::query("CREATE TABLE IF NOT EXISTS visits (page TEXT NOT NULL)")
.execute(&pool)
.await?;
// 20 tâches partagent un seul pool. Cloner un pool ne coûte presque rien : c'est juste une poignée (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")?; // attend la fin des 20 insertions
}

Les 20 tâches tournent en même temps, mais au plus 4 parlent à la base à un instant donné. Les autres attendent un tout petit peu qu’une connexion se libère. Compter les lignes ensuite (avec un visits.db tout neuf) donne :

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

Quelques règles pratiques :

  • Créez un seul pool au démarrage du programme et partagez-le. Dans une appli axum, mettez-le dans l’état de l’application (voir le projet d’API web).
  • Ne créez pas un nouveau pool par requête. Vous perdriez tout l’intérêt.
  • max_connections doit rester bien en dessous de ce que la base autorise. Un serveur Postgres autorise 100 connexions par défaut, et chaque copie de votre appli a son propre pool.
  • Fixez un acquire_timeout, pour que, quand toutes les connexions sont occupées, une requête échoue avec une erreur claire au lieu d’attendre indéfiniment.

Reprenez l’idée du repository en mémoire et ajoutez une méthode search : elle renvoie les tâches dont le titre contient un mot, sans tenir compte des majuscules et des minuscules, triées par id. Testez-la avec « lait ». Indice : title.to_lowercase().contains(&word.to_lowercase()), et un Vec<Task> suffit comme stockage ici.

Voir la solution
#[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("Acheter du lait");
repo.add("Promener le chien");
repo.add("Lait de coco pour le curry");
for task in repo.search("lait") {
println!("{}: {}", task.id, task.title);
}
println!("{} résultat(s) pour \"chat\"", repo.search("chat").len());
}

Sortie :

1: Acheter du lait
3: Lait de coco pour le curry
0 résultat(s) pour "chat"
▶ Essayez par vous-même

Le stockage est passé d’une HashMap à un Vec, mais le code qui appelle le trait n’a pas besoin de le savoir. En SQL, la même recherche s’écrit WHERE title LIKE ? avec .bind(format!("%{word}%")). Notez que les signes % vont dans la valeur liée, pas dans le texte SQL.

2. Un vrai repository avec sqlx (sur votre machine)

Section intitulée « 2. Un vrai repository avec sqlx (sur votre machine) »

Écrivez une struct SqliteTaskRepo qui contient un SqlitePool et possède trois méthodes async : add(title) renvoie le nouvel id, mark_done(id) renvoie true si une ligne a changé, et list_open() renvoie les tâches en cours. Créez la table dans main, ajoutez trois tâches, et marquez comme terminées la tâche 1 et la tâche 99. Indice : rows_affected(), sur le résultat de execute, vous dit combien de lignes un UPDATE a modifiées.

Voir la solution
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

C’est la même forme que la version en mémoire, simplement async et renvoyant un Result, car une vraie base de données peut échouer (un disque plein, un réseau perdu). Prochaine étape : remplacez le Vec de votre projet d’API web par ce repository.

  • rusqlite pour du SQLite synchrone et simple, sqlx pour du SQL simple en async (le premier choix habituel), Diesel pour un constructeur de requêtes vérifié à la compilation, SeaORM pour un ORM async à la Entity Framework.
  • Un repository cache l’endroit où vivent les données derrière quelques méthodes. Commencez avec une HashMap et branchez une base de données plus tard.
  • Avec sqlx : query + .bind() + .execute() pour les modifications, query_as + fetch_all / fetch_one / fetch_optional pour lire des lignes dans une struct #[derive(FromRow)].
  • Gardez la chaîne de connexion dans DATABASE_URL, pas dans votre code. SQLite utilise des placeholders ?, Postgres utilise $1, $2.
  • Modifiez vos tables avec des migrations (sqlx migrate add / run), jamais à la main.
  • Liez toujours les saisies de l’utilisateur. Ne les collez jamais dans du SQL avec format!. Créez un seul pool au démarrage et partagez-le.

Petit quiz

1. Vous voulez écrire du SQL simple, utiliser l'async, et avoir la possibilité de vérifier les requêtes à la compilation. Quelle crate convient le mieux ?

2. Quelle méthode de sqlx utiliser quand une requête peut renvoyer zéro ou une ligne ?

3. Pourquoi format!("... WHERE name = '{name}'") est-il dangereux ?

4. À quoi sert un pool de connexions ?

Score : 0 / 4

  • sqlx sur docs.rs (en anglais) : la référence de l’API, avec les macros query, query_as et query!, les pools et toutes les bases prises en charge.
  • sqlx-cli sur docs.rs (en anglais) : comment installer le CLI, créer des bases, exécuter et annuler des migrations, et utiliser le mode hors ligne pour les macros query!.
  • Getting Started with Diesel (en anglais) : le guide officiel pas à pas, de diesel setup à l’insertion, la modification et la suppression de lignes.
  • Documentation de SeaORM (en anglais) : les entités, les relations, les migrations et le générateur de code sea-orm-cli.
  • OWASP SQL Injection Prevention Cheat Sheet (en anglais) : comment fonctionne l’injection SQL et comment les requêtes paramétrées l’empêchent, dans n’importe quel langage.