Dans la précédente partie nous avons bâti un parseur en utilisant le pattern Visitor et nous vons construit la grammaire des commandes supportées.
Aujourdhui, nous allons utiliser les outils qui sont à notre dispositions et peut-être en créer de nouveau pour parser des versions simplifiées de commande SQL:
CREATE TABLE
SELECT
INSERT
Il y a un peu de travail, mais il est comparativement à la partie 5 sera bien plus mécanique qu'exploratoire.
C'est parti !
Modifications des tokens
Avant de nous lancer dans les parser à proprement parler, je vais retifier le tir sur quelques maladresses dans la gestion des tokens et de leur reconnaissance.
Le token Literal String que l'on a créé précédemment possède deux angles morts:
il ne gère pas les espaces qui seront nécessaire pour certaines valeurs
il ne gère pas les caractères spéciaux comme les symboles dont '@'
D'un autre côté, dans certain cas on veut interdire ces comportements.
On va donc créé une variante Identfier et développer deux matchers:
un pour toutes les chaînes standards
un autre seulement pour les identifiants de champs, tables, etc ...
enumLiteral{/// number recognized
Number,/// string recognized
String,/// a special case of string without space or special characters accepted
Identifier,}
Bien que notre boîte à outils soit déjà bien chargée, certaines commandes vont nous demander des composants que l'on ne possède pas encore.
Mais que l'on peut construire à partir de ce que l'on a déjà.
Consommer des espaces blancs optionnels
Il nous manque dans notre arsenal, la capacité de consommer les espaces blancs surnuméraires dans nos commandes.
En faire un visiteur est très aisé.
pubstructOptionalWhitespaces;impl<'a>Visitable<'a, u8>forOptionalWhitespaces{fnaccept(scanner:&mutScanner<'a, u8>)->parser::Result<Self>{// si on est déjà en fin de chaîne plus
// d'espaces ne sont consommables
if scanner.remaining().is_empty(){returnOk(OptionalWhitespaces);}// on boucle tant qu'on est en mesure de capturer du blanc
whileToken::Whitespace.recognize(scanner)?.is_some(){// si on est déjà en fin de chaîne plus
// d'espaces ne sont consommables
if scanner.remaining().is_empty(){break;}}Ok(OptionalWhitespaces)}}
Reconnaître un groupe qui se termine par un token précis
Ce cas est extrêment précis mais nécessaire pour la suite.
Lors d'un select la projection est défini par des identifiants séparé par des virgules, on sait que l'on arrive à la fin de la projection quand on atteint le token FROM
SELECT id, brand FROM table;
^ ^
début fin
Il faut donc forecast ce groupe comme on l'a fait pour les groupe délimité par des parenthèses.
Comme nos tokens ont des tailles en termes de nombre de bytes différents, il faut connaître pour chacun combien ils prennent de place dans la slice.
On rajoute un trait Size, qui a pour but de fournir ce comportement à nos token.
Un type de colonne textuel est plus complexe car il contient la taille de la chaîne de caractères.
On se retrouve alors avec un token Text suivi du token (, un token literal integer puis finalement un token ).
TEXT(50)
Cette fois-ci la structure va contenir un champ représentant la taille.
structTextField(usize);
L'API de parse que l'on a construit, rend alors les opérations quasi naturelles.
impl<'a>Visitable<'a, u8>forTextField{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie de potentiel blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le token TEXT
recognize(Token::Field(TokenField::Text), scanner)?;// on nettoie de potentiel blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le token (
recognize(Token::OpenParen, scanner)?;// on nettoie de potentiel blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le nombre
let value_token =recognize(Token::Literal(Literal::Number), scanner)?;// on peut alors extraire les bytes qui représente ce nombre
let value_bytes =&value_token.source[value_token.start..value_token.end];// les décoder depuis l'utf-8 ou dans le cas présent l'ASCII
let value_string =String::from_utf8(value_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// pour finalement parser la chaîne vers un usize
let value = value_string.parse().map_err(ParseError::ParseIntError)?;// on nettoie de potentiel blancs
scanner.visit::<OptionalWhitespaces>()?;recognize(Token::CloseParen, scanner)?;// on reconnaît le token (
scanner.visit::<OptionalWhitespaces>()?;Ok(TextField(value))}}
FieldResult
On vient alors fusionné nos deux variantes de parse dans une énumération.
Pour permettre de passer du résultat de l'acceptation à celui du parse on utilise une implémentation du trait From.
implFrom<ColumnTypeResult>forColumnType{fnfrom(value: ColumnTypeResult)->Self{match value {ColumnTypeResult::Integer(_)=>ColumnType::Integer,ColumnTypeResult::Text(TextField(value))=>ColumnType::Text(value),}}}
On peut alors via l'Acceptor tester successivement les types de champs possibles.
impl<'a>Visitable<'a, u8>forColumnDefinition{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie de potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît une chaîne de caractères
let name_tokens =recognize(Token::Literal(Literal::String), scanner)?;// on en récupère les bytes
let name_bytes =&name_tokens.source[name_tokens.start..name_tokens.end];// pour décoder le nom du champ
let name =String::from_utf8(name_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// l'espace blancs est obligatoire
scanner.visit::<Whitespaces>()?;// on visite le ColumnType
let field = scanner.visit::<ColumnType>()?;Ok(ColumnDefinition { name, field })}}
Schéma
Un schéma est un enchaînement de définition de colonne séparées par des virgules, entouré de parenthèses.
impl<'a>Schema{fnparse(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on forecast notre groupe délimité par des parenthèse
let fields_group =forecast(GroupKind::Parenthesis, scanner)?;// on enlève les parenthèses périphériques quio font toutes les deux 1 bytes
let fields_group_bytes =&fields_group.data[1..fields_group.data.len()-1];// on en créé un sous scanner
letmut fields_group_tokenizer =Tokenizer::new(fields_group_bytes);// que l'on visite comme une liste de définition de colonne séparé par des virgules
let columns_definitions = fields_group_tokenizer.visit::<SeparatedList<ColumnDefinition, SeparatorComma>>()?;// que l'on transforme en notre map de définition de champs
let fields = columns_definitions.into_iter().fold(HashMap::new(),|mutfields,column_definition|{ fields.insert(column_definition.name, column_definition.field); fields
},);// on n'oublie pas de déplacer le curseur du scanner externe de la taille du groupe
scanner.bump_by(fields_group.data.len());Ok(Schema { fields })}}
Ce qui nous donne
#[test]fntest_parse_schema(){let data =b"( id integer, name text ( 50 ) )";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");assert_eq!( schema, Schema { fields:HashMap::from([("id".to_string(),ColumnType::Integer),("name".to_string(),ColumnType::Text(50))])});let data =b"(id INTEGER, name TEXT(50))";letmut tokenizer =Tokenizer::new(data);let schema = tokenizer.visit::<Schema>().expect("failed to parse schema");assert_eq!( schema, Schema { fields:HashMap::from([("id".to_string(),ColumnType::Integer),("name".to_string(),ColumnType::Text(50))])});}
La commande Create table
On rassemble tout le monde pour créer notre commande CREATE TABLE.
impl<'a>Visitable<'a, u8>forCreateTableCommand{fnaccept(scanner:&mutScanner<'a, u8>)->parser::Result<Self>{// on nettoie de potentiel blanc
scanner.visit::<OptionalWhitespaces>()?;// on reconnait le token CREATE
recognize(Token::Create, scanner)?;// on reconnait au moins 1 blanc
scanner.visit::<Whitespaces>()?;// on reconnait le token TABLE
recognize(Token::Table, scanner)?;// on reconnait au moins 1 blanc
scanner.visit::<Whitespaces>()?;// on reconnait une literal string représentant le nom de table
let table_name_tokens =recognize(Token::Literal(Literal::String), scanner)?;let table_name_bytes =&table_name_tokens.source[table_name_tokens.start..table_name_tokens.end];let table_name =String::from_utf8(table_name_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// on visite le schéma
let schema = scanner.visit::<Schema>()?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le point-virgule final
recognize(Token::Semicolon, scanner)?;Ok(CreateTableCommand { table_name, schema })}}
On arrive finalement à notre but:
#[test]fntest_parse_create_table_command(){let data =b"create table users (id integer, name text(50) );";letmut scanner =Scanner::new(data);let result =CreateTableCommand::accept(&mut scanner).expect("failed to parse");assert_eq!( result, CreateTableCommand { table_name:"users".to_string(), schema: Schema { fields:HashMap::from([("id".to_string(),ColumnType::Integer),("name".to_string(),ColumnType::Text(50))])}});}
Elle peut faire peur de prime abord, mais elle est relativement innofensive quand on y réfléchie bien.
Elle est composé de 2 grosses partie.
La définition de la table et celle des valeurs.
Définition des champs
Commençons par les champs. Un groupe délimité par des parenthèses séparé par des virgules de literals string est précédé par 2 token INSERT,INTO, {table} où {table} est une literal string représentant le nom de la table.
INSERT INTO ma_table(id, brand)
Et ça rien ce n'est rien qu'on a pas déjà réussi à faire.
Il faut penser à découper astucieusement le travail.
D'abord on reconnaît une Column
structColumn(String);impl<'a>Visitable<'a, u8>forColumn{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnait la literal string
let name_tokens =recognize(Token::Literal(Literal::String), scanner)?;let name_bytes =&name_tokens.source[name_tokens.start..name_tokens.end];let name =String::from_utf8(name_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;Ok(Column(name))}}
Puis ensuite, on peut en faire un groupe de Column que l'on réduit en un wrapper de Vec<String>
#[derive(Debug, PartialEq)]pubstructColumns(Vec<String>);impl<'a>Visitable<'a, u8>forColumns{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le token "("
recognize(Token::OpenParen, scanner)?;// on capture le groupe de nom de colonnes
let columns_group = scanner.visit::<SeparatedList<Column, SeparatorComma>>()?;let colums = columns_group.into_iter();// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le token ")"
recognize(Token::CloseParen, scanner)?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;Ok(Columns(colums.map(|column|column.0).collect()))}}
Et voilà !
#[test]fntest_columns(){let data =b"(id, brand)";letmut tokenizer =Tokenizer::new(data);let result = tokenizer.visit::<Columns>().expect("failed to parse");assert_eq!(result.0,vec!["id","brand"]);}
Définitions des valeurs
Deux types de valeurs sont acceptées:
des entiers : 1235, 42, 0
des string : 'test data', 'email@example.com'
Les chaînes sont entourées par des guillemets simples.
Nous avons donc deux choses à gérer, le type de donnée (INTEGER, TEXT) et le fait que c'est une liste.
Occupons-nous d'abord des entiers.
structIntegerValue(i64);impl<'a>Visitable<'a, u8>forIntegerValue{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on récupère le nombre
let number_token =recognize(Token::Literal(Literal::Number), scanner)?;let number_bytes =&number_token.source[number_token.start..number_token.end];let number_string =String::from_utf8(number_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// que l'on parse vers un i64
let number = number_string.parse().map_err(ParseError::ParseIntError)?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;Ok(IntegerValue(number))}}
On fait de même avec les champs texte
structTextValue(String);impl<'a>Visitable<'a, u8>forTextValue{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnait le début du groupe quoted
recognize(Token::Quote, scanner)?;// on récupère le literal string
let literal_token =recognize(Token::Literal(Literal::String), scanner)?;let literal_bytes =&literal_token.source[literal_token.start..literal_token.end];let literal_string =String::from_utf8(literal_bytes.to_vec()).map_err(ParseError::Utf8Error)?;// on reconnait la fin du groupe quoted
recognize(Token::Quote, scanner)?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;Ok(TextValue(literal_string))}}
implFrom<ValueResult>forValue{fnfrom(value: ValueResult)->Self{match value {ValueResult::Integer(IntegerValue(value))=>Value::Integer(value),ValueResult::Text(TextValue(value))=>Value::Text(value),}}}
Ce qui permet alors d'en faire un Acceptor et de visiter la Value.
#[test]fntest_integer_value(){let data =b"123";letmut tokenizer =Tokenizer::new(data);let value =IntegerValue::accept(&mut tokenizer).expect("failed to parse");assert_eq!(value, IntegerValue(123));}#[test]fntest_text_value(){let data =b"'test'";letmut tokenizer =Tokenizer::new(data);let value =TextValue::accept(&mut tokenizer).expect("failed to parse");assert_eq!(value, TextValue("test".to_string()));}
On peut alors accumuler les Value.
structValues(Vec<Value>);impl<'a>Visitable<'a, u8>forValues{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on récupère le groupe de valeurs
let values_group =forecast(GroupKind::Parenthesis, scanner)?;let values_group_bytes =&values_group.data[1..values_group.data.len()-1];// on créé le tokenizer intermédiaire
letmut value_group_tokenizer =Tokenizer::new(values_group_bytes);// on reconnait les values
let values_list = value_group_tokenizer.visit::<SeparatedList<Value, SeparatorComma>>()?;let values = values_list.into_iter().collect();// on avance le tokenizer externe
scanner.bump_by(values_group.data.len());Ok(Values(values))}}
Et ça marche plutôt bien 😎
#[test]fntext_values(){let data =b"('test1', 120, 'test3')";letmut tokenizer =Tokenizer::new(data);let values =Values::accept(&mut tokenizer).expect("failed to parse");assert_eq!( values, Values(vec![Value::Text("test1".to_string()),Value::Integer(120),Value::Text("test3".to_string())]));}
Commande Insert Into
On modélise la commande par la structure suivante:
impl<'a>Visitable<'a, u8>forInsertIntoCommand{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît le token INSERT
recognize(Token::Insert, scanner)?;// on reconnait au moins un blanc
scanner.visit::<Whitespaces>()?;// on reconnaît le token INTO
recognize(Token::Into, scanner)?;// on reconnait au moins un blanc
scanner.visit::<Whitespaces>()?;// on reconnait le nom de la table
let name_tokens =recognize(Token::Literal(Literal::String), scanner)?;let name_bytes = name_tokens.source[name_tokens.start..name_tokens.end].to_vec();let table_name =String::from_utf8(name_bytes).map_err(ParseError::Utf8Error)?;// on visite les noms de colonne
let columns = scanner.visit::<Columns>()?;// on reconnait au moins un blanc
scanner.visit::<Whitespaces>()?;// on reconnaît le token VALUES
recognize(Token::Values, scanner)?;// on nettoie les potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnaît les values
let values = scanner.visit::<Values>()?;// on zip les couples (colonne, valeur)
let fields =zip(values.0, columns.0).fold(HashMap::new(),|mutmap,(value,column)|{ map.insert(column, value); map
});Ok(InsertIntoCommand { table_name, fields })}}
Qui fonctionne ainsi
#[test]fntest_insert_into_command(){let data =b"INSERT INTO users(id, name, email) VALUES(42, 'user 1', 'email@example.com')";letmut tokenizer =Tokenizer::new(data);let result = tokenizer
.visit::<InsertIntoCommand>().expect("failed to parse");assert_eq!(result.table_name,"users");assert_eq!(result.fields.len(),3);assert_eq!(result.fields.get("id").unwrap(),&Value::Integer(42));assert_eq!( result.fields.get("name").unwrap(),&Value::Text("user 1".to_string()));assert_eq!( result.fields.get("email").unwrap(),&Value::Text("email@example.com".to_string()));assert_eq!(tokenizer.cursor(),76);}
La dernière commande que nous allons parser est le Select, elle semblait la plus simple mais elle m'a donné un peu de réflexion à avoir sur la projection.
Projection
La projection permet de sélectionner les champs qui doivent être renvoyé lors d'un SELECT.
La projection va fonctionner de la même manière que pour les ColunmType, il y là aussi deux variantes:
* : qui projete tous les champs
columns : qui liste des champs précis
Occupons-nous de la version '*'.
structProjectionStar;impl<'a>Visitable<'a, u8>forProjectionStar{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie de potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on reconnait l'étoile
recognize(Token::Star, scanner)?;// on nettoie de potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;Ok(ProjectionStar)}}
Rien de bien exaltant à expliquer.
La version "colonnes" est plus intéressante.
structProjectionColumns(Vec<String>);impl<'a>Visitable<'a, u8>forProjectionColumns{fnaccept(scanner:&mutScanner<'a, u8>)->crate::parser::Result<Self>{// on nettoie de potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on capture le groupe qui se termine par le token FROM
let columns_tokens =forecast(UntilToken(Token::From), scanner)?;let columns_bytes = columns_tokens.data;// on crée un tokenizer pour récupérer les noms de colonnes
letmut columns_tokenizer =Tokenizer::new(columns_bytes);// on visite une liste de colonnes
let columns_list = columns_tokenizer.visit::<SeparatedList<Column, SeparatorComma>>()?;let columns = columns_list
.into_iter().map(|x|x.0).collect::<Vec<String>>();// on nettoie de potentiels blancs
scanner.visit::<OptionalWhitespaces>()?;// on avance le curseur
scanner.bump_by(columns_tokens.data.len());Ok(ProjectionColumns(columns))}}
implFrom<ProjectionResult>forProjection{fnfrom(value: ProjectionResult)->Self{match value {ProjectionResult::Star(ProjectionStar)=>Projection::Star,ProjectionResult::Columns(ProjectionColumns(columns))=>Projection::Columns(columns),}}}
#[test]fntest_projections(){let data =b"* FROM";letmut tokenizer =Tokenizer::new(data);let projection = tokenizer.visit::<Projection>().expect("failed to parse");assert_eq!(projection,Projection::Star);let data =b"id, name FROM";letmut tokenizer =Tokenizer::new(data);let projection = tokenizer.visit::<Projection>().expect("failed to parse");assert_eq!( projection,Projection::Columns(vec!["id".to_string(),"name".to_string()]));}
La commande Select From
Une fois en possession de la Projection, il est possible de mettre en place la commande Select
structSelectCommand{table_name: String,
projection: Projection,
}impl<'a>Visitable<'a, u8>forSelectCommand{fnaccept(scanner:&mutTokenizer<'a>)->parser::Result<Self>{// on nettoie les potentiels espaces
scanner.visit::<OptionalWhitespaces>()?;recognize(Token::Select, scanner)?;// on reconnait au moins un espace
scanner.visit::<Whitespaces>()?;// on reconnait la projection
let projection = scanner.visit::<Projection>()?;// on reconnait le token FROM
recognize(Token::From, scanner)?;// on reconnait au moins un espace
scanner.visit::<Whitespaces>()?;// on reconnait le nom de la table
let name_tokens =recognize(Token::Literal(Literal::Identifier), scanner)?;let name_bytes = name_tokens.source[name_tokens.start..name_tokens.end].to_vec();let table_name =String::from_utf8(name_bytes).map_err(ParseError::Utf8Error)?;// on nettoie les potentiels espaces
scanner.visit::<OptionalWhitespaces>()?;// on reconnait le point virgule terminal
recognize(Token::Semicolon, scanner)?;Ok(SelectCommand { table_name, projection,})}}
Et ça marche comme sur des roulettes !
#[test]fntest_parse_select_command(){let data =b"SELECT * FROM table;";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
});let data =b"SELECT id, brand FROM table_2;";letmut tokenizer =super::Tokenizer::new(data);let result = tokenizer.visit::<SelectCommand>().expect("failed to parse");assert_eq!( result, SelectCommand { table_name:"table_2".to_string(), projection:Projection::Columns(vec!["id".to_string(),"brand".to_string()])})}
Parser les commandes
Comme tout est étagé et isolé. La cerise sur le gâteau est evidente.
#[test]fntest_select(){let data =b"SELECT * FROM table;";letmut tokenizer =Tokenizer::new(data);assert_eq!( tokenizer.visit(),Ok(Command::Select(SelectCommand { table_name:"table".to_string(), projection:Projection::Star
})));let data =b"SELECT id, brand FROM table_2;";letmut tokenizer =Tokenizer::new(data);assert_eq!( tokenizer.visit(),Ok(Command::Select(SelectCommand { table_name:"table_2".to_string(), projection:Projection::Columns(vec!["id".to_string(),"brand".to_string()])})));}#[test]fntest_insert(){let data =b"INSERT INTO Users(id, name, email) VALUES (42, 'user 1', 'email@example.com')";letmut tokenizer =Tokenizer::new(data);assert_eq!( tokenizer.visit(),Ok(Command::InsertInto(InsertIntoCommand { table_name:"Users".to_string(), fields:vec![("id".to_string(),Value::Integer(42)),("name".to_string(),Value::Text("user 1".to_string())),("email".to_string(),Value::Text("email@example.com".to_string()))].into_iter().collect()})));}#[test]fntest_create(){let data =b"CREATE TABLE Users(id INTEGER, name TEXT(50), email TEXT(128));";letmut tokenizer =Tokenizer::new(data);assert_eq!( tokenizer.visit(),Ok(Command::CreateTable(CreateTableCommand { table_name:"Users".to_string(), schema: Schema { fields:HashMap::from([("id".to_string(),ColumnType::Integer),("name".to_string(),ColumnType::Text(50)),("email".to_string(),ColumnType::Text(128)),]),}})))}
Conclusion
Ok ! La parenthèse "parser" est enfin terminée ! Il n'est pas parfait, il a plein de problèmes, mais il va permettre de pouvoir attaquer la création des schémas sur nos tables et donc pouvoir nous débarasser des stuctures User et Car et tous les inconvénients qu'elles comportent.