Publié le 06 décembre 2024
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.
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:
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 {
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> {
let input = input. trim ( ) ;
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" => {
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 ( ) {
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 ( )
} )
} ) )
) ;
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 ( )
} )
} ) )
) ;
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)
) ;
assert_eq! (
SqlCommand:: try_from_str( " insert user one name email@domain.tld" ) ,
Err ( CommandError:: ExpectingInteger)
) ;
assert_eq! ( SqlCommand:: try_from_str( " unknown command" ) , Ok ( None ) ) ;
}
# [ test ]
fn test_parse_command_select ( ) {
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
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " select unknown" ) ,
Err ( CommandError:: UnknownTable( " unknown" . to_string ( ) ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " select user value" ) ,
Err ( CommandError:: TooManyArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " select" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
assert_eq! ( SqlCommand:: try_from_str( " unknown command" ) , Ok ( None ) ) ;
}
# [ test ]
fn test_parse_command_create ( ) {
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
} ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " create unknown" ) ,
Err ( CommandError:: UnknownTable( " unknown" . to_string ( ) ) )
) ;
assert_eq! (
SqlCommand:: try_from_str( " create user value" ) ,
Err ( CommandError:: TooManyArguments)
) ;
assert_eq! (
SqlCommand:: try_from_str( " create" ) ,
Err ( CommandError:: NotEnoughArguments)
) ;
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Ă .