Partie 4 : Tables
Les articles de la série Bonjour à toutes et tous 😃
Dans la précédante partie nous avons bâti une API de haut-niveau que l'on a appelé une Database.
Elle permet de stocker puis de récupérer des enregistrements dans une slice de bytes.
db > insert 1 user1 email1@example.com
User inserted successfully
db > insert 2 user2 email2@example.com
User inserted successfully
db > select
User { id: 1, username: "user1", email: "email1@example.com" }
User { id: 2, username: "user2", email: "email2@example.com" }
db >
On est content mais notre stockage n'est pas très souple, on ne peut stocker qu'un seul type de donnée: des User.
Dans la partie d'aujourd'hui nous allons introduire une subdivision logique de notre base de données que nous allons appeler "Table".
Modification de l'API La première chose que nous allons faire est de modifier l'API de notre REPL.
on rajoute une commande "create" qui permet de créer la table on modifie "insert" pour insérer dans la bonne table on modifie "select" pour sanner la bonne table Schémas Nous allons créer deux type de données:
Le User sera celui que l'on a déjà id:number name:string email:string
Le Car aura comme schéma id:string brand:string
Create table La commande va être simple. Elle prends une chaine de caractères qui peut être soit "user" soit "car" et insensible à la casse.
Cela nous donne la commande suivante.
db > create {table}
Le schéma est pour l'instant statitique, on vera dans une prochaine partie comment rendre tout cela dynamique.
Insert into table Nous allons reprendre la même commande que précédemment mais en y rajoutant deux modifications:
la commande prends le nom de la table en paramètres les données varie d'un type à l'autre db > insert {table} {param...}
On se retrouve avec deux commandes valides:
db > insert user {id} {name} {email}
db > insert car {id} {brand}
Select from table Même combat que pour "insert", le "select" prend désormais le nom de la table.
db > select {table}
Records Pour matérialiser ces noms de tables, nous allons créer une énumération TableName
.
# [ derive ( Debug, PartialEq, Hash, Eq, Clone, PartialOrd, Ord ) ]
pub enum TableName {
User,
Car,
}
Nous créons le "parse" du nom de table.
impl FromStr for TableName {
type Err = crate :: errors:: CommandError;
fn from_str ( s : & str ) -> Result < Self , Self :: Err > {
match s. to_ascii_lowercase ( ) . as_str ( ) {
" user" => Ok ( TableName:: User) ,
" car" => Ok ( TableName:: Car) ,
_ => Err ( crate :: errors:: CommandError:: UnknownTable( s. to_string ( ) ) ) ,
}
}
}
Le CommandError
gagne également une nouvelle variante.
pub enum CommandError {
UnknownTable( String ) ,
}
Puis nous rajouter un nouveau type de données sérializable Car
.
# [ derive ( Debug, PartialEq ) ]
pub struct Car {
id : String,
brand : String,
}
impl Car {
pub fn new ( id : String, brand : String) -> Car {
Self { id, brand }
}
}
impl Serializable for Car {
fn serialize ( & self , cursor : & mut Cursor< & mut [ u8 ] > ) -> Result < ( ) , SerializationError> {
self . id. serialize ( cursor) ? ;
self . brand. serialize ( cursor) ? ;
Ok ( ( ) )
}
}
impl Deserializable for Car {
fn deserialize ( cursor : & mut Cursor< & [ u8 ] > ) -> Result < Self , DeserializationError> {
Ok ( Car {
id: String :: deserialize( cursor) ? ,
brand: String :: deserialize( cursor) ? ,
} )
}
}
Et enfin nous crér des Record
qui seront les données de nos tables.
# [ derive ( Debug, PartialEq ) ]
pub enum Record {
User( User) ,
Car( Car) ,
}
Modifications du Parser Nos SqlCommand
doivent refléter notre nouveau mode de parse
pub enum SqlCommand {
Insert { data: Record } ,
Select { table: TableName } ,
Create { table: TableName } ,
}
On implémente alors une méthode qui se charge de réaliser le parse de la chaîne rentré par l'utilisateur dans le REPL
Et qui conduit à la création d'un Record ou une erreur.
impl Record {
fn from_parameters ( mut parameters : SplitWhitespace) -> Result < Record, CommandError> {
let record_type_string = parameters
. next ( )
. ok_or ( CommandError:: NotEnoughArguments) ?
. to_string ( ) ;
let record_type = TableName:: from_str( record_type_string. as_str ( ) ) ? ;
match record_type {
TableName:: User => {
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 ( Record:: User( User:: new( id, username, email) ) )
}
TableName:: Car => {
let id = parameters
. next ( )
. ok_or ( CommandError:: NotEnoughArguments) ?
. to_string ( ) ;
let brand = parameters
. next ( )
. ok_or ( CommandError:: NotEnoughArguments) ?
. to_string ( ) ;
Ok ( Record:: Car( Car:: new( id, brand) ) )
}
}
}
}
On peut alors modifié la méthode try_from_str
pour la faire utiliser notre nouveau mode de fonctionnement
impl TryFromStr for SqlCommand {
type Error = CommandError;
fn try_from_str ( input : & str ) -> Result < Option < Self > , Self :: Error> {
let input = input. trim ( ) ;
let first_space = input. find ( ' ' ) ;
match first_space {
Some ( first_space_index) => {
let command = & input[ 0 .. first_space_index] ;
let payload = & input[ first_space_index + 1 .. ] ;
match command {
" insert" => {
let parameters = payload. split_whitespace ( ) ;
let data = Record:: from_parameters( parameters) ? ;
Ok ( Some ( SqlCommand:: Insert { data } ) )
}
" select" => {
let mut parameters = payload. split_whitespace ( ) ;
let table = parameters
. next ( )
. ok_or ( CommandError:: NotEnoughArguments) ?
. to_string ( ) ;
let table = TableName:: from_str( table. as_str ( ) ) ? ;
if parameters. next ( ) . is_some ( ) {
return Err ( CommandError:: TooManyArguments) ;
}
Ok ( Some ( SqlCommand:: Select { table } ) )
}
" create" => {
let mut parameters = payload. split_whitespace ( ) ;
let table = parameters
. next ( )
. ok_or ( CommandError:: NotEnoughArguments) ?
. to_string ( ) ;
let table = TableName:: from_str( table. as_str ( ) ) ? ;
if parameters. next ( ) . is_some ( ) {
return Err ( CommandError:: TooManyArguments) ;
}
Ok ( Some ( SqlCommand:: Create { table } ) )
}
_ => Ok ( None ) ,
}
}
None => match input {
" insert" => Err ( CommandError:: NotEnoughArguments) ? ,
" select" => Err ( CommandError:: NotEnoughArguments) ? ,
" create" => Err ( CommandError:: NotEnoughArguments) ? ,
_ => Ok ( None ) ,
} ,
}
}
}
J'ai modifié les tests en conséquence.Tests du parse # [ test ]
fn test_parse_command_insert ( ) {
assert_eq! (
SqlCommand:: try_from_str( " insert User 1 name email@domain.tld" ) ,
Ok ( Some ( SqlCommand:: Insert {
data: Record:: User( User {
id: 1 ,
username: " name" . to_string ( ) ,
email: " email@domain.tld" . to_string ( )
} )
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert User 1 name email@domain.tld " ) ,
Ok ( Some ( SqlCommand:: Insert {
data: Record:: User( User {
id: 1 ,
username: " name" . to_string ( ) ,
email: " email@domain.tld" . to_string ( )
} )
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert user" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert user 1 name" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert user one name email@domain.tld" ) ,
Err ( CommandError:: ExpectingInteger)
) ;
assert_eq! ( SqlCommand:: try_from_str( " unknown command" ) , Ok ( None ) ) ;
}
# [ test ]
fn test_parse_command_select ( ) {
assert_eq! (
SqlCommand:: try_from_str( " select Car" ) ,
Ok ( Some ( SqlCommand:: Select {
table: TableName:: Car
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " select User " ) ,
Ok ( Some ( SqlCommand:: Select {
table: TableName:: User
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " select unknown" ) ,
Err ( CommandError:: UnknownTable( " unknown" . to_string ( ) ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " select user value" ) ,
Err ( CommandError:: TooManyArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " select" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! ( SqlCommand:: try_from_str( " unknown command" ) , Ok ( None ) ) ;
}
# [ test ]
fn test_parse_command_create ( ) {
assert_eq! (
SqlCommand:: try_from_str( " create Car" ) ,
Ok ( Some ( SqlCommand:: Create {
table: TableName:: Car
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " create User " ) ,
Ok ( Some ( SqlCommand:: Create {
table: TableName:: User
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " create unknown" ) ,
Err ( CommandError:: UnknownTable( " unknown" . to_string ( ) ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " create user value" ) ,
Err ( CommandError:: TooManyArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " create" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! ( SqlCommand:: try_from_str( " unknown command" ) , Ok ( None ) ) ;
}
Table Nous introduisons une nouvelle structure de données appellée Table
.
const TABLE_SIZE : usize = 1024 * 1024 ;
pub struct Table {
inner : Vec < u8 > ,
offset : usize ,
row_number : usize ,
}
impl Table {
pub fn new ( ) -> Self {
Self {
inner: vec! [ 0 ; TABLE_SIZE ] ,
offset: 0 ,
row_number: 0 ,
}
}
}
impl Table {
pub fn insert < S: Serializable> ( & mut self , row : S) -> Result < ( ) , InsertionError> {
let mut writer = Cursor:: new( & mut self . inner[ self . offset.. ] ) ;
row. serialize ( & mut writer)
. map_err ( InsertionError:: Serialization) ? ;
self . offset += writer. position ( ) as usize ;
self . row_number += 1 ;
Ok ( ( ) )
}
pub fn select < D: Deserializable> ( & self ) -> Result < Vec < D> , SelectError> {
let mut reader = Cursor:: new( & self . inner[ .. ] ) ;
let mut rows = Vec :: with_capacity( self . row_number) ;
for _row_number in 0 .. self . row_number {
rows. push ( D:: deserialize( & mut reader) . map_err ( SelectError:: Deserialization) ? )
}
Ok ( rows)
}
}
Si ce code vous dit vaguement une idée, c'est normal, c'est celui de Database
. 😄
Database mais avec des tables C'est ici que le gros des modifications vont se passer.
La Database devient un wrapper autours d'une map de tables.
pub struct Database {
tables : HashMap< TableName, Table> ,
}
impl Database {
pub fn new ( ) -> Self {
Self {
tables: Default :: default( ) ,
}
}
}
On définit alors son interface public.
Create table D'abord la création de la table qui renvoie une erreur si la table existe déjà.
La création de la table alloue un espace mémoire pour le stockage des records.
pub fn create_table ( & mut self , table_name : TableName) -> Result < ( ) , CreationError> {
if self . tables. contains_key ( & table_name) {
return Err ( CreationError:: TableAlreadyExist( table_name) )
}
self . tables. insert ( table_name, Table:: new( ) ) ;
Ok ( ( ) )
}
Pour l'occasion, on se créé une nouvelle erreur.
# [ derive ( Debug, PartialEq ) ]
pub enum CreationError {
TableAlreadyExist( TableName) ,
}
impl Display for CreationError {
fn fmt ( & self , f : & mut std:: fmt:: Formatter< '_ > ) -> std:: fmt:: Result {
write! ( f, " {:?} " , self )
}
}
impl Error for CreationError { }
Que l'on enregistre dans le CommandError
pub enum ExecutionError {
Insertion( InsertionError) ,
Select( SelectError) ,
Create( CreationError) ,
}
Insert into table On récupère le nom de la table et on essaie de récupérer la table si elle existe.
Si c'est le cas alors on insert les données dans la table trouvée.
pub fn insert ( & mut self , data : Record) -> Result < ( ) , InsertionError> {
let table_key = match data {
Record:: User( _ ) => TableName:: User,
Record:: Car( _ ) => TableName:: Car,
} ;
match self . tables. get_mut ( & table_key) {
Some ( table) => match data {
Record:: User( user) => {
table. insert ( user) ? ;
}
Record:: Car( car) => {
table. insert ( car) ? ;
}
} ,
None => {
Err ( InsertionError:: TableNotExist( table_key) ) ? ;
}
}
Ok ( ( ) )
}
On ajoute une erreur supplémentaire
pub enum InsertionError {
TableNotExist( TableName) ,
Serialization( SerializationError) ,
}
Select from table Le "select" n'est pas plus complexe à implémenter.
Comme nous savons quelle table nous tapons, nous connaissons le schéma et donc le type vers quoi désérialiser.
Ce qui est reflété par le table.select::<User>()
.
La fonction va alors renvoyer un tableau de User que l'on remap vers des Records
pub fn select ( & mut self , table_name : TableName) -> Result < Vec < Record> , SelectError> {
match self . tables. get ( & table_name) {
Some ( table) => match table_name {
TableName:: User => Ok ( table
. select:: < User> ( ) ?
. into_iter ( )
. map ( Record:: User)
. collect:: < Vec < _ > > ( ) ) ,
TableName:: Car => Ok ( table
. select:: < Car> ( ) ?
. into_iter ( )
. map ( Record:: Car)
. collect:: < Vec < _ > > ( ) ) ,
} ,
None => Err ( SelectError:: TableNotExist( table_name) ) ? ,
}
}
On rajoute l'erreur lors de la sélection.
Ayant créé des couches d'abstractions simples mais évolutives, il est aisé de construire par dessus de l'intelligence.
Modification de l'exécution Les commandes ayant changé, l'implémentation doit le faire également.
Mais comme vous pouvez le voir, rien de dramatique.
La Database est déjà un wrapper sur la logique.
impl Execute for SqlCommand {
fn execute ( self , database : & mut Database) -> Result < ( ) , ExecutionError> {
match self {
SqlCommand:: Insert { data } => {
database. insert ( data) . map_err ( ExecutionError:: Insertion) ? ;
println! ( " Record inserted successfully" ) ;
}
SqlCommand:: Select { table } => {
for user in database. select ( table) . map_err ( ExecutionError:: Select) ? {
println! ( " {:?} " , user) ;
}
}
SqlCommand:: Create { table } => {
database. create_table ( table) . map_err ( ExecutionError:: Create) ? ;
println! ( " Table created successfully" ) ;
}
}
Ok ( ( ) )
}
}
On modifie également la méthode run pour catch les erreur d'exécutions
pub fn run ( ) -> Result < ( ) , Box < dyn Error> > {
let mut database = database:: Database:: new( ) ;
loop {
print! ( " db > " ) ;
std:: io:: stdout( ) . flush ( ) ? ;
let mut command = String :: new( ) ;
std:: io:: stdin( ) . read_line ( & mut command) ? ;
let command = command. trim ( ) ;
match parse ( command) {
Ok ( command) => {
if let Err ( err) = command. execute ( & mut database) {
println! ( " {} " , err)
}
}
Err ( err) => println! ( " Error {err} " ) ,
}
}
}
Tests grandeur nature On peut alors jouer avec notre système.
D'abord refaire ce que l'on faisait précédemment.
db > create user
Table created successfully
db > insert user 1 name email@example.com
Record inserted successfully
db > insert user 2 name2 email2@example.com
Record inserted successfully
db > select user
User(User { id: 1, username: "name", email: "email@example.com" })
User(User { id: 2, username: "name2", email: "email2@example.com" })
Puis commencer à manipuler des tables différentes
db > select car
Select(TableNotExist(Car))
db > create car
Table created successfully
db > select car
db > insert car XXX Volvo
Record inserted successfully
db > insert car YYY Renault
Record inserted successfully
Et s'apercevoir que notre DB commence à avoir de la gueule ! 😍
db > select user
User(User { id: 1, username: "name", email: "email@example.com" })
User(User { id: 2, username: "name2", email: "email2@example.com" })
db > select car
Car(Car { id: "XXX", brand: "Volvo" })
Car(Car { id: "YYY", brand: "Renault" })
Et gère bien les erreurs.
db > create user
Create(TableAlreadyExist(User))
db > create toto
Error UnknownTable("toto")
Conclusion Dans cette partie nous avons rajouté l'abstraction des tables dans notre base de données.
Pour le moment tout est artificiel et statique mais dans la prochaine partie , nous allons dynamiser et généraliser tout cela.
Merci de votre lecture ❤️
Vous pouvez trouver le code la partie ici et le diff là.
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 .