Partie 19: Suppression d'enregistrements
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
Bonjour à toutes et tous 😃
Lors de la partie précédente, nous avons enfin atteint le stade où 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 ❤️
Vous pouvez trouver le code la partie ici et le diff là.
Ce travail est sous licence CC BY-NC-SA 4.0.