Partie 18: Exécution du plan logique
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 défini une directive EXPLAIN et avons propagé un flag dans le reste de l'application.
Celui-ci nous permet de demander à la base de données ce qu'elle compte faire comme opérations pour récupérer des entrées selon des critères.
On a vu que si l'on possédait des index sur les données, il était possible de limiter les opérations de lectures complètes de la table, que l'on appelle plus communément un "full scan".
Aujourd'hui, nous allons modifier la base de code pour réaliser concrètement les opérations de scan d'index et de tables.
Et pour cela, nous allons devoir revoir pas mal de choses sur les index et leur définition.
Mais également tout le processus de lecture de base de données qui, pour le moment, est basé sur un système d'allocation mémoire et de copie de données, système qui fonctionne sur de petits volumes de données, mais sur des tables avec des milliers de lignes, ce n'est juste pas jouable.
Nous allons donc passer tout ce beau monde dans une logique d'itérateur.
Nous avions également abordé dans la conclusion de la partie 12 la notion de "Volcano Model", nous allons le mettre en place également. Cela signifie que des itérateurs consomment d'autres itérateurs jusqu'à produire un résultat streamable.
On verra les détails dans la partie de l'article qui lui est consacrée.
Bref, on a du pain sur la planche !
Index itérables
Théorie
Comme je vous l'ai dit dans l'introduction, les index actuels ne nous permettent pas de scanner les données, de plus la manière même dont ils sont définis ne les rend pas portables dans les itérateurs.
Or, nous voulons streamer le contenu de nos index.
Prenons les données suivantes:
id | nom | prénom | genre | ville |
---|---|---|---|---|
1 | Smith | John | M | Paris |
2 | Martin | Marie | F | New York |
3 | Haddad | Karim (كريم) | M | Tokyo |
4 | Dubois | Sophie | F | Beyrouth |
5 | Tanaka | Hiroshi (ひろし) | M | Beyrouth |
6 | Yamamoto | Sakura (さくら) | F | Paris |
7 | Smith | Emily | F | Osaka |
8 | Martin | Jean | M | Lyon |
9 | Haddad | Layla (ليلى) | F | New York |
10 | Dubois | Paul | M | Tokyo |
Muni des index suivants :
(nom, prénom);
(ville);
Pour l'identité, pas de surprise, il y a un ID qui correspond à un couple unique (nom, prénom).
Donc pour chaque entrée dans l'index, nous avons un tableau "ids" d'un seul élément qui représente la "row_id" de l'entrée correspondant à la valeur de l'index.
En bases, les index vont ressembler à ceci.
nom | prénom | ids |
---|---|---|
Dubois | Paul | [10] |
Dubois | Sophie | [4] |
Haddad | Karim (كريم) | [3] |
Haddad | Layla (ليلى) | [9] |
Martin | Jean | [8] |
Martin | Marie | [2] |
Smith | Emily | [7] |
Smith | John | [1] |
Tanaka | Hiroshi (ひろし) | [5] |
Yamamoto | Sakura (さくら) | [6] |
L'index "idx_ville" est plus intéressant, car il peut y avoir plus d'une personne qui habite la même ville.
ville | ids |
---|---|
Beyrouth | [4, 5] |
Lyon | [8] |
New York | [2, 9] |
Osaka | [7] |
Paris | [1, 6] |
Tokyo | [3, 10] |
Et c'est bien ce qui est stocké en mémoire, non pas un "row_id", mais une collection de "row_id".
Chaque entrée représente une entrée qui possède la ville indexée comme valeur.
Prenons par exemple "Paris". Nous avons [1, 6]
comme valeur.
Et si on regarde nos données, nous avons effectivement deux entrées, la 1 et la 6, qui ont comme valeur ville="Paris".
id | nom | prénom | genre | ville |
---|---|---|---|---|
1 | Smith | John | M | Paris |
6 | Yamamoto | Sakura (さくら) | F | Paris |
Maintenant, imaginez que cette table est le registre mondial d'une grande entreprise.
Ce ne sont pas des dizaines d'entrées, mais des millions qu'il faudra gérer.
Et parmi ces millions de personnes, la probabilité que des dizaines de milliers de personnes habitent au même endroit est plus que probable.
C'est pour cela qu'il nous faut un itérateur. Pour rappel, un itérateur est un mécanisme qui permet de charger des données uniquement lorsque c'est nécessaire.
Si on reprend notre exemple, il y a 15556 personnes habitant à Paris.
Cela donnerai une structure du type:
Paris => 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ..., 15554, 15555, 15556
Il n'est pas exclu de tout charger en mémoire, mais ce n'est pas très efficace.
À la place, nous allons plutôt parcourir le tableau de données de l'index au moyen d'un curseur, capable de "se rappeler de la position où il est".
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ..., 15554, 15555, 15556
↑
Lorsque l'on vient lire l'index, le curseur se déplace d'un cran.
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ..., 15554, 15555, 15556
↑
Et ainsi de suite jusqu'à atteindre la fin de l'index.
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ..., 15554, 15555, 15556
↑
De cette manière, on peut consommer au fur et à mesure de la donnée, sans avoir à charger l'intégralité de l'index en mémoire.
C'était la théorie, maintenant place à la pratique ! 😇
Trait Indexable
La première chose que nous allons faire est de déclarer un trait qui nous permettra de travailler plus confortablement avec nos index.
Étant donné que nous allons utiliser très régulièrement l'itérateur, nous allons créer un alias de type pour le trait.
pub type IndexIteratorData<'a> = ;
Maintenant nous allons pouvoir définir le trait.
Ce trait contient 5 méthodes:
- push qui permet de rajouter un row_id à une entrée de l'index
- check qui valide que la donnée à indexée est conforme à la définition de l'index notamment l'unicité de la valeur indexée
- get_value qui renvoie le fameux itérateur que l'on va définir.
- get_index_name, get_table_name et get_definition, les getters des champs associés
Pour rappel, les Value
sont les valeur de nos tuples.
Voici un exemple de l'utilisation du trait.
// indexation
index.push;
index.push;
index.push;
index.push;
index.push;
let index_iterator = index.get_value;
// On vérifie que l'index existe bien pour la valeur demandée
let index_iterator = index_iterator.except;
// Il peut alors être consommé
for row_id in index_iterator
ValueIndex
Nous allons en définir une implémentation qui a pour rôle de venir indexer des tuples de valeurs.
Nous allons l'appeler ValueIndex
.
Et voici son implémentation du trait Indexable
.
La méthode get_value
renvoie un itérateur sur les row_ids associées à l'entrée de l'index si celle-ci existe.
Il faut remarquer qu'il n'y a aucune allocation mémoire lors de la création de l'itérateur. Cela signifie que l'on peut lire les données sans avoir besoin de copier les données de l'index.
On utilise également une fonctionnalité de Rust pour convertir notre Box d'itérateur en IndexIteratorData
.
Primary Index
Nous avions précédemment défini les index primaires séparément car nous devions nous l'avouer, la modélisation de nos index n'était pas vraiment optimale. 😅
Désormais, nous possédons le trait Indexable
qui définit les comportements des index.
Nous pouvons donc définir une seconde implémentation de Indexable
pour nos index primaires.
pub const PRIMARY_INDEX_NAME: &str = "PRIMARY";
Les index primaires sont unique à une table de donnée, son nom est également unique et se nomme "PRIMARY".
IndexIterator
Nous avons un itérateur de row IDs, mais ce n'est pas ce qui nous intéresse vraiment.
Nous voulons les lignes associées à ces row IDs.
Nous avons donc besoin d'un itérateur sur les lignes.
Pour cela, nous allons définir une structure IndexIterator
.
Cette structure a deux champs:
query_engine
: Le moteur de requête.row_ids
: Un itérateur sur les row IDs.
Pour créer cet itérateur nous allons utiliser la fonction get_row
de QueryEngine
qui renvoie un itérateur sur les rows.
C'est à ce moment-là que nous allons réellement lire l'index et obtenir les row IDs.
Dans notre implémentation actuelle, nos index sont des BTreeMap en mémoire, donc le gain de performance est minime. Par contre, les futurs index seront des fichiers sur disque.
Lire les données de manière "lazy" signifie que les données seront lues lorsque l'on va les utiliser, ce qui n'est pas négligeable pour des accès disque. 😊
Reprenons nos données précédentes:
Sur cette table, il y a 3 index:
PRIMARY id
index primaire.idx_ville ville
index non-unique.idx_identité (nom, prénom)
index unique.
id | nom | prénom | genre | ville |
---|---|---|---|---|
1 | Smith | John | M | Paris |
2 | Martin | Marie | F | New York |
3 | Haddad | Karim (كريم) | M | Tokyo |
6 | Yamamoto | Sakura (さくら) | F | Paris |
10 | Dubois | Paul | M | Tokyo |
On commence par obtenir depuis l'index idx_ville
l'itérateur sur les row IDs qui correspondent à la ville "Paris":
let index_rows = index.get_value;.except;
De cet itérateur nous obtenons ensuite un itérateur sur les rows:
let index_iterator = IndexIterator ;
Celui-ci peut alors être consommé de cette manière:
for row in index_iterator
On induit alors une deuxième couche de laziness: l'index itérateur n'appelle l'itérateur interne que lorsque les données sont utilisées, et l'itérateur interne n'appelle l'index que lorsque l'index itérateur l'appelle.
De ce fait, notre index peut avoir des milliers de lignes, et nous ne lirons les pages de la table que lorsque cela est réellement nécessaire.
On progresse ! 😊
Index Registry
Maintenant que nous avons des index non seulement itérables mais également scannables, nous avons besoin de les stocker afin de pouvoir les retrouver lorsque cela sera necessaire.
Tout d'abord modélisons notre collection d'index.
/// A map of column definitions to index implementations.
type Index = ;
Chaque index possède un nom unique qui nous permettra de le retrouver facilement lorsque cela sera nécessaire.
On pourrait s'arrêter là, mais nous allons légèrement complexifier le modèle pour optimiser les recherches.
Dans une base de données, rechercher par index primaire est toujours plus avantageux que de rechercher dans des index.
Nous voulons donc que les requêtes se fassent sur les index primaires autant que possible.
Si la requête ne permet pas de trouver des rows par index primaire, nous allons chercher par index non-unique ou unique.
Mais dans ces deux index il y a également une notion de performance qui entre en ligne de compte.
En effet, par définition, un index unique associe une seule row à une entrée dans l'index. Au contraire, un index non-unique peut associer plusieurs rows à une seule entrée.
Cela signifie que scanner un index unique est la certitude de n'avoir au plus qu'une seule row, tandis que scanner un index non-unique peut avoir plusieurs rows, parfois des milliers.
La modélisation de notre stockage d'index va matérialiser cette distinction de type d'index, via une map entre le type d'index et son contenu.
Nous allons utiliser une BtreeMap pour cela. Car elle possède la particularité de fournir un itérateur ordonné.
Cela signifie que si nous définissons un ordre de priorité sur nos types d'index, alors nous aurons la certitude que le type d'index scanner sera dans l'ordre d'importance que nous voulons.
PRIMARY > UNIQUE > NON_UNIQUE
Pour cela nous pouvons utiliser une propriété des énumération qui permet de donner explicitement une valeur à une variante.
Ensuite en dérivant Ord et PartialOrd sur notre enum, nous aurons une ordonnancement naturelle.
On définit ensuite notre struct IndexRegistry
qui utilise une BTreeMap associant un IndexType
et un Index
.
On stocke également le nom des index pour ne pas avoir de duplication de nom d'index sur la table.
On peut alors implementer IndexRegistry
:
On commence par l'ajout d'index.
On vérifie que l'index n'a pas de doublon avant de l'ajouter.
On ajoute ensuite le nom de l'index au registre. Nous sommes obligé de découpler les cas d'index unique et non-unique.
L'API entry de BtreeMap permet de créer une entrée si elle n'existe pas alors or_default permet de la créer et de renvoyer une référence mutable vers l'entrée.
let map = ; let value = map.entry.or_default;
Dans le cas o celle-ci n'existe pas,
value
est une référence mutable vers une String vide qui sera crée dans la map à la clef 1.Sinon
value
est une r f rence mutable vers la valeur de la cl 1.
Ensuite, nous implémentons les méthodes permettant d'insérer des valeurs dans les index, ainsi que celle permettant de trouver un index par sa définition.
On implémente également la méthode permettant de vérifier si un index peut être ajouté.
Ici on va utiliser la fonctionnalité de flat_map de Iterator pour itérer sur tous les types d'index et les indexes eux-mêmes.
Mais également le find_map
de la option de Option qui permet de prendre la première valeur de l'option qui satisfait une condition.
Ces deux APIs permettent de traiter de manière indifférentielle les index uniques et non-uniques.
Ce n'est pas réellement nécessaire, mais je voulais m'amuser un peu avec les APIs de Rust. ^^
On implémente la méthode permettant de trouver un index par expression.
Pour chaque table de la base de données. La structure des index est la suivante:
{
Primary: {
1 => [1],
2 => [2],
8 => [8],
9 => [9],
10 => [10],
}
Unique: {
"idx_identité": {
("Smith", "John") => [1],
("Martin", "Marie") => [2],
("Martin", "Jean") => [8],
("Haddad", "Layla") => [9],
("Dubois", "Paul") => [10],
},
},
NonUnique: {
"idx_ville": {
"Beyrouth" => [4, 5],
"Lyon" => [2, 4],
"New York" => [2, 9],
"Osaka" => [7],
},
"idx_nom": {
"Smith" => [1],
"Martin" => [2, 8],
"Haddad" => [9],
"Dubois" => [10],
}
}
}
Comme les index sont stockés par type et que les index uniques sont prioritaires, alors les index uniques seront parcourus en premier.
for index in self.indexes.keys
Donc si un index unique correspondant à l'expression est trouvé, alors on sort de la boucle et on retourne l'index sans passer par les indexes non uniques.
Bon, ça commence à prendre forme. 😄
Full scan
Nous allons adapter le scanner que nous avions fait en partie 12.
Pas grand-chose à ajouter, à part que le scanner utilise désormais le QueryEngine pour réaliser la récupération des données.
Cela permet d'homogénéiser les deux API entre le scan d'index et le scan de table.
Le premier via l'IndexIterator et le deuxième via le Scanner.
Les deux API sont des itérateurs de Vec<Value>
.
Execution du plan logique
Maintenant que tous nos composants de récupération de données sont sous la forme d'itérateurs, nous pouvons passer à l'exécution du plan logique.
Pour rappel, un plan est un ensemble d'étapes de traitement de la donnée, qui va de la récupération de celles-ci dans la base de données jusqu'à leur affichage final.
Trait ExecutePlan
Nous allons maintenant rajouter une trait ExecutePlan
qui va permettre de traiter un plan de maniere dynamique.
Nous allons définir deux type alias pour faciliter la lecture.
/// The input of the step of the plan to execute
type PlanExecInput<'a> = ;
/// The output of the step of the plan executed
pub type PlanExecOutput<'a> =
;
Le boxing est nécessaire car les itérateurs ne sont pas des structures mais des traits.
PlanExecInput
est un itérateur de Vec<Value>
il sera utilisé comme entree de la prochaine étape du plan.
PlanExecOutput
est un itérateur de Vec<Value>
il sera utilisé comme sortie de la prochaine étape du plan.
Chaque étape du plan prendra deux arguments:
database
: la base de donnéesinputs
: la sortie de l'etape precedente
inputs
peut être un seul itérateur ou plusieurs itérateurs ou même aucun.
Plan
Voici sa modélisation en Rust.
```
#### Etapes de scans
Les opérations de scans elles se décomposent en plusieurs types:
- ` ByIndex`
- ` FullScan`
```rust
Un full scan est la manière la plus simple mais aussi généralement la plus lente et peu efficace de récupérer des données.
Le full scan va lire toutes les lignes de la table et les retourner. Cette simplicité se retrouve dans modélisation: seul le nom de la table est requis.
Pour cela on utilise le Scanner
qui va nous retourner un itérateur sur les lignes de la table.
Un scan par index est légèrement plus complexe.
On implémente ExecutePlan
pour ScanByIndex
:
Etape de filtres
Un filtre est une opération qui permet de conserver uniquement certaines lignes d'une table.
Le filtre prend en argument une expression et un nom de table.
Nous allons réutiliser la fonction filter_row de la partie 12.
On vérifie que l'étape possède une seule entrée.
Ensuite, on applique notre filtrage sur cet itérateur.
L'itérateur résultant sera le filtrage du résultat de l'étape précédente.
flowchart Scan--->|Iterator Rows|Filter Filter --->|Iterator Rows filtred|Output
Plan Executor
Maintenant que chaque étape est exécutable, il est temps de mettre en musique tout cela.
Pour cela, nous allons définir un plan executor qui va permettre d'ordonner les étapes et de les exécuter successivement.
Celui-ci prend la base de données et le plan en argument.
Nous allons définir une fonction dummy
qui nous permettra de renvoyer un iterator vide.
Nous pouvons maintenant exécuter notre plan.
Le fonctionnement est le suivant :
- On initialise un itérateur vide.
- On itère sur chaque étape du plan.
- Pour chaque étape, on exécutera :
- Si l'étape est une scan, on l'exécutera et on mettra à jour l'itérateur (
out
). - Si l'étape est un filtre, on l'exécutera avec l'itérateur (
out
) comme entrée et on mettra à jour l'itérateur (out
).
- Si l'étape est une scan, on l'exécutera et on mettra à jour l'itérateur (
Et on renverra l'itérateur contenant les résultats de la dernière étape.
A noter que même à l'issue de la dernière étape, aucune lecture n'a encore eu lieu.
Nous avons simplement défini des itérateurs imbriqués les uns des autres.
Mais tant que l'itérateur n'est pas consommé, aucun accès à la base de données ne sera fait.
Adaptation de la méthode select de la base de données
Nous devons également adapter la méthode select
de la base de données pour la faire utiliser notre plan executor.
Le fonctionnement est le suivant :
- On crée un plan executor avec la base de données et le plan.
- On exécute le plan et on renvoie l'itérateur contenant les résultats.
Adaptation de la déclaration de table
Nous avons transformé nos index primaires en index "classique", il faut donc faire une petite adaptation.
Précédemment nous avons défini la structure de la table comme suit.
Les index primaires étaient séparés des autres index.
Nous allons les stocker de manière naturelle dans l'IndexRegistry
.
On adapte le constructeur en conséquence.
Et on modifie le processus d'insertion.
La différence étant qu'il n'y a plus d'indexation primaire explicite, l'indexation par clé primaire est directement gérée par l'IndexRegistry
. 🤩
On a fini !
Tout est en place, il est maintenant temps de tester.
Toute ressemblance avec la partie précedente est purement fortuite.
Voici notre déclaration de table.
(
id INTEGER PRIMARY KEY,
nom TEXT(50),
prénom Text(50),
genre TEXT(2),
ville Text(100)
);
Muni des index suivants :
(nom, prénom);
(ville);
On fournit le set de données suivant :
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');
Le plan logique va être le suivant:
db > EXPLAIN SELECT * FROM Client WHERE ville = 'Tokyo';
Scan index idx_ville : ville = 'Tokyo' for table Client
Filter: ville = 'Tokyo'
Et lorsque l'on exécute sans le EXPLAIN
, on obitient.
db > SELECT * FROM Client WHERE ville = 'Tokyo';
Ok([Integer(3), Text("Haddad"), Text("Karim (كريم)"), Text("M"), Text("Tokyo")])
Ok([Integer(10), Text("Dubois"), Text("Paul"), Text("M"), Text("Tokyo")])
Ce qui est le résultat attendu !
Nous sommes donc capable d'utiliser les index secondaoires non-uniques pour récupérer nos entrés.
Voyons par accès par clef primaires.
db > EXPLAIN SELECT * FROM Client WHERE id = 7 and ville = 'Paris';
Scan index PRIMARY : id = 7 for table Client
Filter: ville = 'Paris' AND id = 7
Sans EXPLAIN
, on obtient.
db > EXPLAIN SELECT * FROM Client WHERE id = 7 and ville = 'Paris';
Rien! Et c'est normal.
id | nom | prénom | genre | ville |
---|---|---|---|---|
7 | Smith | Emily | F | Osaka |
Personne n'habite à Paris et n'a l'id 7.
Par contre si on enlève le fitre de ville, on obtient.
db > EXPLAIN SELECT * FROM Client WHERE id = 7;
Scan index PRIMARY : id = 7 for table Client
Filter: id = 7
db > SELECT * FROM Client WHERE id = 7;
Ok([Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")])
Et là nous sommes contents! 😇
Si la données n'est indexé nulle part, on va lire chaque entrée de la table.
db > EXPLAIN SELECT * FROM Client WHERE genre = 'F';
Full scan Client
Filter: genre = 'F'
db > SELECT * FROM Client WHERE genre = 'F';
Ok([Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")])
Ok([Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")])
Ok([Integer(6), Text("Yamamoto"), Text("Sakura (さくら)"), Text("F"), Text("Paris")])
Ok([Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")])
Ok([Integer(9), Text("Haddad"), Text("Layla (ليلى)"), Text("F"), Text("New York")])
Si aucune expression n'est défini, alors on n'a même pas de filtrage.
db > EXPLAIN SELECT * FROM Client;
Full scan for table Client
db > SELECT * FROM Client;
Ok([Integer(1), Text("Smith"), Text("John"), Text("M"), Text("Paris")])
Ok([Integer(2), Text("Martin"), Text("Marie"), Text("F"), Text("New York")])
Ok([Integer(3), Text("Haddad"), Text("Karim (كريم)"), Text("M"), Text("Tokyo")])
Ok([Integer(4), Text("Dubois"), Text("Sophie"), Text("F"), Text("Beyrouth")])
Ok([Integer(5), Text("Tanaka"), Text("Hiroshi (ひろし)"), Text("M"), Text("Beyrouth")])
Ok([Integer(6), Text("Yamamoto"), Text("Sakura (さくら)"), Text("F"), Text("Paris")])
Ok([Integer(7), Text("Smith"), Text("Emily"), Text("F"), Text("Osaka")])
Ok([Integer(8), Text("Martin"), Text("Jean"), Text("M"), Text("Lyon")])
Ok([Integer(9), Text("Haddad"), Text("Layla (ليلى)"), Text("F"), Text("New York")])
Ok([Integer(10), Text("Dubois"), Text("Paul"), Text("M"), Text("Tokyo")])
Et voilà, nous avons un système de recherche de données optimisé par index !! 🎉
Conclusion
Et bien, c'était un gros morceau ! On a passé un temps non négligeable sur les index, alors que le titre de l'article était "Exécution du plan logique", mais c'était essentiel.
Une base de données sans index performant est totalement inutilisable.
Or notre plan se base totalement sur les index pour éviter les full-scans.
Le deuxième axe qui nous a occupé une belle partie est la transformation de notre système de récupération de données par recopie et allocation vers un modèle "lazy" via l'utilisation d'itérateurs.
Cette optimisation permet à notre base de données de ne plus être dépendante de la taille de la table qui est lue !
Le système d'itérateur imbriqués permet de donner la responsabilité à une étape du plan de lire ou non de la donnée. Ce système va être essentiel lorsque l'on abordera la jointure de tables.
Dans la prochaine partie nous verrons comment supprimer de la donnée.
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.