Partie 17: Logical Plan
Lecture 9 min. âą
Table des matiĂšres
- Rendre le QueryEngine âintelligentâ et verbeux
- Implémentation
- Intégration du QueryPlanner
- On peut enfin jouer avec !
- 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, on dĂ©finit une directive EXPLAIN et propagĂ© un flag dans le reste de lâapplication.
Mais une question vous brûle les lÚvres :
Question
Pourquoi ???
Rendre le QueryEngine âintelligentâ et verbeux
Et bien, nous allons complexifier de plus en plus la brique QueryEngine au fil des articles qui vont arriver, mais pour Ă©viter de se retrouver devant une âboĂźte noireâ totalement incomprĂ©hensible, nous avons besoin de savoir les choix que le query engine rĂ©alise.
Quels sont-ils ?
Réponse courte : comment récupérer efficacement de la donnée dans la base de données.
Réponse plus élaborée.
Nous avons deux choix pour récupérer de la donnée :
- scanner la base de données en parcourant les pages
- utiliser un index qui donne un accÚs direct à la donnée.
Des index nous en avons de deux types:
- Par clef primaire : il existe toujours et pour toutes les entrĂ©es de notre base, il nâen nâexiste quâun par table
- Par index secondaires : peuvent exister ou pas et peuvent ĂȘtre plus dâun par table
Les index secondaires sont eux mĂȘme dĂ©composĂ© en deux catĂ©gories:
- index unique : chaque clef dâindex pointe sur un record en base
- index non-unique : chaque clef dâindex pointe vers une liste de record en base
Notre EXPLAIN
va avoir deux effets :
- EmpĂȘcher lâexĂ©cution des scans et appels aux index
- Afficher quel a Ă©tĂ© la mĂ©thode dâaccĂšs Ă la donnĂ©e
Prenons une table telle que celle-ci
(
id INTEGER PRIMARY KEY,
nom TEXT(50),
prénom Text(50),
genre TEXT(2),
ville Text(100)
);
INTEGER PRIMARY KEY, nom TEXT(50),prénom Text(50), genre TEXT(2), ville Text(100) );
(id
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');
Si nous demandons toutes les entrĂ©es, aucune intelligence ne va ĂȘtre dĂ©veloppĂ©e, nous rĂ©alisons un Full Scan et câest tout.
SELECT * FROM Client;
Cet Ă©tat va ĂȘtre dĂ©tectĂ©, car il nây a pas de clause_where Ă la suite de la sĂ©lection de table, le Query Engine viendra opĂ©rer lâopĂ©ration la plus coĂ»teuse : lire chaque entrĂ©e de la table âClientâ, dĂ©sĂ©rialiser les donnĂ©es.
Par contre, si on demande toutes les femmes du registre, cette fois-ci, il y a une expression qui sert de clause_where, donc potentiellement de lâintelligence Ă avoir sur la rĂ©cupĂ©ration des entrĂ©es correspondantes.
Chaque table possĂšde une collection dâindex sur divers tuples de champs, le sport intellectuel du Query Engine va ĂȘtre de dĂ©tecter si oui ou non lâexpression de la requĂȘte peut coller avec un index.
Si la requĂȘte est :
SELECT * FROM Client WHERE genre = 'F';
Le query engine va demander Ă la table âClientâ
Question
Câest quoi tes index ?
Et la table va lui répondre:
primary => (id,)
idx_identité => (nom, prénom)
idx_ville => (ville,)
Le Query Engine va alors dĂ©construire lâexpression genre = 'F'
en (genre,)
.
Il va ensuite boucler sur les index Ă la recherche dâune correspondance.
Ici aucune correspondance ne peut ĂȘtre trouvĂ©e, on fallback sur du FullScan que lâon filtrera par la suite.
Par contre, si la requĂȘte est:
SELECT * FROM Client WHERE ville = 'Paris';
De la mĂȘme maniĂšre, le QueryEngine dĂ©construit lâexpression ville = 'Paris'
en (ville,)
.
Cette fois-ci, il existe une correspondance dâindex : idx_ville => (ville,)
.
Le QueryEngine va alors utiliser lâindex idx_ville
pour récupérer la donnée.
Index non-unique qui se présente ainsi:
('Paris',) => [0, 5],
('New York',) => [1, 8],
('Tokyo',) => [2, 9],
('Beyrouth',) => [3, 4],
('Osaka',) => [6]
('Lyon',) => [7]
Des tuples associé à des liste de row_id.
Lorsque lâon demande âles personnes qui habitent Parisâ, ce que lâon fait, câest demander la liste des row_id des entrĂ©es qui correspondent Ă cette ville.
Une fois que lâon a rĂ©cupĂ©rĂ© cette liste, on par row_id, rechercher les tuples de donnĂ©es correspondantes.
Cette fois-ci le mode dâaccĂšs a Ă©tĂ© rĂ©alisĂ© au travers de lâindex secondaire idx_ville
. On obtient alors la liste de row_id suivante [0, 5]
.
Attention
Passer par les index nâest pas toujours le plus efficace, si votre table est petite, il est parfois prĂ©fĂ©rable de full scan que dâabord de rĂ©cupĂ©rer les row_id puis les tuples.
Tout cela dĂ©pend des statistiques de la table, mais nous, nous nâavons pas bĂąti ces statistiques, et donc nâavons pas de moyens de deviner la bonne marche Ă suivre.
Si lâexpression est plus consĂ©quente comme dans:
SELECT * FROM Client WHERE nom = 'Dubois' AND prénom = 'Sophie';
Alors la dĂ©construction doit prendre garde Ă la construction logique de lâexpression.
Il faut analyser lâentiĂšretĂ© de lâexpression et ses relation logiques. Si lâindex est composite comme (nom, prĂ©nom)
alors ça doit se matĂ©rialiser au niveau de lâexpression par une intersection reprĂ©sentĂ© par lâopĂ©rateur logique âANDâ.
On recherche donc une expression qui possĂšde Ă la nom = '...'
ET prénom = '...'
.
Attention
Les champs dâindex peuvent trĂšs bien ĂȘtre sĂ©parĂ©s dans lâexpression par une autre expression qui nâa pas de rapport avec lâindex actuel.
SELECT * FROM Client WHERE nom = 'Dubois' AND id > 10 AND prénom = 'Sophie';
Ici le
id
nâa pas de rĂŽle Ă jouer dans la dĂ©finition de la compatibilitĂ© dâindex.
Trouver lâindex composite sâil existe va donc nĂ©cessiter deux Ă©tapes:
- DĂ©composer lâexpression
- Rechercher un groupe logique qui correspond Ă la dĂ©finition de lâindex
Et cela, il faut le réaliser pour tous les index.
Implémentation
Maintenant que lâon a tout bien dĂ©fini et expliquĂ©, nous allons pouvoir nous attaquer Ă la partie intĂ©ressante de lâimplĂ©mentation de tout cela.
Plan
La premiĂšre chose que nous allons dĂ©clarer est appelĂ©e le Plan, câest toutes les Ă©tapes que le QueryEngine pense rĂ©aliser si on lui demande de rechercher dans la base de donnĂ©es en utilisant telle ou telle Expression.
Cette structure est un wrapper autours dâun Vec<PlanStep>
.
Le PlanStep
est lui-mĂȘme une Ă©numĂ©ration.
Comme nous lâavons vu prĂ©cĂ©demment, il existe une grande variĂ©tĂ© de façons de lire de la donnĂ©e dans la base de donnĂ©es, ces maniĂšres sont matĂ©rialisĂ©es par lâĂ©numĂ©ration.
Ces variantes ont elle-mĂȘme des structures comme valeur, car nous viendrons y accrocher des comportements dans de prochains articles.
/// Represents a scan operation that retrieves rows by their primary key values.
///
/// # Fields
/// - `primary_key`: The primary key columns used for the scan.
/// - `values`: The set of primary key values used to fetch the matching rows.
/// Represents a scan operation using a secondary index.
///
/// # Fields
/// - `index`: The columns that are part of the index.
/// - `name`: The name of the index being used.
/// - `values`: The values used to search the index.
/// - `uniqueness`: Indicates if the index enforces uniqueness (`Unique` or `NonUnique`).
Détermination de la méthode de scan
Pour cela, nous allons devoir faire le mĂȘme chemin mental que lors de la premiĂšre partie de cet article.
La question que lâon se pose est : est-ce que lâexpression associĂ©e Ă la sĂ©lection correspond Ă la dĂ©finition dâun index ou pas?
Le boulot de la méthode expression_from_index_definition_to_tuple
est de rĂ©aliser la premiĂšre Ă©tape qui consiste Ă transformer par rapport Ă une expression, une potentielle dĂ©finition dâindex.
Si cette dĂ©finition dâindex est compatible alors il est possible de construire sur cette expression une clef dâindex.
/// From index column names and an [Expression]
/// creates a tuple only if the expression is
/// compatible to the index definition
Attention
Cette méthode est naïve et comporte des bugs connus, mais est une base suffisante pour débuter.
Pour lâaider dans sa tĂąche, elle peut compter sur
Qui va récursivement décomposer notre Expression afin de déterminer si une expression logique existe et si oui la décomposer en map de colonne_name <=> colonne.
Comme une table peut avoir autant dâindex secondaire que lâon veut et que ceux-ci peuvent avoir la dĂ©finition quâils dĂ©sirent. Il faut que lâIndexRegistry associĂ© Ă la table soit en mesure de dĂ©terminer si lâexpression actuelle est compatible avec un de ses index ou non.
Pour cela, celui-ci va boucler sur ses index à la recherche du premier qui possÚde les critÚres adéquats
Comme il existe deux types dâindex, on se créé une fonction qui va rechercher dans les deux groupes dâindex.
On sâassure de taper les index uniques en premier, car ce sont ceux qui renvoient le moins de rĂ©sultats, 1 seul pour ĂȘtre plus prĂ©cis.
Une fois cela fait on peut alors se construire une mĂ©thode qui va ĂȘtre en musure de trouver la mĂ©thode de lecture de la DB la plus efficace, compte tenu de lâexpression.
/// Determines the most efficient scan method for a query based on the provided table
/// and expression. It evaluates whether the query can utilize primary keys or indexes
/// for optimized access to the table.
///
/// # Arguments
///
/// * `table` - A reference to the `Table` for which the scan method needs to be determined.
/// * `expression` - The query `Expression` that defines conditions for accessing table data.
///
/// # Returns
///
/// A result containing the `ScanKind` variant that represents the scan method:
///
/// - `ScanKind::ByPk` if the query can make use of the table's primary key.
/// - An indexed scan method if the query matches one of the table's defined indexes.
/// - `ScanKind::FullScan` if no specific scan method is applicable.
///
/// Returns a `QueryError` if any issue arises during the determination of the scan kind,
/// such as invalid expressions or index lookup failures.
Construction du Plan
Nous allons définir un QueryPlanner
son travail est de venir ajouter les étapes au plan qui permettent de rechercher de la données dans une table.
/// The `QueryPlanner` struct is responsible for generating query execution plans
/// based on the database, table name, and an optional filter expression.
///
/// # Fields
/// - `database`: A reference to the `Database` that contains the tables being queried.
/// - `table_name`: The name of the table to execute the query against.
/// - `expression`: An optional filter expression to use for query optimization
/// or filtering the results.
Sa méthode plan
à pour travail de faire ces opérations
Modification du ExecuteResult
On modifie le ExecuteResult pour quâil soit en mesure dâafficher le Plan que lâon avait mis en attente sous la forme dâun Vec<String>
dans lâarticle prĂ©cĂ©dent.
Comme Plan
implémente Display
, il est possible lui faire afficher les étapes au travers de la méthode run principale.
Intégration du QueryPlanner
Nous allons enfin pouvoir utiliser le flag venant de la directive EXPLAIN.
Je ne vais pas les recopier ici, mais ci-joint, voici les divers tests qui sâassure du bon fonctionnement de lâensemble.
On peut enfin jouer avec !
Reprenons notre exemple du début.
Au travers de la 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');
Il est alors possible de lui demander ce quâil compte faire.
EXPLAIN SELECT * FROM Client WHERE ville = 'Tokyo';
Il va alors vous dire quâil connait index secondaire idx_ville
qui est apte à donner la bonne réponse sans tout scanner.
Scan secondary index idx_ville : ville = 'Tokyo' for table Client
Filter: ville = 'Tokyo'
Mais rajoute tout de mĂȘme lâĂ©tape de filtration, car il ne sait pas si câest exactement la donnĂ©e attendue, en tout cas les clients sont assurĂ©s dâhabiter Tokyo.
On pourrait par exemple avoir:
SELECT * FROM Client WHERE ville = 'Paris' AND gender = 'F';
Sans filtrage, on aurait alors 2 entrĂ©es provenant directement de lâindex
[Integer(1), Text("Smith"), Text("John"), Text("M"), Text("Paris")]
[Integer(6), Text("Yamamoto"), Text("Sakura (ăăă)"), Text("F"), Text("Paris")]
Or nous ne voulons que les femmes, câest pour cela que le filter vient rajouter la contrainte supplĂ©mentaire.
db > EXPLAIN SELECT * FROM Client WHERE ville = 'Paris' AND gender = 'F';
Scan secondary index idx_ville : ville = 'Paris' for table Client
Filter: gender = 'F' AND ville = 'Paris'
De sorte Ă ce que le rĂ©sultat soit celui que lâon attend bien.
Pour les clefs primaires, câest le mĂȘme fonctionnement.
db > EXPLAIN SELECT * FROM Client WHERE id = 7 and ville = 'Paris';
PK direct access : id = 7 for table Client
Filter: ville = 'Paris' AND id = 7
Et si la donnĂ©es nâest indexĂ©e nulle part, alors on se rabat sur le coĂ»teux fullscan.
db > EXPLAIN SELECT * FROM Client WHERE genre = 'F';
Full scan for table Client
Filter: genre = 'F'
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
Nous venons de dĂ©finir un gros morceau de ce qui fait lâingĂ©niositĂ© dâune base de donnĂ©es, son LogicalPlan.
Conclusion
Cette fois-ci nous sommes en mesure de savoir ce que le QueryEngine pense faire de notre requĂȘte.
Il nâest pas parfait et possĂšde nombreuses failles algorithmiques, mais câest une bonne base de rĂ©flexion pour itĂ©rer par-dessus.
Dans la prochaine partie, nous allons utiliser ce LogicalPlan pour en faire un plan dâĂ©xĂ©cution qui va rĂ©ellement aller rĂ©aliser les opĂ©rations de recherches dans les index et de scan de tables.
Merci de votre lecture â€ïž