Partie 19: Suppression d'enregistrements
Lecture 10 min. âą
Table des matiĂšres
- Page header
- Renvoyer les row ID lors du scan
- Command DELETE FROM
- Implémentation de la commande DELETE
- On peut tester
- 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 đ
Lors de la partie précédente, nous avons enfin atteint le stade auquel nous sommes en mesure de rechercher des enregistrements en nous basant sur des index.
Nous allons réutiliser ces index et le plan logique pour supprimer des enregistrements.
Mais avant cela, nous allons devoir modifier le format de stockage de notre base de données pour indiquer quels enregistrements sont supprimés.
Nous allons également devoir modifier le scanner pour renvoyer le rowid des enregistrements à supprimer.
Cet article sera plus simple que le prĂ©cĂ©dent, mais il y a quelques petites choses Ă faire tout de mĂȘme.
TrĂȘve de palabres, câest parti ! đ
Page header
Théorie
Pour le moment nos pages de stockage ont le layout suivant:
null_table tuple null_table tuple null_table tuple null_table tuple
null_table tuple null_table tuple null_table tuple null_table tuple
null_table tuple null_table tuplenull_table tuple null_table tuple
null_table tuple null_table tuple null_table tuple padding
Chaque enregistrement est prĂ©fixĂ© par un tableau de bits qui indique quels champs du tuple sont NULL. Nous lâavons appelĂ© null_table
. Puis le tuple en lui-meme.
Et tout de suite aprĂšs une autre null_table
du tuple suivant, puis le tuple suivant et ainsi de suite.
La page se termine par un padding permettant à nos enregistrements de rester aligné sur une seule page.
Nous allons modifier le format de stockage de notre base de données pour indiquer quels enregistrements sont supprimés.
Pour cela, nous allons rajouter un header permettant de définir les métadonnées de la page.
Le nouveau format de page devient:
header null_table tuple null_table tuple null_table tuple
null_table tuple null_table tuple null_table tuple null_table tuple
null_table tuple null_table tuple null_table tuple null_table tuple
null_table tuple null_table tuple null_table tuple padding
Ce header lui-mĂȘme contient une null_table
qui indique quels offsets de la page doivent ĂȘtre ignorĂ©s.
Nos null_table
sont alignĂ©es sur un octet, donc 8 bits au minimum. Si cela nâest pas suffisant, nous ajoutons des octets pour le reste.
Pour une page contenant 18 enregistrements, nous aurons un header de 3 octets, car 3 * 8 = 24 bits.
0000 0000 0000 0000 0000 0000
Pour supprimer le premier enregistrement, il faut modifier le premier bit du premier octet du header.
0000 0001 0000 0000 0000 0000
â
Pour supprimer le quatriĂšme enregistrement, il faut modifier le quatriĂšme bit du premier octet du header.
0000 1000 0000 0000 0000 0000
â
Pour supprimer le 18e enregistrement, il faut modifier le 2e bit du 3e octet du header.
0000 0000 0000 0000 0000 0010
â
Jâexplique les techniques pour lire les bits dans la partie 14.
En résumé, pour vérifier si un enregistrement est supprimé, on donne son index dans la page et on regarde si le bit correspondant est 1
ou 0
.
Pour cela, on décale le bit qui nous intéresse et on le compare avec 0b1
.
Si ce bit vaut 0b1
, alors le ET logique renvoie 0b1
et la condition est true
. Le bit est 1
, donc lâenregistrement est supprimĂ©.
Si ce bit vaut 0b0
, alors le ET logique renvoie 0b0
et la condition est false
. Le bit est 0
, donc lâenregistrement nâest pas supprimĂ©.
partition = index / 8
bit = index % 8
null_table[partition] >> bit & 0b1 == 0b1
Manipulation du header de la page
Nous allons crĂ©er deux fonctions permettant de vĂ©rifier les Ă©tats dâenregistrement et de modifier lâen-tĂȘte.
pub const PARTITION_SIZE: usize = 8;
/// Check if a bit in the page header is 1
/// Set a bit in the page header
Les méthodes sont utilisées comme suit:
La référence mutable &mut [u8]
permet de modifier le header en fonction des enregistrements que lâon dĂ©sire supprimer.
Il est Ă remarquer que les bits aprĂšs le 18e (de 19 Ă 24) ne sont pas considĂ©rĂ©s comme des enregistrements et ne sont lĂ que pour respecter lâalignement sur un octet.
Association du header Ă la page
Maintenant que nous avons notre header rudimentaire, nous allons le lier aux pages.
Pour cela, il va falloir faire un peu de gymnastique mathématique.
Nous connaissons la taille dâun enregistrement, qui est dĂ©finie par le schĂ©ma de la table.
Nous connaissons la taille de la page, qui est fixe et qui vaut 4096 octets.
Nous sommes donc capables de calculer le nombre dâenregistrements par page.
$$ \text{nb of records per page} = \frac{\text{page size}}{\text{record size}} $$
Nous avons besoin dâune null_table
qui accueille au moins le nombre dâenregistrement par page.
Les null_table
sont des multiples de 8bits. Nous avons besoin de 1 bit par enregistrement.
$$ \text{null table size} = \left \lceil \frac{\text{nb of records per page}}{8} \right \rceil $$
Nous utilisons ici le ceil
pour ĂȘtre certains dâavoir au moins 1 bit par enregistrement.
Si nous faisions la division entiĂšre pour 18 enregistrements, cela donnerait :
$$ \frac{\text{18}}{8} = 2.25 $$
Ce qui est effectivement ce que lâon sâaperçoit dans lâoccupation de la null_table
. Mais le souci câest que la division entiĂšre nous donnerait seulement 2 octets pour 18 enregistrements.
Lâutilisation de ceil
nous permet de prendre le plus grand entier qui est inférieur ou égal à notre division. Ce qui nous donne 3 octets. Avec 3 * 8 = 24 bits, nous avons suffisamment de place pour 18 enregistrements.
Une fois que nous avons la taille de notre null_table
, nous pouvons la retrancher Ă la taille de la page.
$$ \text{page usable space} = \text{page size} - \text{null table size} $$
Ensuite, nous recalculons le nombre dâenregistrements par page afin dâĂ©viter tout dĂ©salignement des enregistrements suite Ă lâintroduction de lâen-tĂȘte.
$$ \text{nb of records per page computed} = \frac{\text{page usable space}}{\text{record size}} $$
Et on recalcule la taille de la page utilisable pour les enregistrements.
$$ \text{real page usable space} = \text{nb of records per page computed} * \text{record size} $$
Cette opération peut nous donner un
$$\text{nb of records per page computed} < \text{nb of records per page computed}$$
et donc potentiellement un octet de moins nécessaire pour la null_table
, mais câest un sacrifice sur 4 Ko de page qui est acceptable.
Quoi quâil en soit, la taille de la page que lâon utilisera pour les calculs de pages de stockage et dâoffsets dĂ©sormais sera $\text{real page usable space}$.
Nous avons dĂ©sormais un emplacement rĂ©servĂ© dans la page qui ne peut plus ĂȘtre occupĂ© par un enregistrement. Nous pouvons donc lâutiliser comme header.
Récupération du header dans la page
Maintenant que nous avons un emplacement pour notre header, il va sâagir de le positionner dans la page.
Jâai dĂ©cidĂ© de le mettre en haut de la page. Ce qui signifie en octet 0. Nous connaissons sa taille, nous pouvons donc rĂ©cupĂ©rer la slice dâoctets correspondants.
Pour cela, nous allons modifier les fonctions get
et get_mut
qui précédemment ne permettaient que de récupérer les octets aprÚs un certain offset.
Or, nous nous devons de récupérer le header compris entre les octets 0 et header_size
.
Pour cela nous allons utiliser le trait SliceIndex qui permet de prendre un sous ensemble de slice.
Une fois cela fait, on peut définir les méthodes pour récupérer le header.
Le header sera récupéré par row ID, car nous voulons déterminer si un enregistrement est supprimé ou non ou le supprimer.
Pour cela, nous réutilisons la méthode get_position
qui renvoie une Position
que nous avons définie précédemment.
Celui-ci nous donne alors la page concernĂ©e par lâenregistrement.
On récupÚre ensuite la page et on extrait le header.
Comme notre header est de taille header_size
et quâil dĂ©bute en octet 0, la slice est donc ..self.header_size
.
Nous venons de virtuellement positionner notre header en octet 0.
Mais du coup, tous les calculs de position et dâoffsets des enregistrements doivent prendre en compte la place que prend le header et donc dĂ©caler dâautant.
Et câest pour cela que nous rajoutons un self.header_size
aux offsets.
DĂ©sormais, le header est devenu invisible pour les calculs de pages et dâoffsets qui conditionnent lâaccĂšs aux enregistrements.
Nous avons bien notre layout de page souhaité.
header
record
record
record
...
padding
On en profite pour dĂ©finir deux mĂ©thodes, lâune pour supprimer un enregistrement et lâautre pour savoir si un enregistrement est supprimĂ©.
Pas mal, pas mal, on avance ! đ
Renvoyer les row ID lors du scan
Pour le moment, nos méthodes de scans ne renvoient que le tuple du record sans son row ID associé.
Or, pour rĂ©cupĂ©rer le header de la page concernant lâenregistrement Ă supprimer, nous avons besoin de son row ID.
Nous allons donc modifier le retour de nos itérateurs de scans.
Nous définissons un type alias pour le tuple de row ID et de valeurs.
type Row = ;
Puis nous modifions nos itérateurs.
Désormais nos itérateurs produiront des couples RowId
et Vec<Value>
.
(0, [1, 2, 3])
(1, [4, 5, 6])
(2, [7, 8, 9])
Le premier tuple correspond au row ID 0, le deuxieme au 1 et ainsi de suite.
De fait, nos itérateurs de plan se voient également modifiés.
type PlanExecInput<'a> = ;
/// The output of the step of the plan executed
pub type PlanExecOutput<'a> =
;
Un SELECT produira désormais ceci:
SELECT * FROM Client;
0 => [Integer(1), Text("Smith"), Text("John"), Text("M"), Text("Paris")]
1 => [Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")]
2 => [Integer(3), Text("Haddad"), Text("Karim (Ù۱ÙÙ
)"), Text("M"), Text("Tokyo")]
3 => [Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")]
4 => [Integer(5), Text("Tanaka"), Text("Hiroshi (ăČăă)"), Text("M"), Text("Beyrouth")]
5 => [Integer(6), Text("Yamamoto"), Text("Sakura (ăăă)"), Text("F"), Text("Paris")]
6 => [Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")]
7 => [Integer(8), Text("Martin"), Text("Jean"), Text("M"), Text("Lyon")]
8 => [Integer(9), Text("Haddad"), Text("Layla (ÙÙÙÙ)"), Text("F"), Text("New York")]
9 => [Integer(10), Text("Dubois"), Text("Paul"), Text("M"), Text("Tokyo")]
Command DELETE FROM
Maintenant que le stockage pour la suppression est réglé, nous pouvons ajouter la commande DELETE FROM.
DELETE FROM table_name [WHERE condition];
Sa grammaire est trĂšs similaire Ă celle du SELECT, exceptĂ© quâil nây a pas de projection.
On modélise la commande comme suit:
Que lâon parse de cette maniere:
On peut ensuite implémenter la commande au set de commandes connues.
Et finalement ajouter la commande elle-meme.
Une fois cela fait on peut définir son exécution.
Implémentation de la commande DELETE
Maintenant que nous sommes capables de reconnaĂźtre la commande, il faut quâelle interagisse rĂ©ellement avec la base de donnĂ©es.
Pour implémenter la commande DELETE, nous allons réutiliser la fonction select
de la base de données.
En effet, celle-ci gÚre toute la mécanique de gestion du plan de recherche optimisé par index.
En premiĂšre intention, je souhaitais utiliser lâitĂ©rateur du scan sans le collecter, et supprimer au fil de lâeau les enregistrements.
Mais le problĂšme est que lâitĂ©rateur est une rĂ©fĂ©rence non mutable sur la base de donnĂ©es. Et que supprimer un enregistrement nĂ©cessite de modifier la base de donnĂ©es.
Or en Rust, il nâest pas possible dâavoir une rĂ©fĂ©rence mutable et non mutable en mĂȘme temps sur la mĂȘme variable.
Donc je me rabats sur une solution moins optimale, mais qui fonctionne. Scanner quelques enregistrements consomme lâitĂ©rateur et le collecte sous forme de vecteur de row ID.
Ainsi, je relĂąche ma rĂ©fĂ©rence non mutable sur la base de donnĂ©es, ce qui me permets dâen dĂ©clarer une mutable sur laquelle je peux modifier la base de donnĂ©es.
// maximum number of rows to scan
const MAX_SCAN_LENGTH: usize = 1000;
Pour supprimer tous les enregistrements concernés par la commande DELETE, nous allons appeler la fonction collect_row_ids
en boucle jusquâĂ ce quâil nây ait plus de rĂ©sultats dans lâitĂ©rateur.
Le retour de notre appel de suppression sera le nombre de lignes supprimées.
On ajoute dans notre énumération ExecuteResult
une variante pour le nombre de lignes affectées.
On en profite également pour modifier la variante Tuples
pour lui faire renvoyer les row ID associés aux tuples.
// maximum number of rows to scan by default
const DEFAULT_SCAN_LENGTH: usize = 100;
La suppression en tant sur telle dans la table est un appel Ă la fonction set_record_deleted
du pager de la table.
Et voilĂ câest terminĂ© ! đ
On peut tester
(
id INTEGER PRIMARY KEY,
nom TEXT(50),
prénom Text(50),
genre TEXT(2),
ville Text(100)
);
(nom, prénom);
(ville);
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (1, 'Smith', 'John', 'M', 'Paris');
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (2, 'Martin', 'Marie', 'F', 'New York');
INSERT INTO Client (id, nom, prĂ©nom, genre, ville) VALUES (3, 'Haddad', 'Karim (Ù۱ÙÙ
)', 'M', 'Tokyo');
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (4, 'Dubois', 'Sophie', 'F', 'Beyrouth');
INSERT INTO Client (id, nom, prĂ©nom, genre, ville) VALUES (5, 'Tanaka', 'Hiroshi (ăČăă)', 'M', 'Beyrouth');
INSERT INTO Client (id, nom, prĂ©nom, genre, ville) VALUES (6, 'Yamamoto', 'Sakura (ăăă)', 'F', 'Paris');
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (7, 'Smith', 'Emily', 'F', 'Osaka');
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (8, 'Martin', 'Jean', 'M', 'Lyon');
INSERT INTO Client (id, nom, prĂ©nom, genre, ville) VALUES (9, 'Haddad', 'Layla (ÙÙÙÙ)', 'F', 'New York');
INSERT INTO Client (id, nom, prénom, genre, ville) VALUES (10, 'Dubois', 'Paul', 'M', 'Tokyo');
Si on liste nos clients, nous avons 10 lignes. Avec le row ID correspondant.
db > SELECT * FROM Client;
0 => [Integer(1), Text("Smith"), Text("John"), Text("M"), Text("Paris")]
1 => [Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")]
2 => [Integer(3), Text("Haddad"), Text("Karim (Ù۱ÙÙ
)"), Text("M"), Text("Tokyo")]
3 => [Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")]
4 => [Integer(5), Text("Tanaka"), Text("Hiroshi (ăČăă)"), Text("M"), Text("Beyrouth")]
5 => [Integer(6), Text("Yamamoto"), Text("Sakura (ăăă)"), Text("F"), Text("Paris")]
6 => [Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")]
7 => [Integer(8), Text("Martin"), Text("Jean"), Text("M"), Text("Lyon")]
8 => [Integer(9), Text("Haddad"), Text("Layla (ÙÙÙÙ)"), Text("F"), Text("New York")]
9 => [Integer(10), Text("Dubois"), Text("Paul"), Text("M"), Text("Tokyo")]
Supprimons donc tous les clients Homme de la table. Nous avons Ă supprimer 5 lignes.
db > DELETE FROM Client WHERE genre = 'M';
5 rows affected
Le systÚme nous renvoie que 5 lignes ont été affectées.
On full scan la table.
db > SELECT * FROM Client;
1 => [Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")]
3 => [Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")]
5 => [Integer(6), Text("Yamamoto"), Text("Sakura (ăăă)"), Text("F"), Text("Paris")]
6 => [Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")]
8 => [Integer(9), Text("Haddad"), Text("Layla (ÙÙÙÙ)"), Text("F"), Text("New York")]
Il nây effectivement plus que des femmes.
Si on essaie de supprimer lâID 8, le systĂšme nous dit quâaucune ligne nâa Ă©tĂ© affectĂ©e.
db > DELETE FROM Client WHERE id = 8;
0 rows affected
Par contre si on supprime lâID 9, le systĂšme nous dit que 1 ligne a Ă©tĂ© affectĂ©e.
db > DELETE FROM Client WHERE id = 9;
1 rows affected
Un derniĂšre SELECT nous assure que le row ID 9 nâa plus de ligne.
db > SELECT * FROM Client;
1 => [Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")]
3 => [Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")]
5 => [Integer(6), Text("Yamamoto"), Text("Sakura (ăăă)"), Text("F"), Text("Paris")]
6 => [Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")]
Et bien Ă©videmment tout ces requĂȘtes sont dĂ©buggables.
db > EXPLAIN DELETE FROM Client WHERE genre = 'M';
Full scan Client
Filter: genre = 'M'
db > EXPLAIN DELETE FROM Client WHERE id = 9;
Scan index PRIMARY : id = 9 for table Client
Filter: id = 9
db > EXPLAIN DELETE FROM Client WHERE ville = "Paris";
Scan index idx_ville : ville = 'Paris' for table Client
Filter: ville = 'Paris'
Nous avons réutiliser les index pour supprimer des lignes.
Notre base de données commence à avoir une certaine tenue. ^^
Conclusion
En réutilisant le plan logique de la partie 17 et en nous appuyant sur le travail sur les index de la partie 18, nous avons pu efficacement supprimer nos enregistrements de la base de données.
En réutilisant le principe de la null table, nous avons réalisé un raccourci sur les données de chaque page, évitant ainsi de désérialiser des enregistrements avant de nous rendre compte que ceux-ci sont supprimés.
Petit à petit, notre base de données utilise ses propres primitives pour construire des comportements plus complexes.
Dans la prochaine partie, nous allons définir le processus permettant de nettoyer nos pages et nos index.
Merci de votre lecture â€ïž