Partie 7 : Tuples de données
Lecture 5 min. âą
Table des matiĂšres
Les articles de la série
- Partie 1 : Construire le REPL
- Partie 2 : Sérialisation des données
- Partie 3 : Database
- Partie 4 : Tables
- Partie 5 : Parser les commandes, création de la boßte à outils
- Partie 6 : Parser les commandes SQL
- Partie 7 : Tuples de données
- Partie 8 : Contraindre les données via un schéma
- Partie 9 : Découper le stockage des tables en pages
- Partie 10 : Clefs primaires
- Partie 11 : Expressions Logiques SQL
- Partie 12 : Scans et filtrage
- Partie 13 : UTF-8 et caractÚres échappés
- Partie 14 : Attributs nullifiables
- Partie 15 : Index secondaires
- Partie 16 : Directive EXPLAIN
- Partie 17 : Logical Plan
- Partie 18 : Exécution du plan logique
- Partie 19 : Suppression d'enregistrements
- Partie 20 : Réindexation et compactation de tables
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
struct InsertIntoCommand {
pub table_name: String,
pub fields: HashMap<String, Value>,
}Cette Value se dĂ©compe elle-mĂȘme en deux variantes.
enum Value {
Integer(i64),
Text(String),
}La question est alors:
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.
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.
enum DeserializationError {
Buffer(BufferError),
UnableToDeserializeString(FromUtf8Error),
// Ici đ
EnumDiscriminant(u8),
}Maintenant on peut implémenter la sérialisation.
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.
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.
#[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.
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.
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 ! đ
#[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 StringCette structure de données sérialisée est notre Tuple.
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.
struct CreateTableCommand {
pub table_name: String,
pub schema: Schema,
}Et on nâoublie de modifier le constructeur en consĂ©quence.
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.
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>.
enum Record {
User(User),
Car(Car),
}On modifie donc Database pour mapper non pas TableName mais une String Ă nos Table.
struct Database {
tables: HashMap<String, Table>,
}Cela a pour incidence de modifier les signature des fonctions en dessous.
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
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.
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
enum CommandError {
/// Une erreur de parse est survenue
Parse(ParseError),
}On peut alors remplacer notre parser approximatif par quelque chose de bien plus puissant.
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.
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
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
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
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")]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 â€ïž
