Partie 1 : Construire le REPL
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.
Les commandes peuvent posséder des paramètres. "Insert" en possède, "Select" non.
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.
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.
Et lui associer des tests.
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.
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.
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:
Et en définir l'implémentation.
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
les test de la commande "select" sont plus restreints:
Je vous propose cette implémentation qui répond au deux sets de tests.
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.
On peut alors se faire une implémentation de notre méthode de parse
revue et corrigée.
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.
On créer notre erreur en avance également. Notre exécution pourra échouer dans l'avenir.
Et maintenant on peut passer à l'implémentation du trait.
On commence par les méta-commandes.
Puis les commandes SQL
Et finalement notre Command, qui réalise le dispatch explicite de l'éxécution.
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.
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
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. 🤩
Ce travail est sous licence CC BY-NC-SA 4.0.