Partie 1 : Construire le REPL

Lecture 7 min. ‱

Table des matiĂšres

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

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 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,
}

Note

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:

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),
            },
        }
    }
}

Note

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 conclue 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. đŸ€©