Partie 18: Exécution du plan logique

Lecture 16 min. ‱

Table des matiĂšres

Les articles de la série

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:

idnomprénomgenreville
1SmithJohnMParis
2MartinMarieFNew York
3HaddadKarim (ÙƒŰ±ÙŠÙ…)MTokyo
4DuboisSophieFBeyrouth
5TanakaHiroshi (ăČろし)MBeyrouth
6YamamotoSakura (さくら)FParis
7SmithEmilyFOsaka
8MartinJeanMLyon
9HaddadLayla (ليلى)FNew York
10DuboisPaulMTokyo

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.

nomprénomids
DuboisPaul[10]
DuboisSophie[4]
HaddadKarim (ÙƒŰ±ÙŠÙ…)[3]
HaddadLayla (ليلى)[9]
MartinJean[8]
MartinMarie[2]
SmithEmily[7]
SmithJohn[1]
TanakaHiroshi (ăČろし)[5]
YamamotoSakura (さくら)[6]

L’index “idx_ville” est plus intĂ©ressant, car il peut y avoir plus d’une personne qui habite la mĂȘme ville.

villeids
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”.

idnomprénomgenreville
1SmithJohnMParis
6YamamotoSakura (さくら)FParis

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> = 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:

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:

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:

idnomprénomgenreville
1SmithJohnMParis
2MartinMarieFNew York
3HaddadKarim (ÙƒŰ±ÙŠÙ…)MTokyo
6YamamotoSakura (さくら)FParis
10DuboisPaulMTokyo

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_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.

#[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.

Note

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, value est une rĂ©fĂ©rence mutable vers une String vide qui sera créé dans la map Ă  la clef 1.

Sinon value est 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.

Note

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:

Note

inputs peut ĂȘ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 :

  1. On initialise un itérateur vide.
  2. On itÚre sur chaque étape du plan.
  3. Pour chaque étape, on exécutera :
    1. Si l’étape est une scan, on l’exĂ©cutera et on mettra Ă  jour l’itĂ©rateur (out).
    2. 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).

Et on renverra l’itĂ©rateur contenant les rĂ©sultats de la derniĂšre Ă©tape.

Note

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 :

  1. On crée un plan executor avec la base de données et le plan.
  2. 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 = 7

Sans EXPLAIN, on obtient.

db > EXPLAIN SELECT * FROM Client  WHERE id = 7 and ville = 'Paris';

Rien ! Et c’est normal.

idnomprénomgenreville
7SmithEmilyFOsaka

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 ❀

Vous pouvez trouver le code la partie ici et le diff lĂ .