https://lafor.ge/feed.xml

Partie 6 : Parser les commandes SQL

2024-12-12
Les articles de la série

Bonjour à toutes et tous 😃

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 ...
enum Literal {
    /// number recognized
    Number,
    /// string recognized
    String,
    /// a special case of string without space or special characters accepted
    Identifier,
}

On peut en définir les matchers

fn match_string() -> impl Fn(&[u8]) -> (bool, usize) {
    move |x: &[u8]| -> (bool, usize) {
        if x.is_empty() {
            return (false, 0);
        }
        let mut pos = 0;
        let mut found = false;

        let forbiden_chars = ['(', ')', '\'', ','];

        loop {
            if pos == x.len() {
                return (found, pos);
            }

            if forbiden_chars.contains(&(x[pos] as char)) {
                return (found, pos);
            }

            if x[pos].is_ascii_alphanumeric()
                || x[pos].is_ascii_punctuation()
                || x[pos].is_ascii_whitespace()
            {
                found = true;
                pos += 1;
            } else {
                return (found, pos);
            }
        }
    }
}

fn match_identifier() -> impl Fn(&[u8]) -> (bool, usize) {
    move |x: &[u8]| -> (bool, usize) {
        if x.is_empty() {
            return (false, 0);
        }
        let mut pos = 0;
        let mut found = false;

        loop {
            if pos == x.len() {
                return (found, pos);
            }

            if x[pos].is_ascii_alphanumeric() || x[pos] == b'_' {
                found = true;
                pos += 1;
            } else {
                return (found, pos);
            }
        }
    }
}

Pour ensuite les appliquer

impl Match for Literal {
    fn matcher(&self) -> Matcher {
        match self {
            Literal::Number => Box::new(matchers::match_number()),
            Literal::Identifier => Box::new(matchers::match_identifier()),
            Literal::String => Box::new(matchers::match_string()),
        }
    }
}

Composants supplémentaires

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

pub struct OptionalWhitespaces;

impl<'a> Visitable<'a, u8> for OptionalWhitespaces {
    fn accept(scanner: &mut Scanner<'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() {
            return Ok(OptionalWhitespaces);
        }

        // on boucle tant qu'on est en mesure de capturer du blanc
        while Token::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.

pub trait Size {
    fn size(&self) -> usize;
}

impl Size for Token {
    fn size(&self) -> usize {
        match self {
            Token::Create => 6,
            Token::Table => 5,
            Token::Insert => 6,
            Token::Into => 4,
            Token::Values => 6,
            Token::Select => 6,
            Token::From => 4,
            Token::Field(field) => field.size(),
            Token::Literal(_literal) => 0,
            _ => 1,
        }
    }
}

impl Size for Field {
    fn size(&self) -> usize {
        match self {
            Field::Integer => 7,
            Field::Text => 4,
        }
    }
}

Je met un 0 à literal pour signifier qu'il n'a pas de taille définie, on aurait pu mettre un Option à la place, mais ça complique l'API

Pour cela on va se construire un nouvel outil qui prend un token comme paramètre, ce token sera le délimiteur de fin.

struct UntilToken(Token);

On peut alors lui implémenter Forecast

impl<'a> Forecast<'a, u8, Token> for UntilToken {
    fn forecast(&self, data: &mut Scanner<'a, u8>) -> crate::parser::Result<ForecastResult<Token>> {
        let mut tokenizer = Tokenizer::new(data.remaining());

        while !tokenizer.remaining().is_empty() {
            match recognize(self.0, &mut tokenizer) {
                Ok(_element) => {
                    return Ok(ForecastResult::Found {
                        end_slice: tokenizer.cursor() - self.0.size(),
                        start: self.0,
                        end: self.0,
                    });
                }
                Err(_err) => {
                    tokenizer.bump_by(1);
                    continue;
                }
            }
        }

        Ok(ForecastResult::NotFound)
    }
}

Que l'on peut utiliser de cette manière.

#[test]
fn test_until_token() {
    let data = b"id, brand FROM cars";
    let mut tokenizer = Tokenizer::new(data);
    let forecast_result =
        forecast(UntilToken(Token::From), &mut tokenizer).expect("failed to forecast");
    assert_eq!(
        forecast_result,
        Forecasting {
            start: Token::From,
            end: Token::From,
            data: b"id, brand ",
        }
    )
}

Bon avec ça je pense que l'on a tout. ^^

On peut parser les commandes. 🎯

Commande Create Table

Grammaire de CREATE TABLE
CreateTable ::= 'CREATE' 'TABLE' LiteralString OpenParen ColumnDef* CloseParen Semicolon
ColumnDef ::= LiteralString ColumnType | LiteralString ColumnType Comma
ColumnType ::= 'INTEGER' | ColumTypeText
ColumTypeText ::= 'TEXT' OpenParen LiteralInteger CloseParen
LiteralString ::=  [A-Za-z0-9_]*
LiteralInteger ::= [0-9]+
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'

CreateTable:

ColumnType:


ColumTypeText:


LiteralString:


LiteralInteger:

C'est la plus difficile de toute, on va la faire en premier car elle va permettre de dégager le chemin pour toutes les autres.

Elle est composée de deux partie principales:

  • Une sorte de header qui identifie le nom de la table
  • La déclaration du schéma

La schéma est un structure de données qui associe des nom de colonnes à leur définition.

Type de Colonne

Ces définitions sont typées.

Integer

Ce type de colonne est le plus simple, il est constitué d'un token Integer.

INTEGER

Une reconnaissance de token suffit, mais il faut faire attention à d'éventuels espaces blancs avant et après le token.

On créé alors IntegerField pour y accrocher notre Visitor.

struct IntegerField;
impl<'a> Visitable<'a, u8> for IntegerField {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        scanner.visit::<OptionalWhitespaces>()?;
        recognize(Token::Field(TokenField::Integer), scanner)?;
        scanner.visit::<OptionalWhitespaces>()?;
        Ok(IntegerField)
    }
}

Text

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.

struct TextField(usize);

L'API de parse que l'on a construit, rend alors les opérations quasi naturelles.

impl<'a> Visitable<'a, u8> for TextField {
    fn accept(scanner: &mut Scanner<'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.

enum ColumnTypeResult {
    Integer(IntegerField),
    Text(TextField),
}

Et leur pendant d'API publique

enum ColumnType {
    Integer,
    Text(usize),
}

Pour permettre de passer du résultat de l'acceptation à celui du parse on utilise une implémentation du trait From.

impl From<ColumnTypeResult> for ColumnType {
    fn from(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> for ColumnType {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        Acceptor::new(scanner)
            .try_or(|field| Ok(ColumnTypeResult::Integer(field)))?
            .try_or(|field| Ok(ColumnTypeResult::Text(field)))?
            .finish()
            .ok_or(ParseError::UnexpectedToken)
            .map(Into::into)
    }
}

On a alors un mécanisme qui transforme le contenu de notre Scanner en un ColumnType.

Définition de colonne

La définition de la colonne est un nom et un type de colonne séparé d'un blanc.

id INTEGER
nom TEXT(26)

On le modélise par une structure.

struct ColumnDefinition {
    name: String,
    field: ColumnType,
}

Qui peut être rendu visitable

impl<'a> Visitable<'a, u8> for ColumnDefinition {
    fn accept(scanner: &mut Scanner<'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.

(id INTEGER, nom TEXT(26))
struct Schema {
    pub fields: HashMap<String, ColumnType>,
}

On peut alors le visiter

impl<'a> Schema {
    fn parse(scanner: &mut Scanner<'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
        let mut 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(),
            |mut fields, 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]
fn test_parse_schema() {
    let data = b"( id integer, name text  ( 50 ) )";
    let mut 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))";
    let mut 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.

CREATE TABLE table1 (id INTEGER, nom TEXT(26));

Modélisé en:

struct CreateTableCommand {
    pub(crate) table_name: String,
    schema: Schema,
}

Visiter la commande est un jeu de légos. 😎

impl<'a> Visitable<'a, u8> for CreateTableCommand {
    fn accept(scanner: &mut Scanner<'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]
    fn test_parse_create_table_command() {
        let data = b"create table   users   (id   integer,   name   text(50) );";
        let mut 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))
                    ])
                }
            }
        );
    }

Command Insert Into

Grammaire de INSERT INTO
InsertInto ::= 'INSERT' 'INTO' LiteralString  OpenParen Column* CloseParen 'VALUES' OpenParen Value*  CloseParen Semicolon
Column ::= LiteralString | LiteralString Comma
Value ::= (LiteralInteger | LiteralString ) | ((LiteralInteger | LiteralString) Comma ) 
LiteralString ::=  "'" [A-Za-z0-9_]* "'"
LiteralInteger ::= [0-9]+
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'

InsertInto:


Column:


Value:


LiteralString:


LiteralInteger:

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

struct Column(String);

impl<'a> Visitable<'a, u8> for Column {
    fn accept(scanner: &mut Scanner<'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)]
pub struct Columns(Vec<String>);

impl<'a> Visitable<'a, u8> for Columns {
    fn accept(scanner: &mut Scanner<'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]
fn test_columns() {
    let data = b"(id, brand)";
    let mut 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.

struct IntegerValue(i64);

impl<'a> Visitable<'a, u8> for IntegerValue {
    fn accept(scanner: &mut Scanner<'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

struct TextValue(String);

impl<'a> Visitable<'a, u8> for TextValue {
    fn accept(scanner: &mut Scanner<'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))
    }
}

On réalise une union des variantes

enum ValueResult {
    Integer(IntegerValue),
    Text(TextValue),
}

On créé l'énumération des types de valeurs

enum Value {
    Integer(i64),
    Text(String),
}

On lui rajoute de la glue technique

impl From<ValueResult> for Value {
    fn from(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.

impl<'a> Visitable<'a, u8> for Value {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        Acceptor::new(scanner)
            .try_or(|value| Ok(ValueResult::Integer(value)))?
            .try_or(|value| Ok(ValueResult::Text(value)))?
            .finish()
            .ok_or(UnexpectedToken)
            .map(Into::into)
    }
}

Utilisable ainsi.

#[test]
fn test_integer_value() {
    let data = b"123";
    let mut tokenizer = Tokenizer::new(data);
    let value = IntegerValue::accept(&mut tokenizer).expect("failed to parse");
    assert_eq!(value, IntegerValue(123));
}

#[test]
fn test_text_value() {
    let data = b"'test'";
    let mut 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.

struct Values(Vec<Value>);

impl<'a> Visitable<'a, u8> for Values {
    fn accept(scanner: &mut Scanner<'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
        let mut 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]
fn text_values() {
    let data = b"('test1', 120, 'test3')";
    let mut 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:

struct InsertIntoCommand {
    table_name: String,
    fields: HashMap<String, String>
}

On en fait un Visiteur

impl<'a> Visitable<'a, u8> for InsertIntoCommand {
    fn accept(scanner: &mut Scanner<'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(), |mut map, (value, column)| {
            map.insert(column, value);
            map
        });

        Ok(InsertIntoCommand { table_name, fields })
    }
}

Qui fonctionne ainsi

#[test]
fn test_insert_into_command() {
    let data = b"INSERT INTO users(id, name, email) VALUES(42, 'user 1', 'email@example.com')";
    let mut 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);
}

Succès ! 😊

Commande Select From

Grammaire de SELECT
Select ::= 'SELECT' Column* 'FROM' LiteralString Semicolon
Column ::= '*' | LiteralString | LiteralString Comma
LiteralString ::=  [A-Za-z0-9_]*
LiteralInteger ::= [0-9]+
OpenParen ::= '('
CloseParen ::= ')'
Comma ::= ','
Semicolon ::= ';'

Select:

Column:


LiteralString:


LiteralInteger:


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 '*'.

struct ProjectionStar;

impl<'a> Visitable<'a, u8> for ProjectionStar {
    fn accept(scanner: &mut Scanner<'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.

struct ProjectionColumns(Vec<String>);

impl<'a> Visitable<'a, u8> for ProjectionColumns {
    fn accept(scanner: &mut Scanner<'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
        let mut 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))
    }
}

On fusionne les deux types de projection.

enum ProjectionResult {
    Star(ProjectionStar),
    Columns(ProjectionColumns),
}

On créé également la structure de Projection.

enum Projection {
    Columns(Vec<String>),
    Star,
}

Et en faire la glue.

impl From<ProjectionResult> for Projection {
    fn from(value: ProjectionResult) -> Self {
        match value {
            ProjectionResult::Star(ProjectionStar) => Projection::Star,
            ProjectionResult::Columns(ProjectionColumns(columns)) => Projection::Columns(columns),
        }
    }
}

Qui permet finalement d'en créer un visiteur.

impl<'a> Visitable<'a, u8> for Projection {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        Acceptor::new(scanner)
            .try_or(|projection| Ok(ProjectionResult::Star(projection)))?
            .try_or(|projection| Ok(ProjectionResult::Columns(projection)))?
            .finish()
            .ok_or(ParseError::UnexpectedToken)
            .map(Into::into)
    }
}

Qui s'utilise ainsi.

#[test]
fn test_projections() {
    let data = b"* FROM";
    let mut tokenizer = Tokenizer::new(data);
    let projection = tokenizer.visit::<Projection>().expect("failed to parse");
    assert_eq!(projection, Projection::Star);

    let data = b"id, name FROM";
    let mut 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

struct SelectCommand {
    table_name: String,
    projection: Projection,
}

impl<'a> Visitable<'a, u8> for SelectCommand {
    fn accept(scanner: &mut Tokenizer<'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]
fn test_parse_select_command() {
    let data = b"SELECT * FROM table;";
    let mut 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;";
    let mut 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.

enum Command {
    CreateTable(CreateTableCommand),
    Select(SelectCommand),
    InsertInto(InsertIntoCommand),
}

impl<'a> Visitable<'a, u8> for Command {
    fn accept(scanner: &mut Scanner<'a, u8>) -> crate::parser::Result<Self> {
        Acceptor::new(scanner)
            .try_or(|command| Ok(Command::Select(command)))?
            .try_or(|command| Ok(Command::CreateTable(command)))?
            .try_or(|command| Ok(Command::InsertInto(command)))?
            .finish()
            .ok_or(ParseError::UnexpectedToken)
    }
}

Et tout se goupille !! 😍

#[test]
fn test_select() {
    let data = b"SELECT * FROM table;";
    let mut 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;";
    let mut 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]
fn test_insert() {
    let data = b"INSERT INTO Users(id, name, email) VALUES (42, 'user 1', 'email@example.com')";
    let mut 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]
fn test_create() {
    let data = b"CREATE TABLE Users(id INTEGER, name TEXT(50), email TEXT(128));";
    let mut 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.

Dans la prochaine partie on généralisera les données stockées !

Merci de votre lecture ❤️

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

avatar

Auteur: Akanoa

Je découvre, j'apprends, je comprends et j'explique ce que j'ai compris dans ce blog.

Ce travail est sous licence CC BY-NC-SA 4.0.