Introduction

Nous allons étudier comment utiliser une base de données avec SQLx un crate populaire. Il ne s’agit pas d’un ORM. Nous allons écrire nous même des requêtes SQL. SQLx se charge quand-même du remplissage de structures. Il est même capable d’en générer à la compilation grâce au pouvoir des macros.

La base de données dont je me sert pour les exemple se trouve ici : https://git.ecole-89.com/eriizu/legolas_db. Elle peut être déployée à l’aide d’un docker compose up -d, dès lors que vous avez Docker.

Installer tokio et SQLx

Dans un projet faites les commandes suivantes :

cargo add tokio --features full
cargo add sqlx --features runtime-tokio,postgres,chrono

Créez un fichier .env avec la chaîne de connection pour votre base de données :

DATABASE_URL="postgres://legolas:example@localhost/legolas"

Enfin dans un src/main.rs faites :

#[tokio::main]
async fn main() -> Result<(), sqlx::Error> {
    let pool = sqlx::postgres::PgPoolOptions::new()
        .max_connections(5)
        .connect("postgres://legolas:example@localhost/legolas")
        .await?;
    let row = sqlx::query!("SELECT (1) as patate;")
        .fetch_one(&pool)
        .await?;
    if let Some(patate) = row.patate {
        println!("{}", patate);
    } else {
        eprintln!("no potato? :( this shouldn't be possible :(((");
    }
    Ok(())
}

Cet exemple déclare un main asynchrone. Par défaut le compilateur n’aime pas le fait que main soit asynchrone, alors on le décore avec #[tokio::main] pour qu’il puisse être exécuté.

Dans la fonction on se connecte à la base de donnée et on fait une requête qui fait renvoyer à la BDD toujours la même chose. Ces étapes requièrent toutes deux l’utilisation de .await ce qui signifie qu’elles ne bloquent pas le programme s’il a d’autre choses à faire (en l’occurrence il n’a rien d’autre à faire donc ça ne change rien à notre flux d’instruction habituel.)

Dernier élément intéressant : les fonctions PgPoolOptions::connect et query::fetch_one n’ont aucun effet tant que nous n’écrivons pas .await. C’est le cas de toute fonction marquée async ou de toute fonction qui renvoie un Future.

SQLx

SQLx est un crate qui permet les requêtes sur des bases de données SQL avec plusieurs fonctionnalités intéressantes.

Déjà ce n’est pas un ORM. Il n’est pas question d’écrire votre schéma de base de données d’une façon que SQLx peut comprendre car : il s’en moque.

Ce qui fait la force de SQLx c’est sa capacité à faire des requêtes SQL et de déserialiser leur résultat dans une structure Rust classique. Mais aussi et surtout le fait de pouvoir vérifier la validité des requêtes SQL à la compilation du programme, et de vous offrir un typage fort sur le résultat d’une requête sans avoir eu à définir de type vous-même.

Enfin, SQLx vous protège des injections SQL avec bind.

Requêtes SQL

Une fois connectés à une base de données, on peut faire des requêtes avec les fonction et macros query*. On va voir quelques options, leurs avantages et inconvénients.

  • query simple requête avec mise en cache de l’instruction parsée
  • query_as requête avec :
  • query! comme query mais
    • compile la requête au moment de la compilation du programme
    • génère une structure de réponse qui correspond au colonnes sélectionnées par la requête, pour traiter le résultat de requête sans avoir a écrire de structure
    • pratique pour des requêtes peut fréquentes dont le résultat n’a pas besoin d’être passé en paramètre quelque part ou renvoyé.
  • query_as! comme query_as mais
    • compile la requête au moment de la compilation du programme
    • dé-sérialise dans la structure demandée
    • vérifie que la structure est compatible avec la requête AU MOMENT DE LA COMPILATION (ce que ne peut pas faire query_as, la fonction)

Voyons des exemples, ainsi que les inconvénients liés à chaque méthode.

Fonction query

Cette fonction est adaptée aux requêtes dont les colones des lignes renvoyées ne nous intéressent pas vraiment. Il est tout de même possible de récupérer les valeurs des colonnes, mais c’est beaucoup moins pratique qu’avec les macros ou query_as.

// provides `try_get`
use sqlx::Row;

let row = sqlx::query("SELECT (1) as patate;")
	.fetch_one(&pool)
	.await?;
let patate_res: Result<i32, sqlx::Error> = row.try_get("patate");
if let Ok(patate) = patate_res {
	println!("J'ai bien ma patate {patate}");
} else {
	eprintln!("no potato? :()");
}

Fonction query_as

Comme query, mais le code qui extrait les valeurs des colonnes est déjà fait, tout ce que la fonction veut c’est une structure qui dervive(sqlx::FromRow).

#[derive(Debug, sqlx::FromRow)]
struct Tag {
    id: i32,
    name: String,
}

async fn get_tags(pool: &sqlx::Pool<sqlx::Postgres>) -> Result<Vec<Tag>, sqlx::Error> {
	let mut query = sqlx::query_as("SELECT * FROM tag;");
	let rows: Vec<Tag> = query.fetch_all(pool).await?;
	rows
}

Mettons maintenant on veut faire une fonction pour récupérer une donnée en fonction de son identifiant, on peut utiliser bind pour assigner la valeur sans risque d’injection SQL.

#[derive(Debug, sqlx::FromRow)]
struct Tag {
    id: i32,
    name: String,
}

async fn tag_get_by_id(pool: &sqlx::Pool<sqlx::Postgres>, id: i32) -> Result<Tag, sqlx::Error> {
	let mut query = sqlx::query_as(r#"SELECT * FROM tag WHERE id = $1;"#);
	query = query.bind(id);
	let row: Tag = query.fetch_one(pool).await?;
	row
}

Macro query!

Même chose que query sauf que le nom et le type de chaque colonne figure sur les objets renvoyés. Nom et type des champs/colonnes sont récupéré auprès de la BDD lors de la compilation du programme.

let row = sqlx::query!("SELECT (1) as patate;")
	.fetch_one(&pool)
	.await?;
let patate: i32 = row.patate.expect("column to have a value");
println!("J'ai bien ma patate {patate}");

SQLx sait sur quelle base de donnée faire la requête car il y regarde le fichier .env :

DATABASE_URL="postgres://legolas:example@localhost/legolas"

Avec query! on peut faire quelque chose de similaire à bind. Il ne s’agit pas d’une méthode à appeler, mais d’arguments à ajouter à l’appel de la macro.

let bound_id = 1;
sqlx::query!(r#"SELECT * FROM tags WHERE id = $1;"#, bound_id).fetch_all(&pool).await?;

On peut ajouter autant d’arguments qu’il y a de valeurs à associer.

Macro : query_as!

C’est la combinaison de query! et de query_as. Comme pour query! la requête est vérifiée et compilée au moment de la compilation du programme. Comme pour query_as le type renvoyé est la structure de notre choix, rendant possible des passages en arguments, des appels de méthodes, etc. sans conversion supplémentaire.

async fn focuses_by_author(pool: &sqlx::Pool<sqlx::Postgres>, user_id: i32) -> Result<Vec<FocusUsername>, sqlx::Error> {
	let rows = sqlx::query_as!(
		FocusUsername,
		r#"
		SELECT f.*, u.name as author_name
		FROM focus f
		JOIN "user" u ON author_id=u.id
		WHERE f.author_id = $1;
		"#,
		user_id
	)
	.fetch_all(pool)
	.await?;

	Ok(rows)
}

Ici on précise le type que l’on souhaite dans l’argument juste avant le corps de la requête. À la compilation query_as! vérifie que ce que la requête renvoie est compatible avec notre type.

Pour pouvoir query_as! avec un type, il doit implémenter sqlx::FromRow. On peut derive sur une structure dont les champs sont compatibles avec le moteur de BDD que l’on utilise.

#[derive(Debug, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
struct Tag {
    id: i32,
    name: String,
}

Pour chaque moteur de BDD une table de correspondance existe, exemple : https://docs.rs/sqlx/latest/sqlx/postgres/types/index.html.