Partie 18: Exécution du plan logique
Lecture 16 min. âą
Table des matiĂšres
- Index itérables
- IndexIterator
- Index Registry
- Full scan
- Execution du plan logique
- Plan Executor
- Adaptation de la méthode select de la base de données
- Adaptation de la déclaration de table
- On a fini !
- 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 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 :
CREATE UNIQUE INDEX idx_identité ON Client(nom, prénom);
CREATE INDEX idx_ville ON Client(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, 15556Il 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> = Box<dyn Iterator<Item = &'a usize> + 'a>;Maintenant nous allons pouvoir définir le trait.
trait Indexable: DerefMut<Target = BTreeMap<Vec<Value>, Vec<usize>>> {
/// Push a new value to the index
fn push(&mut self, row_id: usize, value: Vec<Value>);
/// Check value is compatible to the index
fn check(&self, columns: &[String], row: &HashMap<String, Value>) -> Result<(), IndexError>;
/// Get the iterator for the given value
fn get_value(&self, value: &[Value]) -> Option<IndexIteratorData>;
/// Get the index name
fn get_index_name(&self) -> &str;
/// Get the table name
fn get_table_name(&self) -> &str;
/// Get the index definition
fn get_definition(&self) -> &Vec<String>;
}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.
enum Value {
Integer(i64),
Text(String),
Null,
}Voici un exemple de lâutilisation du trait.
// indexation
index.push(12, vec![Value::String("Paris".into())]);
index.push(68, vec![Value::String("Tokyo".into())]);
index.push(22, vec![Value::String("Paris".into())]);
index.push(35, vec![Value::String("Buenas Aires".into())]);
index.push(9, vec![Value::String("Paris".into())]);
let index_iterator = index.get_value(&[Value::Text("Paris".into)]);
// On vérifie que l'index existe bien pour la valeur demandée
let index_iterator = index_iterator.except("Index entry not found");
// Il peut alors ĂȘtre consommĂ©
for row_id in index_iterator {
// iter 12, 22, 9
}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.
pub struct ValueIndex {
/// The underlying BTreeMap that stores the index data
inner: BTreeMap<Vec<Value>, Vec<usize>>,
/// The name of the table that the index belongs to
table_name: String,
/// The name of the index
index_name: String,
/// The uniqueness of the index
uniqueness: Uniqueness,
/// The definition of the index
definition: Vec<String>,
}Et voici son implémentation du trait Indexable.
impl Indexable for ValueIndex {
/// Insert a new value into the index.
///
/// If the index has `Uniqueness::Unique`, it will check if the value is already present
/// in the index. If it is, it will return an error. If not, it will add the value to the
/// index and store the row id that the value is associated with.
///
/// If the index has `Uniqueness::NonUnique`, it will simply add the value to the index
/// and store the row id that the value is associated with.
///
/// # Parameters
/// - `row_id`: The row id that the value is associated with
/// - `value`: The value to insert into the index
fn push(&mut self, row_id: usize, value: Vec<Value>) {
match self.uniqueness {
Uniqueness::Unique => {
println!(
"Insert {:?} into unique index {} => row_id {row_id}",
value, self.index_name
);
self.insert(value, vec![row_id]);
}
Uniqueness::NonUnique => {
println!(
"Insert {:?} into non unique index {} => row_id {row_id}",
value, self.index_name
);
self.entry(value).or_default().push(row_id);
}
}
}
/// Check if the given row can be inserted into the index, given the columns of the index.
///
/// If the index has `Uniqueness::Unique`, it will check if the value is already present in the index.
/// If it is, it will return an `IndexError::DuplicateIndexValue` error. If not, it will return `Ok(())`.
///
/// If the index has `Uniqueness::NonUnique`, it will simply return `Ok(())`.
///
/// # Parameters
/// - `row`: The row to check
///
/// # Errors
/// - `IndexError::DuplicateIndexValue`: If the index has `Uniqueness::Unique` and the value is already present in the index.
fn check(&self, row: &HashMap<String, Value>) -> Result<(), IndexError> {
match self.uniqueness {
Uniqueness::Unique => {
let key = as_index_tuple(self.get_definition(), row);
if self.contains_key(&key) {
return Err(IndexError::DuplicateIndexValue {
index_name: self.index_name.to_string(),
table_name: self.table_name.to_string(),
values: key,
});
}
}
Uniqueness::NonUnique => {}
}
Ok(())
}
/// Returns an iterator over the row ids associated with the given value.
///
/// # Parameters
/// - `value`: The value to search for in the index.
///
/// # Returns
/// - `Option<IndexIteratorData>`: An iterator over the row ids associated with the given value,
/// or `None` if the value is not found in the index.
fn get_value(&self, value: &[Value]) -> Option<IndexIteratorData> {
self.get(value)
.map(|v| Box::new(v.iter()) as IndexIteratorData)
}
fn get_index_name(&self) -> &str {
&self.index_name
}
fn get_table_name(&self) -> &str {
&self.table_name
}
fn get_definition(&self) -> &Vec<String> {
&self.definition
}
}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";
struct PrimaryIndex {
table_name: String,
definition: Vec<String>,
inner: BTreeMap<Vec<Value>, Vec<usize>>,
}
impl Indexable for PrimaryIndex {
fn push(&mut self, row_id: usize, value: Vec<Value>) {
self.entry(value).or_default().push(row_id);
}
fn check(&self, _row: &HashMap<String, Value>) -> Result<(), IndexError> {
Ok(())
}
fn get_index_name(&self) -> &str {
PRIMARY_INDEX_NAME
}
fn get_table_name(&self) -> &str {
&self.table_name
}
fn get_definition(&self) -> &Vec<String> {
&self.definition
}
}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.
pub struct IndexIterator<'a> {
/// The query engine
query_engine: QueryEngine<'a>,
/// The iterator over the row ids
row_ids: IndexIteratorData<'a>,
}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.
impl Iterator for IndexIterator<'_> {
type Item = Result<Vec<Value>, SelectError>;
fn next(&mut self) -> Option<Self::Item> {
match self.row_ids.next() {
// Retourne `None` si l'itérateur est fini
None => None,
// Appel du moteur de requĂȘte pour obtenir la row associĂ©e au row_id
// lecture de la table et des pages contenant les rows
Some(row_id) => Some(self.query_engine.get_row(*row_id)),
}
}
}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 idindex primaire.idx_ville villeindex 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(&[Value::Text("Paris".into)]);.except("Index entry not found");De cet itérateur nous obtenons ensuite un itérateur sur les rows:
let index_iterator = IndexIterator {
query_engine: query_engine,
row_ids: index_rows,
};Celui-ci peut alors ĂȘtre consommĂ© de cette maniĂšre:
for row in index_iterator {
// [Value::Text("Smith")), Value::Text("John")Value::Text("M".into()), Value::Text("Paris")]
// [Value::Text("Yamamoto")), Value::Text("Sakura (ăăă)")Value::Text("F".into()), Value::Text("Paris")]
}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 = BTreeMap<String, Box<dyn Indexable>>;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_UNIQUEPour 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.
#[derive(PartialEq, PartialOrd, Eq, Ord)]
pub enum IndexType {
Primary = 0,
ValueUnique = 1,
ValueNonUnique = 2,
}On définit ensuite notre struct IndexRegistry qui utilise une BTreeMap associant un IndexType et un Index.
#[derive(Default)]
pub struct IndexRegistry {
indexes: BTreeMap<IndexType, Index>,
names: BTreeSet<String>,
}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.
impl IndexRegistry {
/// Add a value index to the registry
pub fn add_value_index(&mut self, index: ValueIndex) -> Result<(), IndexError> {
let name = index.get_index_name();
let table_name = index.get_table_name();
if self.names.contains(name) {
return Err(IndexError::DuplicateIndex {
index_name: name.to_string(),
table_name: table_name.to_string(),
});
}
self.names.insert(name.to_string());
match index.get_uniqueness() {
Uniqueness::Unique => {
self.indexes
.entry(IndexType::ValueUnique)
.or_default()
.insert(name.to_string(), index.boxed());
}
Uniqueness::NonUnique => {
self.indexes
.entry(IndexType::ValueNonUnique)
.or_default()
.insert(name.to_string(), index.boxed());
}
}
Ok(())
}
/// Add a primary index to the registry
pub fn add_primary_index(&mut self, index: PrimaryIndex) {
// Check if the primary index is already added
// If it is, do nothing
if self.names.contains(PRIMARY_INDEX_NAME) {
return;
}
self.names.insert(PRIMARY_INDEX_NAME.to_string());
self.indexes
.entry(IndexType::Primary)
.or_default()
.insert(PRIMARY_INDEX_NAME.to_string(), index.boxed());
}
}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 = BTreeMap::new::<usize, String>(); let value = map.entry(1).or_default();Dans le cas oĂč celle-ci nâexiste pas,
valueest une référence mutable vers une String vide qui sera créé dans la map à la clef 1.Sinon
valueest une réference mutable vers la valeur de la clef1.
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Ă©.
impl IndexRegistry {
/// Check if the given value can be inserted into the index
pub fn check(&mut self, value: &HashMap<String, Value>) -> Result<(), IndexError> {
for index in self
.indexes
.iter()
.flat_map(|(_, indexes)| indexes.values())
{
index.check(value)?;
}
Ok(())
}
/// Insert a value into all indexes matching the value
pub fn insert_into_index(
&mut self,
value: &HashMap<String, Value>,
row_id: usize,
) -> Result<(), IndexError> {
for index in self
.indexes
.iter_mut()
.flat_map(|(_, indexes)| indexes.values_mut())
{
let key = as_index_tuple(index.get_definition(), value);
index.push(row_id, key);
}
Ok(())
}
/// Get an index by its definition
pub fn get_index(&self, name: &str) -> Option<&dyn Indexable> {
self.indexes
.values()
.find_map(|indexes| indexes.get(name))
.map(|v| v.as_ref())
}
}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.
impl IndexRegistry {
/// From an [Expression] get the potential index matching it
/// Priority is given to unique index
pub fn get_index_by_expression(
&self,
expression: &Expression,
table_name: &str,
) -> Result<Option<ScanKind>, QueryError> {
let mut scan_kind = None;
// Indexes are stored by type, because unique index has higher priority
// unique index will be checked first
for (index_columns, index) in self.indexes.iter().flat_map(|(_, indexes)| indexes) {
if let Some(index_values) =
expression_from_index_definition_to_tuple(index_columns, expression)?
{
scan_kind = Some(ScanKind::ByIndex(ScanByIndex::new(
index_columns.clone(),
index.get_index_name().to_string(),
index_values,
table_name,
)));
break;
}
}
Ok(scan_kind)
}
}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() {
// IndexType::ValueUnique
// IndexType::ValueNonUnique
// IndexType::ValueNonUnique
}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.
pub struct Scanner<'a> {
query_engine: QueryEngine<'a>,
current_row: usize,
}
impl<'a> Scanner<'a> {
pub fn new(query_engine: QueryEngine<'a>) -> Self {
Self {
query_engine,
current_row: 0,
}
}
}
impl Iterator for Scanner<'_> {
type Item = Result<Vec<Value>, SelectError>;
fn next(&mut self) -> Option<Self::Item> {
// on va jusqu'au dernier tuple de la table
if self.current_row >= self.query_engine.get_table().row_number {
return None;
}
let row = self.query_engine.get_row(self.current_row);
match row {
Ok(row) => {
self.current_row += 1;
Some(Ok(row))
}
Err(err) => Some(Err(err)),
}
}
}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> = Box<dyn Iterator<Item = Result<Vec<Value>, SelectError>> + 'a>;
/// The output of the step of the plan executed
pub type PlanExecOutput<'a> =
Result<Box<dyn Iterator<Item = Result<Vec<Value>, SelectError>> + 'a>, SelectError>;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.
trait ExecutePlan {
fn execute<'a>(
self,
database: &'a Database,
inputs: Vec<PlanExecInput<'a>>,
) -> PlanExecOutput<'a>;
}Chaque étape du plan prendra deux arguments:
database: la base de donnĂ©esinputs: la sortie de lâetape precedente
inputspeut ĂȘtre un seul itĂ©rateur ou plusieurs itĂ©rateurs ou mĂȘme aucun.
Plan
Voici sa modélisation en Rust.
struct Plan {
steps: Vec<PlanStep>,
}
enum PlanStep {
/// Represents a scan operation in a query plan.
Scan(ScanKind),
/// Represents a filter operation in a query plan.
Filter(FilterRow),
}
```
#### Etapes de scans
Les opérations de scans elles se décomposent en plusieurs types:
- `ScanKind::ByIndex`
- `ScanKind::FullScan`
```rust
enum ScanKind {
/// A scan that reads all rows in the table (Full Table Scan).
FullScan(FullScan),
/// A scan that retrieves rows using an index
ByIndex(ScanByIndex),
}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.
struct FullScan {
// nom de la table Ă parcourir
table_name: String,
}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.
impl ExecutePlan for FullScan {
fn execute<'a>(
self,
database: &'a Database,
_inputs: Vec<PlanExecInput<'a>>,
) -> PlanExecOutput<'a> {
let table_name = &self.table_name;
// Get the table
let table = database
.get_table(table_name)
.ok_or(SelectError::TableNotExist(table_name.to_string()))?;
// Create the query engine
let query_engine = QueryEngine::new(table);
// Create the scanner
let scanner = Scanner::new(query_engine);
Ok(Box::new(scanner))
}
}Un scan par index est légÚrement plus complexe.
struct ScanByIndex {
// composants de l'index
definition: Vec<String>,
// nom de l'index
name: String,
// valeur de l'entrée de l'index
values: Vec<Value>,
// nom de la table associé à l'index
table_name: String,
}On implémente ExecutePlan pour ScanByIndex:
impl ExecutePlan for ScanByIndex {
fn execute<'a>(
self,
database: &'a Database,
_inputs: Vec<PlanExecInput<'a>>,
) -> PlanExecOutput<'a> {
let table_name = &self.table_name;
// Get the table
let table = database
.get_table(table_name)
.ok_or(SelectError::TableNotExist(table_name.to_string()))?;
// Get the index by its name
let index = table.indexes.get_index(&self.name);
// Get the row ids iterator from the index
let result = match index {
Some(index) => match index.get_value(&self.values) {
Some(rows) => rows,
None => return Ok(dummy()),
},
None => return Ok(dummy()),
};
// Create the index iterator over rows
let index_iterator = IndexIterator::new(table_name.to_string(), database, result)?;
Ok(Box::new(index_iterator))
}
}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.
struct FilterRow {
pub expression: Expression,
pub table_name: String,
}Nous allons réutiliser la fonction filter_row de la partie 12.
impl ExecutePlan for FilterRow {
fn execute<'a>(
self,
database: &'a Database,
mut inputs: Vec<PlanExecInput<'a>>,
) -> PlanExecOutput<'a> {
// Check the number of inputs
if inputs.len() != 1 {
return Err(SelectError::InvalidFilter(
"Invalid number of inputs".to_string(),
));
}
// Get the first iterator of the input
let input = inputs.remove(0);
let expression = self.expression;
// Get the table
let table_name = &self.table_name;
let table = database
.get_table(table_name)
.ok_or(SelectError::TableNotExist(table_name.to_string()))?;
// Filter the iterator
let result = input.filter_map(move |row| match row {
Ok(row) => match filter_row(&row, &expression, &table.schema) {
// tuple matches
Ok(true) => Some(Ok(row)),
// tuple doesn't match
Ok(false) => None,
// Propagate the error
Err(err) => Some(Err(SelectError::Query(err))),
},
Err(err) => Some(Err(err)),
});
Ok(Box::new(result))
}
}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.
struct PlanExecutor<'a> {
database: &'a Database,
plan: Plan,
}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.
fn dummy() -> Box<dyn Iterator<Item = Result<Vec<Value>, SelectError>>> {
Box::new(vec![].into_iter())
}Nous pouvons maintenant exécuter notre plan.
impl<'a> PlanExecutor<'a> {
/// Execute the plan and return the results.
pub fn exec(self) -> PlanExecOutput<'a> {
let mut out = dummy();
for step in self.plan.steps.into_iter() {
match step {
PlanStep::Scan(scan_step) => match scan_step {
ScanKind::FullScan(scan) => {
out = scan.execute(self.database, vec![])?;
}
ScanKind::ByIndex(scan) => {
out = scan.execute(self.database, vec![])?;
}
},
PlanStep::Filter(filter) => {
out = filter.execute(self.database, vec![out])?;
}
}
}
Ok(out)
}
}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.
impl Database {
pub fn select(
&mut self,
table_name: String,
where_clause: Option<WhereClause>,
explain: bool,
) -> Result<ExecuteResult, SelectError> {
let expression = where_clause
.as_ref()
.map(|where_clause| &where_clause.expression);
// Create the planner
let query_planner = QueryPlanner::new(self, &table_name, expression);
let mut plan = Plan::new();
// Plan the query
query_planner.plan(&mut plan).map_err(SelectError::Query)?;
// Display the plan and return if in explain mode
if explain {
return Ok(ExecuteResult::Explain(plan));
}
// Declare the plan executor
let plan_executor = PlanExecutor::new(self, plan);
// Execute the plan
let rows = plan_executor.exec()?;
// Return the final iterator
Ok(ExecuteResult::Tuples(rows))
}
}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.
pub struct Table {
table_name: String,
schema: Schema,
row_number: usize,
primary_indexes: Index<usize>, // đ index primaires prĂ©cĂ©dents
pager: Pager,
indexes: IndexRegistry,
}Les index primaires étaient séparés des autres index.
Nous allons les stocker de maniĂšre naturelle dans lâIndexRegistry.
struct Table {
/// Name of the table
table_name: String,
/// Schema of the table
schema: Schema,
/// Max ever row number
row_number: usize,
/// Page manager
pager: Pager,
/// Index registry
indexes: IndexRegistry,
}On adapte le constructeur en conséquence.
impl Table {
pub fn new(schema: Schema, table_name: String) -> Self {
// instanciation de l'index registry
let mut indexes = IndexRegistry::default();
// création de l'index primaire
let primary_index = PrimaryIndex::new(table_name.clone(), schema.primary_key.clone());
// ajout de l'index primaire
indexes.add_primary_index(primary_index);
Table {
pager: Pager::new(schema.size()),
schema,
row_number: 0,
indexes,
table_name,
}
}
}Et on modifie le processus dâinsertion.
impl Table {
pub fn insert(&mut self, row: HashMap<String, Value>) -> Result<(), InsertionError> {
// récupération de la slice de données stockée par la page du tuple
let page = self.pager.write(self.row_number);
let mut writer = Cursor::new(page);
// appel à la sérialisation au travers du schéma
// la vérification est également faite à ce moment
let row = self
.schema
.prepare_values(row)
.map_err(InsertionError::Serialization)?;
// on vérifie que les données à indexer sont cohérentes
self.indexes
.check(&row)
.map_err(InsertionError::IndexError)?;
// insertion des données
self.schema
.serialize(&mut writer, &row)
.map_err(InsertionError::Serialization)?;
// insersion des indexes
self.indexes
.insert_into_index(&row, self.row_number)
.map_err(InsertionError::IndexError)?;
// incrémentation de l'ID interne
self.row_number += 1;
Ok(())
}
}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.
CREATE TABLE Client (
id INTEGER PRIMARY KEY,
nom TEXT(50),
prénom Text(50),
genre TEXT(2),
ville Text(100)
);Muni des index suivants :
CREATE UNIQUE INDEX idx_identité ON Client(nom, prénom);
CREATE INDEX idx_ville ON Client(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 = 7Sans 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Ă©rateurs 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 â€ïž
