https://lafor.ge/feed.xml

Partie 1 : Construire le REPL

2024-11-26
Les articles de la série

Bonjour à toutes et à tous 😀

Mon métier de tous les jours est de fabriquer des bases de données, mais en vrai je ne sais pas vraiment comment ça marche. 😅

Au détours, d'une visite sur twitter je suis tombé sur une mine d'or. Une personne qui a documenté son voyage en C. 🤩

Mais moi je ne suis pas un "gars du pointeur", je préfère les références et l'absence de Undefined Behavior, bref je fais du Rust! 🦀 Je vous ai déjà parlé de Rust ..? 🤣

Pour cette série d'article car oui ça sera une série, sinon je vais vous noyer. Nous allons réimplémeter sqlite, la base de données relationnelle, en utilisant uniquement la lib standard et en limitant au strict minimum l'usage de l'unsafe.

Exit donc l'usage de la lib serde, nous allons devoir nous débrouiller sans ^^'

La première chose que nous allons réaliser est la création de l'invite de commande ou Read Eval Print Loop.

Ce qui signifie Boucle de lecture et d'évaluation et d'affichage. En d'autres terme, le mécanisme qui permettre à un utilisateur d'entrer une commande qui va être parsée puis exécuté par la base de données.

Cahier des charges

Il est toujours bon, d'avoir un cap à suivre pour éviter l'over-engineering ou au contraire de manquer complètement sa cible.

Nous allons dire que notre programme renvoie à chaque commande exécutée et au démarrage un invite de commandes

db > commande utilisateur
retour de la DB
peut-être multiligne
db >

Pour commencer, nous allons simplifier drastiquement le probème en définissant un set très réduit de commandes

  • .exit : permet de quitter l'invite de commande et de fermer le programme
  • insert 1 name email : insert un utilisateur d'ID 1 et de nom "name" et d'email "email"
  • select : renvoie toutes les entrées stockées

Cela n'a l'air de rien mais juste cela demande de réfléchir un peu. 😅

Nomemclature

Nous allons décomposer nos commandes en deux lots:

  • les méta-commandes qui n'intéragissent pas avec la base de données à proprement parler
  • les commandes qui manipulent de la données

Les méta-commandes commencent toutes par un ".". Ceci est un critère qui peut être pris en compte plus tard.

Modélisation

L'expressivité de Rust est formidable outils de modélisation. Modélisons donc notre set de commandes supportées.

Les méta-commandes pour commencer, on ne possède qu'une seule commande.

#[derive(Debug, PartialEq)]
enum MetaCommand {
    Exit,
}

Les commandes peuvent posséder des paramètres. "Insert" en possède, "Select" non.

#[derive(Debug, PartialEq)]
enum SqlCommand {
    Insert {
        id: i64,
        username: String,
        email: String,
    },
    Select,
}

La modélisation est naïve et restrictive pour ce premier jet. Je préfère itérer sur du simple que de devoir gérer la complexité tout de suite.

Nous allons en faire l'union via une troisième enumération. On rajoute une lifetime 'a pour nous épargner une copie en cas d'erreur.

#[derive(Debug, PartialEq)]
enum Command<'a> {
    Sql(SqlCommand),
    Meta(MetaCommand),
    Unknown { command: &'a str },
}

L'utilisateur va utiliser notre REPL pour entrer des commandes. Ce qui signifie que nous allons traiter des chaîne de caractères.

En informatique, l'analyse et la transformation d'une chaîne de caractères en des données manipulable se nomme un parsing.

Nous allons également modéliser ce parse.

Nous pouvons nous aider du TDD pour nous guider dans sa construction.

On peut définir une méthode parse pour faire ce que l'on désire.

fn parse(input: &str) -> Command

Et lui associer des tests.

#[test]
fn test_parse() {
    assert_eq!(parse(".exit"), Command::Meta(MetaCommand::Exit));
    assert_eq!(parse("insert 1 name email@domain.tld"), Command::Sql(SqlCommand::Insert {
        id: 1,
        username: "name".to_string(),
        email: "email@domain.tld".to_string()
    }));
    assert_eq!(parse("select"), Command::Sql(SqlCommand::Select));
}

On se retrouve à devoir gérer trop de choses à parser. On va donc diviser pour mieux régner, et pour ce faire nous allons créer un nouveau trait.

trait TryFromStr {
    type Error;
    fn try_from_str(src: &str) -> Result<Option<Self>, Self::Error>
    where
        Self: Sized;
}

Le but de ce trait est de permettre d'essayer de transformer une chaîne de caractères en quelque chose qui nous intéresse. On se ménage aussi la possibilité d'échouer de deux manière: soit on ne trouve pas d'alternative possible et on renvoie un None d'où l'Option, soit une alternative a été trouvé mais c'est un échec.

Nous allons modéliser également cette échec par une énumération.

#[derive(Debug, PartialEq)]
pub enum CommandError {
    /// Pas assez d'arguments  dans la commande
    NotEnoughArguments,
    /// Trop d'arguments dans la commande
    TooManyArguments,
    /// L'argument attendu devait être un entier
    ExpectingInteger,
}

impl core::fmt::Display for CommandError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl core::error::Error for CommandError {}

On lui adjoint le nécessaire pour en faire une Error.

Parsing

Notre modélisation est complète, nous allons pouvoir passer à l'implémentation.

Méta-commandes

Nous n'avons qu'une méta-commande: ".exit".

Ainsi nous avons le set de tests suivant:

#[test]
fn test_parse_meta_command() {
    assert_eq!(
        MetaCommand::try_from_str(".exit"),
        Ok(Some(MetaCommand::Exit))
    );
    assert_eq!(MetaCommand::try_from_str("unknown command"), Ok(None));
}

Et en définir l'implémentation.

impl TryFromStr for MetaCommand {
    type Error = CommandError;
    fn try_from_str(input: &str) -> Result<Option<Self>, Self::Error> {
        match input {
            ".exit" => Ok(Some(MetaCommand::Exit)),
            _ => Ok(None),
        }
    }
}

Commandes SQL

Deux commandes sont à implémenter:

  • insert 1 name email : insert un utilisateur d'ID 1 et de nom "name" et d'email "email"
  • select : renvoie toutes les entrées stockées

La commande d'insert est la plus demandante en terme de contrôle des données.

Voici un set de tests qui couvrent

#[test]
fn test_parse_command_insert() {
    // command d'insert correct
    assert_eq!(
        SqlCommand::try_from_str("insert 1 name email@domain.tld"),
        Ok(Some(SqlCommand::Insert {
            id: 1,
            username: "name".to_string(),
            email: "email@domain.tld".to_string()
        }))
    );
    // robustesse sur le nombre d'espaces
    assert_eq!(
        SqlCommand::try_from_str("    insert     1     name     email@domain.tld     "),
        Ok(Some(SqlCommand::Insert {
            id: 1,
            username: "name".to_string(),
            email: "email@domain.tld".to_string()
        }))
    );
    // pas assez d'arguments
    assert_eq!(
        SqlCommand::try_from_str("insert"),
        Err(CommandError::NotEnoughArguments)
    );
    assert_eq!(
        SqlCommand::try_from_str("insert 1"),
        Err(CommandError::NotEnoughArguments)
    );
    assert_eq!(
        SqlCommand::try_from_str("insert 1 name"),
        Err(CommandError::NotEnoughArguments)
    );
    // mauvais type d'argument
    assert_eq!(
        SqlCommand::try_from_str("insert one name email@domain.tld"),
        Err(CommandError::ExpectingInteger)
    );
    // commande inconnue
    assert_eq!(SqlCommand::try_from_str("unknown command"), Ok(None));
}

les test de la commande "select" sont plus restreints:

#[test]
fn test_parse_command_select() {
    // commande select correcte
    assert_eq!(
        SqlCommand::try_from_str("select"),
        Ok(Some(SqlCommand::Select))
    );
    // robustesse sur les espaces blancs
    assert_eq!(
        SqlCommand::try_from_str("    select    "),
        Ok(Some(SqlCommand::Select))
    );
    // trop d'arguments
    assert_eq!(
        SqlCommand::try_from_str("select args value"),
        Err(CommandError::TooManyArguments)
    );
    // commande inconnue
    assert_eq!(SqlCommand::try_from_str("unknown command"), Ok(None));
}

Je vous propose cette implémentation qui répond au deux sets de tests.

impl TryFromStr for SqlCommand {
    type Error = CommandError;

    fn try_from_str(input: &str) -> Result<Option<Self>, Self::Error> {
        // nettoyage des espaces blancs supplémentaires
        let input = input.trim();
        // On vérifie s'il y a des espaces blancs
        let first_space = input.find(' ');
        // La commande possède des arguments
        match first_space {
            Some(first_space_index) => {
                let command = &input[0..first_space_index];
                let payload = &input[first_space_index + 1..];
                match command {
                    "insert" => {
                        // création d'un itérateur sur les espaces blancs
                        let mut parameters = payload.split_whitespace();
                        let id = parameters
                            .next()
                            .ok_or(CommandError::NotEnoughArguments)?
                            .parse()
                            .map_err(|_| CommandError::ExpectingInteger)?;
                        let username = parameters
                            .next()
                            .ok_or(CommandError::NotEnoughArguments)?
                            .to_string();
                        let email = parameters
                            .next()
                            .ok_or(CommandError::NotEnoughArguments)?
                            .to_string();
                        Ok(Some(SqlCommand::Insert {
                            id,
                            username,
                            email,
                        }))
                    }
                    "select" => Err(CommandError::TooManyArguments)?,
                    _ => Ok(None),
                }
            }
            None => match input {
                "insert" => Err(CommandError::NotEnoughArguments)?,
                "select" => Ok(Some(SqlCommand::Select)),
                _ => Ok(None),
            },
        }
    }
}

Ceci est une approche naïve du parse, elle ne sera pas notre implémentation finale, car elle a plein de problèmes dont l'incapacité de permettre de mettre des espaces dans les champs par exemple.

Nous règlerons les soucis progressivement.

Commandes

On peut alors tout rassembler.

Etant donné que nous avons introduit de la fallibilité dans le parse de la commande SQL.

Nous devons la refléter également dans nos tests. Je ne vérifie que les cas voulus, les cas d'erreurs étant géré par les tests des commandes spécifiques.

#[test]
fn test_parse() {
    assert_eq!(parse(".exit"), Ok(Command::Meta(MetaCommand::Exit)));
    assert_eq!(
        parse("insert 1 name email@domain.tld"),
        Ok(Command::Sql(SqlCommand::Insert {
            id: 1,
            username: "name".to_string(),
            email: "email@domain.tld".to_string()
        }))
    );
    assert_eq!(parse("select"), Ok(Command::Sql(SqlCommand::Select)));
    assert_eq!(
        parse("unknown command"),
        Ok(Command::Unknown {
            command: "unknown command"
        })
    );
}

On peut alors se faire une implémentation de notre méthode de parse revue et corrigée.

fn parse(input: &str) -> Result<Command, CommandError> {
    let input = input.trim_start();
    // on utilise le . comme discriminant de meta-commande  
    let command = if input.starts_with(".") {
        // le map permet de transformer en énumération Command notre résultat si c'est un Some
        MetaCommand::try_from_str(input)?.map(Command::Meta)
    } else {
        SqlCommand::try_from_str(input)?.map(Command::Sql)
    }
    // si aucun parser n'est capable de trouver une alternative valable
    // alors la commande est inconnue
    .unwrap_or(Command::Unknown { command: input });
    Ok(command)
}

Le fait d'avoir implémenter un trait permet de normaliser tous les comportements et d'écrire du code simple à lire et comprendre.

Evaluation et affichage

On a fait le R de REPL. Nous savons lire et interpréter la commande, mais il nous reste encore du chemin.

Notre prochaine étape est d'en faire quelque de cette commande.

Nous allons faire très simple dans un premier temps.

La commande ".exit" va fermer avec succès le programme.

Les commandes "select" et "insert" afficher un petit message dans la console.

Nous avons la chance d'être en Rust, donc continuons à modéliser correctement les choses.

Nous pouvons déclarer un trait qui se chargera de gérer l'exécution de la commande.

trait Execute {
    fn execute(self) -> Result<(), ExecutionError>;
}

On créer notre erreur en avance également. Notre exécution pourra échouer dans l'avenir.

#[derive(Debug, PartialEq)]
pub enum ExecutionError {}

impl Display for ExecutionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl Error for ExecutionError {}

Et maintenant on peut passer à l'implémentation du trait.

On commence par les méta-commandes.

impl Execute for MetaCommand {
    fn execute(self) -> Result<(), ExecutionError> {
        match self {
            MetaCommand::Exit => {
                std::process::exit(0);
            }
        }
    }
}

Puis les commandes SQL

impl Execute for SqlCommand {
    fn execute(self) -> Result<(), ExecutionError> {
        match self {
            SqlCommand::Insert { .. } => {
                println!("Ici sera la future commande insert");
            }
            SqlCommand::Select => {
                println!("Ici sera la future commande select");
            }
        }
        Ok(())
    }
}

Et finalement notre Command, qui réalise le dispatch explicite de l'éxécution.

impl Execute for Command<'_> {
    fn execute(self) -> Result<(), ExecutionError> {
        match self {
            Command::Meta(command) => command.execute(),
            Command::Sql(command) => command.execute()
            Command::Unknown {command} => {
                println!("Command not found: {}", command);
                Ok(())
            }
        }
    }
}

On ne fera pas de TDD ici, car ça s'y prête difficilement, il faudrait tordre le code pour y arriver, et ce n'est pas souhaitable.

Au lieu de ça, nous allons continuer la construction du REPL.

Boucle d'écoute

Si on ne veux pas que notre REPL se coupe dès la première commande, il faut en faire une boucle.

fn run() -> Result<(), Box<dyn Error>> {
    loop {
        print!("db > ");
        // permet de tout conserver sur une ligne
        std::io::stdout().flush()?;
        // lecture de l'entrée utilisateur
        let mut command = String::new();
        std::io::stdin().read_line(&mut command)?;
        // parse et exécution
        match parse(&command) {
            Ok(command) => {
                // Si une commande a été trouvée, on l'exécute
                command.execute()?;
            }
            // sinon on affiche l'erreur de parse
            Err(err) => println!("Error {err}"),
        }
    }
}

Etant donné que toute nos erreurs sont compatible avec le trait Error, il est aisé de renvoyer l'erreur sans la définir explicitement.

Cette fonction run concle le L de notre REPL.

Vérification de notre REPL

On se créé une méthode main

fn main() {
    run().expect("Error occurred")
}

Et cargo run !

db > select
Ici sera la future commande select
db > insert 1 name email@domain.tld
Ici sera la future commande insert
db > insert
Error NotEnoughArguments
db > insert one name email@domain.tld
Error ExpectingInteger
db > select toto
Error TooManyArguments
db > command unknown
Command not found: command unknown
db > .exit

Nous avons le résultat escompté 😍

Conclusion

Ceci conclu notre première partie. Nous nous sommes largement reposé sur le système de type de Rust pour concevoir notre modélisation et nous allons continuer.

Comme vous pouvez le voir, la librairie standard de Rust permet de faire beaucoup de choses sans avoir à tirer des libs externes.

Nous allons continuer dans cette voie.

Dans la prochaine partie nous allons aborder le sujets de la sérialisation de la données que nous allons insérer en base de données.

Vous pouvez trouver ci-joint le lien vers la branche du repo git contenant le code de cette partie.

Merci de votre lecture et à la prochaine pour sérialiser de la data. 🤩

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.