Aller au contenu

Bibliothèque en Rust : egui + SQLite

Objectif de l'exercice

Reproduire, côté desktop natif, le même exercice CRUD que la bibliothèque PHP/PDO/MariaDB déjà réalisée. Même logique métier, même structure de validation — mais sans HTTP, sans rechargement de page, et avec le compilateur Rust comme garde-fou supplémentaire.

Cette page suppose que vous avez déjà suivi les deux exercices précédents : le compteur egui et le formulaire avec validation.

Comparaison avec l'exercice PHP/PDO

Étape PHP / MariaDB Rust / SQLite (ici)
Connexion à la base PDO (config.php) Connection::open() (ouvrir_connexion)
Création de la table schema.sql init_db()
Lister les livres SELECT * (index.php) charger_livres()
Ajouter un livre INSERT (ajouter.php) inserer_livre()
Modifier un livre UPDATE (modifier.php) mettre_a_jour_livre()
Supprimer un livre DELETE (supprimer.php) supprimer_livre()
Validation avant écriture fonctions PHP dédiées fonctions valider_* renvoyant Result
Affichage tabulaire <table> HTML TableBuilder (crate egui_extras)

Différence structurelle à souligner en classe

En PHP, chaque action est une page séparée : cycle complet requête → réponse → rechargement de navigateur. Ici, tout vit dans une seule fenêtre, en mémoire, et l'on recharge simplement les données depuis SQLite après chaque écriture. Pas de HTTP, pas de session : c'est du desktop natif.

Prérequis

Installation

  • Rust installé via rustup (voir la page d'installation)
  • Aucun serveur de base de données à lancer : SQLite est un simple fichier (bibliotheque.db), créé automatiquement au premier lancement.

Cargo.toml :

[package]
name = "bibliotheque"
version = "0.1.0"
edition = "2021"

[dependencies]
eframe = "0.36.1"
egui_extras = "0.36.1"
rusqlite = { version = "0.32", features = ["bundled"] }

Le feature bundled

Il compile SQLite directement dans le binaire : pas besoin d'installer libsqlite3-dev sur les machines des élèves. Un seul cargo build suffit, quelle que soit la distribution utilisée en classe.

Le code, par blocs logiques

use eframe::egui;
use egui_extras::{Column, TableBuilder};
use rusqlite::{Connection, Result as SqlResult};

struct MonApplication {
    conn: Connection,
    livres: Vec<Livre>,
    titre_saisi: String,
    auteur_saisi: String,
    annee_saisie: String,
    isbn_saisi: String,
    id_en_edition: Option<i64>,
    message_erreur: Option<String>,
}

impl Default for MonApplication {
    fn default() -> Self {
        let conn = ouvrir_connexion().expect("connexion SQLite impossible");
        init_db(&conn).expect("création de la table impossible");
        let livres = charger_livres(&conn).unwrap_or_default();
        Self {
            conn,
            livres,
            titre_saisi: String::new(),
            auteur_saisi: String::new(),
            annee_saisie: String::new(),
            isbn_saisi: String::new(),
            id_en_edition: None,
            message_erreur: None,
        }
    }
}

Pour vos élèves

Faire remarquer que MonApplication regroupe tout l'état du programme : la connexion à la base, la liste affichée, et les champs du formulaire en cours de saisie. En PHP, cet état était éclaté entre variables de requête ($_POST), session et base de données ; ici, tout vit dans une seule structure Rust.

#[derive(Debug, Clone)]
struct Livre {
    id: i64,
    titre: String,
    auteur: String,
    annee: i32,
    isbn: String,
}

fn ouvrir_connexion() -> SqlResult<Connection> {
    Connection::open("bibliotheque.db")
}

fn init_db(conn: &Connection) -> SqlResult<()> {
    conn.execute(
        "CREATE TABLE IF NOT EXISTS livres (
            id     INTEGER PRIMARY KEY AUTOINCREMENT,
            titre  TEXT NOT NULL,
            auteur TEXT NOT NULL,
            annee  INTEGER NOT NULL,
            isbn   TEXT NOT NULL
        )",
        [],
    )?;
    Ok(())
}

fn charger_livres(conn: &Connection) -> SqlResult<Vec<Livre>> {
    let mut stmt =
        conn.prepare("SELECT id, titre, auteur, annee, isbn FROM livres ORDER BY titre")?;
    let lignes = stmt.query_map([], |ligne| {
        Ok(Livre {
            id: ligne.get(0)?,
            titre: ligne.get(1)?,
            auteur: ligne.get(2)?,
            annee: ligne.get(3)?,
            isbn: ligne.get(4)?,
        })
    })?;
    lignes.collect()
}

Pour vos élèves

Faire le lien avec la classe DAO PHP : ici, ces fonctions ne connaissent rien à l'interface graphique. On pourrait les réutiliser telles quelles dans une version en ligne de commande, exactement comme une classe DAO PHP resterait utilisable derrière une API plutôt qu'un formulaire HTML.

fn valider_titre(titre: &str) -> Result<String, String> {
    let titre = titre.trim();
    if titre.is_empty() {
        Err("Le titre ne peut pas être vide.".to_string())
    } else {
        Ok(titre.to_string())
    }
}

fn valider_annee(annee: &str) -> Result<i32, String> {
    let annee = annee.trim();
    if annee.is_empty() {
        Err("L'année ne peut pas être vide.".to_string())
    } else {
        match annee.parse::<i32>() {
            Ok(valeur) if (0..=2100).contains(&valeur) => Ok(valeur),
            Ok(_) => Err("L'année doit être comprise entre 0 et 2100.".to_string()),
            Err(_) => Err("L'année doit être un nombre entier.".to_string()),
        }
    }
}

Result<T, String> vs validation PHP

Le compilateur force à traiter le cas d'erreur : impossible d'ignorer un Result sans avertissement. En PHP, un if de validation oublié compile et s'exécute sans broncher. Bon point de discussion sur la philosophie de sûreté de Rust.

impl eframe::App for MonApplication {
    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        egui::CentralPanel::default().show(ui, |ui| {
            // formulaire (Grid), validation, bouton Ajouter/Enregistrer
            // -> à compléter par vos soins : reprendre le principe du
            //    formulaire avec validation vu dans l'exercice précédent,
            //    en appelant valider_titre() / valider_annee() avant
            //    d'insérer ou de mettre à jour un livre.

            // Tableau "façon tableur"
            let livres_affiches = self.livres.clone();
            let mut id_a_modifier: Option<i64> = None;
            let mut id_a_supprimer: Option<i64> = None;

            TableBuilder::new(ui)
                .striped(true)
                .column(Column::auto().at_least(30.0))
                .column(Column::remainder().at_least(120.0))
                // ...
                .body(|mut body| {
                    for livre in &livres_affiches {
                        body.row(24.0, |mut row| {
                            row.col(|ui| { ui.label(livre.id.to_string()); });
                            // ...
                            row.col(|ui| {
                                if ui.small_button("Modifier").clicked() {
                                    id_a_modifier = Some(livre.id);
                                }
                                if ui.small_button("Supprimer").clicked() {
                                    id_a_supprimer = Some(livre.id);
                                }
                            });
                        });
                    }
                });

            // Actions différées : voir encadré ci-dessous
        });
    }
}

Pourquoi cloner self.livres avant le tableau ?

Le compilateur refuse de modifier self (via les boutons Modifier/Supprimer) pendant qu'on itère sur self.livres : c'est le borrow checker qui protège contre un accès concurrent aux mêmes données. Solution : on clone la liste pour l'affichage, on note l'action demandée dans une variable (id_a_modifier, id_a_supprimer), et on l'applique après la boucle. Excellent cas concret pour faire toucher du doigt ce que "ownership" et "emprunt" signifient en pratique.

fn main() -> eframe::Result<()> {
    let options = eframe::NativeOptions::default();
    eframe::run_native(
        "Bibliothèque",
        options,
        Box::new(|_cc| Ok(Box::new(MonApplication::default()))),
    )
}

Assembler son fichier complet

À vous de jouer

Il n'y a pas de fichier tout prêt à télécharger : c'est volontaire. Le but est de reconstituer vous-même bibliotheque_egui_sqlite.rs en assemblant, dans cet ordre, les blocs de la section précédente dans un seul fichier src/main.rs :

  1. Imports & structure (use ..., struct MonApplication, impl Default)
  2. Modèle & accès aux données (struct Livre, ouvrir_connexion, init_db, charger_livres — ajoutez aussi vos fonctions inserer_livre, mettre_a_jour_livre, supprimer_livre sur le même principe)
  3. Validation (valider_titre, valider_annee — et une éventuelle valider_isbn)
  4. Interface (impl eframe::App for MonApplication, avec la méthode ui)
  5. main()

Les parties marquées // ... ou // à compléter par vos soins dans le bloc « Interface » sont à écrire vous-même : c'est l'exercice. Elles reprennent des principes déjà vus (formulaire avec egui::Grid, validation avant écriture) dans les exercices précédents.

# Une fois le fichier assemblé, compiler et lancer
cargo run

Vérifier au fur et à mesure

Compilez après chaque bloc ajouté (cargo check) plutôt que d'attendre d'avoir tout collé : le compilateur Rust signale immédiatement une fonction ou un type manquant, ce qui aide à repérer l'ordre logique des dépendances entre les blocs.

Piège rencontré : changement d'API eframe

update devenu ui depuis eframe 0.34

Un tutoriel trouvé en ligne peut viser une ancienne version d'eframe. Depuis la version 0.34, le trait App exige une méthode ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) et non plus update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame). Symptôme : erreurs E0407 / E0046 / E0308 à la compilation.

Leçon pour les élèves : toujours vérifier la version installée (cargo tree | grep eframe) et se référer au CHANGELOG de la crate plutôt qu'à un tutoriel daté, surtout dans un écosystème aussi actif que Rust.

Pour aller plus loin (exercices élèves)

Pistes d'extension

  • Ajouter une confirmation avant suppression (egui::Window ou Modal)
  • Ajouter un champ de recherche filtrant le tableau par titre ou auteur
  • Trier le tableau par colonne (cliquer sur l'en-tête)
  • Comparer avec la version MariaDB : remplacer rusqlite par sqlx ou mysql et discuter des différences (serveur à lancer, connexion réseau, gestion des identifiants)

Persistance des données

Vérification à faire en classe

Contrairement au formulaire seul (exercice précédent) qui perdait tout en mémoire à la fermeture, ici les données survivent : fermer la fenêtre, relancer cargo run, et constater que le catalogue est toujours là — preuve que bibliotheque.db a bien persisté sur le disque entre les deux exécutions.