Partie 1 : Construire le REPL
Lecture 7 min. âą
Table des matiĂšres
- Cahier des charges
- Modélisation
- Parsing
- Evaluation et affichage
- Boucle dâĂ©coute
- Vérification de notre REPL
- Conclusion
Les articles de la série
- Partie 1 : Construire le REPL
- Partie 2 : Sérialisation des données
- Partie 3 : Database
- Partie 4 : Tables
- Partie 5 : Parser les commandes, création de la boßte à outils
- Partie 6 : Parser les commandes SQL
- Partie 7 : Tuples de données
- Partie 8 : Contraindre les données via un schéma
- Partie 9 : Découper le stockage des tables en pages
- Partie 10 : Clefs primaires
- Partie 11 : Expressions Logiques SQL
- Partie 12 : Scans et filtrage
- Partie 13 : UTF-8 et caractÚres échappés
- Partie 14 : Attributs nullifiables
- Partie 15 : Index secondaires
- Partie 16 : Directive EXPLAIN
- Partie 17 : Logical Plan
- Partie 18 : Exécution du plan logique
- Partie 19 : Suppression d'enregistrements
- Partie 20 : Réindexation et compactation de tables
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 programmeinsert 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) -> CommandEt 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 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 > .exitNous 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. đ€©
