https://lafor.ge/feed.xml

Partie 4 : Tables

2024-12-06
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:

  • User
  • Car

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 {
    /// ...
    /// La table n'existe pas
    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> {
        // nettoyage des espaces blancs supplémentaires
        let input = input.trim();
        // Check if source is whitespace separated
        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" => {
                        // création d'un itérateur sur les espaces blancs
                        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() {
    // command d'insert correct
    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()
            })
        }))
    );
    // robustesse sur le nombre d'espaces
    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()
            })
        }))
    );
    // pas assez d'arguments
    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)
    );
    // mauvais type d'argument
    assert_eq!(
        SqlCommand::try_from_str("insert user one name email@domain.tld"),
        Err(CommandError::ExpectingInteger)
    );
    // commande inconnue
    assert_eq!(SqlCommand::try_from_str("unknown command"), Ok(None));
}

#[test]
fn test_parse_command_select() {
    // commande select correcte
    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
        }))
    );
    // table inconnue
    assert_eq!(
        SqlCommand::try_from_str("select unknown"),
        Err(CommandError::UnknownTable("unknown".to_string()))
    );
    // trop d'arguments
    assert_eq!(
        SqlCommand::try_from_str("select user value"),
        Err(CommandError::TooManyArguments)
    );
    // pas assez d'arguments
    assert_eq!(
        SqlCommand::try_from_str("select"),
        Err(CommandError::NotEnoughArguments)
    );
    // commande inconnue
    assert_eq!(SqlCommand::try_from_str("unknown command"), Ok(None));
}

#[test]
fn test_parse_command_create() {
    // commande select correcte
    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
        }))
    );
    // table inconnue
    assert_eq!(
        SqlCommand::try_from_str("create unknown"),
        Err(CommandError::UnknownTable("unknown".to_string()))
    );
    // trop d'arguments
    assert_eq!(
        SqlCommand::try_from_str("create user value"),
        Err(CommandError::TooManyArguments)
    );
    // pas assez d'arguments
    assert_eq!(
        SqlCommand::try_from_str("create"),
        Err(CommandError::NotEnoughArguments)
    );
    // commande inconnue
    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à.

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.