Partie 4 : Tables

Lecture 4 min. ‱

Table des matiĂšres

Les articles de la série

Bonjour à toutes et tous 😃

Dans la [prĂ©cĂ©dente partie](/rustqlite-3 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.

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:

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Ă .