Partie 4 : Tables
Lecture 4 min. âą
Table des matiĂšres
- Modification de lâAPI
- Records
- Modifications du Parser
- Table
- Database mais avec des tables
- Modification de lâexĂ©cution
- Tests grandeur nature
- Conclusion
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 đ
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.
- 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 successfullyEt 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 â€ïž
