Pour le moment notre seule façon de récupérer de la donnée depuis notre table consiste à réaliser un full-scan, ce qui est veut dire partir du premier byte de la table et désésérialiser jusqu'à la fin.
Autant dire que ce n'est pas vraiment optimale si on cherche un tuple en milieu de table. 😅
Tout comme à l'époque du téléphone à fil, il y avait un gros bouquin qui référençait le nom de la personne par rapport à son numéro. Nous allons faire de même avec nos tuples.
La question maintenant est de savoir dans notre cas qui est le "nom" et qu'est ce que le "numéro" de notre "annuaire" à nous.
Il va nous falloir plus d'outils pour répondre à ces questions.
Modifiation du parser
La première chose que l'on va rajouter c'est des nouvelles capacité à notre parser.
Nouvelle grammaire
Pour pouvoir définir qui sera le "nom" de notre annuaire, nous allons définir un flag sur un des champs du schéma de la table à la création de celle-ci.
Ce flag va se nommer PRIMARY KEY, un champ disposant de cette contrainte se nomme la "clef primaire" de la table, autrement dit la manière la plus simple de retrouver de la données dans une table.
Voici la nouvelle grammaire du parser pour la commande CREATE TABLE.CREATE TABLE avec PRIMARY KEY
Les colonnes disposent désormais d'un nouveau composant Constraint qui va être soit l'enchaînement des tokens PRIMARY KEY soit NOT NULL.
Il est possible de d'avoir à la fois PRIMARY KEY et NOT NULL sur une même colonne.
La deuxième modification va être sur la commande SELECT.
Pour le moment, on réalise uniquement ce que l'on nomme des "full-scan", c'est à dire que l'on part de l'index 0 de nos tuples, démarrant à la page 0 de notre table, et on itère jusqu'à ne plus avoir d'entrée à retourner.
Cela est du à la pauvreté de notre commande SELECT qui ne permet pas de nommer ce que l'on désire. Il nous manque littéralement des "mots" pour nous exprimer! 😅
Nous allons les rajouter avec cette nouvelle grammaire:SELECT FROM avec clause WHERE
Elle introduit le nouveau mot-clef WHERE qui permet d'écrire une expression du type champ = value, ce qui signifie : "renvoie moi le champ qui possède cette valeur".
Ajouts des tokens
Nous allons rajouter 6 tokens supplémentaires:
PRIMARY
KEY
NOT
NULL
WHERE
=
enumToken{// ...
/// PRIMARY token
Primary,/// KEY token
Key,/// NOT token
Not,/// NULL token
Null,/// = token
Equal,/// WHERE token
Where,/// Technical token never matched
Technic
// ...
}
J'en profite pour définir un token technique qui permet d'utiliser l'API Token sans devoir définir quelque chose de précis à reconnaître.
On consomme jusqu'à la fin, son existence peut sembler absurde, mais il va permettre de construire des API d'alternatives de reconnaissances de pattern bien plus naturellement.
Définition des contraintes
Pour parser nos contraintes PRIMARY KEY et NOT NUL, il faut pouvoir les reconnaître.
Pour se faire on rajoute deux visiteurs:
PrimaryKeyConstraint qui reconnaît l'enchaînement de tokens PRIMARY suivi d'un nombre supérieur à 1 d'espace puis le token KEY.
Un groupe de contraintes est séparé par un espace blanc.
Lorsque l'on défini des containtes, on peut se retrouver dans deux cas:
soit c'est un groupe de containtes sur un champ du schéma qui n'est pas le dernier champ du schéma : ce champ se termine par une virgule ,
soit le groupe de contraintes est sur le dernier champ du schéma : ce champ se termine par rien du tout
impl<'a>Visitable<'a, u8>forColumnDefinition{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// On reconnaît un groupe qui se termine
let maybe_constraints =Forcaster::new(scanner)// soit par une virgule
.try_or(UntilToken(Token::Comma))?// soit c'est la fin de la slice
.try_or(UntilEnd)?.finish();letmut constraints =Vec::new();// si un groupe est trouvé
ifletSome(Forecasting { data,..})= maybe_constraints {letmut constrait_tokenizer =Tokenizer::new(data);// si le groupe contient au moins un byte
if!constrait_tokenizer.remaining().is_empty(){// on en fait une liste de contraintes séparées par au moins un blanc
let constraints_group = constrait_tokenizer.visit::<SeparatedList<Constraint, Whitespaces>>()?; constraints = constraints_group.into_iter().collect();// on avance le scanner
scanner.bump_by(data.len());}}// on retourne la colonne avec ses contraintes
Ok(ColumnDefinition { name, field, constraints,})}}
Modification du visiteur Schema
impl<'a>Schema{fnparse(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on visite la liste de colonnes
let columns_definitions = fields_group_tokenizer.visit::<SeparatedList<ColumnDefinition, SeparatorComma>>()?;// que l'on transforme en des définitions de colonnes
let fields = columns_definitions
.into_iter().fold(Vec::new(),|mutfields,column_definition|{ fields.push(( column_definition.name,crate::data::ColumnDefinition::new( column_definition.field,// 👇 avec les contraintes associées
column_definition.constraints,),)); fields
});}}
Ce qui donne:
#[test]fntest_constraints(){let data =b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL)";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");assert_eq!( schema,Schema::new(vec![("id".to_string(),ColumnDefinition::new(ColumnType::Integer,vec![Constraint::NotNull,Constraint::PrimaryKey,])),("name_1".to_string(),ColumnDefinition::new(ColumnType::Text(50),vec![Constraint::NotNull]))],))}
Nous sommes désormais capables de parser les contraintes de la tables ! 🤩
Clef primaire du schéma
Une des contraintes qui le plus important pour nous à l'instant présent est PRIMARY KEY, celle ci dispose plusieurs règle de définition.
au moins un des champs doit-être une clef primaire
il ne peut pas y avoir plus d'un champ comme clef primaire d'une table
Nous allons matérialiser cela à la fois via des erreurs
et une méthode qui a pour objet de trouver la contrainte unique de clef primaire et renvoyer dans les autres cas.
La primary key est un Vec<String> car il est possible d'associer plus d'un champ pour construire une clef primaire.
On nomme ça des clefs composites, si vous voulez prendre de l'avance sur la suite. 😇
fnparse_primary_key(fields:&Vec<(String, crate::data::ColumnDefinition)>,
)->crate::parser::Result<Vec<String>>{letmut primary_keys =Vec::new();// on boucle sur les champs du schéma
for(field_name, column_definition)in fields {// si au moins une des contraintes de la colonne est PRIMARY KEY
if!column_definition
.constraints
.iter().filter(|constraint|constraint ==&&Constraint::PrimaryKey).collect::<Vec<_>>().is_empty(){// alors on rajoute le nom de la colonne
primary_keys.push(vec![field_name.clone()]);}}// si aucune PRIMARY KEY n'est trouvable, le schéma est invalide
if primary_keys.is_empty(){returnErr(ParseError::PrimaryKeyNotExists);}// si plus d'un champ est une PRIMARY KEY, le schéma est également invalide
if primary_keys.len()>1{returnErr(ParseError::NonUniquePrimaryKey(primary_keys));}Ok(primary_keys.remove(0))}
On défini un nouveau champ primary_key sur notre Schema.
Lors du parse on va alors appeler notre méthode parse_primary_key depuis notre visiteur de Schéma.
impl<'a>Schema{fnparse(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// ...
// on récupère la primary key
let primary_key =parse_primary_key(&fields)?;Ok(Schema::new(fields, primary_key))}}
Ce qui permet de parser nos schémas
#[test]fntest_constraints(){let data =b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL)";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");assert_eq!( schema,Schema::new(vec![("id".to_string(),ColumnDefinition::new(ColumnType::Integer,vec![Constraint::NotNull,Constraint::PrimaryKey,])),("name_1".to_string(),ColumnDefinition::new(ColumnType::Text(50),vec![Constraint::NotNull]))],vec!["id".to_string()]))}#[test]fntest_constraints_non_existent_primary_key(){let data =b"(id INTEGER NOT NULL, name_1 TEXT(50) NOT NULL)";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>();assert_eq!(schema,Err(crate::parser::ParseError::PrimaryKeyNotExists))}#[test]fntest_constraints_multiple_primary_key(){let data =b"(id INTEGER NOT NULL PRIMARY KEY, name_1 TEXT(50) NOT NULL PRIMARY KEY)";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>();assert_eq!( schema,Err(crate::parser::ParseError::NonUniquePrimaryKey(vec![vec!["id".to_string()],vec!["name_1".to_string()]])))}
Nous avons désormais la certitude d'avoir une clef primaire unique dans la définition du schéma de notre table.
On avance ! 🤩
Clause Where
Notre clause est extrêmement simplifié par rapport à la réalité de SQL (chaque chose en son temps 😅).
On associe un champ et une valeur séparé par un token =.
Notre association se matérialise par la structure
structWhereClause{field: String,
value: Value,
}
Que l'on visite
impl<'a>Visitable<'a, u8>forWhereClause{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{ scanner.visit::<OptionalWhitespaces>()?;// doit commencer par WHERE
recognize(Token::Where, scanner)?;// suivi d'un espace
scanner.visit::<Whitespaces>()?;// suivi d'un nom de colonne
let column = scanner.visit::<Column>()?;// on reconnaît le =
recognize(Token::Equal, scanner)?;// suivi d'un espace
scanner.visit::<Whitespaces>()?;// suivi d'une valeur
let value = scanner.visit::<Value>()?;Ok(Self{ field: column.0, value,})}}
Ce qui donne
#[test]fntest_where_clause(){letmut scanner =Scanner::new(b"WHERE id = 42");let where_clause = scanner.visit::<WhereClause>().unwrap();assert_eq!(where_clause.field,"id".to_string());assert_eq!(where_clause.value,Value::Integer(42));}
Modification de la commande SELECT
On rajoute alors notre clause where qui peut optionnellement exister
impl<'a>Visitable<'a, u8>forSelectCommand{fnaccept(scanner:&mutTokenizer<'a>)->parser::Result<Self>{// ...
// le modificateur d'optionalité permet de ne pas contraindre l'existence du WHERE
let where_clause = scanner.visit::<Optional<WhereClause>>()?.0;// on reconnait le point virgule terminal
recognize(Token::Semicolon, scanner)?;Ok(SelectCommand { table_name, projection, where_clause,})}}
Donnant:
#[test]fntest_parse_select_command_with_where_clause(){let data =b"SELECT * FROM table WHERE id = 1;";letmut tokenizer =super::Tokenizer::new(data);let result = tokenizer.visit::<SelectCommand>().expect("failed to parse");assert_eq!( result, SelectCommand { table_name:"table".to_string(), projection:Projection::Star, where_clause:Some(WhereClause { field:"id".to_string(), value:Value::Integer(1)})});}
Indexation des entrées
Maintenant que notre parser est amélioré pour définir la clef primaire de la table, nous allons pouvoir l'utiliser pour remplir notre "annuaire".
Et voici la réponse à la question "Qui est le nom et le numéro de notre annuaire ?".
Notre "nom" sera un tableau de valeur, un tuple en fait
Et notre "numéro" sera l'ID du tuple dans la table.
typePrimaryIndexes=BTreeMap<Vec<Value>, usize>;
Notre "annuaire" sera stocké au niveau de la table
Si la clef primaire est impossible à trouver, ceci est une erreur, même si dans les faits, cette erreur ne peut pas exister car le schéma vérifie préemptivement le tuple à insérer.
Il est alors possible avant insertion dans la page de venir rajouter notre clef primaire dans l'index primaire.
implTable{pubfninsert(&mutself, 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);letmut writer =Cursor::new(page);// appel à la sérialisation au travers du schéma
// la vérification est également faite à ce moment
let pk_fields =&self.schema.primary_key;let pk =get_pk_value(row, pk_fields)?;// insertion dans l'index primaire de notre tuple
// et de l'ID courant d'insertion
self.primary_indexes.insert(pk,self.row_number);self.schema
.serialize(&mut writer, row).map_err(InsertionError::Serialization)?;self.row_number +=1;Ok(())}}
Query Engine
Pour simplifier les recherches et les scans de tables et surtout pour éviter que le fichier tables n'enfle démesurément, nous allons mettre la logique de recherche au sein d'un QueryEngine.
Celui-ci prend une référence de Table en paramètre.
Et une méthode get_by_pk, le pk signifie "Primary Key".
implQueryEngine<'_>{pubfnget_by_pk(&self,
values:&Vec<Value>,
primary_indexes:&PrimaryIndexes,
)->Result<Vec<Vec<Value>>, SelectError>{// récupération l'ID du tuple
let row_number = primary_indexes
.get(values).ok_or(SelectError::PrimaryKeyNotExists(values.clone()))?;// récupération de la page
let row =self.get_row(*row_number)?;Ok(vec![row])}}
Utilisation du Query Engine
Tout étant correctement découpé, il est possible de segmenter nos recherches entre le full scan et la recherche par PK.
implTable{pubfnselect(&self,
where_clause:Option<WhereClause>,
)->Result<Vec<Vec<Value>>, SelectError>{// instanciation du Query Engine pour la table
let engine =QueryEngine::new(self);match where_clause {// s'il n'y a pas de clause where on scan tout
None=> engine.full_scan(self.row_number),// sinon si la clause where concerne la clef primaire
Some(WhereClause { field, value })ifself.schema.primary_key ==vec![field.clone()]=>{// on récupère l'entrée désignée
engine.get_by_pk(&vec![value],&self.primary_indexes)}_=>Err(SelectError::NotImplemented),}}}
Testons !!
On se créé une table qui possède une clef primaire "id".
db > CREATE TABLE Users (id INTEGER PRIMARY KEY, name TEXT(20), email TEXT(50));
db > INSERT INTO Users (id, name, email) VALUES (42, 'john.doe', 'john.doe@example.com');
db > INSERT INTO Users (id, name, email) VALUES (666, 'jane.doe', 'jane.doe@example.com');
db > INSERT INTO Users (id, name, email) VALUES (1, 'admin', 'admin@example.com');
On full scan la table
db > SELECT * FROM Users;
[Integer(42), Text("john.doe"), Text("john.doe@example.com")]
[Integer(666), Text("jane.doe"), Text("jane.doe@example.com")]
[Integer(1), Text("admin"), Text("admin@example.com")]
On récupère par PK.
db > SELECT * FROM Users WHERE id = 1;
[Integer(1), Text("admin"), Text("admin@example.com")]
db > SELECT * FROM Users WHERE id = 42;
[Integer(42), Text("john.doe"), Text("john.doe@example.com")]
db > SELECT * FROM Users WHERE id = 666;
[Integer(666), Text("jane.doe"), Text("jane.doe@example.com")]
db >
Succès total et absolu !! 😍
Conclusion
Notre Query Engine a encore plein de problème et notre clef primaire ne supporte pas encore les clefs composites. Mais on a un début de quelque chose. ^^'
Dans la prochaine partie nous verrons les expressions qui permettront de faire des recherches plus intéressantes.
Merci de votre lecture ❤️
Vous pouvez trouver le code la partie ici et le diff là.
Auteur: Akanoa
Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.