Partie 7 : Tuples de données

Lecture 5 min. ‱

Table des matiĂšres

Les articles de la série

Bonjour à toutes et tous 😃

Depuis la [partie 6](/rustqlite-6 nous sommes capables de parser une commande une commande permettant de crĂ©er une table avec un schĂ©ma arbitraire, puis d’y insĂ©rer et finalement d’y rĂ©cupĂ©rer des records.

Aujourd’hui nous allons gĂ©nĂ©raliser les entitĂ©s User et Car que nous avions utilisĂ©s comme placeholder pour simuler les opĂ©rations d’enregistrements et de lecture sans devoir se soucier des problĂ©matique de schĂ©mas.

Nous allons toujours pas nous occuper des schĂ©mas, mais par contre nous allons introduire le concept fondamental qui permettra de stocker de maniĂšre optimale des enregistrement et nous donnera de maniĂšre quasi gratuite la l’atomicitĂ© des update de colonnes.

Mais le chemin est encore long. 😅

On va commencer par déjà généraliser nos données stockées.

Tuples

Rappelez vous la commande d’insertion est rĂ©duite en une InsertIntoCommand, qui possĂšde une map de Value

rust
struct InsertIntoCommand {
    pub table_name: String,
    pub fields: HashMap<String, Value>,
}

Cette Value se dĂ©compe elle-mĂȘme en deux variantes.

rust
enum Value {
    Integer(i64),
    Text(String),
}

La question est alors:

Question

Comment peut-on stocker cette énumération en base de données?

La rĂ©ponse ne va pas vous dĂ©friser, il faut sĂ©rialiser, tout comme l’on avait fait avec User et Car.

Comme c’est une Ă©numĂ©ration, il faut qu’à la dĂ©sĂ©rialisation on puisse recrĂ©er la bonne variante.

Pour cela on se créé une autre énumération qui va servir de discriminant à la désérialisation.

rust
enum Discrimant {
    Integer = 0,
    Text,
}

impl TryFrom<u8> for Discrimant {
    type Error = DeserializationError;

    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            0 => Ok(Discrimant::Integer),
            1 => Ok(Discrimant::Text),
            _ => Err(DeserializationError::EnumDiscriminant(value)),
        }
    }
}

On rajoute une erreur de désérialisation supplémentaire.

rust
enum DeserializationError {
    Buffer(BufferError),
    UnableToDeserializeString(FromUtf8Error),
    // Ici 👇
    EnumDiscriminant(u8),
}

Maintenant on peut implémenter la sérialisation.

rust
impl Serializable for Value {
    fn serialize(&self, cursor: &mut Cursor<&mut [u8]>) -> Result<(), SerializationError> {
        // on récupÚre le discrimnant
        let discriminant = match self {
            Value::Integer(_) => Discrimant::Integer,
            Value::Text(_) => Discrimant::Text,
        };

        // on stocke dans 1 byte le discriminant
        cursor
            .write_all(&[discriminant as u8])
            .map_err(|e| SerializationError::Buffer(BufferError::BufferFull(e.to_string())))?;

        // la données interne est alors sérialisée
        match self {
            Value::Integer(data) => data.serialize(cursor)?,
            Value::Text(data) => data.serialize(cursor)?,
        }

        Ok(())
    }
}

Et son complémentaire de désérialisation.

rust
impl Deserializable for Value {
    fn deserialize(cursor: &mut Cursor<&[u8]>) -> Result<Self, DeserializationError> {
        // on lit le premier byte
        let mut discrimant = [0u8];
        cursor.read_exact(&mut discrimant).map_err(|err| {
            DeserializationError::Buffer(BufferError::ReadTooMuch(err.to_string()))
        })?;
        // que tente de convertir en un Discriminant
        let discriminant = Discrimant::try_from(discrimant[0])?;
        // puis on désérialise vers le bon type avant de wrap le résultat dans la variante
        match discriminant {
            Discrimant::Integer => i64::deserialize(cursor).map(Value::Integer),
            Discrimant::Text => String::deserialize(cursor).map(Value::Text),
        }
    }
}

Nous sommes désormais capables de sérialiser nos Value.

rust
#[test]
fn test_serialize_deserialize_integer() {
    let value = Value::Integer(1);
    let mut buffer = [0u8; 1024];
    let mut writer = Cursor::new(&mut buffer[..]);
    value.serialize(&mut writer).unwrap();
    let mut reader = Cursor::new(&buffer[..]);
    let deserialized = Value::deserialize(&mut reader).unwrap();
    assert_eq!(value, deserialized);
}

#[test]
fn test_serialize_deserialize_text() {
    let value = Value::Text("texte de test".to_string());
    let mut buffer = [0u8; 1024];
    let mut writer = Cursor::new(&mut buffer[..]);
    value.serialize(&mut writer).unwrap();
    let mut reader = Cursor::new(&buffer[..]);
    let deserialized = Value::deserialize(&mut reader).unwrap();
    assert_eq!(value, deserialized);
}

Mais notre commande d’insertion comporte plusieurs valeurs. Ce n’est donc pas une Value mais un Vec<Value>.

Alors on pourrait implĂ©menter Serializable sur Vec<Value>, mais on va se donner le luxe d’utiliser les outils de Rust et utiliser la blanket implementation.

On a exactement le mĂȘme concept que pour l’énumĂ©ration, Ă  la dĂ©sĂ©rialisation, il faut qu’on soit capable de dĂ©terminer combien d’élements sont constitutifs du Vec.

rust
impl<T: Serializable> Serializable for Vec<T> {
    fn serialize(&self, cursor: &mut Cursor<&mut [u8]>) -> Result<(), SerializationError> {
        // on stocke dans le premier byte le nombre d'élements sérialisé
        cursor
            .write_all(&[self.len() as u8])
            .map_err(|err| SerializationError::Buffer(BufferError::BufferFull(err.to_string())))?;
        // on sérialise à la chaßne chaque valeur
        for item in self {
            item.serialize(cursor)?;
        }
        Ok(())
    }
}

La dĂ©sĂ©rialisation n’est pas plus complexe.

rust
impl<T: Deserializable> Deserializable for Vec<T> {
    fn deserialize(cursor: &mut Cursor<&[u8]>) -> Result<Self, DeserializationError> {
        // on lit le premier byte qui est la taille du Vec
        let mut size = [0u8; 1];
        cursor.read_exact(&mut size).map_err(|err| {
            DeserializationError::Buffer(BufferError::ReadTooMuch(err.to_string()))
        })?;
        let size = size[0] as usize;
        
        // on alloue suffisamment de place dans un Vec pour
        // accueillir les éléments
        let mut items = Vec::with_capacity(size);

        // on désérialise à la chaßne les N éléments du Vec
        for _ in 0..size {
            items.push(T::deserialize(cursor)?);
        }
        Ok(items)
    }
}

Et cette fois-ci on est bon ! 😎

rust
#[test]
fn test_serialize_deserialize_tuple() {
    let tuple = vec![
        Value::Integer(1),
        Value::Text("John Doe".to_string()),
        Value::Text("john.doe@example.com".to_string()),
    ];

    let mut buffer = [0u8; 1024];
    let mut writer = Cursor::new(&mut buffer[..]);
    tuple.serialize(&mut writer).unwrap();
    let mut reader = Cursor::new(&buffer[..]);
    let deserialized = <Vec<Value>>::deserialize(&mut reader).unwrap();
    assert_eq!(tuple, deserialized);
}

Si on résume notre sérialisation, on se retrouve pour un [Integer(42), Text("test")] avec ceci en mémoire.

0x02         0x00      0x00 0x00 0x00 0x00 0x00 0x00 0x00 0xd6   0x01       0x04       0x74 0x65 0x73 0x74
^            ^         ^                                         ^          ^          ^
taille       D=Int     42 sur 8 bytes encodé en litte-endian     D=Text     taille     "test" encodé en UTF-8
du Vec                                                                      String

Cette structure de données sérialisée est notre Tuple.

Note

Le format va radicalement changer lorsque l’on introduira le schĂ©ma dans la sĂ©rialisation. Pour le moment nos donnĂ©es sont auto-porteuses du schĂ©ma, mais on gĂąche des bytes Ă  encoder des donnĂ©es pas forcĂ©ment utile comme les tailles de vecteur, les tailles de string et les discriminants.

Sur des millions d’enregistrements, cela peut avoir un poids considĂ©rable !!

Nos entiers également prennent vraiment trop de place, on va également revoir leur stockage.

Intrduction du nouveau parser

Maintenant que l’on a notre tuple de donnĂ©es.

Modification de Table

MĂȘme si pour le moment nous n’allons pas rĂ©ellement l’utiliser, nous allons prĂ©parer le terrain pour les futurs travaux.

Nous allons doter la table d’un SchĂ©ma, directement issu de la commande CreateTableCommand.

rust
struct CreateTableCommand {
    pub table_name: String,
    pub schema: Schema,
}

Et on n’oublie de modifier le constructeur en consĂ©quence.

rust
struct Table {
    inner: Vec<u8>,
    schema: Schema,
    offset: usize,
    row_number: usize,
}

impl Table {
    pub fn new(schema: Schema) -> Self {
        Self {
            schema,
            inner: vec![0; TABLE_SIZE],
            offset: 0,
            row_number: 0,
        }
    }
}

Modification de Database

PrĂ©dĂ©mment pour identifier les tables dans la Database on se servait de l’énumĂ©ration TableName.

rust
enum TableName {
    User,
    Car,
}

Or celle-ci n’a plus de sens dĂ©sormais car l’utilisateur Ă  la crĂ©ation de la table la nomme comme il l’entend.

De mĂȘme la notion de Record est complĂštement caduque et remplacĂ©e par le tuple Vec<Value>.

rust
enum Record {
    User(User),
    Car(Car),
}

On modifie donc Database pour mapper non pas TableName mais une String Ă  nos Table.

rust
struct Database {
    tables: HashMap<String, Table>,
}

Cela a pour incidence de modifier les signature des fonctions en dessous.

rust
impl Database {
    pub fn create_table(
        &mut self,
        // le nom de la table est arbitraire
        table_name: String,
        // le schéma est rajouté
        schema: Schema,
    ) -> Result<(), CreationError> {
        if self.tables.contains_key(&table_name) {
            return Err(CreationError::TableAlreadyExist(table_name));
        }
        self.tables.insert(table_name, Table::new(schema));
        Ok(())
    }

    pub fn insert(
        &mut self,
        // le nom de la table est désormais arbitraire
        table: String,
        // ce n'est plus un Record mais une map de Value
        data: HashMap<String, Value>,
    ) -> Result<(), InsertionError> {
        match self.tables.get_mut(&table) {
            Some(table) => {
                // on collecte les valeurs, on fera les check de schéma
                // plus tard
                let values = data.into_values().collect::<Vec<Value>>();
                table.insert(values)?;
            }
            None => {
                Err(InsertionError::TableNotExist(table))?;
            }
        }

        Ok(())
    }

    pub fn select(
        &mut self,
        // le nom de la table est désormais arbitraire 
        table_name: String
    ) -> Result<
            // le retour n'est plus un Vec<Record> mais un Vec<Vec<Value>>
            Vec<Vec<Value>>, 
            SelectError
        > {
        match self.tables.get(&table_name) {
            // on désérialise vers un Vec<Value> chaque enregistrement
            Some(table) => table.select::<Vec<Value>>(),
            None => Err(SelectError::TableNotExist(table_name))?,
        }
    }
}

Utilisation du nouveau parser

Pour rappel nous avons l’énumĂ©ration Command comme suit

rust
enum Command {
    CreateTable(create_table::CreateTableCommand),
    Select(select::SelectCommand),
    InsertInto(insert_into::InsertIntoCommand),
}

Ce Command est visitable. On peut donc en faire un parser en 2 lignes.

rust
fn parse_sql_command(data: &[u8]) -> crate::parser::Result<Command> {
    let mut scanner = Scanner::new(data);
    Command::accept(&mut scanner)
}

On introduit une nouvelle CommandError

rust
enum CommandError {
    /// Une erreur de parse est survenue
    Parse(ParseError),
}

On peut alors remplacer notre parser approximatif par quelque chose de bien plus puissant.

rust
pub fn parse(input: &str) -> Result<Command, CommandError> {
    let input = input.trim_start();
    let command = if input.starts_with(".") {
        meta::MetaCommand::try_from_str(input)?.map(Command::Meta)
    } else {
        parse_sql_command(input.as_bytes())
            .map(|command| Some(Command::Sql(command)))
            .map_err(CommandError::Parse)?
    }
    .unwrap_or(Command::Unknown { command: input });
    Ok(command)
}

Implémentation des commandes

Il nous reste alors d’implĂ©menter le trait Execute pour les diffĂ©rente commandes.

D’abord le CREATE TABLE.

rust
impl Execute for CreateTableCommand {
    fn execute(self, database: &mut Database) -> Result<(), ExecutionError> {
        let CreateTableCommand { schema, table_name } = self;
        database
            .create_table(table_name, schema)
            .map_err(ExecutionError::Create)?;
        Ok(())
    }
}

Puis le INSERT INTO

rust
impl Execute for InsertIntoCommand {
    fn execute(self, database: &mut Database) -> Result<(), ExecutionError> {
        let InsertIntoCommand { table_name, fields } = self;
        database
            .insert(table_name, fields)
            .map_err(ExecutionError::Insertion)
    }
}

Finalement SELECT FROM

rust
impl Execute for SelectCommand {
    fn execute(self, database: &mut Database) -> Result<(), ExecutionError> {
        let SelectCommand { table_name, .. } = self;
        let rows = database
            .select(table_name)
            .map_err(ExecutionError::Select)?;
        for row in rows {
            // on affiche juste le tuple sans formattage avancé
            println!("{:?}", row);
        }
        Ok(())
    }
}

Tout ceci permettant de faire remonter l’exĂ©cution jusq’à la commande

rust
impl Execute for Command {
    fn execute(self, database: &mut Database) -> Result<(), ExecutionError> {
        match self {
            Command::CreateTable(command) => command.execute(database),
            Command::Select(command) => command.execute(database),
            Command::InsertInto(command) => command.execute(database),
        }
    }
}

N’ayant pas modifiĂ© l’interface publique de notre API, nous avons dĂ©jĂ  quelque chose de fonctionnel ! 😍

Petit tests

Ce que l’on pouvait faire avant, on peut toujours le faire.

db > CREATE TABLE Users (id INTEGER, name TEXT(50), email TEXT(128));
db > INSERT INTO Users(id, name, email) VALUES (1, 'John Doe', 'john.doe@example.com');
db > INSERT INTO Users(id, name, email) VALUES (2, 'Jane Doe', 'jane.doe@example.com'); 
db > SELECT * FROM Users;
[Text("john.doe@example.com"), Integer(1), Text("John Doe")]
[Text("Jane Doe"), Integer(2), Text("jane.doe@example.com")]

Note

On voit que le tuple n’est pas dans le bon sens car le HashMap.values().collect() ne conserve pas l’ordre d’insertion. On remĂ©diera au problĂšme Ă  l’introduction du schĂ©ma.

Et on peut désormais créer des tables arbitrairement nommées et avec un nombre et des types de champs eux aussi arbitraires.

db > CREATE TABLE Birds (name TEXT(50), specie TEXT(128));
db > INSERT INTO Birds(name, specie) VALUES ('titi', 'canary');  
db > INSERT INTO Birds(name, specie) VALUES ('Iago', 'parrot'); 
db > SELECT * FROM Birds;
[Text("canary"), Text("titi")]
[Text("Iago"), Text("parrot")]
db > 

On est pas mal quand mĂȘme non ? đŸ€©

Conclusion

Notre implĂ©mentation du tuple est approximative, mais donne une bonne idĂ©e de l’API finale.

Dans la prochaine partie on mettra en place ce schéma tant désiré !

Merci de votre lecture ❀

Vous pouvez trouver le code la partie ici et le diff lĂ .